Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨(react) rework the behavior of the Select component #166

Merged
merged 1 commit into from
Sep 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/wicked-games-tie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@openfun/cunningham-react": minor
---

rework the behavior of the Select component
21 changes: 20 additions & 1 deletion packages/react/src/components/Forms/Select/mono-searchable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,25 @@ export const SelectMonoSearchable = (props: SubProps) => {
downshiftReturn.selectItem(optionToSelect ?? null);
}, [props.value, props.options, props.downshiftProps]);

// Even there is already a value selected, when opening the combobox menu we want to display all available choices.
useEffect(() => {
if (downshiftReturn.isOpen) {
setOptionsToDisplay(props.options);
}
}, [downshiftReturn.isOpen]);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
}, [downshiftReturn.isOpen, props.options]);

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doing this will cause:
-> If options change when typing for filtering it will display all options totally ignoring the filter. Which is a non-sense

WDYT?

Copy link
Contributor

@AntoLC AntoLC Sep 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the options change when the select is open, the new options will not pass in setOptionsToDisplay, so actually if the user clicks on one of the option proposed, it will send back in onChange an old value who could be not part of the new options. Depend the case it could potentially create a bug.

Copy link
Member Author

@NathanVss NathanVss Sep 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imo this is out the scope of this useEffect which is role is "Display all options only once when isOpen turns true". Adding here this new dep would result in the bug I described. It is some sort of event emitter "onOpen".

Regarding your use case we could do another PR to treat this potential issue which needs to be done in another part of the code, because this was existing before this PR.

In reality imo we cannot always apply the "add all used variables to the deps", in most cases the called methods use lots of other hidden variables that we never add as deps and that's ok. Sometimes it could cause bugs like the one I described.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would have been nice to fix directly these 2 issues when we could do it directly:

-> If options change when typing for filtering it will display all options totally ignoring the filter.

If the options change when the select is open, the new options will not pass in setOptionsToDisplay, so actually if the user clicks on one of the option proposed, it will send back in onChange an old value who could be not part of the new options. Depend the case it could potentially create a bug.

But let's create another PR if you feel more confortable.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I want to be able to make a release in between 😬

const onInputBlur = () => {
setHasInputFocused(false);
if (downshiftReturn.selectedItem) {
// Here the goal is to make sure that when the input in blurred then the input value
// has exactly the selectedItem label. Which is not the case by default.
downshiftReturn.selectItem(downshiftReturn.selectedItem);
} else {
// We want the input to be empty when no item is selected.
downshiftReturn.setInputValue("");
}
};

