Target setting new design (#4241)

Co-authored-by: Kamil Kisiela <kamil.kisiela@gmail.com>
This commit is contained in:
Tuval Simha 2024-06-04 14:15:09 +03:00 committed by GitHub
parent 23433de2bf
commit da1d8ac20f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 943 additions and 696 deletions

View file

@ -8,7 +8,7 @@ if (!document.body.className.includes('dark')) {
const preview = { const preview = {
parameters: { parameters: {
backgrounds: { backgrounds: {
default: 'dark', default: 'black',
}, },
actions: { argTypesRegex: '^on[A-Z].*' }, actions: { argTypesRegex: '^on[A-Z].*' },
controls: { controls: {

View file

@ -233,6 +233,7 @@ export const TargetLayout = ({
projectId: props.projectId, projectId: props.projectId,
targetId: props.targetId, targetId: props.targetId,
}} }}
search={{ page: 'general' }}
> >
Settings Settings
</Link> </Link>

View file

@ -14,7 +14,6 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { CardDescription, CardTitle } from '@/components/ui/card';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -32,6 +31,7 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form'; import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { SubPageLayout, SubPageLayoutHeader } from '@/components/ui/page-content-layout';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { FragmentType, graphql, useFragment } from '@/gql'; import { FragmentType, graphql, useFragment } from '@/gql';
import { useClipboard } from '@/lib/hooks'; import { useClipboard } from '@/lib/hooks';
@ -428,21 +428,16 @@ export function OrganizationInvitations(props: {
); );
return ( return (
<div className="space-y-6"> <SubPageLayout>
<div className="flex flex-row items-center justify-between"> <SubPageLayoutHeader
<div className="space-y-2"> title="Pending invitations"
<CardTitle>Pending invitations</CardTitle> description="Active invitations to join this organization. Invitations expire after 7 days."
<CardDescription> >
Active invitations to join this organization. Invitations expire after 7 days. <MemberInvitationButton
</CardDescription> refetchInvitations={props.refetchInvitations}
</div> organization={organization}
<div> />
<MemberInvitationButton </SubPageLayoutHeader>
refetchInvitations={props.refetchInvitations}
organization={organization}
/>
</div>
</div>
{organization.invitations.nodes.length > 0 ? ( {organization.invitations.nodes.length > 0 ? (
<table className="w-full table-auto divide-y-[1px] divide-gray-500/20"> <table className="w-full table-auto divide-y-[1px] divide-gray-500/20">
<thead> <thead>
@ -476,6 +471,6 @@ export function OrganizationInvitations(props: {
</div> </div>
</div> </div>
)} )}
</div> </SubPageLayout>
); );
} }

View file

@ -14,13 +14,13 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { CardDescription, CardTitle } from '@/components/ui/card';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { SubPageLayout, SubPageLayoutHeader } from '@/components/ui/page-content-layout';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { ChangePermissionsModal } from '@/components/v2/modals'; import { ChangePermissionsModal } from '@/components/v2/modals';
@ -526,21 +526,16 @@ export function OrganizationMembers(props: {
); );
return ( return (
<div className="space-y-6"> <SubPageLayout>
<div className="flex flex-row items-center justify-between"> <SubPageLayoutHeader
<div className="space-y-2"> title="List of organization members"
<CardTitle>List of organization members</CardTitle> description="Manage the members of your organization and their permissions."
<CardDescription> >
Manage the members of your organization and their permissions. <MemberInvitationButton
</CardDescription> refetchInvitations={props.refetchMembers}
</div> organization={organization}
<div> />
<MemberInvitationButton </SubPageLayoutHeader>
refetchInvitations={props.refetchMembers}
organization={organization}
/>
</div>
</div>
<table className="w-full table-auto divide-y-[1px] divide-gray-500/20"> <table className="w-full table-auto divide-y-[1px] divide-gray-500/20">
<thead> <thead>
<tr> <tr>
@ -589,6 +584,6 @@ export function OrganizationMembers(props: {
))} ))}
</tbody> </tbody>
</table> </table>
</div> </SubPageLayout>
); );
} }

View file

@ -16,7 +16,7 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { CardDescription, CardTitle } from '@/components/ui/card'; import { CardDescription } from '@/components/ui/card';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -30,6 +30,7 @@ import { ProductUpdatesLink } from '@/components/ui/docs-note';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form'; import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'; import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { SubPageLayout, SubPageLayoutHeader } from '@/components/ui/page-content-layout';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
@ -849,22 +850,26 @@ export function OrganizationMemberRolesMigration(props: {
); );
return ( return (
<div className="space-y-6"> <SubPageLayout>
<div className="space-y-2"> <SubPageLayoutHeader
<CardTitle>Migration Wizard</CardTitle> title="Migration Wizard"
<CardDescription> description={
This wizard will help you migrate your organization's members to the new permissions <>
system. <CardDescription>
</CardDescription> This wizard will help you migrate your organization's members to the new permissions
<CardDescription> system.
Members are grouped by their access scopes. </CardDescription>
<br /> You can choose to migrate all members from each group to a new role or assign them <CardDescription>
to an existing role. Members are grouped by their access scopes.
</CardDescription> <br /> You can choose to migrate all members from each group to a new role or assign
<ProductUpdatesLink href="2023-12-05-member-roles"> them to an existing role.
Read "Introducing Member Roles" product update to learn more. </CardDescription>
</ProductUpdatesLink> <ProductUpdatesLink href="2023-12-05-member-roles">
</div> Read "Introducing Member Roles" product update to learn more.
</ProductUpdatesLink>
</>
}
/>
{organization.unassignedMembersToMigrate.length > 0 ? ( {organization.unassignedMembersToMigrate.length > 0 ? (
<table className="w-full table-auto divide-y-[1px] divide-gray-500/20"> <table className="w-full table-auto divide-y-[1px] divide-gray-500/20">
<thead> <thead>
@ -900,6 +905,6 @@ export function OrganizationMemberRolesMigration(props: {
</div> </div>
</div> </div>
)} )}
</div> </SubPageLayout>
); );
} }

View file

@ -15,7 +15,6 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { CardDescription, CardTitle } from '@/components/ui/card';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -40,6 +39,7 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { SubPageLayout, SubPageLayoutHeader } from '@/components/ui/page-content-layout';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@ -894,21 +894,16 @@ export function OrganizationMemberRoles(props: {
</AlertDialogContent> </AlertDialogContent>
) : null} ) : null}
</AlertDialog> </AlertDialog>
<div className="space-y-6"> <SubPageLayout>
<div className="flex flex-row items-center justify-between"> <SubPageLayoutHeader
<div className="space-y-2"> title="List of roles"
<CardTitle>List of roles</CardTitle> description="Manage the roles that can be assigned to members of this organization."
<CardDescription> >
Manage the roles that can be assigned to members of this organization. <OrganizationMemberRoleCreateButton
</CardDescription> me={organization.me}
</div> organizationCleanId={organization.cleanId}
<div> />
<OrganizationMemberRoleCreateButton </SubPageLayoutHeader>
me={organization.me}
organizationCleanId={organization.cleanId}
/>
</div>
</div>
<table className="w-full table-auto divide-y-[1px] divide-gray-500/20"> <table className="w-full table-auto divide-y-[1px] divide-gray-500/20">
<thead> <thead>
<tr> <tr>
@ -930,7 +925,7 @@ export function OrganizationMemberRoles(props: {
))} ))}
</tbody> </tbody>
</table> </table>
</div> </SubPageLayout>
</> </>
); );
} }

