feat: Revamp GlobalVariableModal (#5512)

* 📝 (GlobalVariableModal.tsx): Refactor GlobalVariableModal component to use Tabs component instead of Select for type selection and improve layout and styling of input fields and labels
🔧 (index.tsx): Add popoverWidth prop to InputComponent to allow setting the width of the popover in CustomInputPopover component
🔧 (index.tsx): Add popoverWidth prop to InputComponent in InputGlobalComponent to set the width of the popover to 315px

 (tabs-button.tsx): introduce new TabsButton component to handle tab functionality in UI components
📝 (tabs-button.tsx): add documentation for Tabs, TabsContent, TabsList, TabsTrigger components
📝 (components/index.ts): add popoverWidth property to InputComponentType to control the width of the popover in UI components

*  (GlobalVariableModal.tsx): Add ForwardedIconComponent to display an icon next to the modal header text for better visual representation
📝 (GlobalVariableModal.tsx): Update TabsTrigger components to include data-testid attribute for testing purposes

 (index.tsx): Introduce new components OptionBadge, CommandItemContent, and SelectionIndicator to improve code organization and reusability
♻️ (index.tsx): Refactor handleRemoveOption function to have a more descriptive parameter signature and improve readability
📝 (index.tsx): Add comments to clarify the purpose of handleOptionSelect function and improve code documentation
📝 (index.tsx): Add comments to describe the purpose of getInputClassName and getAnchorClassName functions for better code understanding

 (globalVariables.spec.ts): improve placeholder text for variable name and value fields for better user understanding and experience

* 🐛 (GlobalVariableModal.tsx): Fix disabled state logic for TabsTrigger components to correctly reflect initialData type
📝 (GlobalVariableModal.tsx): Update label for submit button to dynamically change based on the presence of initialData

* 🐛 (index.tsx): Fix issue where options were not being correctly memoized as a Set to prevent unnecessary re-renders. Update memoizedOptions to correctly memoize options as a Set.

* 📝 (userSettings.spec.ts): Update placeholder text for variable name and value fields to improve clarity and user experience.
This commit is contained in:
Cristhian Zanforlin Lousa 2025-01-07 09:07:08 -03:00 committed by GitHub
commit af4fb3774e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 327 additions and 194 deletions

View file

@ -7,17 +7,10 @@ import getUnavailableFields from "@/stores/globalVariablesStore/utils/get-unavai
import { GlobalVariable } from "@/types/global_variables";
import { useEffect, useState } from "react";
import ForwardedIconComponent from "@/components/common/genericIconComponent";
import { ForwardedIconComponent } from "@/components/common/genericIconComponent";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs-button";
import BaseModal from "@/modals/baseModal";
import useAlertStore from "@/stores/alertStore";
import { useTypesStore } from "@/stores/typesStore";
@ -134,88 +127,89 @@ export default function GlobalVariableModal({
onSubmit={submitForm}
disable={disabled}
>
<BaseModal.Header
description={
"This variable will be available for you to use in any of your projects."
}
>
<span className="pr-2">
{" "}
{initialData ? "Update" : "Create"} Variable{" "}
</span>
<BaseModal.Header description="This variable will be available for use across your flows.">
<ForwardedIconComponent
name="Globe"
className="h-6 w-6 pl-1 text-primary"
className="h-6 w-6 pr-1 text-primary"
aria-hidden="true"
/>
{initialData ? "Update Variable" : "Create Variable"}
</BaseModal.Header>
<BaseModal.Trigger disable={disabled} asChild={asChild}>
{children}
</BaseModal.Trigger>
<BaseModal.Content>
<div className="flex h-full w-full flex-col gap-4 align-middle">
<Label>Variable Name</Label>
<Input
value={key}
onChange={(e) => {
setKey(e.target.value);
}}
placeholder="Insert a name for the variable..."
></Input>
<Label>Type (optional)</Label>
<Select
disabled={disabled}
onValueChange={setType}
value={type}
defaultValue={type}
>
<SelectTrigger
className="h-full w-full"
data-testid="select-type-global-variables"
<div className="flex h-full w-full flex-col gap-4">
<div className="space-y-2">
<Label>Type*</Label>
<Tabs
defaultValue={type}
onValueChange={setType}
className="w-full"
>
<SelectValue placeholder="Choose a type for the variable..." />
</SelectTrigger>
<SelectContent id="type-global-variables">
{["Generic", "Credential"].map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger
disabled={!!initialData?.type}
data-testid="credential-tab"
value="Credential"
>
Credential
</TabsTrigger>
<TabsTrigger
disabled={!!initialData?.type}
data-testid="generic-tab"
value="Generic"
>
Generic
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<Label>Value</Label>
{type === "Credential" ? (
<div className="space-y-2">
<Label>Name*</Label>
<Input
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="Enter a name for the variable..."
/>
</div>
<div className="space-y-2">
<Label>Value*</Label>
{type === "Credential" ? (
<InputComponent
password
value={value}
onChange={(e) => setValue(e)}
placeholder="Enter a value for the variable..."
nodeStyle
/>
) : (
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Enter a value for the variable..."
/>
)}
</div>
<div className="space-y-2">
<Label>Apply to fields</Label>
<InputComponent
password
value={value}
onChange={(e) => {
setValue(e);
}}
placeholder="Insert a value for the variable..."
nodeStyle
setSelectedOptions={(value) => setFields(value)}
selectedOptions={fields}
options={availableFields}
password={false}
placeholder="Choose a field for the variable..."
id="apply-to-fields"
popoverWidth="520px"
optionsPlaceholder="Fields"
/>
) : (
<Textarea
value={value}
onChange={(e) => {
setValue(e.target.value);
}}
placeholder="Insert a value for the variable..."
className="w-full resize-none custom-scroll"
/>
)}
<Label>Apply To Fields (optional)</Label>
<InputComponent
setSelectedOptions={(value) => setFields(value)}
selectedOptions={fields}
options={availableFields}
password={false}
placeholder="Choose a field for the variable..."
id={"apply-to-fields"}
></InputComponent>
<div className="text-xs text-muted-foreground">
Selected fields will auto-apply the variable as a default value.
</div>
</div>
</div>
</BaseModal.Content>
<BaseModal.Footer

View file

@ -15,7 +15,129 @@ import {
import { cn } from "@/utils/utils";
import { PopoverAnchor } from "@radix-ui/react-popover";
import { X } from "lucide-react";
import { useState } from "react";
import { ReactNode, useMemo, useState } from "react";
const OptionBadge = ({
option,
onRemove,
variant = "emerald",
className = "",
}: {
option: string;
variant?:
| "default"
| "emerald"
| "gray"
| "secondary"
| "destructive"
| "outline"
| "secondaryStatic"
| "pinkStatic"
| "successStatic"
| "errorStatic";
className?: string;
onRemove: (e: React.MouseEvent<HTMLButtonElement>) => void;
}) => (
<Badge
variant={
variant as
| "default"
| "emerald"
| "gray"
| "secondary"
| "destructive"
| "outline"
| "secondaryStatic"
| "pinkStatic"
| "successStatic"
| "errorStatic"
}
className={cn("flex items-center gap-1 truncate", className)}
>
<div className="truncate">{option}</div>
<X
className="h-3 w-3 cursor-pointer bg-transparent hover:text-destructive"
onClick={(e) =>
onRemove(e as unknown as React.MouseEvent<HTMLButtonElement>)
}
data-testid="remove-icon-badge"
/>
</Badge>
);
const CommandItemContent = ({
option,
isSelected,
optionButton,
}: {
option: string;
isSelected: boolean;
optionButton: (option: string) => ReactNode;
}) => (
<div className="group flex w-full items-center justify-between">
<div className="flex items-center justify-between">
<SelectionIndicator isSelected={isSelected} />
<span className="max-w-52 truncate pr-2">{option}</span>
</div>
{optionButton && optionButton(option)}
</div>
);
const SelectionIndicator = ({ isSelected }: { isSelected: boolean }) => (
<div
className={cn(
"relative mr-2 h-4 w-4",
isSelected ? "opacity-100" : "opacity-0",
)}
>
<div className="absolute opacity-100 transition-all group-hover:opacity-0">
<ForwardedIconComponent
name="Check"
className="mr-2 h-4 w-4 text-primary"
aria-hidden="true"
/>
</div>
<div className="absolute opacity-0 transition-all group-hover:opacity-100">
<ForwardedIconComponent
name="X"
className="mr-2 h-4 w-4 text-status-red"
aria-hidden="true"
/>
</div>
</div>
);
const getInputClassName = (
editNode: boolean,
disabled: boolean,
password: boolean,
selectedOptions: string[],
) => {
return cn(
"popover-input nodrag w-full truncate px-1 pr-4",
editNode && "px-2",
editNode && disabled && "h-fit w-fit",
disabled &&
"disabled:text-muted disabled:opacity-100 placeholder:disabled:text-muted-foreground",
password && "text-clip pr-14",
selectedOptions?.length >= 0 && "cursor-default",
);
};
const getAnchorClassName = (
editNode: boolean,
disabled: boolean,
isFocused: boolean,
) => {
return cn(
"primary-input noflow nopan nodelete nodrag border-1 flex h-full min-h-[2.375rem] cursor-default flex-wrap items-center px-2",
editNode && "min-h-7 p-0 px-1",
editNode && disabled && "min-h-5 border-muted",
disabled && "bg-muted text-muted",
isFocused &&
"border-foreground ring-1 ring-foreground hover:border-foreground",
);
};
const CustomInputPopover = ({
id,
@ -43,16 +165,20 @@ const CustomInputPopover = ({
nodeStyle,
optionButton,
autoFocus,
className,
popoverWidth,
}) => {
const [isFocused, setIsFocused] = useState(false);
const memoizedOptions = useMemo(() => new Set<string>(options), [options]);
const PopoverContentInput = editNode
? PopoverContent
: PopoverContentWithoutPortal;
const handleRemoveOption = (optionToRemove, e) => {
e.stopPropagation(); // Prevent the popover from opening when removing badges
const handleRemoveOption = (
optionToRemove: string,
e: React.MouseEvent<HTMLButtonElement>,
) => {
e.stopPropagation();
if (setSelectedOptions) {
setSelectedOptions(
selectedOptions.filter((option) => option !== optionToRemove),
@ -62,58 +188,52 @@ const CustomInputPopover = ({
}
};
const handleOptionSelect = (currentValue: string) => {
if (setSelectedOption) {
setSelectedOption(currentValue === selectedOption ? "" : currentValue);
}
if (setSelectedOptions) {
setSelectedOptions(
selectedOptions?.includes(currentValue)
? selectedOptions.filter((item) => item !== currentValue)
: [...(selectedOptions || []), currentValue],
);
}
!setSelectedOptions && setShowOptions(false);
};
return (
<Popover modal open={showOptions} onOpenChange={setShowOptions}>
<PopoverAnchor>
<div
data-testid={`anchor-${id}`}
className={cn(
"primary-input noflow nopan nodelete nodrag border-1 flex h-full min-h-[2.375rem] cursor-default flex-wrap items-center px-2",
editNode && "min-h-7 p-0 px-1",
editNode && disabled && "min-h-5 border-muted",
disabled && "bg-muted text-muted",
isFocused &&
"border-foreground ring-1 ring-foreground hover:border-foreground",
)}
onClick={() => {
if (!nodeStyle && !disabled) {
setShowOptions(true);
}
}}
className={getAnchorClassName(editNode, disabled, isFocused)}
onClick={() => !nodeStyle && !disabled && setShowOptions(true)}
>
{selectedOptions?.length > 0 ? (
selectedOptions.map((option) => (
<Badge
key={option}
variant="secondary"
className="m-[1px] flex items-center gap-1 truncate px-1"
>
<div className="truncate">{option}</div>
<X
className="h-3 w-3 cursor-pointer bg-transparent hover:text-destructive"
onClick={(e) => handleRemoveOption(option, e)}
<div className="mr-5 flex flex-wrap gap-2">
{selectedOptions.map((option) => (
<OptionBadge
key={option}
option={option}
onRemove={(e) => handleRemoveOption(option, e)}
className="rounded-[3px] p-1 font-mono"
/>
</Badge>
))
))}
</div>
) : selectedOption?.length > 0 ? (
<Badge
<OptionBadge
option={selectedOption}
onRemove={(e) => handleRemoveOption(selectedOption, e)}
variant={nodeStyle ? "emerald" : "secondary"}
className={cn(
"flex items-center gap-1 truncate",
editNode && "text-xs",
nodeStyle ? "font-jetbrains rounded-[3px] px-1" : "bg-muted",
nodeStyle ? "rounded-[3px] px-1 font-mono" : "bg-muted",
)}
>
<div className="max-w-36 truncate">{selectedOption}</div>
<X
className="h-3 w-3 cursor-pointer bg-transparent hover:text-destructive"
onClick={(e) => handleRemoveOption(selectedOption, e)}
data-testid={"remove-icon-badge"}
/>
</Badge>
/>
) : null}
{!selectedOption && (
{!selectedOption?.length && !selectedOptions?.length && (
<input
autoComplete="off"
onFocus={() => setIsFocused(true)}
@ -128,13 +248,11 @@ const CustomInputPopover = ({
value={value || ""}
disabled={disabled}
required={required}
className={cn(
"popover-input nodrag w-full truncate px-1 pr-4",
editNode && "px-2",
editNode && disabled && "h-fit w-fit",
disabled &&
"disabled:text-muted disabled:opacity-100 placeholder:disabled:text-muted-foreground",
password && "text-clip pr-14",
className={getInputClassName(
editNode,
disabled,
password,
selectedOptions,
)}
placeholder={
selectedOptions?.length > 0 || selectedOption ? "" : placeholder
@ -149,9 +267,13 @@ const CustomInputPopover = ({
)}
</div>
</PopoverAnchor>
<PopoverContentInput
className="noflow nowheel nopan nodelete nodrag p-0"
style={{ minWidth: refInput?.current?.clientWidth ?? "200px" }}
style={{
minWidth: refInput?.current?.clientWidth ?? "200px",
width: popoverWidth ?? null,
}}
side="bottom"
align="start"
>
@ -168,59 +290,21 @@ const CustomInputPopover = ({
<CommandInput placeholder={optionsPlaceholder} />
<CommandList>
<CommandGroup>
{options.map((option, id) => (
{Array.from(memoizedOptions).map((option, id) => (
<CommandItem
key={option + id}
value={option}
onSelect={(currentValue) => {
if (setSelectedOption) {
setSelectedOption(
currentValue === selectedOption ? "" : currentValue,
);
}
if (setSelectedOptions) {
setSelectedOptions(
selectedOptions?.includes(currentValue)
? selectedOptions.filter(
(item) => item !== currentValue,
)
: [...(selectedOptions || []), currentValue],
);
}
!setSelectedOptions && setShowOptions(false);
}}
onSelect={handleOptionSelect}
className="group"
>
<div className="group flex w-full items-center justify-between">
<div className="flex items-center justify-between">
<div
className={cn(
"relative mr-2 h-4 w-4",
selectedOption === option ||
selectedOptions?.includes(option)
? "opacity-100"
: "opacity-0",
)}
>
<div className="absolute opacity-100 transition-all group-hover:opacity-0">
<ForwardedIconComponent
name="Check"
className="mr-2 h-4 w-4 text-primary"
aria-hidden="true"
/>
</div>
<div className="absolute opacity-0 transition-all group-hover:opacity-100">
<ForwardedIconComponent
name="X"
className="mr-2 h-4 w-4 text-status-red"
aria-hidden="true"
/>
</div>
</div>
<span className="max-w-52 truncate pr-2">{option}</span>
</div>
{optionButton && optionButton(option)}
</div>
<CommandItemContent
option={option}
isSelected={
selectedOption === option ||
selectedOptions?.includes(option)
}
optionButton={optionButton}
/>
</CommandItem>
))}
{optionsButton}

View file

@ -39,6 +39,7 @@ export default function InputComponent({
onChangeFolderName,
nodeStyle,
isToolMode,
popoverWidth,
}: InputComponentType): JSX.Element {
const [pwdVisible, setPwdVisible] = useState(false);
const refInput = useRef<HTMLInputElement>(null);
@ -148,8 +149,8 @@ export default function InputComponent({
blurOnEnter={blurOnEnter}
options={options}
optionsPlaceholder={optionsPlaceholder}
className={className}
nodeStyle={nodeStyle}
popoverWidth={popoverWidth}
/>
)}
</>

View file

@ -90,6 +90,7 @@ export default function InputGlobalComponent({
return (
<InputComponent
nodeStyle
popoverWidth="315px"
placeholder={getPlaceholder(disabled, placeholder)}
id={id}
editNode={editNode}

View file

@ -0,0 +1,54 @@
"use client";
import { cn } from "@/utils/utils";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsContent, TabsList, TabsTrigger };

View file

@ -46,6 +46,7 @@ export type InputComponentType = {
onChangeFolderName?: (e: any) => void;
nodeStyle?: boolean;
isToolMode?: boolean;
popoverWidth?: string;
};
export type DropDownComponent = {
disabled?: boolean;

View file

@ -33,11 +33,11 @@ test(
await page.getByTestId("icon-Globe").nth(0).click();
await page.getByText("Add New Variable", { exact: true }).click();
await page
.getByPlaceholder("Insert a name for the variable...")
.getByPlaceholder("Enter a name for the variable...")
.fill(genericName);
await page.getByText("Generic", { exact: true }).first().isVisible();
await page
.getByPlaceholder("Insert a value for the variable...")
.getByPlaceholder("Enter a value for the variable...")
.fill("This is a test of generic variable value");
await page.getByText("Save Variable", { exact: true }).click();
expect(page.getByText(genericName, { exact: true })).not.toBeNull();
@ -45,12 +45,11 @@ test(
await page.getByText("Add New Variable", { exact: true }).click();
await page
.getByPlaceholder("Insert a name for the variable...")
.getByPlaceholder("Enter a name for the variable...")
.fill(credentialName);
await page.getByTestId("select-type-global-variables").first().click();
await page.getByText("Credential", { exact: true }).last().click();
await page.getByTestId("credential-tab").click();
await page
.getByPlaceholder("Insert a value for the variable...")
.getByPlaceholder("Enter a value for the variable...")
.fill("This is a test of credential variable value");
await page.getByText("Save Variable", { exact: true }).click();
expect(page.getByText(credentialName, { exact: true })).not.toBeNull();

View file

@ -44,40 +44,39 @@ test(
.isVisible();
await page.getByText("Add New").click();
await page
.getByPlaceholder("Insert a name for the variable...")
.getByPlaceholder("Enter a name for the variable...")
.fill(randomName);
await page.getByTestId("select-type-global-variables").first().click();
await page.getByText("Generic", { exact: true }).last().isVisible();
await page.getByText("Generic", { exact: true }).last().click();
await page
.getByPlaceholder("Insert a value for the variable...")
.getByPlaceholder("Enter a value for the variable...")
.fill("testtesttesttesttesttesttesttest");
await page.getByTestId("popover-anchor-apply-to-fields").click();
await page.getByPlaceholder("Search options...").waitFor({
await page.getByPlaceholder("Fields").waitFor({
state: "visible",
timeout: 30000,
});
await page.getByPlaceholder("Search options...").fill("System");
await page.getByPlaceholder("Fields").fill("System");
await page.waitForSelector("text=System", { timeout: 30000 });
await page.getByText("System").last().click();
await page.getByPlaceholder("Search options...").fill("openAI");
await page.getByPlaceholder("Fields").fill("openAI");
await page.waitForSelector("text=openai", { timeout: 30000 });
await page.getByText("openai").last().click();
await page.getByPlaceholder("Search options...").waitFor({
await page.getByPlaceholder("Fields").waitFor({
state: "visible",
timeout: 30000,
});
await page.getByPlaceholder("Search options...").fill("ollama");
await page.getByPlaceholder("Fields").fill("ollama");
await page.keyboard.press("Escape");
await page.getByText("Save Variable", { exact: true }).click();
@ -87,13 +86,13 @@ test(
await page.getByText(randomName).last().click();
await page.getByText(randomName).last().click();
await page.getByPlaceholder("Insert a name for the variable...").waitFor({
await page.getByPlaceholder("Enter a name for the variable...").waitFor({
state: "visible",
timeout: 30000,
});
await page
.getByPlaceholder("Insert a name for the variable...")
.getByPlaceholder("Enter a name for the variable...")
.fill(randomName2);
await page.getByText("Update Variable", { exact: true }).last().click();
@ -102,13 +101,13 @@ test(
await page.getByText(randomName2).last().click();
await page.getByPlaceholder("Insert a name for the variable...").waitFor({
await page.getByPlaceholder("Enter a name for the variable...").waitFor({
state: "visible",
timeout: 30000,
});
await page
.getByPlaceholder("Insert a name for the variable...")
.getByPlaceholder("Enter a name for the variable...")
.fill(randomName3);
await page.getByText("Update Variable", { exact: true }).last().click();