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:
parent
7dd75a658c
commit
92bd92b1eb
4 changed files with 108 additions and 26 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export default function DropdownComponent({
|
|||
id={`dropdown_${id}`}
|
||||
name={name}
|
||||
dialogInputs={dialogInputs}
|
||||
handleOnNewValue={handleOnNewValue} // TODO: Remove this
|
||||
handleOnNewValue={handleOnNewValue}
|
||||
{...baseInputProps}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue