fix: Improve Dropdown component handling of custom values and add regression test (#7486)

* ✨ (frontend): Add handleOnNewValue prop to Dropdown component to allow selecting a value not in the list
🔧 (frontend): Remove TODO comment and keep handleOnNewValue prop in DropdownComponent
✅ (frontend): Add test for selecting a value not in the list in Dropdown component

* 🐛 (dropdownComponent/index.tsx): fix issue where custom value was not being added to validOptions and filteredOptions when pressing Enter
✨ (dropdownComponent/index.tsx): improve functionality to reset filtered options and custom value input when opening the dropdown

* 🔧 (dropdownComponent/index.tsx): improve styling and layout of dropdown component for better user experience

* ♻️ (dropdownComponent/index.tsx): remove unnecessary comments and improve code readability by removing redundant comments and separating render helper functions from logic blocks.

* 📝 (dropdownComponent/index.tsx): add 'no-focus-visible' class to dropdown component to remove focus outline for better accessibility
📝 (applies.css): add styles for 'no-focus-visible' class to remove focus outline for better accessibility
This commit is contained in:
Cristhian Zanforlin Lousa 2025-04-10 07:05:29 -03:00 • committed by GitHub
commit 92bd92b1eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 108 additions and 26 deletions

View file

@ -52,9 +52,13 @@ export default function Dropdown({
handleNodeClass, handleNodeClass,
name, name,
dialogInputs, dialogInputs,
handleOnNewValue,
...baseInputProps ...baseInputProps
}: BaseInputProps & DropDownComponent): JSX.Element { }: BaseInputProps & DropDownComponent): JSX.Element {
const validOptions = useMemo(() => filterNullOptions(options), [options]); const validOptions = useMemo(
() => filterNullOptions(options),
[options, value],
);
// Initialize state and refs // Initialize state and refs
const [open, setOpen] = useState(children ? true : false); const [open, setOpen] = useState(children ? true : false);
@ -66,11 +70,12 @@ export default function Dropdown({
const refButton = useRef<HTMLButtonElement>(null); const refButton = useRef<HTMLButtonElement>(null);
value = useMemo(() => { value = useMemo(() => {
if (!options.includes(value)) { if (!options.includes(value) && !filteredOptions.includes(value)) {
return null; return null;
} }
return value; return value;
}, [value, options]); }, [value, options, filteredOptions]);
// Initialize utilities and constants // Initialize utilities and constants
const placeholderName = name const placeholderName = name
? formatPlaceholderName(name) ? formatPlaceholderName(name)
@ -101,14 +106,24 @@ export default function Dropdown({
const searchRoleByTerm = async (event: ChangeEvent<HTMLInputElement>) => { const searchRoleByTerm = async (event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value; const value = event.target.value;
setCustomValue(value);
if (!value) {
// If search is cleared, show all options
setFilteredOptions(validOptions);
setFilteredMetadata(optionsMetaData);
return;
}
// Search existing options
const searchValues = fuse.search(value); const searchValues = fuse.search(value);
const filtered = searchValues.map((search) => search.item); const filtered = searchValues.map((search) => search.item);
// Update filteredOptions with the search results // Update filteredOptions with the search results
setFilteredOptions(value ? filtered : validOptions); setFilteredOptions(filtered);
// Update filteredMetadata to match the filtered options // Update filteredMetadata to match the filtered options
if (value && optionsMetaData) { if (optionsMetaData) {
const newMetadata = filtered.map((option) => { const newMetadata = filtered.map((option) => {
const originalIndex = validOptions.indexOf(option); const originalIndex = validOptions.indexOf(option);
return optionsMetaData[originalIndex]; return optionsMetaData[originalIndex];
@ -117,8 +132,6 @@ export default function Dropdown({
} else { } else {
setFilteredMetadata(optionsMetaData); setFilteredMetadata(optionsMetaData);
} }
setCustomValue(value);
}; };
const handleRefreshButtonPress = async () => { const handleRefreshButtonPress = async () => {
@ -166,15 +179,27 @@ export default function Dropdown({
useEffect(() => { useEffect(() => {
if (open) { if (open) {
const filtered = cloneDeep(validOptions); setFilteredOptions(validOptions);
if (customValue === value && value && combobox) { setCustomValue("");
filtered.push(customValue);
}
setFilteredOptions(filtered);
} }
}, [open]); }, [open, validOptions]);
// Render helper functions const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
if (open && customValue) {
const newOptions = [...validOptions];
if (!newOptions.includes(customValue)) {
newOptions.push(customValue);
}
setFilteredOptions(newOptions);
handleOnNewValue?.({ value: customValue });
onSelect(customValue);
setOpen(false);
}
}
};
const renderLoadingButton = () => ( const renderLoadingButton = () => (
<Button <Button
@ -206,11 +231,11 @@ export default function Dropdown({
editNode editNode
? "dropdown-component-outline input-edit-node" ? "dropdown-component-outline input-edit-node"
: "dropdown-component-false-outline py-2", : "dropdown-component-false-outline py-2",
"w-full justify-between font-normal", "no-focus-visible w-full justify-between font-normal",
)} )}
> >
<span <span
className="flex items-center gap-2 truncate" className="flex w-full items-center gap-2 overflow-hidden"
data-testid={`value-dropdown-${id}`} data-testid={`value-dropdown-${id}`}
> >
{optionsMetaData?.[ {optionsMetaData?.[
@ -222,12 +247,14 @@ export default function Dropdown({
filteredOptions.findIndex((option) => option === value) filteredOptions.findIndex((option) => option === value)
]?.icon ]?.icon
} }
className="h-4 w-4" className="h-4 w-4 flex-shrink-0"
/> />
)} )}
{value && filteredOptions.includes(value) <span className="truncate">
? value {value && filteredOptions.includes(value)
: placeholderName}{" "} ? value
: placeholderName}{" "}
</span>
</span> </span>
<ForwardedIconComponent <ForwardedIconComponent
name="ChevronsUpDown" name="ChevronsUpDown"
@ -256,9 +283,11 @@ export default function Dropdown({
/> />
<input <input
onChange={searchRoleByTerm} onChange={searchRoleByTerm}
onKeyDown={handleInputKeyDown}
placeholder="Search options..." placeholder="Search options..."
className="flex h-9 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50" className="flex h-9 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
autoComplete="off" autoComplete="off"
data-testid="dropdown_search_input"
/> />
</div> </div>
); );
@ -363,7 +392,7 @@ export default function Dropdown({
</span> </span>
</div> </div>
{filteredMetadata && filteredMetadata?.length > 0 ? ( {filteredMetadata && filteredMetadata?.length > 0 ? (
<div className="flex w-full items-center text-muted-foreground"> <div className="flex w-full items-center overflow-hidden text-muted-foreground">
{Object.entries( {Object.entries(
filterMetadataKeys(filteredMetadata?.[index] || {}), filterMetadataKeys(filteredMetadata?.[index] || {}),
) )
@ -375,18 +404,19 @@ export default function Dropdown({
<div <div
key={key} key={key}
className={cn("flex items-center", { className={cn("flex items-center", {
truncate: i === arr.length - 1, "flex-1 overflow-hidden":
i === arr.length - 1,
})} })}
> >
{i > 0 && ( {i > 0 && (
<ForwardedIconComponent <ForwardedIconComponent
name="Circle" name="Circle"
className="mx-1 h-1 w-1 overflow-visible fill-muted-foreground" className="mx-1 h-1 w-1 flex-shrink-0 overflow-visible fill-muted-foreground"
/> />
)} )}
<div <div
className={cn("text-xs", { className={cn("text-xs", {
truncate: i === arr.length - 1, "w-full truncate": i === arr.length - 1,
})} })}
>{`${String(value)} ${key}`}</div> >{`${String(value)} ${key}`}</div>
</div> </div>
@ -453,7 +483,7 @@ export default function Dropdown({
) : refreshOptions || isLoading ? ( ) : refreshOptions || isLoading ? (
renderLoadingButton() renderLoadingButton()
) : ( ) : (
renderTriggerButton() <div className="w-full truncate">{renderTriggerButton()}</div>
)} )}
{renderPopoverContent()} {renderPopoverContent()}
</Popover> </Popover>

View file

@ -36,7 +36,7 @@ export default function DropdownComponent({
id={`dropdown_${id}`} id={`dropdown_${id}`}
name={name} name={name}
dialogInputs={dialogInputs} dialogInputs={dialogInputs}
handleOnNewValue={handleOnNewValue} // TODO: Remove this handleOnNewValue={handleOnNewValue}
{...baseInputProps} {...baseInputProps}
/> />
); );

View file

@ -1284,6 +1284,14 @@
.btn-playground-actions { .btn-playground-actions {
@apply flex h-[32px] w-[32px] items-center justify-center rounded-md bg-muted font-bold transition-all; @apply flex h-[32px] w-[32px] items-center justify-center rounded-md bg-muted font-bold transition-all;
} }
.no-focus-visible{
@apply focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0;
--tw-ring-offset-width: none !important;
--tw-ring-shadow: none !important;
--tw-ring-offset-shadow: none !important;
--tw-ring-color: none !important;
}
} }
/* Gradient background */ /* Gradient background */

View file

@ -0,0 +1,44 @@
import { expect, test } from "@playwright/test";
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
test(
"user must be able to select a value from dropdown that is not in the list",
{ tag: ["@release", "@components"] },
async ({ page }) => {
await awaitBootstrapTest(page);
await page.waitForSelector('[data-testid="blank-flow"]', {
timeout: 30000,
});
await page.getByTestId("blank-flow").click();
await page.getByTestId("sidebar-search-input").click();
await page.getByTestId("sidebar-search-input").fill("openai");
await page.waitForSelector('[data-testid="modelsOpenAI"]', {
timeout: 1000,
});
await page
.getByTestId("modelsOpenAI")
.hover()
.then(async () => {
await page.getByTestId("add-component-button-openai").last().click();
});
await page.getByTestId("fit_view").click();
await page.getByTestId("dropdown_str_model_name").click();
await page.getByTestId("dropdown_search_input").click();
await page
.getByTestId("dropdown_search_input")
.fill("this is a test langflow");
await page.keyboard.press("Enter");
await page.waitForTimeout(500);
const value = await page
.getByTestId("value-dropdown-dropdown_str_model_name")
.textContent();
expect(value?.trim()).toBe("this is a test langflow");
},
);