diff --git a/skyvern-frontend/package-lock.json b/skyvern-frontend/package-lock.json index f6fba3fed..c97fee720 100644 --- a/skyvern-frontend/package-lock.json +++ b/skyvern-frontend/package-lock.json @@ -35,6 +35,7 @@ "axios": "^1.6.8", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "cmdk": "^1.0.0", "cors": "^2.8.5", "embla-carousel-react": "^8.0.0", "express": "^4.19.2", @@ -3856,6 +3857,19 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.0.0.tgz", + "integrity": "sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==", + "dependencies": { + "@radix-ui/react-dialog": "1.0.5", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/codemirror": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz", diff --git a/skyvern-frontend/package.json b/skyvern-frontend/package.json index a75780c59..cbdb3dcfc 100644 --- a/skyvern-frontend/package.json +++ b/skyvern-frontend/package.json @@ -43,6 +43,7 @@ "axios": "^1.6.8", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "cmdk": "^1.0.0", "cors": "^2.8.5", "embla-carousel-react": "^8.0.0", "express": "^4.19.2", diff --git a/skyvern-frontend/src/components/ui/command.tsx b/skyvern-frontend/src/components/ui/command.tsx new file mode 100644 index 000000000..bbd300645 --- /dev/null +++ b/skyvern-frontend/src/components/ui/command.tsx @@ -0,0 +1,153 @@ +import * as React from "react"; +import { type DialogProps } from "@radix-ui/react-dialog"; +import { MagnifyingGlassIcon } from "@radix-ui/react-icons"; +import { Command as CommandPrimitive } from "cmdk"; + +import { cn } from "@/util/utils"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +CommandShortcut.displayName = "CommandShortcut"; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/skyvern-frontend/src/components/ui/multi-select.tsx b/skyvern-frontend/src/components/ui/multi-select.tsx new file mode 100644 index 000000000..84b38ae17 --- /dev/null +++ b/skyvern-frontend/src/components/ui/multi-select.tsx @@ -0,0 +1,338 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { + CheckIcon, + Cross2Icon, + CrossCircledIcon, + ChevronDownIcon, +} from "@radix-ui/react-icons"; + +import { cn } from "@/util/utils"; +import { Separator } from "@/components/ui/separator"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; + +/** + * Variants for the multi-select component to handle different styles. + * Uses class-variance-authority (cva) to define different styles based on "variant" prop. + */ +const multiSelectVariants = cva("m-1", { + variants: { + variant: { + default: "border-foreground/10 text-foreground bg-card hover:bg-card/80", + secondary: + "border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + inverted: "inverted", + }, + }, + defaultVariants: { + variant: "default", + }, +}); + +/** + * Props for MultiSelect component + */ +interface MultiSelectProps + extends React.ButtonHTMLAttributes, + VariantProps { + /** + * An array of option objects to be displayed in the multi-select component. + * Each option object has a label, value, and an optional icon. + */ + options: { + /** The text to display for the option. */ + label: string; + /** The unique value associated with the option. */ + value: string; + /** Optional icon component to display alongside the option. */ + icon?: React.ComponentType<{ className?: string }>; + }[]; + + /** + * Callback function triggered when the selected values change. + * Receives an array of the new selected values. + */ + onValueChange: (value: string[]) => void; + + /** The default selected values when the component mounts. */ + value: string[]; + + /** + * Placeholder text to be displayed when no values are selected. + * Optional, defaults to "Select options". + */ + placeholder?: string; + + /** + * Maximum number of items to display. Extra selected items will be summarized. + * Optional, defaults to 3. + */ + maxCount?: number; + + /** + * The modality of the popover. When set to true, interaction with outside elements + * will be disabled and only popover content will be visible to screen readers. + * Optional, defaults to false. + */ + modalPopover?: boolean; + + /** + * Additional class names to apply custom styles to the multi-select component. + * Optional, can be used to add custom styles. + */ + className?: string; +} + +export const MultiSelect = React.forwardRef< + HTMLButtonElement, + MultiSelectProps +>( + ( + { + options, + onValueChange, + variant, + value, + placeholder = "Select options", + maxCount = 3, + modalPopover = false, + className, + ...props + }, + ref, + ) => { + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); + + const handleInputKeyDown = ( + event: React.KeyboardEvent, + ) => { + if (event.key === "Enter") { + setIsPopoverOpen(true); + } else if (event.key === "Backspace" && !event.currentTarget.value) { + const newSelectedValues = [...value]; + newSelectedValues.pop(); + onValueChange(newSelectedValues); + } + }; + + const toggleOption = (option: string) => { + const newSelectedValues = value.includes(option) + ? value.filter((v) => v !== option) + : [...value, option]; + onValueChange(newSelectedValues); + }; + + const handleClear = () => { + onValueChange([]); + }; + + const handleTogglePopover = () => { + setIsPopoverOpen((prev) => !prev); + }; + + const clearExtraOptions = () => { + const newSelectedValues = value.slice(0, maxCount); + onValueChange(newSelectedValues); + }; + + const toggleAll = () => { + if (value.length === options.length) { + handleClear(); + } else { + const allValues = options.map((option) => option.value); + onValueChange(allValues); + } + }; + + return ( + + + + + setIsPopoverOpen(false)} + > + + + + No results found. + + +
+ +
+ (Select All) +
+ {options.map((option) => { + const isSelected = value.includes(option.value); + return ( + toggleOption(option.value)} + className="cursor-pointer" + > +
+ +
+ {option.icon && ( + + )} + {option.label} +
+ ); + })} +
+ + +
+ {value.length > 0 && ( + <> + + Clear + + + + )} + setIsPopoverOpen(false)} + className="max-w-full flex-1 cursor-pointer justify-center" + > + Close + +
+
+
+
+
+
+ ); + }, +); + +MultiSelect.displayName = "MultiSelect"; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNodeParametersPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNodeParametersPanel.tsx index 3e526c907..db0e8f8e2 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNodeParametersPanel.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/TaskNode/TaskNodeParametersPanel.tsx @@ -1,6 +1,4 @@ -import { Checkbox } from "@/components/ui/checkbox"; -import { useWorkflowParametersState } from "../../useWorkflowParametersState"; -import { Label } from "@/components/ui/label"; +import { MultiSelect } from "@/components/ui/multi-select"; import { Tooltip, TooltipContent, @@ -8,6 +6,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { QuestionMarkCircledIcon } from "@radix-ui/react-icons"; +import { useWorkflowParametersState } from "../../useWorkflowParametersState"; type Props = { availableOutputParameters: Array; @@ -25,15 +24,12 @@ function TaskNodeParametersPanel({ .map((parameter) => parameter.key) .concat(availableOutputParameters); - function toggleParameter(key: string) { - if (parameters.includes(key)) { - onParametersChange( - parameters.filter((parameterKey) => parameterKey !== key), - ); - } else { - onParametersChange([...parameters, key]); - } - } + const options = keys.map((key) => { + return { + label: key, + value: key, + }; + }); return (
@@ -50,33 +46,13 @@ function TaskNodeParametersPanel({ -
- {keys.map((key) => { - return ( -
- { - toggleParameter(key); - }} - /> - -
- ); - })} -
+
); } diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/components/EditableNodeTitle.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/components/EditableNodeTitle.tsx index e228ed1bb..b0d13eb2c 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/components/EditableNodeTitle.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/components/EditableNodeTitle.tsx @@ -42,7 +42,6 @@ function EditableNodeTitle({ { if (!editable) { diff --git a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts index 6f836f6d5..a43d06615 100644 --- a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts +++ b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts @@ -658,6 +658,17 @@ function getOutputParameterKey(label: string) { return label + "_output"; } +function isOutputParameterKey(value: string) { + return value.endsWith("_output"); +} + +function getBlockNameOfOutputParameterKey(value: string) { + if (isOutputParameterKey(value)) { + return value.substring(0, value.length - 7); + } + return value; +} + function getUpdatedNodesAfterLabelUpdateForParameterKeys( id: string, newLabel: string, @@ -765,4 +776,6 @@ export { getUpdatedNodesAfterLabelUpdateForParameterKeys, getAdditionalParametersForEmailBlock, getLabelForExistingNode, + isOutputParameterKey, + getBlockNameOfOutputParameterKey, };