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

View file

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

View file

@ -1284,6 +1284,14 @@
.btn-playground-actions {
@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 */

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");
},
);