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 = {
parameters: {
backgrounds: {
default: 'dark',
default: 'black',
},
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,8 @@ import { useMutation, useQuery } from 'urql';
import * as Yup from 'yup';
import { z } from 'zod';
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 {
DocsLink,
Heading,
@ -354,7 +355,9 @@ export function CDNAccessTokens(props: {
const closeModal = () => {
void router.navigate({
search: {},
search: {
page: 'cdn',
},
});
};
@ -375,107 +378,112 @@ export function CDNAccessTokens(props: {
const canManage = canAccessTarget(TargetAccessScope.Settings, me);
return (
<Card>
<CardHeader>
<CardTitle id="cdn-access-tokens">CDN Access Token</CardTitle>
<CardDescription>
CDN Access Tokens are used to access to Hive High-Availability CDN and read your schema
artifacts.
</CardDescription>
<CardDescription>
<DocsLink
href="/management/targets#cdn-access-tokens"
className="text-gray-500 hover:text-gray-300"
>
Learn more about CDN Access Tokens
</DocsLink>
</CardDescription>
</CardHeader>
<CardContent>
{canManage && (
<div className="my-3.5 flex justify-between">
<Button asChild>
<Link
search={{
cdn: 'create',
}}
<SubPageLayout>
<SubPageLayoutHeader
title="CDN Access Token"
description={
<>
<CardDescription>
CDN Access Tokens are used to access to Hive High-Availability CDN and read your
schema artifacts.
</CardDescription>
<CardDescription>
<DocsLink
href="/management/targets#cdn-access-tokens"
className="text-gray-500 hover:text-gray-300"
>
Create new CDN token
</Link>
</Button>
</div>
)}
<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: {
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);
});
Learn more about CDN Access Tokens
</DocsLink>
</CardDescription>
</>
}
/>
{canManage && (
<div className="my-3.5 flex justify-between">
<Button asChild>
<Link
search={{
page: 'cdn',
cdn: 'create',
}}
>
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}
Create new CDN token
</Link>
</Button>
</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' ? (
<CreateCDNAccessTokenModal
onCreateCDNAccessToken={() => {
@ -499,6 +507,6 @@ export function CDNAccessTokens(props: {
targetId={props.targetId}
/>
) : null}
</Card>
</SubPageLayout>
);
}

View file

@ -5,7 +5,7 @@ import { useMutation, useQuery } from 'urql';
import * as Yup from 'yup';
import { Badge } from '@/components/ui/badge';
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 { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command';
import {
@ -26,6 +26,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { SubPageLayout, SubPageLayoutHeader } from '@/components/ui/page-content-layout';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Table,
@ -167,163 +168,168 @@ export function SchemaContracts(props: {
return (
<>
<Card>
<CardHeader>
<CardTitle>Schema Contracts</CardTitle>
<CardDescription>
Schema Contracts allow you to have separate public graphs that are a subset of the main
graph.
</CardDescription>
<CardDescription>
<DocsLink href="/management/contracts" className="text-gray-500 hover:text-gray-300">
Learn more about Schema Contracts
</DocsLink>
</CardDescription>
</CardHeader>
<CardContent>
<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>
)}
</CardContent>
</Card>
{disabledContractId && (
<DisableContractDialog
contractId={disabledContractId}
onClose={() => {
setDisabledContractId(null);
}}
<SubPageLayout>
<SubPageLayoutHeader
title="Schema Contracts"
description={
<>
<CardDescription>
Schema Contracts allow you to have separate public graphs that are a subset of the
main graph.
</CardDescription>
<CardDescription>
<DocsLink
href="/management/contracts"
className="text-gray-500 hover:text-gray-300"
>
Learn more about Schema Contracts
</DocsLink>
</CardDescription>
</>
}
/>
)}
<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">
To authenticate,{' '}
<Link
search={{
page: 'cdn',
}}
variant="primary"
className="font-bold underline"
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 { Button } from '@/components/ui/button';
import { Meta } from '@/components/ui/meta';
import { NavLayout, PageLayout, PageLayoutContent } from '@/components/ui/page-content-layout';
import { QueryError } from '@/components/ui/query-error';
import { FragmentType, graphql, useFragment } from '@/gql';
import { OrganizationAccessScope, useOrganizationAccess } from '@/lib/access/organization';
@ -71,49 +72,46 @@ function PageContent(props: {
}
return (
<div className="flex flex-col gap-y-4">
<div className="flex flex-row gap-x-6 py-6">
<nav className="flex w-48 flex-col space-x-0 space-y-1">
{subPages.map(subPage => {
// hide migration page from non-admins
if (subPage.key === 'migration' && !organization.me.isAdmin) {
return null;
}
return (
<Button
key={subPage.key}
variant="ghost"
className={cn(
props.page === subPage.key
? 'bg-muted hover:bg-muted'
: 'hover:bg-transparent hover:underline',
'justify-start',
)}
onClick={() => props.onPageChange(subPage.key)}
>
{subPage.title}
</Button>
);
})}
</nav>
<div className="grow">
{props.page === 'roles' ? <OrganizationMemberRoles organization={organization} /> : null}
{props.page === 'list' ? (
<OrganizationMembers refetchMembers={props.refetchQuery} organization={organization} />
) : null}
{props.page === 'invitations' ? (
<OrganizationInvitations
refetchInvitations={props.refetchQuery}
organization={organization}
/>
) : null}
{props.page === 'migration' && organization.me.isAdmin ? (
<OrganizationMemberRolesMigration organization={organization} />
) : null}
</div>
</div>
</div>
<PageLayout>
<NavLayout>
{subPages.map(subPage => {
// hide migration page from non-admins
if (subPage.key === 'migration' && !organization.me.isAdmin) {
return null;
}
return (
<Button
key={subPage.key}
variant="ghost"
className={cn(
props.page === subPage.key
? 'bg-muted hover:bg-muted'
: 'hover:bg-transparent hover:underline',
'justify-start',
)}
onClick={() => props.onPageChange(subPage.key)}
>
{subPage.title}
</Button>
);
})}
</NavLayout>
<PageLayoutContent>
{props.page === 'roles' ? <OrganizationMemberRoles organization={organization} /> : null}
{props.page === 'list' ? (
<OrganizationMembers refetchMembers={props.refetchQuery} organization={organization} />
) : null}
{props.page === 'invitations' ? (
<OrganizationInvitations
refetchInvitations={props.refetchQuery}
organization={organization}
/>
) : null}
{props.page === 'migration' && organization.me.isAdmin ? (
<OrganizationMemberRolesMigration organization={organization} />
) : null}
</PageLayoutContent>
</PageLayout>
);
}

View file

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