View file

@ -4,7 +4,8 @@ import { useMutation, useQuery } from 'urql';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { z } from 'zod'; import { z } from 'zod';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { CardDescription } from '@/components/ui/card';
import { SubPageLayout, SubPageLayoutHeader } from '@/components/ui/page-content-layout';
import { import {
DocsLink, DocsLink,
Heading, Heading,
@ -354,7 +355,9 @@ export function CDNAccessTokens(props: {
const closeModal = () => { const closeModal = () => {
void router.navigate({ void router.navigate({
search: {}, search: {
page: 'cdn',
},
}); });
}; };
@ -375,107 +378,112 @@ export function CDNAccessTokens(props: {
const canManage = canAccessTarget(TargetAccessScope.Settings, me); const canManage = canAccessTarget(TargetAccessScope.Settings, me);
return ( return (
<Card> <SubPageLayout>
<CardHeader> <SubPageLayoutHeader
<CardTitle id="cdn-access-tokens">CDN Access Token</CardTitle> title="CDN Access Token"
<CardDescription> description={
CDN Access Tokens are used to access to Hive High-Availability CDN and read your schema <>
artifacts. <CardDescription>
</CardDescription> CDN Access Tokens are used to access to Hive High-Availability CDN and read your
<CardDescription> schema artifacts.
<DocsLink </CardDescription>
href="/management/targets#cdn-access-tokens" <CardDescription>
className="text-gray-500 hover:text-gray-300" <DocsLink
> href="/management/targets#cdn-access-tokens"
Learn more about CDN Access Tokens className="text-gray-500 hover:text-gray-300"
</DocsLink>
</CardDescription>
</CardHeader>
<CardContent>
{canManage && (
<div className="my-3.5 flex justify-between">
<Button asChild>
<Link
search={{
cdn: 'create',
}}
> >
Create new CDN token Learn more about CDN Access Tokens
</Link> </DocsLink>
</Button> </CardDescription>
</div> </>
)} }
<Table> />
<TBody> {canManage && (
{target?.data?.target?.cdnAccessTokens.edges?.map(edge => { <div className="my-3.5 flex justify-between">
const node = useFragment(CDNAccessTokeRowFragment, edge.node); <Button asChild>
<Link
return ( search={{
<Tr key={node.id}> page: 'cdn',
<Td> cdn: 'create',
{node.firstCharacters + new Array(10).fill('•').join('') + node.lastCharacters}
</Td>
<Td>{node.alias}</Td>
<Td align="right">
created <TimeAgo date={node.createdAt} />
</Td>
<Td align="right">
<Button
className="hover:text-red-500"
variant="ghost"
onClick={() => {
void router.navigate({
search: {
cdn: 'delete',
id: edge.node.id,
},
});
}}
>
<TrashIcon />
</Button>
</Td>
</Tr>
);
})}
</TBody>
</Table>
<div className="my-3.5 flex justify-end">
{target.data?.target?.cdnAccessTokens.pageInfo.hasPreviousPage ? (
<Button
variant="secondary"
className="mr-2 px-5"
onClick={() => {
setEndCursors(cursors => {
if (cursors.length === 0) {
return cursors;
}
return cursors.slice(0, cursors.length - 1);
});
}} }}
> >
Previous Page Create new CDN token
</Button> </Link>
) : null} </Button>
{target.data?.target?.cdnAccessTokens.pageInfo.hasNextPage ? (
<Button
variant="secondary"
className="px-5"
onClick={() => {
setEndCursors(cursors => {
if (!target.data?.target?.cdnAccessTokens.pageInfo.endCursor) {
return cursors;
}
return [...cursors, target.data?.target?.cdnAccessTokens.pageInfo.endCursor];
});
}}
>
Next Page
</Button>
) : null}
</div> </div>
</CardContent> )}
<Table>
<TBody>
{target?.data?.target?.cdnAccessTokens.edges?.map(edge => {
const node = useFragment(CDNAccessTokeRowFragment, edge.node);
return (
<Tr key={node.id}>
<Td>
{node.firstCharacters + new Array(10).fill('•').join('') + node.lastCharacters}
</Td>
<Td>{node.alias}</Td>
<Td align="right">
created <TimeAgo date={node.createdAt} />
</Td>
<Td align="right">
<Button
className="hover:text-red-500"
variant="ghost"
onClick={() => {
void router.navigate({
search: {
page: 'cdn',
cdn: 'delete',
id: node.id,
},
});
}}
>
<TrashIcon />
</Button>
</Td>
</Tr>
);
})}
</TBody>
</Table>
<div className="my-3.5 flex justify-end">
{target.data?.target?.cdnAccessTokens.pageInfo.hasPreviousPage ? (
<Button
variant="secondary"
className="mr-2 px-5"
onClick={() => {
setEndCursors(cursors => {
if (cursors.length === 0) {
return cursors;
}
return cursors.slice(0, cursors.length - 1);
});
}}
>
Previous Page
</Button>
) : null}
{target.data?.target?.cdnAccessTokens.pageInfo.hasNextPage ? (
<Button
variant="secondary"
className="px-5"
onClick={() => {
setEndCursors(cursors => {
if (!target.data?.target?.cdnAccessTokens.pageInfo.endCursor) {
return cursors;
}
return [...cursors, target.data?.target?.cdnAccessTokens.pageInfo.endCursor];
});
}}
>
Next Page
</Button>
) : null}
</div>
{searchParams.cdn === 'create' ? ( {searchParams.cdn === 'create' ? (
<CreateCDNAccessTokenModal <CreateCDNAccessTokenModal
onCreateCDNAccessToken={() => { onCreateCDNAccessToken={() => {
@ -499,6 +507,6 @@ export function CDNAccessTokens(props: {
targetId={props.targetId} targetId={props.targetId}
/> />
) : null} ) : null}
</Card> </SubPageLayout>
); );
} }

View file

@ -5,7 +5,7 @@ import { useMutation, useQuery } from 'urql';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { CardDescription } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'; import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command';
import { import {
@ -26,6 +26,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { SubPageLayout, SubPageLayoutHeader } from '@/components/ui/page-content-layout';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { import {
Table, Table,
@ -167,163 +168,168 @@ export function SchemaContracts(props: {
return ( return (
<> <>
<Card> <SubPageLayout>
<CardHeader> <SubPageLayoutHeader
<CardTitle>Schema Contracts</CardTitle> title="Schema Contracts"
<CardDescription> description={
Schema Contracts allow you to have separate public graphs that are a subset of the main <>
graph. <CardDescription>
</CardDescription> Schema Contracts allow you to have separate public graphs that are a subset of the
<CardDescription> main graph.
<DocsLink href="/management/contracts" className="text-gray-500 hover:text-gray-300"> </CardDescription>
Learn more about Schema Contracts <CardDescription>
</DocsLink> <DocsLink
</CardDescription> href="/management/contracts"
</CardHeader> className="text-gray-500 hover:text-gray-300"
<CardContent> >
<div className="my-3.5 flex justify-between"> Learn more about Schema Contracts
<Dialog> </DocsLink>
<DialogTrigger> </CardDescription>
<Button>Create new contract</Button> </>
</DialogTrigger> }
<DialogContent>
<CreateContractDialogContent
target={schemaContractsQuery.data?.target ?? null}
onCreateContract={refetchQuery}
/>
</DialogContent>
</Dialog>
</div>
{!!contracts?.length && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Contract Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Included Tags</TableHead>
<TableHead>Excluded Tags</TableHead>
<TableHead>Remove unreachable API Types</TableHead>
<TableHead className="text-right">Created at</TableHead>
<TableHead className="text-right" />
</TableRow>
</TableHeader>
<TableBody>
{contracts.map(({ node }) => (
<TableRow key={node.id}>
<TableCell className={cn(node.isDisabled && 'opacity-30')}>
{node.contractName}
</TableCell>
<TableCell>
<div className="flex items-center">
{node.isDisabled ? (
<>
<span className="text-yellow-500">Inactive</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="icon-sm"
className="ml-2 text-yellow-500"
>
<InfoCircledIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-md p-4 font-normal">
<p>
This Contract is no longer active and no more contract versions
or contract checks will be published for it.
</p>
<p className="mt-1">
It is not possible to enable a contract again. Please create a
new contract instead.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
) : (
<>
<span>Active</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button variant="ghost" size="icon-sm" className="ml-2">
<InfoCircledIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-md p-4 font-normal">
<p>
This Contract is active. Schema publishes and checks will
attempt to also build the contract schema.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)}
</div>
</TableCell>
<TableCell className={cn(node.isDisabled && 'opacity-30')}>
{node.includeTags?.map(tag => (
<Badge className="mr-1" key={tag}>
{tag}
</Badge>
)) ?? 'None'}
</TableCell>
<TableCell className={cn(node.isDisabled && 'opacity-30')}>
{node.excludeTags?.map(tag => (
<Badge className="mr-1" key={tag}>
{tag}
</Badge>
)) ?? 'None'}
</TableCell>
<TableCell className={cn('text-center', node.isDisabled && 'opacity-30')}>
{node.removeUnreachableTypesFromPublicApiSchema ? (
<Check className="size-4" />
) : (
<X className="size-4" />
)}
</TableCell>
<TableCell className={cn('text-right', node.isDisabled && 'opacity-30')}>
<TimeAgo date={node.createdAt} />
</TableCell>
<TableCell className="text-end">
{node.viewerCanDisableContract && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="size-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
className="text-red-500"
onClick={() => onDisable(node.id)}
>
Disable
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{disabledContractId && (
<DisableContractDialog
contractId={disabledContractId}
onClose={() => {
setDisabledContractId(null);
}}
/> />
)} <div className="my-3.5 flex justify-between">
<Dialog>
<DialogTrigger>
<Button>Create new contract</Button>
</DialogTrigger>
<DialogContent>
<CreateContractDialogContent
target={schemaContractsQuery.data?.target ?? null}
onCreateContract={refetchQuery}
/>
</DialogContent>
</Dialog>
</div>
{!!contracts?.length && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Contract Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Included Tags</TableHead>
<TableHead>Excluded Tags</TableHead>
<TableHead>Remove unreachable API Types</TableHead>
<TableHead className="text-right">Created at</TableHead>
<TableHead className="text-right" />
</TableRow>
</TableHeader>
<TableBody>
{contracts.map(({ node }) => (
<TableRow key={node.id}>
<TableCell className={cn(node.isDisabled && 'opacity-30')}>
{node.contractName}
</TableCell>
<TableCell>
<div className="flex items-center">
{node.isDisabled ? (
<>
<span className="text-yellow-500">Inactive</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="icon-sm"
className="ml-2 text-yellow-500"
>
<InfoCircledIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-md p-4 font-normal">
<p>
This Contract is no longer active and no more contract versions or
contract checks will be published for it.
</p>
<p className="mt-1">
It is not possible to enable a contract again. Please create a new
contract instead.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
) : (
<>
<span>Active</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button variant="ghost" size="icon-sm" className="ml-2">
<InfoCircledIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-md p-4 font-normal">
<p>
This Contract is active. Schema publishes and checks will attempt
to also build the contract schema.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)}
</div>
</TableCell>
<TableCell className={cn(node.isDisabled && 'opacity-30')}>
{node.includeTags?.map(tag => (
<Badge className="mr-1" key={tag}>
{tag}
</Badge>
)) ?? 'None'}
</TableCell>
<TableCell className={cn(node.isDisabled && 'opacity-30')}>
{node.excludeTags?.map(tag => (
<Badge className="mr-1" key={tag}>
{tag}
</Badge>
)) ?? 'None'}
</TableCell>
<TableCell className={cn('text-center', node.isDisabled && 'opacity-30')}>
{node.removeUnreachableTypesFromPublicApiSchema ? (
<Check className="size-4" />
) : (
<X className="size-4" />
)}
</TableCell>
<TableCell className={cn('text-right', node.isDisabled && 'opacity-30')}>
<TimeAgo date={node.createdAt} />
</TableCell>
<TableCell className="text-end">
{node.viewerCanDisableContract && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="size-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
className="text-red-500"
onClick={() => onDisable(node.id)}
>
Disable
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{disabledContractId && (
<DisableContractDialog
contractId={disabledContractId}
onClose={() => {
setDisabledContractId(null);
}}
/>
)}
</SubPageLayout>
</> </>
); );
} }

View file

@ -0,0 +1,73 @@
import { forwardRef, HTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { CardDescription, CardTitle } from './card';
type NavLayoutProps = {
children: ReactNode;
} & HTMLAttributes<HTMLDivElement>;
const NavLayout = forwardRef<HTMLDivElement, NavLayoutProps>(({ children, ...props }, ref) => (
<nav ref={ref} className="flex w-48 flex-col space-x-0 space-y-1" {...props}>
{children}
</nav>
));
NavLayout.displayName = 'NavLayout';
type PageLayoutProps = {
children: ReactNode;
} & HTMLAttributes<HTMLDivElement>;
const PageLayout = forwardRef<HTMLDivElement, PageLayoutProps>(({ children, ...props }, ref) => (
<div ref={ref} className="flex flex-col gap-y-4" {...props}>
<div className="flex flex-row gap-x-6 py-6" {...props}>
{children}
</div>
</div>
));
PageLayout.displayName = 'PageLayout';
type PageLayoutContentProps = {
children: ReactNode;
mainTitlePage?: string;
} & HTMLAttributes<HTMLDivElement>;
const PageLayoutContent = forwardRef<HTMLDivElement, PageLayoutContentProps>(
({ children, mainTitlePage, ...props }, ref) => (
<div ref={ref} className={cn('grow', props.className)} {...props}>
<h1 className="mb-2 text-2xl font-semibold">{mainTitlePage}</h1>
{mainTitlePage ? <div className="mb-3 h-[1px] w-full bg-gray-700" /> : null}
{children}
</div>
),
);
PageLayoutContent.displayName = 'PageLayoutContent';
const SubPageLayout = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('space-y-2', className)} {...props} />
),
);
SubPageLayout.displayName = 'SubPageLayout';
type SubPageLayoutHeaderProps = {
children?: ReactNode;
title?: string;
description?: string | ReactNode;
} & HTMLAttributes<HTMLDivElement>;
const SubPageLayoutHeader = forwardRef<HTMLDivElement, SubPageLayoutHeaderProps>(({ ...props }) => (
<div className="flex flex-row items-center justify-between">
<div className="space-y-1.5">
<CardTitle>{props.title}</CardTitle>
{typeof props.description === 'string' ? (
<CardDescription>{props.description}</CardDescription>
) : (
props.description
)}
</div>
<div>{props.children}</div>
</div>
));
SubPageLayoutHeader.displayName = 'SubPageLayoutHeader';
export { PageLayout, NavLayout, PageLayoutContent, SubPageLayout, SubPageLayoutHeader };

View file

@ -158,6 +158,9 @@ export const ConnectSchemaModal = (props: {
<span className="text-sm text-gray-500"> <span className="text-sm text-gray-500">
To authenticate,{' '} To authenticate,{' '}
<Link <Link
search={{
page: 'cdn',
}}
variant="primary" variant="primary"
className="font-bold underline" className="font-bold underline"
to="/$organizationId/$projectId/$targetId/settings" to="/$organizationId/$projectId/$targetId/settings"

View file

@ -6,6 +6,7 @@ import { OrganizationMemberRolesMigration } from '@/components/organization/memb
import { OrganizationMemberRoles } from '@/components/organization/members/roles'; import { OrganizationMemberRoles } from '@/components/organization/members/roles';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Meta } from '@/components/ui/meta'; import { Meta } from '@/components/ui/meta';
import { NavLayout, PageLayout, PageLayoutContent } from '@/components/ui/page-content-layout';
import { QueryError } from '@/components/ui/query-error'; import { QueryError } from '@/components/ui/query-error';
import { FragmentType, graphql, useFragment } from '@/gql'; import { FragmentType, graphql, useFragment } from '@/gql';
import { OrganizationAccessScope, useOrganizationAccess } from '@/lib/access/organization'; import { OrganizationAccessScope, useOrganizationAccess } from '@/lib/access/organization';
@ -71,49 +72,46 @@ function PageContent(props: {
} }
return ( return (
<div className="flex flex-col gap-y-4"> <PageLayout>
<div className="flex flex-row gap-x-6 py-6"> <NavLayout>
<nav className="flex w-48 flex-col space-x-0 space-y-1"> {subPages.map(subPage => {
{subPages.map(subPage => { // hide migration page from non-admins
// hide migration page from non-admins if (subPage.key === 'migration' && !organization.me.isAdmin) {
if (subPage.key === 'migration' && !organization.me.isAdmin) { return null;
return null; }
} return (
<Button
return ( key={subPage.key}
<Button variant="ghost"
key={subPage.key} className={cn(
variant="ghost" props.page === subPage.key
className={cn( ? 'bg-muted hover:bg-muted'
props.page === subPage.key : 'hover:bg-transparent hover:underline',
? 'bg-muted hover:bg-muted' 'justify-start',
: 'hover:bg-transparent hover:underline', )}
'justify-start', onClick={() => props.onPageChange(subPage.key)}
)} >
onClick={() => props.onPageChange(subPage.key)} {subPage.title}
> </Button>
{subPage.title} );
</Button> })}
); </NavLayout>
})} <PageLayoutContent>
</nav> {props.page === 'roles' ? <OrganizationMemberRoles organization={organization} /> : null}
<div className="grow"> {props.page === 'list' ? (
{props.page === 'roles' ? <OrganizationMemberRoles organization={organization} /> : null} <OrganizationMembers refetchMembers={props.refetchQuery} organization={organization} />
{props.page === 'list' ? ( ) : null}
<OrganizationMembers refetchMembers={props.refetchQuery} organization={organization} /> {props.page === 'invitations' ? (
) : null} <OrganizationInvitations
{props.page === 'invitations' ? ( refetchInvitations={props.refetchQuery}
<OrganizationInvitations organization={organization}
refetchInvitations={props.refetchQuery} />
organization={organization} ) : null}
/> {props.page === 'migration' && organization.me.isAdmin ? (
) : null} <OrganizationMemberRolesMigration organization={organization} />
{props.page === 'migration' && organization.me.isAdmin ? ( ) : null}
<OrganizationMemberRolesMigration organization={organization} /> </PageLayoutContent>
) : null} </PageLayout>
</div>
</div>
</div>
); );
} }

View file

@ -965,6 +965,9 @@ function LaboratoryPageContent(props: {
projectId: props.projectId, projectId: props.projectId,
targetId: props.targetId, targetId: props.targetId,
}} }}
search={{
page: 'general',
}}
> >
<Button variant="outline" className="mr-2" size="sm"> <Button variant="outline" className="mr-2" size="sm">
Connect GraphQL API Endpoint Connect GraphQL API Endpoint

View file

@ -9,18 +9,17 @@ import { SchemaEditor } from '@/components/schema-editor';
import { CDNAccessTokens } from '@/components/target/settings/cdn-access-tokens'; import { CDNAccessTokens } from '@/components/target/settings/cdn-access-tokens';
import { SchemaContracts } from '@/components/target/settings/schema-contracts'; import { SchemaContracts } from '@/components/target/settings/schema-contracts';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { CardDescription } from '@/components/ui/card';
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { DocsLink } from '@/components/ui/docs-note'; import { DocsLink } from '@/components/ui/docs-note';
import { Meta } from '@/components/ui/meta'; import { Meta } from '@/components/ui/meta';
import { Subtitle, Title } from '@/components/ui/page'; import {
NavLayout,
PageLayout,
PageLayoutContent,
SubPageLayout,
SubPageLayoutHeader,
} from '@/components/ui/page-content-layout';
import { QueryError } from '@/components/ui/query-error'; import { QueryError } from '@/components/ui/query-error';
import { TimeAgo } from '@/components/ui/time-ago'; import { TimeAgo } from '@/components/ui/time-ago';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
@ -36,6 +35,7 @@ import { ProjectType } from '@/gql/graphql';
import { canAccessTarget, TargetAccessScope } from '@/lib/access/target'; import { canAccessTarget, TargetAccessScope } from '@/lib/access/target';
import { subDays } from '@/lib/date-time'; import { subDays } from '@/lib/date-time';
import { useToggle } from '@/lib/hooks'; import { useToggle } from '@/lib/hooks';
import { cn } from '@/lib/utils';
import { Link, useRouter } from '@tanstack/react-router'; import { Link, useRouter } from '@tanstack/react-router';
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -137,67 +137,69 @@ function RegistryAccessTokens(props: {
const canManage = canAccessTarget(TargetAccessScope.TokensWrite, me); const canManage = canAccessTarget(TargetAccessScope.TokensWrite, me);
return ( return (
<Card> <SubPageLayout>
<CardHeader> <SubPageLayoutHeader
<CardTitle>Registry Access Tokens</CardTitle> title="Registry Access Tokens"
<CardDescription> description={
Registry Access Tokens are used to access to Hive Registry and perform actions on your <>
targets/projects. In most cases, this token is used from the Hive CLI. <CardDescription>
</CardDescription> Registry Access Tokens are used to access to Hive Registry and perform actions on your
<CardDescription> targets/projects. In most cases, this token is used from the Hive CLI.
<DocsLink </CardDescription>
href="/management/targets#registry-access-tokens" <CardDescription>
className="text-gray-500 hover:text-gray-300" <DocsLink
> href="/management/targets#registry-access-tokens"
Learn more about Registry Access Tokens className="text-gray-500 hover:text-gray-300"
</DocsLink> >
</CardDescription> Learn more about Registry Access Tokens
</CardHeader> </DocsLink>
<CardContent> </CardDescription>
{canManage && ( </>
<div className="my-3.5 flex justify-between"> }
<Button onClick={toggleModalOpen}>Create new registry token</Button> />
{checked.length === 0 ? null : ( {canManage && (
<Button variant="destructive" disabled={deleting} onClick={deleteTokens}> <div className="my-3.5 flex justify-between">
Delete ({checked.length || null}) <Button onClick={toggleModalOpen}>Create new registry token</Button>
</Button> {checked.length === 0 ? null : (
)} <Button variant="destructive" disabled={deleting} onClick={deleteTokens}>
</div> Delete ({checked.length || null})
)} </Button>
<Table> )}
<TBody> </div>
{tokens?.map(token => ( )}
<Tr key={token.id}> <Table>
<Td width="1"> <TBody>
<Checkbox {tokens?.map(token => (
onCheckedChange={isChecked => <Tr key={token.id}>
setChecked( <Td width="1">
isChecked ? [...checked, token.id] : checked.filter(k => k !== token.id), <Checkbox
) onCheckedChange={isChecked =>
} setChecked(
checked={checked.includes(token.id)} isChecked ? [...checked, token.id] : checked.filter(k => k !== token.id),
disabled={!canManage} )
/> }
</Td> checked={checked.includes(token.id)}
<Td>{token.alias}</Td> disabled={!canManage}
<Td>{token.name}</Td> />
<Td align="right"> </Td>
{token.lastUsedAt ? ( <Td>{token.alias}</Td>
<> <Td>{token.name}</Td>
last used <TimeAgo date={token.lastUsedAt} /> <Td align="right">
</> {token.lastUsedAt ? (
) : ( <>
'not used yet' last used <TimeAgo date={token.lastUsedAt} />
)} </>
</Td> ) : (
<Td align="right"> 'not used yet'
created <TimeAgo date={token.date} /> )}
</Td> </Td>
</Tr> <Td align="right">
))} created <TimeAgo date={token.date} />
</TBody> </Td>
</Table> </Tr>
</CardContent> ))}
</TBody>
</Table>
{isModalOpen && ( {isModalOpen && (
<CreateAccessTokenModal <CreateAccessTokenModal
organizationId={props.organizationId} organizationId={props.organizationId}
@ -207,7 +209,7 @@ function RegistryAccessTokens(props: {
toggleModalOpen={toggleModalOpen} toggleModalOpen={toggleModalOpen}
/> />
)} )}
</Card> </SubPageLayout>
); );
} }
@ -240,40 +242,42 @@ const ExtendBaseSchema = (props: {
const isUnsaved = baseSchema?.trim() !== props.baseSchema?.trim(); const isUnsaved = baseSchema?.trim() !== props.baseSchema?.trim();
return ( return (
<Card> <SubPageLayout>
<CardHeader> <SubPageLayoutHeader
<CardTitle>Extend Your Schema</CardTitle> title="Extend Your Schema"
<CardDescription> description={
Schema Extensions is pre-defined GraphQL schema that is automatically merged with your <>
published schemas, before being checked and validated. <CardDescription>
</CardDescription> Schema Extensions is pre-defined GraphQL schema that is automatically merged with your
<CardDescription> published schemas, before being checked and validated.
<DocsLink </CardDescription>
href="/management/targets#schema-extensions" <CardDescription>
className="text-gray-500 hover:text-gray-300" <DocsLink
> href="/management/targets#schema-extensions"
You can find more details and examples in the documentation className="text-gray-500 hover:text-gray-300"
</DocsLink> >
</CardDescription> You can find more details and examples in the documentation
</CardHeader> </DocsLink>
<CardContent> </CardDescription>
<SchemaEditor </>
theme="vs-dark" }
options={{ readOnly: mutation.fetching }} />
value={baseSchema} <SchemaEditor
height={300} theme="vs-dark"
onChange={value => setBaseSchema(value ?? '')} options={{ readOnly: mutation.fetching }}
/> value={baseSchema}
{mutation.data?.updateBaseSchema.error && ( height={300}
<div className="text-red-500">{mutation.data.updateBaseSchema.error.message}</div> onChange={value => setBaseSchema(value ?? '')}
)} />
{mutation.error && ( {mutation.data?.updateBaseSchema.error && (
<div className="text-red-500"> <div className="text-red-500">{mutation.data.updateBaseSchema.error.message}</div>
{mutation.error?.graphQLErrors[0]?.message ?? mutation.error.message} )}
</div> {mutation.error && (
)} <div className="text-red-500">
</CardContent> {mutation.error?.graphQLErrors[0]?.message ?? mutation.error.message}
<CardFooter className="flex items-center gap-x-3"> </div>
)}
<div className="flex items-center gap-x-3">
<Button <Button
className="px-5" className="px-5"
disabled={mutation.fetching} disabled={mutation.fetching}
@ -313,8 +317,8 @@ const ExtendBaseSchema = (props: {
Reset Reset
</Button> </Button>
{isUnsaved && <span className="text-sm text-green-500">Unsaved changes!</span>} {isUnsaved && <span className="text-sm text-green-500">Unsaved changes!</span>}
</CardFooter> </div>
</Card> </SubPageLayout>
); );
}; };
@ -532,46 +536,47 @@ const ConditionalBreakingChanges = (props: {
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Card> <SubPageLayout>
<CardHeader> <SubPageLayoutHeader
<CardTitle className="flex items-center justify-between gap-x-5"> title="Conditional Breaking Changes"
<div>Conditional Breaking Changes</div> description={
{targetSettings.fetching ? ( <>
<Spinner /> <CardDescription>
) : ( Conditional Breaking Changes can change the behavior of schema checks, based on real
<Switch traffic data sent to Hive.
className="shrink-0" </CardDescription>
checked={isEnabled} <CardDescription>
onCheckedChange={async enabled => { <DocsLink
await setValidation({ href="/management/targets#conditional-breaking-changes"
input: { className="text-gray-500 hover:text-gray-300"
target: props.targetId, >
project: props.projectId, Learn more
organization: props.organizationId, </DocsLink>
enabled, </CardDescription>
}, </>
}); }
}}
disabled={targetValidation.fetching}
/>
)}
</CardTitle>
<CardDescription>
Conditional Breaking Changes can change the behavior of schema checks, based on real
traffic data sent to Hive.
</CardDescription>
<CardDescription>
<DocsLink
href="/management/targets#conditional-breaking-changes"
className="text-gray-500 hover:text-gray-300"
>
Learn more
</DocsLink>
</CardDescription>
</CardHeader>
<CardContent
className={clsx('text-gray-300', !isEnabled && 'pointer-events-none opacity-25')}
> >
{targetSettings.fetching ? (
<Spinner />
) : (
<Switch
className="shrink-0"
checked={isEnabled}
onCheckedChange={async enabled => {
await setValidation({
input: {
target: props.targetId, // targetId is the target we are updating
project: props.projectId,
organization: props.organizationId,
enabled,
},
});
}}
disabled={targetValidation.fetching}
/>
)}
</SubPageLayoutHeader>
<div className={clsx('text-gray-300', !isEnabled && 'pointer-events-none opacity-25')}>
<div> <div>
A schema change is considered as breaking only if it affects more than A schema change is considered as breaking only if it affects more than
<Input <Input
@ -686,7 +691,7 @@ const ConditionalBreakingChanges = (props: {
{touched.targets && errors.targets && ( {touched.targets && errors.targets && (
<div className="text-red-500">{errors.targets}</div> <div className="text-red-500">{errors.targets}</div>
)} )}
<div className="mt-5 space-y-2 rounded border-l-2 border-l-gray-800 bg-gray-600/10 py-2 pl-5 text-gray-400"> <div className="mb-3 mt-5 space-y-2 rounded border-l-2 border-l-gray-800 bg-gray-600/10 py-2 pl-5 text-gray-400">
<div> <div>
<div className="font-semibold">Example settings</div> <div className="font-semibold">Example settings</div>
<div className="text-sm">Removal of a field is considered breaking if</div> <div className="text-sm">Removal of a field is considered breaking if</div>
@ -705,8 +710,6 @@ const ConditionalBreakingChanges = (props: {
- the field was requested by more than 10% of all GraphQL operations in recent 30 days - the field was requested by more than 10% of all GraphQL operations in recent 30 days
</div> </div>
</div> </div>
</CardContent>
<CardFooter>
<Button type="submit" disabled={isSubmitting}> <Button type="submit" disabled={isSubmitting}>
Save Save
</Button> </Button>
@ -715,8 +718,8 @@ const ConditionalBreakingChanges = (props: {
{mutation.error.graphQLErrors[0]?.message ?? mutation.error.message} {mutation.error.graphQLErrors[0]?.message ?? mutation.error.message}
</span> </span>
)} )}
</CardFooter> </div>
</Card> </SubPageLayout>
</form> </form>
); );
}; };
@ -764,6 +767,9 @@ function TargetName(props: {
projectId: props.projectId, projectId: props.projectId,
targetId: newTargetId, targetId: newTargetId,
}, },
search: {
page: subPages[0].key,
},
}); });
} else if (result.error || result.data?.updateTargetName.error?.message) { } else if (result.error || result.data?.updateTargetName.error?.message) {
toast({ toast({
@ -776,53 +782,55 @@ function TargetName(props: {
}); });
return ( return (
<Card> <SubPageLayout>
<CardHeader> <SubPageLayoutHeader
<CardTitle>Target Name</CardTitle> title="Target Name"
<CardDescription> description={
Changing the name of your target will also change the slug of your target URL, and will <>
invalidate any existing links to your target. <CardDescription>
</CardDescription> Changing the name of your target will also change the slug of your target URL, and
<CardDescription> will invalidate any existing links to your target.
<DocsLink </CardDescription>
href="/management/targets#rename-a-target" <CardDescription>
className="text-gray-500 hover:text-gray-300" <DocsLink
> href="/management/targets#rename-a-target"
You can read more about it in the documentation className="text-gray-500 hover:text-gray-300"
</DocsLink> >
</CardDescription> You can read more about it in the documentation
</CardHeader> </DocsLink>
</CardDescription>
</>
}
/>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<CardContent> <div className="flex flex-row items-center gap-x-2">
<div className="flex flex-row items-center gap-x-2"> <Input
<Input placeholder="Target name"
placeholder="Target name" name="name"
name="name" value={values.name}
value={values.name} onChange={handleChange}
onChange={handleChange} onBlur={handleBlur}
onBlur={handleBlur} disabled={isSubmitting}
disabled={isSubmitting} isInvalid={touched.name && !!errors.name}
isInvalid={touched.name && !!errors.name} className="w-96"
className="w-96" />
/> <Button type="submit" disabled={isSubmitting}>
<Button type="submit" disabled={isSubmitting}> Save
Save </Button>
</Button> </div>
</div>
{touched.name && (errors.name || mutation.error) && ( {touched.name && (errors.name || mutation.error) && (
<div className="mt-2 text-red-500"> <div className="mt-2 text-red-500">
{errors.name ?? mutation.error?.graphQLErrors[0]?.message ?? mutation.error?.message} {errors.name ?? mutation.error?.graphQLErrors[0]?.message ?? mutation.error?.message}
</div> </div>
)} )}
{mutation.data?.updateTargetName.error?.inputErrors?.name && ( {mutation.data?.updateTargetName.error?.inputErrors?.name && (
<div className="mt-2 text-red-500"> <div className="mt-2 text-red-500">
{mutation.data.updateTargetName.error.inputErrors.name} {mutation.data.updateTargetName.error.inputErrors.name}
</div> </div>
)} )}
</CardContent>
</form> </form>
</Card> </SubPageLayout>
); );
} }
@ -891,56 +899,58 @@ function GraphQLEndpointUrl(props: {
}); });
return ( return (
<Card> <SubPageLayout>
<CardHeader> <SubPageLayoutHeader
<CardTitle>GraphQL Endpoint URL</CardTitle> title="GraphQL Endpoint URL"
<CardDescription> description={
The endpoint url will be used for querying the target from the{' '} <>
<Link <CardDescription>
to="/$organizationId/$projectId/$targetId/laboratory" The endpoint url will be used for querying the target from the{' '}
params={{ <Link
organizationId: props.organizationId, to="/$organizationId/$projectId/$targetId/laboratory"
projectId: props.projectId, params={{
targetId: props.targetId, organizationId: props.organizationId,
}} projectId: props.projectId,
> targetId: props.targetId,
Hive Laboratory }}
</Link> >
. Hive Laboratory
</CardDescription> </Link>
</CardHeader> .
</CardDescription>
</>
}
/>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<CardContent> <div className="flex flex-row items-center gap-x-2">
<div className="flex flex-row items-center gap-x-2"> <Input
<Input placeholder="Endpoint Url"
placeholder="Endpoint Url" name="graphqlEndpointUrl"
name="graphqlEndpointUrl" value={values.graphqlEndpointUrl}
value={values.graphqlEndpointUrl} onChange={handleChange}
onChange={handleChange} onBlur={handleBlur}
onBlur={handleBlur} disabled={isSubmitting}
disabled={isSubmitting} isInvalid={touched.graphqlEndpointUrl && !!errors.graphqlEndpointUrl}
isInvalid={touched.graphqlEndpointUrl && !!errors.graphqlEndpointUrl} className="w-96"
className="w-96" />
/> <Button type="submit" disabled={isSubmitting}>
<Button type="submit" disabled={isSubmitting}> Save
Save </Button>
</Button> </div>
{touched.graphqlEndpointUrl && (errors.graphqlEndpointUrl || mutation.error) && (
<div className="mt-2 text-red-500">
{errors.graphqlEndpointUrl ??
mutation.error?.graphQLErrors[0]?.message ??
mutation.error?.message}
</div> </div>
{touched.graphqlEndpointUrl && (errors.graphqlEndpointUrl || mutation.error) && ( )}
<div className="mt-2 text-red-500"> {mutation.data?.updateTargetGraphQLEndpointUrl.error && (
{errors.graphqlEndpointUrl ?? <div className="mt-2 text-red-500">
mutation.error?.graphQLErrors[0]?.message ?? {mutation.data.updateTargetGraphQLEndpointUrl.error.message}
mutation.error?.message} </div>
</div> )}
)}
{mutation.data?.updateTargetGraphQLEndpointUrl.error && (
<div className="mt-2 text-red-500">
{mutation.data.updateTargetGraphQLEndpointUrl.error.message}
</div>
)}
</CardContent>
</form> </form>
</Card> </SubPageLayout>
); );
} }
@ -991,29 +1001,30 @@ function TargetDelete(props: { organizationId: string; projectId: string; target
const [isModalOpen, toggleModalOpen] = useToggle(); const [isModalOpen, toggleModalOpen] = useToggle();
return ( return (
<> <SubPageLayout>
<Card className="mb-10"> <SubPageLayoutHeader
<CardHeader> title="Delete Target"
<CardTitle>Delete Target</CardTitle> description={
<CardDescription> <>
Deleting an project also delete all schemas and data associated with it. <CardDescription>
</CardDescription> Deleting an project also delete all schemas and data associated with it.
<CardDescription> </CardDescription>
<DocsLink <CardDescription>
href="/management/targets#delete-a-target" <DocsLink
className="text-gray-500 hover:text-gray-300" href="/management/targets#delete-a-target"
> className="text-gray-500 hover:text-gray-300"
<strong>This action is not reversible!</strong> You can find more information about >
this process in the documentation <strong>This action is not reversible!</strong> You can find more information about
</DocsLink> this process in the documentation
</CardDescription> </DocsLink>
</CardHeader> </CardDescription>
<CardFooter> </>
<Button variant="destructive" onClick={toggleModalOpen}> }
Delete Target />
</Button> <Button variant="destructive" onClick={toggleModalOpen}>
</CardFooter> Delete Target
</Card> </Button>
<DeleteTargetModal <DeleteTargetModal
organizationId={props.organizationId} organizationId={props.organizationId}
projectId={props.projectId} projectId={props.projectId}
@ -1021,7 +1032,7 @@ function TargetDelete(props: { organizationId: string; projectId: string; target
isOpen={isModalOpen} isOpen={isModalOpen}
toggleModalOpen={toggleModalOpen} toggleModalOpen={toggleModalOpen}
/> />
</> </SubPageLayout>
); );
} }
@ -1052,11 +1063,42 @@ const TargetSettingsPageQuery = graphql(`
} }
`); `);
const subPages = [
{
key: 'general',
title: 'General',
},
{
key: 'cdn',
title: 'CDN Tokens',
},
{
key: 'registry-token',
title: 'Registry Tokens',
},
{
key: 'breaking-changes',
title: 'Breaking Changes',
},
{
key: 'base-schema',
title: 'Base Schema',
},
{
key: 'schema-contracts',
title: 'Schema Contracts',
},
] as const;
type SubPage = (typeof subPages)[number]['key'];
function TargetSettingsContent(props: { function TargetSettingsContent(props: {
organizationId: string; organizationId: string;
projectId: string; projectId: string;
targetId: string; targetId: string;
page?: SubPage;
}) { }) {
const router = useRouter();
const [query] = useQuery({ const [query] = useQuery({
query: TargetSettingsPageQuery, query: TargetSettingsPageQuery,
variables: { variables: {
@ -1073,6 +1115,7 @@ function TargetSettingsContent(props: {
TargetSettingsPage_OrganizationFragment, TargetSettingsPage_OrganizationFragment,
currentOrganization, currentOrganization,
); );
const targetForSettings = useFragment(TargetSettingsPage_TargetFragment, currentTarget); const targetForSettings = useFragment(TargetSettingsPage_TargetFragment, currentTarget);
const canAccessTokens = canAccessTarget( const canAccessTokens = canAccessTarget(
@ -1092,66 +1135,103 @@ function TargetSettingsContent(props: {
organizationId={props.organizationId} organizationId={props.organizationId}
page={Page.Settings} page={Page.Settings}
> >
<div className="py-6">
<Title>Settings</Title>
<Subtitle>Manage your target settings.</Subtitle>
</div>
{currentOrganization && currentProject && currentTarget && organizationForSettings ? ( {currentOrganization && currentProject && currentTarget && organizationForSettings ? (
<div className="flex flex-col gap-y-4"> <PageLayout>
<TargetName <NavLayout>
targetName={currentTarget.name} {subPages.map(subPage => {
targetId={currentTarget.cleanId} if (
projectId={currentProject.cleanId} subPage.key === 'schema-contracts' &&
organizationId={currentOrganization.cleanId} currentProject.type !== ProjectType.Federation
/> ) {
<GraphQLEndpointUrl return null;
targetId={currentTarget.cleanId} }
projectId={currentProject.cleanId} return (
organizationId={currentOrganization.cleanId} <Button
graphqlEndpointUrl={currentTarget.graphqlEndpointUrl ?? null} key={subPage.key}
/> variant="ghost"
{canAccessTokens && ( onClick={() => {
<RegistryAccessTokens void router.navigate({
targetId={currentTarget.cleanId} search: {
projectId={currentProject.cleanId} page: subPage.key,
organizationId={currentOrganization.cleanId} },
me={organizationForSettings.me} });
/> }}
)} className={cn(
{canAccessTokens && ( props.page === subPage.key
<CDNAccessTokens ? 'bg-muted hover:bg-muted'
organizationId={props.organizationId} : 'hover:bg-transparent hover:underline',
projectId={props.projectId} 'w-full justify-start text-left',
targetId={props.targetId} )}
me={organizationForSettings.me} >
/> {subPage.title}
)} </Button>
{currentProject.type === ProjectType.Federation && ( );
<SchemaContracts })}
organizationId={props.organizationId} </NavLayout>
projectId={props.projectId} <PageLayoutContent>
targetId={props.targetId} {currentOrganization && currentProject && currentTarget && organizationForSettings ? (
/> <div className="space-y-12">
)} {props.page === 'general' ? (
<ConditionalBreakingChanges <>
targetId={currentTarget.cleanId} <TargetName
projectId={currentProject.cleanId} targetName={currentTarget.name}
organizationId={currentOrganization.cleanId} targetId={currentTarget.cleanId}
/> projectId={currentProject.cleanId}
<ExtendBaseSchema organizationId={currentOrganization.cleanId}
targetId={currentTarget.cleanId} />
projectId={currentProject.cleanId} <GraphQLEndpointUrl
organizationId={currentOrganization.cleanId} targetId={currentTarget.cleanId}
baseSchema={targetForSettings?.baseSchema ?? ''} projectId={currentProject.cleanId}
/> organizationId={currentOrganization.cleanId}
{canDelete && ( graphqlEndpointUrl={currentTarget.graphqlEndpointUrl ?? null}
<TargetDelete />
targetId={currentTarget.cleanId} {canDelete && (
projectId={currentProject.cleanId} <TargetDelete
organizationId={currentOrganization.cleanId} targetId={currentTarget.cleanId}
/> projectId={currentProject.cleanId}
)} organizationId={currentOrganization.cleanId}
</div> />
)}
</>
) : null}
{props.page === 'cdn' && canAccessTokens ? (
<CDNAccessTokens
me={organizationForSettings.me}
organizationId={props.organizationId}
projectId={props.projectId}
targetId={props.targetId}
/>
) : null}
{props.page === 'registry-token' && canAccessTokens ? (
<RegistryAccessTokens
me={organizationForSettings.me}
organizationId={props.organizationId}
projectId={props.projectId}
targetId={props.targetId}
/>
) : null}
{props.page === 'breaking-changes' ? (
<ConditionalBreakingChanges
organizationId={props.organizationId}
projectId={props.projectId}
targetId={props.targetId}
/>
) : null}
{props.page === 'base-schema' ? (
<ExtendBaseSchema
baseSchema={targetForSettings?.baseSchema ?? ''}
organizationId={props.organizationId}
projectId={props.projectId}
targetId={props.targetId}
/>
) : null}
{props.page === 'schema-contracts' ? (
<SchemaContracts organizationId="" projectId="" targetId="" />
) : null}
</div>
) : null}
</PageLayoutContent>
</PageLayout>
) : null} ) : null}
</TargetLayout> </TargetLayout>
); );
@ -1161,11 +1241,17 @@ export function TargetSettingsPage(props: {
organizationId: string; organizationId: string;
projectId: string; projectId: string;
targetId: string; targetId: string;
page?: SubPage;
}) { }) {
return ( return (
<> <>
<Meta title="Settings" /> <Meta title="Settings" />
<TargetSettingsContent {...props} /> <TargetSettingsContent
organizationId={props.organizationId}
projectId={props.projectId}
targetId={props.targetId}
page={props.page}
/>
</> </>
); );
} }

View file

@ -383,16 +383,36 @@ const targetIndexRoute = createRoute({
}, },
}); });
const TargetSettingRouteSearch = z.object({
page: z
.enum([
'general',
'cdn',
'registry-token',
'breaking-changes',
'base-schema',
'schema-contracts',
])
.default('general')
.optional(),
});
const targetSettingsRoute = createRoute({ const targetSettingsRoute = createRoute({
getParentRoute: () => targetRoute, getParentRoute: () => targetRoute,
path: 'settings', path: 'settings',
validateSearch(search) {
return TargetSettingRouteSearch.parse(search);
},
component: function TargetSettingsRoute() { component: function TargetSettingsRoute() {
const { organizationId, projectId, targetId } = targetSettingsRoute.useParams(); const { organizationId, projectId, targetId } = targetSettingsRoute.useParams();
const { page } = targetSettingsRoute.useSearch();
return ( return (
<TargetSettingsPage <TargetSettingsPage
organizationId={organizationId} organizationId={organizationId}
projectId={projectId} projectId={projectId}
targetId={targetId} targetId={targetId}
page={page}
/> />
); );
}, },

View file

@ -0,0 +1,59 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { NavLayout, PageLayout, PageLayoutContent } from '@/components/ui/page-content-layout';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof PageLayout> = {
title: 'Layout/Sub page layout',
component: PageLayout,
};
export default meta;
const subPages = [
{ key: 'general', title: 'General' },
{ key: 'cdn', title: 'CDN Tokens' },
{ key: 'registry-token', title: 'Registry Tokens' },
{ key: 'breaking-changes', title: 'Breaking Changes' },
{ key: 'base-schema', title: 'Base Schema' },
{ key: 'schema-contracts', title: 'Schema Contracts' },
];
const Template: StoryObj<typeof PageLayout> = {
render: () => {
const [page, setPage] = useState('general');
return (
<PageLayout>
<NavLayout>
{subPages.map(subPage => {
return (
<Button
key={subPage.key}
variant="ghost"
onClick={() => setPage(subPage.key)}
className={
page === subPage.key
? 'bg-muted hover:bg-muted'
: 'hover:bg-transparent hover:underline'
}
>
{subPage.title}
</Button>
);
})}
</NavLayout>
<PageLayoutContent mainTitlePage={subPages.find(subPage => subPage.key === page)?.title}>
{page === 'general' && <div className="flex flex-col gap-10">General</div>}
{page === 'cdn' && <div>CDN Tokens</div>}
{page === 'registry-token' && <div>Registry Tokens</div>}
{page === 'breaking-changes' && <div>Breaking Changes</div>}
{page === 'base-schema' && <div>Base Schema</div>}
{page === 'schema-contracts' && <div>Schema Contracts</div>}
</PageLayoutContent>
</PageLayout>
);
},
};
export const Default = Template;