Handle subgraph schemas shareable (#4804)

Co-authored-by: Kamil Kisiela <kamil.kisiela@gmail.com>
This commit is contained in:
Tuval Simha 2024-05-22 15:18:34 +03:00 committed by GitHub
parent 816107370b
commit d486638b9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 134 additions and 221 deletions

View file

@ -2,11 +2,10 @@
import React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { Search, XIcon } from 'lucide-react';
import { Search } from 'lucide-react';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import { type DialogProps } from '@radix-ui/react-dialog';
import { Button } from './button';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
@ -45,7 +44,7 @@ const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
CommandInputProps
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b pl-3" cmdk-input-wrapper="">
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
@ -55,18 +54,6 @@ const CommandInput = React.forwardRef<
)}
{...props}
/>
{props.closeFn && (
<Button
type="reset"
variant="link"
className="text-muted-foreground hover:text-foreground"
onClick={() => {
props.closeFn?.();
}}
>
<XIcon width={16} height={16} />
</Button>
)}
</div>
));

View file

@ -1,7 +1,6 @@
import { ChangeEventHandler, ReactElement, useCallback, useEffect, useRef, useState } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { ReactElement, useState } from 'react';
import { ChevronsUpDown, XIcon } from 'lucide-react';
import { useQuery } from 'urql';
import { useDebouncedCallback } from 'use-debounce';
import { Page, TargetLayout } from '@/components/layouts/target';
import { MarkAsValid } from '@/components/target/history/MarkAsValid';
import { Button } from '@/components/ui/button';
@ -18,18 +17,24 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { QueryError } from '@/components/ui/query-error';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Accordion } from '@/components/v2/accordion';
import { noSchema, noSchemaVersion } from '@/components/v2/empty-list';
import { EmptyList, noSchema, noSchemaVersion } from '@/components/v2/empty-list';
import { GraphQLBlock, GraphQLHighlight } from '@/components/v2/graphql-block';
import { DocumentType, FragmentType, graphql, useFragment } from '@/gql';
import { ProjectType, RegistryModel } from '@/gql/graphql';
import { TargetAccessScope, useTargetAccess } from '@/lib/access/target';
import { cn } from '@/lib/utils';
import { Link, useRouter } from '@tanstack/react-router';
type CompositeSchema = Extract<
DocumentType<typeof SchemaView_SchemaFragment>,
{
__typename?: 'CompositeSchema';
__typename: 'CompositeSchema';
}
>;
type SingleSchema = Extract<
DocumentType<typeof SchemaView_SchemaFragment>,
{
__typename: 'SingleSchema';
}
>;
@ -39,21 +44,11 @@ function isCompositeSchema(
return schema.__typename === 'CompositeSchema';
}
function SchemaBlock({ schema, scrollToMe }: { schema: CompositeSchema; scrollToMe: boolean }) {
const ref = useRef<HTMLDivElement>(null);
const scrolled = useRef(false);
useEffect(() => {
if (scrollToMe && ref.current && scrolled.current === false) {
ref.current.scrollIntoView({ behavior: 'smooth' });
scrolled.current = true;
}
}, [scrollToMe]);
function SchemaBlock({ schema }: { schema: CompositeSchema }) {
return (
<Accordion.Item value={schema.id} key={schema.id} className="border-2 border-gray-900/50">
<Accordion.Header>
<div ref={ref}>
<div>
<div className="text-base" id={schema.service ? `service-${schema.service}` : undefined}>
{schema.service ?? 'SDL'}
</div>
@ -69,83 +64,47 @@ function SchemaBlock({ schema, scrollToMe }: { schema: CompositeSchema; scrollTo
);
}
const Schemas_ProjectFragment = graphql(`
fragment Schemas_ProjectFragment on Project {
id
type
}
`);
function Schemas({
filterService,
openItems,
setOpenItems,
...props
}: {
project: FragmentType<typeof Schemas_ProjectFragment>;
schemas: FragmentType<typeof SchemaView_SchemaFragment>[];
filterService?: string;
openItems: string[];
setOpenItems: (items: string[]) => void;
}): ReactElement {
const project = useFragment(Schemas_ProjectFragment, props.project);
const schemas = useFragment(SchemaView_SchemaFragment, props.schemas);
if (project.type === ProjectType.Single) {
const [schema] = schemas;
function Schemas(props: { schemas?: readonly CompositeSchema[]; schema?: SingleSchema }) {
if (props.schema) {
return (
<GraphQLBlock
className="mb-6"
sdl={schema.source}
url={'url' in schema && typeof schema.url === 'string' ? schema.url : ''}
sdl={props.schema.source}
url={'url' in props.schema && typeof props.schema.url === 'string' ? props.schema.url : ''}
/>
);
}
const filteredSchemas = schemas.filter(isCompositeSchema).filter(schema => {
if (filterService && 'service' in schema && schema.service) {
return schema.service.toLowerCase() === filterService.toLowerCase();
}
return true;
});
if (!props.schemas) {
console.error('No schema or schemas props provided');
return null;
}
// Display format should be defined based on the length of `schemas`, and not `filteredSchemas`.
// Otherwise, the accordion will be displayed by default but the list (disabled accordion) when filtering.
const displayFormat = schemas.length > 1 ? 'dynamic' : 'static';
if (props.schemas.length > 1) {
return (
<Accordion className="space-y-4" type="single">
{props.schemas.map(schema => (
<SchemaBlock key={schema.id} schema={schema} />
))}
</Accordion>
);
}
const schema = props.schemas[0];
if (!schema) {
return (
<EmptyList
title="Service not found"
description="You can publish the missing service with Hive CLI"
/>
);
}
return (
<div className="flex flex-col gap-8">
{displayFormat === 'dynamic' ? (
<Accordion
type="multiple"
value={openItems}
onValueChange={(items: string[]) => setOpenItems(items)}
>
{filteredSchemas.map(schema => (
<SchemaBlock
key={schema.id}
schema={schema}
scrollToMe={filterService?.toLowerCase() === schema.service?.toLowerCase()}
/>
))}
</Accordion>
) : (
<Accordion
type="multiple"
value={openItems}
disabled
onValueChange={(items: string[]) => setOpenItems(items)}
>
{filteredSchemas.map(schema => (
<SchemaBlock
key={schema.id}
schema={schema}
scrollToMe={filterService?.toLowerCase() === schema.service?.toLowerCase()}
/>
))}
</Accordion>
)}
</div>
<Accordion type="single" disabled value={schema.id}>
<SchemaBlock key={schema.id} schema={schema} />
</Accordion>
);
}
@ -165,25 +124,21 @@ const SchemaView_ProjectFragment = graphql(`
cleanId
type
registryModel
...Schemas_ProjectFragment
}
`);
const SchemaView_SchemaFragment = graphql(`
fragment SchemaView_SchemaFragment on Schema {
__typename
... on SingleSchema {
id
author
source
commit
}
... on CompositeSchema {
id
author
source
service
url
commit
}
}
`);
@ -209,33 +164,23 @@ function SchemaView(props: {
organization: FragmentType<typeof SchemaView_OrganizationFragment>;
project: FragmentType<typeof SchemaView_ProjectFragment>;
target: FragmentType<typeof SchemaView_TargetFragment>;
highlightedService: string | null;
}): ReactElement | null {
const organization = useFragment(SchemaView_OrganizationFragment, props.organization);
const project = useFragment(SchemaView_ProjectFragment, props.project);
const target = useFragment(SchemaView_TargetFragment, props.target);
const [filterService, setFilterService] = useState(props.highlightedService ?? '');
const [term, setTerm] = useState(props.highlightedService ?? '');
const [openItems, setOpenItems] = useState<string[]>([]);
const debouncedFilter = useDebouncedCallback((value: string) => {
setFilterService(value);
}, 500);
const handleChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
event => {
const value = event.target.value;
debouncedFilter(value);
setFilterService(value);
setTerm(value);
setOpen(false);
setOpenItems(prevItems => [...new Set([...prevItems, value])]);
},
[debouncedFilter, setTerm],
);
const reset = useCallback(() => {
setOpenItems([]);
setFilterService('');
setTerm('');
}, [setFilterService]);
const router = useRouter();
const selectedServiceName =
'service' in router.latestLocation.search &&
typeof router.latestLocation.search.service === 'string'
? router.latestLocation.search.service
: null;
const [open, setOpen] = useState(false);
const reset = () => {
void router.navigate({
search: {},
});
};
const isDistributed =
project.type === ProjectType.Federation || project.type === ProjectType.Stitching;
@ -258,95 +203,86 @@ function SchemaView(props: {
return noSchema;
}
const canMarkAsValid = project.registryModel === RegistryModel.Legacy;
const showExtra = canManage;
const canMarkAsValid = project.registryModel === RegistryModel.Legacy && canManage;
const [open, setOpen] = useState<boolean>(false);
const schemas = useFragment(SchemaView_SchemaFragment, target.latestSchemaVersion?.schemas.nodes);
const compositeSchemas = schemas?.filter(isCompositeSchema) as CompositeSchema[];
const singleSchema = schemas?.filter(schema => !isCompositeSchema(schema))[0] as
| SingleSchema
| undefined;
const schemasToDisplay = selectedServiceName
? compositeSchemas.filter(schema => schema.service === selectedServiceName)
: compositeSchemas;
return (
<>
{showExtra ? (
<div className="mb-5 flex flex-row items-center justify-between">
<div className="flex flex-row items-center gap-4">
{isDistributed && (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-[400px] justify-between">
{filterService
? compositeSchemas?.find(schema => schema.service === filterService)?.service
: 'Filter schema'}
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] truncate p-0">
<Command>
<CommandInput
closeFn={reset}
className="w-[400px]"
placeholder="Search schema"
value={term}
onValueChange={setTerm}
/>
<CommandEmpty>No schema found.</CommandEmpty>
<CommandGroup>
<ScrollArea className="relative h-80 w-full">
<div className="p-4">
{compositeSchemas?.map(schema => (
<CommandItem
key={schema.service}
value={schema.service as string}
onSelect={currentValue => {
const selectedSchema = compositeSchemas.find(
s => s.service === currentValue,
);
if (selectedSchema) {
setOpenItems(prevItems => [
...new Set([...prevItems, selectedSchema.id]),
]);
handleChange({ target: { value: currentValue } } as any);
}
}}
className="cursor-pointer"
>
<Check
className={cn(
'mr-2 size-4',
term === schema.id ? 'opacity-100' : 'opacity-0',
)}
/>
{schema.service}
</CommandItem>
))}
</div>
</ScrollArea>
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
)}
{canMarkAsValid ? (
<>
<MarkAsValid
organizationId={organization.cleanId}
projectId={project.cleanId}
targetId={target.cleanId}
version={latestSchemaVersion}
/>{' '}
</>
) : null}
</div>
<div className="mb-5 flex flex-row items-center justify-between">
<div className="flex flex-row items-center gap-x-4">
{isDistributed && schemas && schemas.length > 1 && (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-[400px] justify-between"
aria-expanded={open}
>
{selectedServiceName ?? 'Select service'}
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
{selectedServiceName ? (
<Button variant="outline" onClick={reset}>
<XIcon width={16} height={16} />
</Button>
) : null}
<PopoverContent className="w-[400px] truncate p-0">
<Command>
<CommandInput
closeFn={reset}
className="w-[400px]"
placeholder="Search service..."
/>
<CommandEmpty>No results.</CommandEmpty>
<CommandGroup>
<ScrollArea className="relative h-80 w-full">
{compositeSchemas?.map(schema => (
<CommandItem
key={schema.service}
value={schema.service as string}
onSelect={serviceName => {
setOpen(false);
void router.navigate({
search: { service: serviceName },
});
}}
className="cursor-pointer truncate"
>
<div>
<div>{schema.service}</div>
<div className="text-muted-foreground text-xs">{schema.url}</div>
</div>
</CommandItem>
))}
</ScrollArea>
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
)}
{canMarkAsValid ? (
<>
<MarkAsValid
organizationId={organization.cleanId}
projectId={project.cleanId}
targetId={target.cleanId}
version={latestSchemaVersion}
/>{' '}
</>
) : null}
</div>
) : null}
<Schemas
project={project}
filterService={filterService}
schemas={target.latestSchemaVersion?.schemas.nodes ?? []}
openItems={openItems}
setOpenItems={setOpenItems}
key={filterService}
/>
</div>
{isDistributed ? <Schemas schemas={schemasToDisplay} /> : <Schemas schema={singleSchema} />}
</>
);
}
@ -368,7 +304,6 @@ const TargetSchemaPageQuery = graphql(`
`);
function TargetSchemaPage(props: { organizationId: string; projectId: string; targetId: string }) {
const router = useRouter();
const [query] = useQuery({
query: TargetSchemaPageQuery,
variables: {
@ -386,9 +321,6 @@ function TargetSchemaPage(props: { organizationId: string; projectId: string; ta
const currentProject = query.data?.project;
const target = query.data?.target;
// TODO(router) check if it works
const serviceNameFromHash = router.latestLocation.hash?.replace('service-', '') ?? null;
return (
<TargetLayout
targetId={props.targetId}
@ -431,12 +363,7 @@ function TargetSchemaPage(props: { organizationId: string; projectId: string; ta
</div>
<div>
{query.fetching ? null : currentOrganization && currentProject && target ? (
<SchemaView
organization={currentOrganization}
project={currentProject}
target={target}
highlightedService={serviceNameFromHash}
/>
<SchemaView organization={currentOrganization} project={currentProject} target={target} />
) : null}
</div>
</TargetLayout>

View file

@ -1,4 +1,3 @@
/* eslint-disable import/no-extraneous-dependencies */
import { useState } from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { Button } from '@/components/ui/button';