feat: create initial collection prompt + edit name (#7989)

This commit is contained in:
Michael Skorokhodov 2026-04-17 13:00:28 +02:00 committed by GitHub
parent 64e7fed1d4
commit 863f920b86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 510 additions and 556 deletions

View 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.

View file

@ -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 &&

View file

@ -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>

View file

@ -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>

View file

@ -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({

View file

@ -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',

View file

@ -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,
};

View file

@ -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"

View file

@ -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,

View file

@ -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: {

View file

@ -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,
);

View file

@ -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],
);

View file

@ -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: {