const inputProps = downshiftReturn.getInputProps({
ref: inputRef,
disabled: props.disabled,
Expand Down Expand Up @@ -82,7 +101,7 @@ export const SelectMonoSearchable = (props: SubProps) => {
setHasInputFocused(true);
}}
onBlur={() => {
setHasInputFocused(false);
onInputBlur();
}}
/>
</SelectMonoAux>
Expand Down
153 changes: 150 additions & 3 deletions packages/react/src/components/Forms/Select/mono.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import { CunninghamProvider } from ":/components/Provider";
import {
expectMenuToBeClosed,
expectMenuToBeOpen,
expectNoOptions,
expectOptions,
expectOptionToBeDisabled,
expectOptionToBeSelected,
expectOptionToBeUnselected,
} from ":/components/Forms/Select/test-utils";
import { Input } from ":/components/Forms/Input";

describe("<Select/>", () => {
describe("Searchable", () => {
Expand Down Expand Up @@ -139,6 +141,141 @@ describe("<Select/>", () => {

expect(input).toHaveValue("Paris");
});
it("clears the text input if no item is selected", async () => {
const Wrapper = () => {
const [value, setValue] = useState<string | number | undefined>();
return (
<CunninghamProvider>
<div>Value = {value}|</div>
<Select
label="City"
options={[
{
label: "Paris",
value: "paris",
},
{
label: "Panama",
value: "panama",
},
{
label: "London",
value: "london",
},
{
label: "New York",
value: "new_york",
},
{
label: "Tokyo",
value: "tokyo",
},
]}
searchable={true}
value={value}
onChange={(e) => setValue(e.target.value as any)}
/>
<Input name="Something else" />
</CunninghamProvider>
);
};
render(<Wrapper />);

const user = userEvent.setup();
const input = screen.getByRole("combobox", {
name: "City",
});
screen.getByText("Value = |");
// It returns the input.
expect(input.tagName).toEqual("INPUT");

const menu: HTMLDivElement = screen.getByRole("listbox", {
name: "City",
});

expectMenuToBeClosed(menu);

// Click on the input.
await user.click(input);
expectMenuToBeOpen(menu);
expectOptions(["Paris", "Panama", "London", "New York", "Tokyo"]);

// Verify that filtering works.
await user.type(input, "Pa");
expectMenuToBeOpen(menu);
expectOptions(["Paris", "Panama"]);

await user.type(input, "rr");
expectNoOptions();

expect(input).toHaveValue("Parr");

// This is a way to blur the combobox.
await user.click(screen.getByRole("textbox"));

expect(input).toHaveValue("");
screen.getByText("Value = |");
});
it("clears the added text to the existing value input on blur if no other item is selected", async () => {
const Wrapper = () => {
const [value, setValue] = useState<string | number | undefined>(
"london",
);
return (
<CunninghamProvider>
<div>Value = {value}|</div>
<Select
label="City"
options={[
{
label: "Paris",
value: "paris",
},
{
label: "Panama",
value: "panama",
},
{
label: "London",
value: "london",
},
{
label: "New York",
value: "new_york",
},
{
label: "Tokyo",
value: "tokyo",
},
]}
searchable={true}
value={value}
onChange={(e) => setValue(e.target.value as any)}
/>
<Input name="Something else" />
AntoLC marked this conversation as resolved.
Show resolved Hide resolved
</CunninghamProvider>
);
};
render(<Wrapper />);

const user = userEvent.setup();
const input = screen.getByRole("combobox", {
name: "City",
});
screen.getByText("Value = london|");
// It returns the input.
expect(input.tagName).toEqual("INPUT");

expect(input).toHaveValue("London");
await user.type(input, "rr");
expect(input).toHaveValue("Londonrr");

// This is a way to blur the combobox.
await user.click(screen.getByRole("textbox"));

expect(input).toHaveValue("London");
screen.getByText("Value = london|");
});
it("clears value", async () => {
render(
<CunninghamProvider>
Expand Down Expand Up @@ -290,10 +427,12 @@ describe("<Select/>", () => {
const [value, setValue] = useState<string | number | undefined>(
"london",
);
const [onChangeCounts, setOnChangeCounts] = useState(0);
return (
<CunninghamProvider>
<div>
<div>Value = {value}|</div>
<div>onChangeCounts = {onChangeCounts}|</div>
<Button onClick={() => setValue(undefined)}>Clear</Button>
<Button onClick={() => setValue("paris")}>Set Paris</Button>
<Select
Expand All @@ -317,7 +456,10 @@ describe("<Select/>", () => {
},
]}
value={value}
onChange={(e) => setValue(e.target.value as string)}
onChange={(e) => {
setValue(e.target.value as string);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bit out of the scope, but I can see value can be from type string | number | string[] | undefined when Option.value can only be with string | undefined(https://github.com/openfun/cunningham/blob/main/packages/react/src/components/Forms/Select/mono.tsx#L9), I suppose they should have the same type and if possible infer from each other to avoid casting, what do you think ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I agree with you, we took the constraint to have the exact type as the native <select/> which uses this union. However I agree that there is maybe something to work on on this.

  • I think having value that could only be of type string is a mistake has it could be useful to allow number too

setOnChangeCounts(onChangeCounts + 1);
}}
searchable={true}
/>
</div>
Expand All @@ -332,6 +474,7 @@ describe("<Select/>", () => {

// Make sure value is selected.
screen.getByText("Value = london|");
screen.getByText("onChangeCounts = 0|");

// Change value.
const user = userEvent.setup();
Expand All @@ -343,14 +486,16 @@ describe("<Select/>", () => {
});
expectOptionToBeSelected(option);

// List should show only selected value.
expectOptions(["London"]);
// List should display all available options when re-opening.
expectOptions(["Paris", "London", "New York", "Tokyo"]);

// Clear value.
const button = screen.getByRole("button", {
name: "Clear",
});
await user.click(button);
screen.getByText("Value = |");
await screen.findByText("onChangeCounts = 0|");

// Select an option.
await user.click(input);
Expand All @@ -362,6 +507,7 @@ describe("<Select/>", () => {

// Make sure value is selected.
screen.getByText("Value = new_york|");
screen.getByText("onChangeCounts = 1|");

// clear value.
await user.click(button);
Expand All @@ -377,6 +523,7 @@ describe("<Select/>", () => {

screen.getByText("Value = paris|");
expect(input).toHaveValue("Paris");
screen.getByText("onChangeCounts = 1|");
});
it("renders disabled", async () => {
render(
Expand Down
8 changes: 6 additions & 2 deletions packages/react/src/components/Forms/Select/mono.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ export const Controlled = () => {
label="Select a city"
options={OPTIONS}
value={value}
onChange={(e) => setValue(e.target.value as string)}
onChange={(e) => {
setValue(e.target.value as string);
}}
/>
<Button onClick={() => setValue("")}>Reset</Button>
<Button onClick={() => setValue(OPTIONS[0].value)}>
Expand Down Expand Up @@ -159,7 +161,9 @@ export const SearchableControlled = () => {
options={OPTIONS}
searchable={true}
value={value}
onChange={(e) => setValue(e.target.value as string)}
onChange={(e) => {
setValue(e.target.value as string);
}}
/>
<Button onClick={() => setValue("")}>Reset</Button>
<Button onClick={() => setValue(OPTIONS[0].value)}>
Expand Down
18 changes: 13 additions & 5 deletions packages/react/src/components/Forms/Select/mono.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,19 @@ export const SelectMono = (props: SelectProps) => {
const commonDownshiftProps: SubProps["downshiftProps"] = {
initialSelectedItem: defaultSelectedItem,
onSelectedItemChange: (e: UseSelectStateChange<Option>) => {
props.onChange?.({
target: {
value: e.selectedItem ? optionToValue(e.selectedItem) : undefined,
},
});
const eventCmp = e.selectedItem ? optionToValue(e.selectedItem) : null;
const valueCmp = props.value ?? null;
// We make sure to not trigger a onChange event if the value are not different.
// This could happen on first render when the component is controlled, the value will be
// set inside a useEffect down in SelectMonoSearchable or SelectMonoSimple. So that means the
// downshift component will always render empty the first time.
if (eventCmp !== valueCmp) {
props.onChange?.({
target: {
value: e.selectedItem ? optionToValue(e.selectedItem) : undefined,
},
});
}
},
isItemDisabled: (item) => !!item.disabled,
};
Expand Down
9 changes: 5 additions & 4 deletions packages/react/src/components/Forms/Select/test-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,18 @@ export const expectOptions = (expectedOptions: string[]) => {
expect(option).toHaveTextContent(expectedOptions[i]);
});
};
export const expectNoOptions = () => {
const options = screen.getAllByRole("listitem");
expect(options.length).toBe(1);
expect(options[0]).toHaveTextContent("No options available");
};
export const expectMenuToBeClosed = (menu: HTMLDivElement) => {
expect(Array.from(menu.classList)).not.contains("c__select__menu--opened");
};
export const expectOptionToBeSelected = (option: HTMLLIElement) => {
expect(option).toHaveAttribute("aria-selected", "true");
expect(Array.from(option.classList)).contains(
"c__select__menu__item--selected",
);
expect(Array.from(option.classList)).contains(
"c__select__menu__item--highlight",
);
};
export const expectOptionToBeUnselected = (option: HTMLLIElement) => {
expect(option).toHaveAttribute("aria-selected", "false");
Expand Down