Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/app-elements/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"react-hook-form": "7.62.0",
"react-i18next": "^15.7.4",
"react-select": "^5.10.2",
"react-select-async-paginate": "^0.7.11",
"react-toastify": "^11.1.0",
"react-tooltip": "^5.30.1",
"stable-hash": "^0.0.6",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { forwardRef } from "react"
import type {
GroupBase,
OptionsOrGroups,
SelectInstance,
StylesConfig,
} from "react-select"
import {
AsyncPaginate,
type LoadOptions,
type ReduceOptions,
wrapMenuList,
} from "react-select-async-paginate"
import type {
InputSelectBaseProps,
InputSelectValue,
LoadAsyncValuesPaginated,
} from "./InputSelect"
import overrides from "./overrides"

/**
* What the loader carries from one page to the next.
*
* The library calls it `additional` and hands it back untouched on the following
* request, which is where the page cursor lives.
*/
interface Additional {
page: number
}

type Options = OptionsOrGroups<InputSelectValue, GroupBase<InputSelectValue>>

export interface AsyncPaginateSelectComponentProps
extends Omit<
InputSelectBaseProps,
"label" | "hint" | "asTextSearch" | "isCreatable"
> {
loadAsyncValues: LoadAsyncValuesPaginated
styles: StylesConfig<InputSelectValue>
}

/**
* The library wraps `MenuList` to watch its scroll position, but its own
* wrapping runs *before* the components we pass in, so ours would replace it and
* take the scroll listener with it. Wrapping ours here keeps both: the footer and
* the styling we override, plus the scroll detection paging depends on.
*/
const components = {
...overrides,
MenuList: wrapMenuList(overrides.MenuList),
}

const componentsWithoutDropdownIndicator = {
...components,
DropdownIndicator: null,
}

/**
* react-select hands any prop it does not know to `selectProps`, which is how the
* `MenuList` override tells "loading the next page" — a footer under the options
* already shown — apart from "loading the first one", which react-select covers
* on its own. Spread rather than written as an attribute, so it is not rejected
* as an unknown JSX prop.
*/
const paginatedSelectProps = { isPaginated: true }

/**
* The async select that loads the next page as its menu is scrolled, instead of
* leaving everything past the first page reachable only by typing.
*
* Options are always what the server returned — client-side filtering stays off,
* as filtering a list that is only partly loaded would quietly hide matches
* sitting on a page that has not been fetched yet.
*/
export const AsyncPaginateSelectComponent = forwardRef<
SelectInstance<InputSelectValue, boolean, GroupBase<InputSelectValue>>,
AsyncPaginateSelectComponentProps
>(
(
{
onSelect,
noOptionsMessage,
// deliberately dropped: see the note on paging from the first page below
initialValues: _initialValues,
isOptionDisabled,
loadAsyncValues,
hideDropdownIndicator = false,
debounceMs = 500,
...rest
},
ref,
) => {
const loadOptions: LoadOptions<
InputSelectValue,
GroupBase<InputSelectValue>,
Additional
> = async (inputValue, _loadedOptions, additional) => {
const page = additional?.page ?? 1
const { options, hasMore } = await loadAsyncValues(inputValue, { page })

return {
options,
hasMore,
additional: { page: page + 1 },
}
}

return (
<AsyncPaginate<
InputSelectValue,
GroupBase<InputSelectValue>,
Additional,
boolean
>
{...rest}
{...paginatedSelectProps}
selectRef={ref}
loadOptions={loadOptions}
// Paging always starts at the first page, and `initialValues` seeds
// nothing.
//
// Seeding would mean claiming those values *are* the first page, which
// the component cannot know: a caller composes them freely, and even the
// filters bar can hand over a lone selected record that resolved before
// the list did. Starting from the second page in that case would skip the
// first one for good. So the menu fetches page one when it opens, and
// `initialValues` keeps to what it is elsewhere — the labels the closed
// control needs.
additional={{ page: 1 }}
reduceOptions={reduceOptions}
debounceTimeout={debounceMs}
closeMenuOnSelect={rest.isMulti !== true}
isOptionDisabled={isOptionDisabled}
onChange={onSelect}
noOptionsMessage={() => noOptionsMessage}
components={
hideDropdownIndicator
? componentsWithoutDropdownIndicator
: components
}
classNames={{
control: (state) => (state.isFocused ? "z-[101]" : ""),
}}
/>
)
},
)

/**
* Appends a page to what the menu already shows, dropping anything already
* there.
*
* A duplicate is not hypothetical: the selected option is fetched on its own so
* that its label resolves, and it turns up again once its own page is reached.
*/
const reduceOptions: ReduceOptions<
InputSelectValue,
GroupBase<InputSelectValue>,
Additional
> = (prevOptions: Options, loadedOptions: Options): Options => {
const seen = new Set(
prevOptions.map((option) => ("value" in option ? option.value : undefined)),
)

return [
...prevOptions,
...loadedOptions.filter(
(option) => !("value" in option) || !seen.has(option.value),
),
]
}

AsyncPaginateSelectComponent.displayName = "AsyncPaginateSelectComponent"
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { forwardRef } from "react"
import type { GroupBase, SelectInstance, StylesConfig } from "react-select"
import CreatableSelect from "react-select/creatable"
import type { InputSelectProps, InputSelectValue } from "./InputSelect"
import type { InputSelectBaseProps, InputSelectValue } from "./InputSelect"
import components from "./overrides"

