mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
feat: create initial collection prompt + edit name (#7989)
This commit is contained in:
parent
64e7fed1d4
commit
863f920b86
13 changed files with 510 additions and 556 deletions
7
.changeset/neat-symbols-count.md
Normal file
7
.changeset/neat-symbols-count.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
'@graphql-hive/laboratory': patch
|
||||
'@graphql-hive/render-laboratory': patch
|
||||
---
|
||||
|
||||
Enhanced behavior when no collection exists and the user attempts to save an operation, along with
|
||||
the ability to edit the collection name.
|
||||
|
|
@ -1,12 +1,20 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
CheckIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
FolderPlusIcon,
|
||||
PencilIcon,
|
||||
SearchIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from '@/components/ui/input-group';
|
||||
import { TooltipTrigger } from '@radix-ui/react-tooltip';
|
||||
import type { LaboratoryCollection, LaboratoryCollectionOperation } from '../../lib/collections';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
|
@ -44,6 +52,7 @@ export const CollectionItem = (props: { collection: LaboratoryCollection }) => {
|
|||
addOperation,
|
||||
setActiveOperation,
|
||||
deleteCollection,
|
||||
updateCollection,
|
||||
deleteOperationFromCollection,
|
||||
addTab,
|
||||
setActiveTab,
|
||||
|
|
@ -51,67 +60,155 @@ export const CollectionItem = (props: { collection: LaboratoryCollection }) => {
|
|||
} = useLaboratory();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedName, setEditedName] = useState(props.collection.name);
|
||||
|
||||
const hasActiveOperation = useMemo(() => {
|
||||
return props.collection.operations.some(operation => operation.id === activeOperation?.id);
|
||||
}, [props.collection.operations, activeOperation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasActiveOperation) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [hasActiveOperation]);
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="bg-background group sticky top-0 w-full justify-start px-2"
|
||||
size="sm"
|
||||
>
|
||||
{isOpen ? (
|
||||
<FolderOpenIcon className="text-muted-foreground size-4" />
|
||||
) : (
|
||||
<FolderIcon className="text-muted-foreground size-4" />
|
||||
)}
|
||||
{props.collection.name}
|
||||
{checkPermissions?.('collections:delete') && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
{isEditing ? (
|
||||
<InputGroup className="!bg-accent/50 h-8 border-none">
|
||||
<InputGroupAddon className="pl-2.5">
|
||||
{isOpen ? (
|
||||
<FolderOpenIcon className="text-muted-foreground size-4" />
|
||||
) : (
|
||||
<FolderIcon className="text-muted-foreground size-4" />
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
autoFocus
|
||||
defaultValue={editedName}
|
||||
className="!pl-1.5 font-medium"
|
||||
onChange={e => setEditedName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
updateCollection(props.collection.id, {
|
||||
name: editedName,
|
||||
});
|
||||
setIsEditing(false);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setEditedName(props.collection.name);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
className="p-1!"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
|
||||
updateCollection(props.collection.id, {
|
||||
name: editedName,
|
||||
});
|
||||
|
||||
setIsEditing(false);
|
||||
}}
|
||||
>
|
||||
<CheckIcon />
|
||||
</InputGroupButton>
|
||||
<InputGroupButton
|
||||
className="p-1!"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
|
||||
setIsEditing(false);
|
||||
setEditedName(props.collection.name);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="bg-background !hover:bg-accent/50 group sticky top-0 w-full justify-start px-2"
|
||||
size="sm"
|
||||
>
|
||||
{isOpen ? (
|
||||
<FolderOpenIcon className="text-muted-foreground size-4" />
|
||||
) : (
|
||||
<FolderIcon className="text-muted-foreground size-4" />
|
||||
)}
|
||||
{props.collection.name}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{checkPermissions?.('collections:update') && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-muted-foreground hover:text-destructive p-1! pr-0! ml-auto opacity-0 transition-opacity group-hover:opacity-100"
|
||||
className="text-muted-foreground p-1! pr-0! opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
<PencilIcon />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure you want to delete collection?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{props.collection.name} will be permanently deleted. All operations in this
|
||||
collection will be deleted as well.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit collection</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{checkPermissions?.('collections:delete') && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
variant="link"
|
||||
className="text-muted-foreground hover:text-destructive p-1! pr-0! opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
deleteCollection(props.collection.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete collection</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure you want to delete collection?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{props.collection.name} will be permanently deleted. All operations in
|
||||
this collection will be deleted as well.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
deleteCollection(props.collection.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete collection</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className={cn('border-border ml-4 flex flex-col gap-1 border-l pl-2')}>
|
||||
{isOpen &&
|
||||
|
|
|
|||
|
|
@ -648,157 +648,6 @@ export const Laboratory = (
|
|||
ref={setContainer}
|
||||
>
|
||||
<Toaster richColors closeButton position="top-right" theme={props.theme} />
|
||||
<Dialog open={isUpdateEndpointDialogOpen} onOpenChange={setIsUpdateEndpointDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update endpoint</DialogTitle>
|
||||
<DialogDescription>Update the endpoint of your laboratory.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<form
|
||||
id="update-endpoint-form"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
void updateEndpointForm.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<updateEndpointForm.Field name="endpoint">
|
||||
{field => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
aria-invalid={isInvalid}
|
||||
placeholder="Enter endpoint"
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</updateEndpointForm.Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" form="update-endpoint-form">
|
||||
Update endpoint
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<PreflightPromptModal
|
||||
open={isPreflightPromptModalOpen}
|
||||
onOpenChange={setIsPreflightPromptModalOpen}
|
||||
{...preflightPromptModalProps}
|
||||
/>
|
||||
<Dialog open={isAddCollectionDialogOpen} onOpenChange={setIsAddCollectionDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add collection</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new collection of operations to your laboratory.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<form
|
||||
id="add-collection-form"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
void addCollectionForm.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<addCollectionForm.Field name="name">
|
||||
{field => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
aria-invalid={isInvalid}
|
||||
placeholder="Enter name of the collection"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
</addCollectionForm.Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" form="add-collection-form">
|
||||
Add collection
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={isAddTestDialogOpen} onOpenChange={setIsAddTestDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add test</DialogTitle>
|
||||
<DialogDescription>Add a new test to your laboratory.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<form
|
||||
id="add-test-form"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
void addTestForm.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<addTestForm.Field name="name">
|
||||
{field => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
aria-invalid={isInvalid}
|
||||
placeholder="Enter name of the test"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
</addTestForm.Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" form="add-test-form">
|
||||
Add test
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<LaboratoryProvider
|
||||
{...props}
|
||||
|
|
@ -822,6 +671,157 @@ export const Laboratory = (
|
|||
isFullScreen={isFullScreen}
|
||||
checkPermissions={checkPermissions}
|
||||
>
|
||||
<Dialog open={isUpdateEndpointDialogOpen} onOpenChange={setIsUpdateEndpointDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update endpoint</DialogTitle>
|
||||
<DialogDescription>Update the endpoint of your laboratory.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<form
|
||||
id="update-endpoint-form"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
void updateEndpointForm.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<updateEndpointForm.Field name="endpoint">
|
||||
{field => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
aria-invalid={isInvalid}
|
||||
placeholder="Enter endpoint"
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</updateEndpointForm.Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" form="update-endpoint-form">
|
||||
Update endpoint
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<PreflightPromptModal
|
||||
open={isPreflightPromptModalOpen}
|
||||
onOpenChange={setIsPreflightPromptModalOpen}
|
||||
{...preflightPromptModalProps}
|
||||
/>
|
||||
<Dialog open={isAddCollectionDialogOpen} onOpenChange={setIsAddCollectionDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add collection</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new collection of operations to your laboratory.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<form
|
||||
id="add-collection-form"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
void addCollectionForm.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<addCollectionForm.Field name="name">
|
||||
{field => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
aria-invalid={isInvalid}
|
||||
placeholder="Enter name of the collection"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
</addCollectionForm.Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" form="add-collection-form">
|
||||
Add collection
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={isAddTestDialogOpen} onOpenChange={setIsAddTestDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add test</DialogTitle>
|
||||
<DialogDescription>Add a new test to your laboratory.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<form
|
||||
id="add-test-form"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
void addTestForm.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<addTestForm.Field name="name">
|
||||
{field => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
aria-invalid={isInvalid}
|
||||
placeholder="Enter name of the test"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
</addTestForm.Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" form="add-test-form">
|
||||
Add test
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<LaboratoryContent />
|
||||
</LaboratoryProvider>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { compressToEncodedURIComponent } from 'lz-string';
|
|||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { QueryPlanSchema } from '@/lib/query-plan/schema';
|
||||
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu';
|
||||
|
|
@ -45,7 +46,7 @@ import {
|
|||
} from '../ui/dialog';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from '../ui/dropdown-menu';
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../ui/empty';
|
||||
import { Field, FieldGroup, FieldLabel } from '../ui/field';
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field';
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../ui/resizable';
|
||||
import { ScrollArea, ScrollBar } from '../ui/scroll-area';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
|
|
@ -461,6 +462,7 @@ export const Query = (props: {
|
|||
updateActiveOperation,
|
||||
collections,
|
||||
addOperationToCollection,
|
||||
addCollection,
|
||||
addHistory,
|
||||
stopActiveOperation,
|
||||
addResponseToHistory,
|
||||
|
|
@ -616,15 +618,34 @@ export const Query = (props: {
|
|||
return;
|
||||
}
|
||||
|
||||
addOperationToCollection(value.collectionId, {
|
||||
id: operation.id ?? '',
|
||||
name: operation.name ?? '',
|
||||
query: operation.query ?? '',
|
||||
variables: operation.variables ?? '',
|
||||
headers: operation.headers ?? '',
|
||||
extensions: operation.extensions ?? '',
|
||||
description: '',
|
||||
});
|
||||
const collection = collections.find(c => c.id === value.collectionId);
|
||||
|
||||
if (!collection) {
|
||||
addCollection({
|
||||
name: value.collectionId,
|
||||
operations: [
|
||||
{
|
||||
id: operation.id ?? '',
|
||||
name: operation.name ?? '',
|
||||
query: operation.query ?? '',
|
||||
variables: operation.variables ?? '',
|
||||
headers: operation.headers ?? '',
|
||||
extensions: operation.extensions ?? '',
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
addOperationToCollection(value.collectionId, {
|
||||
id: operation.id ?? '',
|
||||
name: operation.name ?? '',
|
||||
query: operation.query ?? '',
|
||||
variables: operation.variables ?? '',
|
||||
headers: operation.headers ?? '',
|
||||
extensions: operation.extensions ?? '',
|
||||
description: '',
|
||||
});
|
||||
}
|
||||
|
||||
setIsSaveToCollectionDialogOpen(false);
|
||||
},
|
||||
|
|
@ -668,10 +689,8 @@ export const Query = (props: {
|
|||
<Dialog open={isSaveToCollectionDialogOpen} onOpenChange={setIsSaveToCollectionDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add collection</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new collection of operations to your laboratory.
|
||||
</DialogDescription>
|
||||
<DialogTitle>Save operation to collection</DialogTitle>
|
||||
<DialogDescription>Save the current operation to a collection.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<form
|
||||
|
|
@ -682,33 +701,58 @@ export const Query = (props: {
|
|||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<saveToCollectionForm.Field name="collectionId">
|
||||
{field => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
{collections.length > 0 ? (
|
||||
<saveToCollectionForm.Field name="collectionId">
|
||||
{field => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldLabel htmlFor={field.name}>Collection</FieldLabel>
|
||||
<Select
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onValueChange={field.handleChange}
|
||||
>
|
||||
<SelectTrigger id={field.name} aria-invalid={isInvalid}>
|
||||
<SelectValue placeholder="Select collection" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{collections.map(c => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
</saveToCollectionForm.Field>
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldLabel htmlFor={field.name}>Collection</FieldLabel>
|
||||
<Select
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onValueChange={field.handleChange}
|
||||
>
|
||||
<SelectTrigger id={field.name} aria-invalid={isInvalid}>
|
||||
<SelectValue placeholder="Select collection" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{collections.map(c => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
</saveToCollectionForm.Field>
|
||||
) : (
|
||||
<saveToCollectionForm.Field name="collectionId">
|
||||
{field => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldLabel htmlFor={field.name}>New collection name</FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
aria-invalid={isInvalid}
|
||||
placeholder="Enter name of the collection"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
</saveToCollectionForm.Field>
|
||||
)}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useLaboratory } from '../laboratory/context';
|
||||
import { buttonVariants } from './button';
|
||||
|
||||
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
|
|
@ -13,7 +14,11 @@ function AlertDialogTrigger({
|
|||
}
|
||||
|
||||
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
|
||||
const { container } = useLaboratory();
|
||||
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} container={container} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const buttonVariants = cva(
|
|||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'!text-white hover:bg-destructive/90 focus-visible:ring-destructive/40 bg-destructive/60',
|
||||
'!text-white !hover:bg-destructive/90 !focus-visible:ring-destructive/40 !bg-destructive/60',
|
||||
outline:
|
||||
'border shadow-sm hover:text-accent-foreground bg-input/30 border-input hover:bg-input/50',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
|
|
|
|||
|
|
@ -1,275 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { CheckIcon, XIcon } from 'lucide-react';
|
||||
import { Combobox as ComboboxPrimitive } from '@base-ui/react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useLaboratory } from '../laboratory/context';
|
||||
import { Button } from './button';
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from './input-group';
|
||||
|
||||
const Combobox = ComboboxPrimitive.Root;
|
||||
|
||||
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
|
||||
}
|
||||
|
||||
function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Trigger
|
||||
data-slot="combobox-trigger"
|
||||
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ComboboxPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Clear
|
||||
data-slot="combobox-clear"
|
||||
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.Clear>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxInput({
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
showTrigger = true,
|
||||
showClear = false,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props & {
|
||||
showTrigger?: boolean;
|
||||
showClear?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<InputGroup className={cn('w-auto', className)}>
|
||||
<ComboboxPrimitive.Input render={<InputGroupInput disabled={disabled} />} {...props} />
|
||||
<InputGroupAddon align="inline-end">
|
||||
{showTrigger && (
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
asChild
|
||||
data-slot="input-group-button"
|
||||
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
||||
disabled={disabled}
|
||||
>
|
||||
<ComboboxTrigger />
|
||||
</InputGroupButton>
|
||||
)}
|
||||
{showClear && <ComboboxClear disabled={disabled} />}
|
||||
</InputGroupAddon>
|
||||
{children}
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
className,
|
||||
side = 'bottom',
|
||||
sideOffset = 6,
|
||||
align = 'start',
|
||||
alignOffset = 0,
|
||||
anchor,
|
||||
...props
|
||||
}: ComboboxPrimitive.Popup.Props &
|
||||
Pick<
|
||||
ComboboxPrimitive.Positioner.Props,
|
||||
'side' | 'align' | 'sideOffset' | 'alignOffset' | 'anchor'
|
||||
>) {
|
||||
const { container } = useLaboratory();
|
||||
|
||||
return (
|
||||
<ComboboxPrimitive.Portal container={container}>
|
||||
<ComboboxPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
anchor={anchor}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
'group/combobox-content w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) bg-popover text-popover-foreground ring-foreground/10 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 relative max-h-96 min-w-[calc(var(--anchor-width)+--spacing(7))] overflow-hidden rounded-md shadow-md ring-1 duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.List
|
||||
data-slot="combobox-list"
|
||||
className={cn(
|
||||
'data-empty:p-0 max-h-[min(calc(--spacing(96)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto p-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Item
|
||||
data-slot="combobox-item"
|
||||
className={cn(
|
||||
"outline-hidden data-highlighted:bg-accent data-highlighted:text-accent-foreground relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ComboboxPrimitive.ItemIndicator
|
||||
data-slot="combobox-item-indicator"
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-coarse:size-5 pointer-events-none size-4" />
|
||||
</ComboboxPrimitive.ItemIndicator>
|
||||
</ComboboxPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Group data-slot="combobox-group" className={cn(className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.GroupLabel
|
||||
data-slot="combobox-label"
|
||||
className={cn(
|
||||
'text-muted-foreground pointer-coarse:px-3 pointer-coarse:py-2 pointer-coarse:text-sm px-2 py-1.5 text-xs',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
||||
return <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />;
|
||||
}
|
||||
|
||||
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Empty
|
||||
data-slot="combobox-empty"
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-empty/combobox-content:flex hidden w-full justify-center py-2 text-center text-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Separator
|
||||
data-slot="combobox-separator"
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChips({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> & ComboboxPrimitive.Chips.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chips
|
||||
data-slot="combobox-chips"
|
||||
className={cn(
|
||||
'border-input shadow-xs focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-[3px] has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1.5 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40 flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm transition-[color,box-shadow] focus-within:ring-[3px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChip({
|
||||
className,
|
||||
children,
|
||||
showRemove = true,
|
||||
...props
|
||||
}: ComboboxPrimitive.Chip.Props & {
|
||||
showRemove?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chip
|
||||
data-slot="combobox-chip"
|
||||
className={cn(
|
||||
'bg-muted text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0 flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 whitespace-nowrap rounded-sm px-1.5 text-xs font-medium',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showRemove && (
|
||||
<ComboboxPrimitive.ChipRemove
|
||||
render={<Button variant="ghost" size="icon-sm" />}
|
||||
className="-ml-1 opacity-50 hover:opacity-100"
|
||||
data-slot="combobox-chip-remove"
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ChipRemove>
|
||||
)}
|
||||
</ComboboxPrimitive.Chip>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChipsInput({ className, ...props }: ComboboxPrimitive.Input.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Input
|
||||
data-slot="combobox-chip-input"
|
||||
className={cn('min-w-16 flex-1 outline-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useComboboxAnchor() {
|
||||
return React.useRef<HTMLDivElement | null>(null);
|
||||
}
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxGroup,
|
||||
ComboboxLabel,
|
||||
ComboboxCollection,
|
||||
ComboboxEmpty,
|
||||
ComboboxSeparator,
|
||||
ComboboxChips,
|
||||
ComboboxChip,
|
||||
ComboboxChipsInput,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
};
|
||||
|
|
@ -45,10 +45,8 @@ function DialogContent({
|
|||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
const { container } = useLaboratory();
|
||||
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal" container={container}>
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ function SelectContent({
|
|||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-[var(--radix-select-content-available-height)] min-w-[8rem] origin-[var(--radix-select-content-transform-origin)] overflow-y-auto overflow-x-hidden rounded-md border shadow-md',
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-200 relative max-h-[var(--radix-select-content-available-height)] min-w-[8rem] origin-[var(--radix-select-content-transform-origin)] overflow-y-auto overflow-x-hidden rounded-md border shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import * as TogglePrimitive from '@radix-ui/react-toggle';
|
|||
import { cn } from '../../lib/utils';
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
"cursor-pointer inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
|
|
|||
|
|
@ -20,8 +20,10 @@ export interface LaboratoryCollection {
|
|||
|
||||
export interface LaboratoryCollectionsActions {
|
||||
addCollection: (
|
||||
collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'>,
|
||||
) => void;
|
||||
collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'> & {
|
||||
operations?: Omit<LaboratoryCollectionOperation, 'createdAt'>[];
|
||||
},
|
||||
) => LaboratoryCollection;
|
||||
addOperationToCollection: (
|
||||
collectionId: string,
|
||||
operation: Omit<LaboratoryCollectionOperation, 'createdAt'>,
|
||||
|
|
@ -30,7 +32,7 @@ export interface LaboratoryCollectionsActions {
|
|||
deleteOperationFromCollection: (collectionId: string, operationId: string) => void;
|
||||
updateCollection: (
|
||||
collectionId: string,
|
||||
collection: Omit<LaboratoryCollection, 'id' | 'createdAt'>,
|
||||
collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'>,
|
||||
) => void;
|
||||
updateOperationInCollection: (
|
||||
collectionId: string,
|
||||
|
|
@ -73,17 +75,27 @@ export const useCollections = (
|
|||
);
|
||||
|
||||
const addCollection = useCallback(
|
||||
(collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'>) => {
|
||||
(
|
||||
collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'> & {
|
||||
operations?: Omit<LaboratoryCollectionOperation, 'createdAt'>[];
|
||||
},
|
||||
) => {
|
||||
const newCollection: LaboratoryCollection = {
|
||||
...collection,
|
||||
id: uuidv4(),
|
||||
createdAt: new Date().toISOString(),
|
||||
operations: [],
|
||||
operations:
|
||||
collection.operations?.map(operation => ({
|
||||
...operation,
|
||||
createdAt: new Date().toISOString(),
|
||||
})) ?? [],
|
||||
};
|
||||
const newCollections = [...collections, newCollection];
|
||||
setCollections(newCollections);
|
||||
props.onCollectionsChange?.(newCollections);
|
||||
props.onCollectionCreate?.(newCollection);
|
||||
|
||||
return newCollection;
|
||||
},
|
||||
[collections, props],
|
||||
);
|
||||
|
|
@ -94,6 +106,7 @@ export const useCollections = (
|
|||
...operation,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const newCollections = collections.map(collection =>
|
||||
collection.id === collectionId
|
||||
? {
|
||||
|
|
@ -105,7 +118,9 @@ export const useCollections = (
|
|||
|
||||
setCollections(newCollections);
|
||||
props.onCollectionsChange?.(newCollections);
|
||||
|
||||
const updatedCollection = newCollections.find(collection => collection.id === collectionId);
|
||||
|
||||
if (updatedCollection) {
|
||||
props.onCollectionUpdate?.(updatedCollection);
|
||||
props.onCollectionOperationCreate?.(updatedCollection, newOperation);
|
||||
|
|
@ -158,7 +173,10 @@ export const useCollections = (
|
|||
);
|
||||
|
||||
const updateCollection = useCallback(
|
||||
(collectionId: string, collection: Omit<LaboratoryCollection, 'id' | 'createdAt'>) => {
|
||||
(
|
||||
collectionId: string,
|
||||
collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'>,
|
||||
) => {
|
||||
const newCollections = collections.map(c =>
|
||||
c.id === collectionId ? { ...c, ...collection } : c,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
DocumentNode,
|
||||
ExecutionResult,
|
||||
getOperationAST,
|
||||
GraphQLError,
|
||||
Kind,
|
||||
parse,
|
||||
type GraphQLSchema,
|
||||
|
|
@ -459,43 +460,58 @@ export const useOperations = (
|
|||
},
|
||||
}));
|
||||
|
||||
const response = await executor({
|
||||
document,
|
||||
variables,
|
||||
extensions: {
|
||||
...extensions,
|
||||
headers: mergedHeaders,
|
||||
},
|
||||
signal: abortController.signal,
|
||||
});
|
||||
try {
|
||||
const response = await executor({
|
||||
document,
|
||||
operationName: options?.operationName,
|
||||
variables,
|
||||
extensions: {
|
||||
...extensions,
|
||||
headers: mergedHeaders,
|
||||
},
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (isAsyncIterable(response)) {
|
||||
try {
|
||||
for await (const item of response) {
|
||||
options?.onResponse?.(JSON.stringify(item ?? {}));
|
||||
if (isAsyncIterable(response)) {
|
||||
try {
|
||||
for await (const item of response) {
|
||||
options?.onResponse?.(JSON.stringify(item ?? {}));
|
||||
}
|
||||
} finally {
|
||||
setStopOperationsFunctions(prev => {
|
||||
const newStopOperationsFunctions = { ...prev };
|
||||
delete newStopOperationsFunctions[activeOperation.id];
|
||||
return newStopOperationsFunctions;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setStopOperationsFunctions(prev => {
|
||||
const newStopOperationsFunctions = { ...prev };
|
||||
delete newStopOperationsFunctions[activeOperation.id];
|
||||
return newStopOperationsFunctions;
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
if (response.extensions?.response?.body) {
|
||||
delete response.extensions.response.body;
|
||||
}
|
||||
|
||||
setStopOperationsFunctions(prev => {
|
||||
const newStopOperationsFunctions = { ...prev };
|
||||
delete newStopOperationsFunctions[activeOperation.id];
|
||||
return newStopOperationsFunctions;
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
setStopOperationsFunctions(prev => {
|
||||
const newStopOperationsFunctions = { ...prev };
|
||||
delete newStopOperationsFunctions[activeOperation.id];
|
||||
return newStopOperationsFunctions;
|
||||
});
|
||||
|
||||
if (error instanceof Error) {
|
||||
return new GraphQLError(error.message);
|
||||
}
|
||||
|
||||
return new GraphQLError('An unknown error occurred');
|
||||
}
|
||||
|
||||
if (response.extensions?.response?.body) {
|
||||
delete response.extensions.response.body;
|
||||
}
|
||||
|
||||
setStopOperationsFunctions(prev => {
|
||||
const newStopOperationsFunctions = { ...prev };
|
||||
delete newStopOperationsFunctions[activeOperation.id];
|
||||
return newStopOperationsFunctions;
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
[activeOperation, props.preflightApi, props.envApi, props.pluginsApi, props.settingsApi],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -168,6 +168,26 @@ export const CreateCollectionMutation = graphql(`
|
|||
}
|
||||
`);
|
||||
|
||||
const UpdateCollectionMutation = graphql(`
|
||||
mutation LaboratoryUpdateCollection(
|
||||
$selector: TargetSelectorInput!
|
||||
$input: UpdateDocumentCollectionInput!
|
||||
) {
|
||||
updateDocumentCollection(selector: $selector, input: $input) {
|
||||
error {
|
||||
message
|
||||
}
|
||||
ok {
|
||||
collection {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const UpdateOperationMutation = graphql(`
|
||||
mutation LaboratoryUpdateOperation(
|
||||
$selector: TargetSelectorInput!
|
||||
|
|
@ -464,6 +484,26 @@ function useLaboratoryState(props: {
|
|||
[mutateAddCollection, props.targetSlug, props.organizationSlug, props.projectSlug],
|
||||
);
|
||||
|
||||
const [, mutateUpdateCollection] = useMutation(UpdateCollectionMutation);
|
||||
const updateCollection = useMemo(
|
||||
() =>
|
||||
throttle((collection: LaboratoryCollection) => {
|
||||
void mutateUpdateCollection({
|
||||
selector: {
|
||||
targetSlug: props.targetSlug,
|
||||
organizationSlug: props.organizationSlug,
|
||||
projectSlug: props.projectSlug,
|
||||
},
|
||||
input: {
|
||||
collectionId: collection.id,
|
||||
name: collection.name,
|
||||
description: collection.description,
|
||||
},
|
||||
});
|
||||
}, 1000),
|
||||
[mutateUpdateCollection, props.targetSlug, props.organizationSlug, props.projectSlug],
|
||||
);
|
||||
|
||||
const [, mutateUpdatePreflight] = useMutation(UpdatePreflightScriptMutation);
|
||||
|
||||
const updatePreflight = useMemo(
|
||||
|
|
@ -647,6 +687,9 @@ function useLaboratoryState(props: {
|
|||
onCollectionCreate: (collection: LaboratoryCollection) => {
|
||||
addCollection(collection);
|
||||
},
|
||||
onCollectionUpdate: (collection: LaboratoryCollection) => {
|
||||
updateCollection(collection);
|
||||
},
|
||||
onSettingsChange: (settings: LaboratorySettings | null) => {
|
||||
setLocalStorageState('settings', settings);
|
||||
},
|
||||
|
|
@ -660,6 +703,7 @@ function useLaboratoryState(props: {
|
|||
},
|
||||
collections: {
|
||||
create: data?.target?.viewerCanModifyLaboratory === true,
|
||||
update: data?.target?.viewerCanModifyLaboratory === true,
|
||||
delete: data?.target?.viewerCanModifyLaboratory === true,
|
||||
},
|
||||
collectionsOperations: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue