mirror of
https://github.com/graphql-hive/console
synced 2026-05-23 09:08:34 +00:00
(4) Drop v2: Create Operation Modal (#5315)
This commit is contained in:
parent
b93687441e
commit
5b06215320
2 changed files with 297 additions and 126 deletions
|
|
@ -1,14 +1,31 @@
|
|||
import { ReactElement } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import { useForm, UseFormReturn } from 'react-hook-form';
|
||||
import { useMutation } from 'urql';
|
||||
import * as Yup from 'yup';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Heading } from '@/components/ui/heading';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
|
||||
import { Input, Modal } from '@/components/v2';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { graphql } from '@/gql';
|
||||
import { DocumentCollection } from '@/gql/graphql';
|
||||
import { useCollections } from '@/pages/target-laboratory';
|
||||
import { useEditorContext } from '@graphiql/react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
||||
const CreateOperationMutation = graphql(`
|
||||
mutation CreateOperation(
|
||||
|
|
@ -50,6 +67,24 @@ const CreateOperationMutation = graphql(`
|
|||
|
||||
export type CreateOperationMutationType = typeof CreateOperationMutation;
|
||||
|
||||
const createOperationModalFormSchema = z.object({
|
||||
name: z
|
||||
.string({
|
||||
required_error: 'Operation name is required',
|
||||
})
|
||||
.min(3, {
|
||||
message: 'Operation name must be at least 3 characters long',
|
||||
})
|
||||
.max(50, {
|
||||
message: 'Operation name must be less than 50 characters long',
|
||||
}),
|
||||
collectionId: z.string({
|
||||
required_error: 'Collection is required',
|
||||
}),
|
||||
});
|
||||
|
||||
export type CreateOperationModalFormValues = z.infer<typeof createOperationModalFormSchema>;
|
||||
|
||||
export function CreateOperationModal(props: {
|
||||
isOpen: boolean;
|
||||
close: () => void;
|
||||
|
|
@ -58,6 +93,7 @@ export function CreateOperationModal(props: {
|
|||
projectId: string;
|
||||
targetId: string;
|
||||
}): ReactElement {
|
||||
const { toast } = useToast();
|
||||
const { isOpen, close, onSaveSuccess } = props;
|
||||
const [mutationCreate, mutateCreate] = useMutation(CreateOperationMutation);
|
||||
|
||||
|
|
@ -66,137 +102,175 @@ export function CreateOperationModal(props: {
|
|||
projectId: props.projectId,
|
||||
targetId: props.targetId,
|
||||
});
|
||||
|
||||
const { queryEditor, variableEditor, headerEditor } = useEditorContext({
|
||||
nonNull: true,
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
values,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
errors,
|
||||
isValid,
|
||||
touched,
|
||||
isSubmitting,
|
||||
resetForm,
|
||||
setFieldValue,
|
||||
} = useFormik({
|
||||
initialValues: {
|
||||
const form = useForm<CreateOperationModalFormValues>({
|
||||
mode: 'onChange',
|
||||
resolver: zodResolver(createOperationModalFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
collectionId: '',
|
||||
},
|
||||
validationSchema: Yup.object().shape({
|
||||
name: Yup.string().min(3).required(),
|
||||
collectionId: Yup.string().required('Collection is a required field'),
|
||||
}),
|
||||
async onSubmit(values) {
|
||||
const response = await mutateCreate({
|
||||
selector: {
|
||||
target: props.targetId,
|
||||
organization: props.organizationId,
|
||||
project: props.projectId,
|
||||
},
|
||||
input: {
|
||||
name: values.name,
|
||||
collectionId: values.collectionId,
|
||||
query: queryEditor?.getValue() ?? '',
|
||||
variables: variableEditor?.getValue(),
|
||||
headers: headerEditor?.getValue(),
|
||||
},
|
||||
});
|
||||
const result = response.data;
|
||||
const error = response.error || response.data?.createOperationInDocumentCollection.error;
|
||||
|
||||
if (!error) {
|
||||
onSaveSuccess?.(result?.createOperationInDocumentCollection.ok?.operation.id);
|
||||
resetForm();
|
||||
close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: CreateOperationModalFormValues) {
|
||||
if (mutationCreate.error) {
|
||||
form.setError('name', {
|
||||
message: mutationCreate.error.message,
|
||||
});
|
||||
}
|
||||
const response = await mutateCreate({
|
||||
selector: {
|
||||
target: props.targetId,
|
||||
organization: props.organizationId,
|
||||
project: props.projectId,
|
||||
},
|
||||
input: {
|
||||
name: values.name,
|
||||
collectionId: values.collectionId,
|
||||
query: queryEditor?.getValue() ?? '',
|
||||
variables: variableEditor?.getValue(),
|
||||
headers: headerEditor?.getValue(),
|
||||
},
|
||||
});
|
||||
const result = response.data;
|
||||
const error = response.error || response.data?.createOperationInDocumentCollection.error;
|
||||
|
||||
if (!error) {
|
||||
onSaveSuccess?.(result?.createOperationInDocumentCollection.ok?.operation.id);
|
||||
form.reset();
|
||||
close();
|
||||
} else {
|
||||
toast({
|
||||
title: 'Could not create operation',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onOpenChange={close}>
|
||||
{!fetching && (
|
||||
<form className="flex flex-col gap-8" onSubmit={handleSubmit}>
|
||||
<Heading className="text-center">Create Operation</Heading>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className="text-sm font-semibold" htmlFor="name">
|
||||
Operation Name
|
||||
</label>
|
||||
<Input
|
||||
name="name"
|
||||
placeholder="Your Operation Name"
|
||||
value={values.name}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
isInvalid={!!(touched.name && errors.name)}
|
||||
data-cy="input.name"
|
||||
/>
|
||||
{touched.name && errors.name && (
|
||||
<div className="text-sm text-red-500">{errors.name}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className="text-sm font-semibold" htmlFor="name">
|
||||
Which collection would you like to save this operation to?
|
||||
</label>
|
||||
<Select
|
||||
value={values.collectionId}
|
||||
onValueChange={async v => {
|
||||
await setFieldValue('collectionId', v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{collections.find(c => c.id === values.collectionId)?.name || 'Select collection'}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-[--radix-select-trigger-width]">
|
||||
{collections.map(c => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
<div className="mt-1 line-clamp-1 text-xs opacity-50">{c.description}</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{touched.collectionId && errors.collectionId && (
|
||||
<div className="text-sm text-red-500">{errors.collectionId}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mutationCreate.error && (
|
||||
<div className="text-sm text-red-500">{mutationCreate.error.message}</div>
|
||||
)}
|
||||
|
||||
<div className="flex w-full gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="w-full justify-center"
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
close();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="w-full justify-center"
|
||||
variant="primary"
|
||||
disabled={isSubmitting || !isValid || values.collectionId === ''}
|
||||
data-cy="confirm"
|
||||
>
|
||||
Add Operation
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
<CreateOperationModalContent
|
||||
close={close}
|
||||
onSubmit={onSubmit}
|
||||
isOpen={isOpen}
|
||||
organizationId={props.organizationId}
|
||||
projectId={props.projectId}
|
||||
targetId={props.targetId}
|
||||
fetching={fetching}
|
||||
form={form}
|
||||
collections={collections}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DocumentCollectionWithOutOperations = Omit<
|
||||
DocumentCollection,
|
||||
'createdBy' | 'createdAt' | 'updatedAt' | 'operations' | 'pageInfo'
|
||||
>;
|
||||
|
||||
export function CreateOperationModalContent(props: {
|
||||
isOpen: boolean;
|
||||
close: () => void;
|
||||
onSubmit: (values: CreateOperationModalFormValues) => void;
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
form: UseFormReturn<CreateOperationModalFormValues>;
|
||||
targetId: string;
|
||||
fetching: boolean;
|
||||
collections: DocumentCollectionWithOutOperations[];
|
||||
}): ReactElement {
|
||||
return (
|
||||
<Dialog open={props.isOpen} onOpenChange={props.close}>
|
||||
<DialogContent className="container w-4/5 max-w-[600px] md:w-3/5">
|
||||
{!props.fetching && (
|
||||
<Form {...props.form}>
|
||||
<form className="space-y-8" onSubmit={props.form.handleSubmit(props.onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Operation</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-8">
|
||||
<FormField
|
||||
control={props.form.control}
|
||||
name="name"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Operation Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Your Operation Name" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={props.form.control}
|
||||
name="collectionId"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Collection Description</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={async v => {
|
||||
await field.onChange(v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{props.collections.find(c => c.id === field.value)?.name ??
|
||||
'Select a Collection'}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-[--radix-select-trigger-width]">
|
||||
{props.collections.map(c => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
<div className="mt-1 line-clamp-1 text-xs opacity-50">
|
||||
{c.description}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="w-full justify-center"
|
||||
onClick={ev => {
|
||||
ev.preventDefault();
|
||||
props.close();
|
||||
props.form.reset();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="w-full justify-center"
|
||||
variant="primary"
|
||||
disabled={props.form.formState.isSubmitting || !props.form.formState.isValid}
|
||||
data-cy="confirm"
|
||||
>
|
||||
Add Operation
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
CreateOperationModalContent,
|
||||
CreateOperationModalFormValues,
|
||||
} from '@/components/target/laboratory/create-operation-modal';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Meta, StoryFn, StoryObj } from '@storybook/react';
|
||||
|
||||
const createOperationModalFormSchema = z.object({
|
||||
name: z
|
||||
.string({
|
||||
required_error: 'Operation name is required',
|
||||
})
|
||||
.min(2, {
|
||||
message: 'Operation name must be at least 2 characters long',
|
||||
})
|
||||
.max(50, {
|
||||
message: 'Operation name must be less than 50 characters long',
|
||||
}),
|
||||
collectionId: z.string({
|
||||
required_error: 'Collection is required',
|
||||
}),
|
||||
});
|
||||
|
||||
const meta: Meta<typeof CreateOperationModalContent> = {
|
||||
title: 'Modals/Create Operation Modal',
|
||||
component: CreateOperationModalContent,
|
||||
argTypes: {
|
||||
isOpen: {
|
||||
control: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
fetching: {
|
||||
control: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
close: {
|
||||
action: 'close',
|
||||
},
|
||||
collections: {
|
||||
control: {
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
onSubmit: {
|
||||
action: 'onSubmit',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof CreateOperationModalContent>;
|
||||
|
||||
const Template: StoryFn<typeof CreateOperationModalContent> = args => {
|
||||
const form = useForm<CreateOperationModalFormValues>({
|
||||
mode: 'onChange',
|
||||
resolver: zodResolver(createOperationModalFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
collectionId: '',
|
||||
},
|
||||
});
|
||||
|
||||
return <CreateOperationModalContent {...args} form={form} />;
|
||||
};
|
||||
|
||||
export const Default: Story = Template.bind({});
|
||||
Default.args = {
|
||||
isOpen: true,
|
||||
close: () => {},
|
||||
collections: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Collection 1',
|
||||
__typename: 'DocumentCollection',
|
||||
description: 'Collection 1 description',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Collection 2',
|
||||
__typename: 'DocumentCollection',
|
||||
description: 'Collection 2 description',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Collection 3',
|
||||
__typename: 'DocumentCollection',
|
||||
description: 'Collection 3 description',
|
||||
},
|
||||
],
|
||||
fetching: false,
|
||||
onSubmit: () => {},
|
||||
};
|
||||
Loading…
Reference in a new issue