export interface CreatableComponentProps
extends Omit<InputSelectProps, "loadAsyncValues" | "label" | "hint"> {
extends Omit<InputSelectBaseProps, "label" | "hint"> {
styles: StylesConfig<InputSelectValue>
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ import type {
} from "react-select"
import type AsyncSelect from "react-select/async"
import type AsyncCreatableSelect from "react-select/async-creatable"
import type { SetRequired } from "type-fest"
import type {
GroupedSelectValues,
InputSelectProps,
InputSelectBaseProps,
InputSelectValue,
LoadAsyncValues,
} from "./InputSelect"
import components from "./overrides"
import { isSingleValueSelected } from "./utils"
Expand Down Expand Up @@ -52,10 +52,8 @@ interface AsyncAdditionalProps<Option, Group extends GroupBase<Option>> {
}

export interface GenericAsyncSelectComponentProps
extends Omit<
SetRequired<InputSelectProps, "loadAsyncValues">,
"label" | "hint"
> {
extends Omit<InputSelectBaseProps, "label" | "hint"> {
loadAsyncValues: LoadAsyncValues
styles: StylesConfig<InputSelectValue>
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import type { GroupBase, SelectInstance } from "react-select"
import { useValidationFeedback } from "../ReactHookForm"
import {
InputSelect,
type InputSelectProps,
type InputSelectAsyncProps,
type InputSelectBaseProps,
type InputSelectValue,
type PossibleSelectValue,
} from "./InputSelect"
import { flatSelectValues, getDefaultValueFromFlatten } from "./utils"

export interface HookedInputSelectProps
extends Omit<InputSelectProps, "onSelect" | "defaultValue"> {
interface HookedInputSelectOwnProps
extends Omit<InputSelectBaseProps, "onSelect" | "defaultValue"> {
/**
* field name to match hook-form state
*/
Expand Down Expand Up @@ -40,6 +41,14 @@ export interface HookedInputSelectProps
onSelect?: (value: PossibleSelectValue) => void
}

/**
* The async half is intersected in rather than derived with `Omit`/`Pick`, as
* both collapse a union — and with it the rule that `infiniteScroll` requires the
* paginated loader.
*/
export type HookedInputSelectProps = HookedInputSelectOwnProps &
InputSelectAsyncProps

/**
* `InputSelect` component ready to be used with the `react-hook-form` context.
* Value to be stored in the field can be controlled from the `pathToValue` prop.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,100 @@ describe("InputSelect", () => {
container.querySelector('[class*="indicatorContainer"] svg'),
).not.toBeInTheDocument()
})

describe("with infinite scroll", () => {
// jsdom gives every element a zero height, which the library reads as "the
// menu is already scrolled to its bottom" — so simply opening the menu is
// what asks for the next page here
const openMenu = (getByText: (text: string) => HTMLElement): void => {
fireEvent.keyDown(getByText("Please select an option"), {
key: "ArrowDown",
})
}

test("loads the following pages as the menu is scrolled", async () => {
const loadAsyncValues = vi.fn(async (_hint: string, { page }) => ({
options: [{ value: `page-${page}`, label: `Page ${page}` }],
hasMore: page < 2,
}))

const { getByText, queryByText } = render(
<InputSelect
onSelect={() => {}}
initialValues={[]}
placeholder="Please select an option"
infiniteScroll
loadAsyncValues={loadAsyncValues}
debounceMs={0}
/>,
)

openMenu(getByText)

await waitFor(() => {
expect(queryByText("Page 2")).toBeVisible()
})
expect(queryByText("Page 1")).toBeVisible()
expect(loadAsyncValues.mock.calls.map(([, meta]) => meta.page)).toEqual([
1, 2,
])
})

// `initialValues` is whatever the caller composed — the labels the closed
// control needs — and never a claim about which page they are, so taking them
// for the first page could skip it for good
test("still starts from the first page when given initial values", async () => {
const loadAsyncValues = vi.fn(async (_hint: string, { page }) => ({
options: [{ value: `page-${page}`, label: `Page ${page}` }],
hasMore: false,
}))

const { getByText, queryByText } = render(
<InputSelect
onSelect={() => {}}
initialValues={[{ value: "chosen", label: "Chosen" }]}
placeholder="Please select an option"
infiniteScroll
loadAsyncValues={loadAsyncValues}
debounceMs={0}
/>,
)

openMenu(getByText)

await waitFor(() => {
expect(queryByText("Page 1")).toBeVisible()
})
expect(loadAsyncValues.mock.calls.map(([, meta]) => meta.page)).toEqual([
1,
])
})

// the selected option is fetched on its own so its label resolves, and shows
// up again once its own page is reached
test("does not repeat an option already in the menu", async () => {
const loadAsyncValues = vi.fn(async (_hint: string, { page }) => ({
options: [{ value: "paris", label: "Paris" }],
hasMore: page < 2,
}))

const { getByText, queryAllByText } = render(
<InputSelect
onSelect={() => {}}
initialValues={[]}
placeholder="Please select an option"
infiniteScroll
loadAsyncValues={loadAsyncValues}
debounceMs={0}
/>,
)

openMenu(getByText)

await waitFor(() => {
expect(loadAsyncValues).toHaveBeenCalledTimes(2)
})
expect(queryAllByText("Paris")).toHaveLength(1)
})
})
})
Loading
Loading