mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
385 lines
12 KiB
TypeScript
385 lines
12 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useForm } from 'react-hook-form';
|
|
import { CombinedError, useQuery } from 'urql';
|
|
import { z } from 'zod';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ProductUpdatesLink } from '@/components/ui/docs-note';
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from '@/components/ui/form';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import { FragmentType, graphql, useFragment } from '@/gql';
|
|
import { UpdateSchemaCompositionInput } from '@/gql/graphql';
|
|
import { useNotifications } from '@/lib/hooks';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { CheckIcon, Cross2Icon, ReloadIcon, UpdateIcon } from '@radix-ui/react-icons';
|
|
|
|
const ExternalCompositionStatus_TestQuery = graphql(`
|
|
query ExternalCompositionStatus_TestQuery($selector: TestExternalSchemaCompositionInput!) {
|
|
testExternalSchemaComposition(selector: $selector) {
|
|
ok {
|
|
id
|
|
isNativeFederationEnabled
|
|
externalSchemaComposition {
|
|
endpoint
|
|
}
|
|
}
|
|
error {
|
|
message
|
|
}
|
|
}
|
|
}
|
|
`);
|
|
|
|
const ExternalCompositionSettings_OrganizationFragment = graphql(`
|
|
fragment ExternalCompositionSettings_OrganizationFragment on Organization {
|
|
id
|
|
slug
|
|
}
|
|
`);
|
|
|
|
const ExternalCompositionSettings_ProjectFragment = graphql(`
|
|
fragment ExternalCompositionSettings_ProjectFragment on Project {
|
|
id
|
|
slug
|
|
isNativeFederationEnabled
|
|
externalSchemaComposition {
|
|
endpoint
|
|
}
|
|
}
|
|
`);
|
|
|
|
const ExternalCompositionSettings_UpdateResultFragment = graphql(`
|
|
fragment ExternalCompositionSettings_UpdateResultFragment on UpdateSchemaCompositionResult {
|
|
ok {
|
|
updatedProject {
|
|
id
|
|
externalSchemaComposition {
|
|
endpoint
|
|
}
|
|
}
|
|
}
|
|
error {
|
|
message
|
|
... on UpdateSchemaCompositionExternalError {
|
|
inputErrors {
|
|
endpoint
|
|
secret
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`);
|
|
|
|
enum TestState {
|
|
LOADING,
|
|
ERROR,
|
|
SUCCESS,
|
|
}
|
|
|
|
const ExternalCompositionStatus = ({
|
|
projectSlug,
|
|
organizationSlug,
|
|
}: {
|
|
projectSlug: string;
|
|
organizationSlug: string;
|
|
}) => {
|
|
const [{ data, error: gqlError, fetching }, executeTestQuery] = useQuery({
|
|
query: ExternalCompositionStatus_TestQuery,
|
|
variables: {
|
|
selector: {
|
|
projectSlug,
|
|
organizationSlug,
|
|
},
|
|
},
|
|
requestPolicy: 'network-only',
|
|
});
|
|
const error = gqlError?.message ?? data?.testExternalSchemaComposition?.error?.message;
|
|
const testState = fetching
|
|
? TestState.LOADING
|
|
: error
|
|
? TestState.ERROR
|
|
: data?.testExternalSchemaComposition?.ok?.externalSchemaComposition?.endpoint
|
|
? TestState.SUCCESS
|
|
: null;
|
|
|
|
const [hidden, setHidden] = useState<boolean>();
|
|
|
|
useEffect(() => {
|
|
// only hide the success icon after the duration
|
|
if (testState !== TestState.SUCCESS) return;
|
|
const timerId = setTimeout(() => {
|
|
if (testState === TestState.SUCCESS) {
|
|
setHidden(false);
|
|
}
|
|
}, 5000);
|
|
|
|
return () => {
|
|
clearTimeout(timerId);
|
|
};
|
|
}, [testState]);
|
|
|
|
return (
|
|
<TooltipProvider delayDuration={100}>
|
|
{testState === TestState.LOADING ? (
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<UpdateIcon
|
|
className="text-neutral-10 size-5 animate-spin cursor-default"
|
|
onClick={e => e.preventDefault()}
|
|
/>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">Connecting...</TooltipContent>
|
|
</Tooltip>
|
|
) : (
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<ReloadIcon
|
|
className="size-5"
|
|
onClick={e => {
|
|
e.preventDefault();
|
|
setHidden(true);
|
|
executeTestQuery();
|
|
}}
|
|
/>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="mr-1">
|
|
Execute test
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
{testState === TestState.ERROR ? (
|
|
<Tooltip defaultOpen>
|
|
<TooltipTrigger>
|
|
<Cross2Icon
|
|
className="size-5 cursor-default text-red-500"
|
|
onClick={e => e.preventDefault()}
|
|
/>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="max-w-sm">
|
|
{error}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : null}
|
|
{testState === TestState.SUCCESS && !hidden ? (
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<CheckIcon
|
|
className="size-5 cursor-default text-green-500"
|
|
onClick={e => e.preventDefault()}
|
|
/>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="max-w-sm">
|
|
Service is available
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : null}
|
|
</TooltipProvider>
|
|
);
|
|
};
|
|
|
|
const formSchema = z.object({
|
|
endpoint: z
|
|
.string({
|
|
required_error: 'Please provide an endpoint',
|
|
})
|
|
.url({
|
|
message: 'Invalid URL',
|
|
}),
|
|
secret: z
|
|
.string({
|
|
required_error: 'Please provide a secret',
|
|
})
|
|
.min(2, 'Too short')
|
|
.max(256, 'Max 256 characters long'),
|
|
});
|
|
|
|
type FormValues = z.infer<typeof formSchema>;
|
|
|
|
export const ExternalCompositionSettings = (props: {
|
|
project: FragmentType<typeof ExternalCompositionSettings_ProjectFragment>;
|
|
organization: FragmentType<typeof ExternalCompositionSettings_OrganizationFragment>;
|
|
activeCompositionMode: 'native' | 'external' | 'legacy';
|
|
onMutate: (
|
|
input: UpdateSchemaCompositionInput,
|
|
) => Promise<
|
|
FragmentType<typeof ExternalCompositionSettings_UpdateResultFragment> | CombinedError
|
|
>;
|
|
}) => {
|
|
const project = useFragment(ExternalCompositionSettings_ProjectFragment, props.project);
|
|
const organization = useFragment(
|
|
ExternalCompositionSettings_OrganizationFragment,
|
|
props.organization,
|
|
);
|
|
const notify = useNotifications();
|
|
const [error, setError] = useState<string>();
|
|
const [isMutating, setIsMutating] = useState(false);
|
|
|
|
const form = useForm<FormValues>({
|
|
resolver: zodResolver(formSchema),
|
|
mode: 'onChange',
|
|
defaultValues: {
|
|
endpoint: project.externalSchemaComposition?.endpoint ?? '',
|
|
secret: '',
|
|
},
|
|
disabled: isMutating,
|
|
});
|
|
|
|
function onSubmit(values: FormValues) {
|
|
setError(undefined);
|
|
setIsMutating(true);
|
|
void props
|
|
.onMutate({
|
|
project: {
|
|
bySelector: {
|
|
projectSlug: project.slug,
|
|
organizationSlug: organization.slug,
|
|
},
|
|
},
|
|
method: {
|
|
external: {
|
|
endpoint: values.endpoint,
|
|
secret: values.secret,
|
|
},
|
|
},
|
|
})
|
|
.then(result => {
|
|
setIsMutating(false);
|
|
if (result instanceof CombinedError) {
|
|
notify(result.message, 'error');
|
|
setError(result.message);
|
|
} else {
|
|
// actually not a hook
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
const updateResult = useFragment(
|
|
ExternalCompositionSettings_UpdateResultFragment,
|
|
result,
|
|
);
|
|
if (updateResult.ok) {
|
|
const endpoint = updateResult.ok.updatedProject.externalSchemaComposition?.endpoint;
|
|
|
|
notify('External composition enabled.', 'success');
|
|
|
|
if (endpoint) {
|
|
form.reset(
|
|
{
|
|
endpoint,
|
|
secret: '',
|
|
},
|
|
{
|
|
keepDirty: false,
|
|
keepDirtyValues: false,
|
|
},
|
|
);
|
|
}
|
|
} else if (updateResult.error) {
|
|
notify(updateResult.error.message, 'error');
|
|
setError(updateResult.error.message);
|
|
|
|
if (updateResult.error.__typename === 'UpdateSchemaCompositionExternalError') {
|
|
if (updateResult.error.inputErrors?.endpoint) {
|
|
form.setError('endpoint', {
|
|
type: 'manual',
|
|
message: updateResult.error.inputErrors.endpoint,
|
|
});
|
|
}
|
|
|
|
if (updateResult.error.inputErrors?.secret) {
|
|
form.setError('secret', {
|
|
type: 'manual',
|
|
message: updateResult.error.inputErrors.secret,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col items-start gap-y-6">
|
|
<div>
|
|
<p className="text-neutral-10 max-w-2xl text-sm">
|
|
For advanced users, you can configure an endpoint for external schema compositions. This
|
|
can be used to implement custom composition logic.
|
|
</p>
|
|
<ProductUpdatesLink href="https://the-guild.dev/graphql/hive/docs/features/external-schema-composition">
|
|
Read about external schema composition in our documentation.
|
|
</ProductUpdatesLink>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
<div className="flex flex-wrap gap-x-24 gap-y-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="endpoint"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>HTTP Endpoint</FormLabel>
|
|
<FormDescription>A POST request will be sent to that endpoint</FormDescription>
|
|
<div className="flex w-full items-center space-x-2">
|
|
<FormControl>
|
|
<Input
|
|
className="max-w-md shrink-0"
|
|
placeholder="Endpoint"
|
|
type="text"
|
|
autoComplete="off"
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
{!form.formState.isDirty && project.externalSchemaComposition?.endpoint ? (
|
|
<ExternalCompositionStatus
|
|
projectSlug={project.slug}
|
|
organizationSlug={organization.slug}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="secret"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Secret</FormLabel>
|
|
<FormDescription>
|
|
The secret is needed to sign and verify the request.
|
|
</FormDescription>
|
|
<FormControl>
|
|
<Input
|
|
className="w-full max-w-md"
|
|
placeholder="Secret"
|
|
type="password"
|
|
autoComplete="off"
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
{error && <div className="mt-2 text-xs text-red-500">{error}</div>}
|
|
<div className="flex flex-row items-center gap-x-8">
|
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
|
{props.activeCompositionMode === 'external'
|
|
? 'Save Configuration'
|
|
: 'Use External Composition'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Form>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|