From 5906a8bd7f13c945be6541539b00e993c5da421e Mon Sep 17 00:00:00 2001 From: Arnei Date: Mon, 24 Aug 2026 10:53:10 +0200 Subject: [PATCH 1/2] Search ACL roles on the server instead of fetching them all upfront The Access Policy tab (and the corresponding wizards) fetched every role in the system (limit: -1) on open to populate each role dropdown client-side. On instances with many thousands of roles this was a multi-second, multi-megabyte request fired once per open. fetchRolesWithTarget now accepts query/limit/offset/hasUser, and AccessPolicyTable's role dropdowns search the backend as the user types instead. The parent components only fetch a single role up front, just to read the isSanitize flag that decides whether users and non-user roles are split into separate tables. DropDown gains a loadOptionsOnMount flag so a fetchOptions-based dropdown can skip its eager default-option fetch on mount (used here since AccessPolicyTable renders one dropdown per existing policy row, which would otherwise fire one identical request per row on tab open) and instead fetch its default option list lazily, the first time that particular instance is opened. This also fixes the virtualized MenuList silently rendering nothing instead of react-select's no-options/loading message when there are no options yet. --- .../ModalTabsAndPages/NewAccessPage.tsx | 26 +++---- src/components/shared/DropDown.tsx | 29 ++++++- .../modals/ResourceDetailsAccessPolicyTab.tsx | 78 +++++++++++-------- .../users/partials/wizard/AclAccessPage.tsx | 26 +++---- src/slices/aclSlice.ts | 12 ++- 5 files changed, 105 insertions(+), 66 deletions(-) diff --git a/src/components/events/partials/ModalTabsAndPages/NewAccessPage.tsx b/src/components/events/partials/ModalTabsAndPages/NewAccessPage.tsx index b2a7d9961b..07ad6d0a24 100644 --- a/src/components/events/partials/ModalTabsAndPages/NewAccessPage.tsx +++ b/src/components/events/partials/ModalTabsAndPages/NewAccessPage.tsx @@ -2,14 +2,13 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import Notifications from "../../../shared/Notifications"; import { - Role, checkAcls, fetchAclActions, fetchAclTemplates, fetchRolesWithTarget, } from "../../../../slices/aclSlice"; import { FormikProps } from "formik"; -import { policiesFiltered, rolesFiltered } from "../../../../utils/aclUtils"; +import { policiesFiltered } from "../../../../utils/aclUtils"; import { useAppDispatch, useAppSelector } from "../../../../store"; import { fetchSeriesDetailsAcls } from "../../../../slices/seriesDetailsSlice"; import { getSeriesDetailsAcl } from "../../../../selectors/seriesDetailsSelectors"; @@ -55,21 +54,23 @@ const NewAccessPage = ({ // States containing response from server concerning acl templates, actions and roles const [aclTemplates, setAclTemplates] = useState<{ id: string, value: string}[]>([]); const [aclActions, setAclActions] = useState<{ id: string, value: string}[]>([]); - const [roles, setRoles] = useState([]); + const [isSanitize, setIsSanitize] = useState(undefined); const [loading, setLoading] = useState(false); const seriesAcl = useAppSelector(state => getSeriesDetailsAcl(state)); const user = useAppSelector(state => getUserInformation(state)); useEffect(() => { - // fetch data about roles, acl templates and actions from backend + // fetch data about acl templates and actions from backend async function fetchData() { setLoading(true); const [responseTemplates, responseActions, responseRoles] = await Promise.all([ - fetchAclTemplates(), fetchAclActions(), fetchRolesWithTarget("ACL")]); + fetchAclTemplates(), fetchAclActions(), fetchRolesWithTarget("ACL", { limit: 1 })]); setAclTemplates(responseTemplates); setAclActions(responseActions); - setRoles(responseRoles); + if (responseRoles.length > 0) { + setIsSanitize(responseRoles[0].isSanitize); + } setLoading(false); } @@ -118,13 +119,13 @@ const NewAccessPage = ({ defaultUser={user} /> - {roles.length > 0 && !roles[0].isSanitize && + {isSanitize === false && <> {hasAccess(viewUsersAccessRole, user) && ({ hasActions={aclActions.length > 0} transactions={{ readOnly: false }} aclActions={aclActions} - roles={roles} editAccessRole={editAccessRole} /> } @@ -141,7 +141,7 @@ const NewAccessPage = ({ ({ hasActions={aclActions.length > 0} transactions={{ readOnly: false }} aclActions={aclActions} - roles={roles} editAccessRole={editAccessRole} /> } } - {roles.length > 0 && roles[0].isSanitize && + {isSanitize === true && <> 0} transactions={{ readOnly: false }} aclActions={aclActions} - roles={roles} editAccessRole={editAccessRole} />
diff --git a/src/components/shared/DropDown.tsx b/src/components/shared/DropDown.tsx index 1f83acd0d6..42f2a1c92f 100644 --- a/src/components/shared/DropDown.tsx +++ b/src/components/shared/DropDown.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { dropDownSpacingTheme, @@ -61,6 +61,7 @@ const DropDown = ({ optionHeight = 25, customCSS, fetchOptions, + loadOptionsOnMount = true, }: { ref?: React.RefObject, boolean, GroupBase>> | null> value: T @@ -87,12 +88,23 @@ const DropDown = ({ optionLineHeight?: string }, fetchOptions?: (inputValue: string) => Promise[]> + // Whether an async (fetchOptions-based) dropdown should eagerly fetch its default option list on + // mount, before the user has interacted with it. Set to false when many instances of this dropdown + // may be mounted at once (e.g. one per table row), to avoid firing one request per instance on + // render; the default option list is then fetched lazily, the first time this instance's menu is + // opened, instead of unconditionally on mount. + loadOptionsOnMount?: boolean }) => { const { t } = useTranslation(); const internalRef = useRef, boolean, GroupBase>> | null>(null); const selectRef = ref ?? internalRef; + // Holds the result of the one-off default-option fetch below, once it has completed. + const [preloadedOptions, setPreloadedOptions] = useState[] | undefined>(undefined); + const [isPreloading, setIsPreloading] = useState(false); + const hasStartedPreload = useRef(false); + const style = dropDownStyle(customCSS ?? {}); useEffect(() => { @@ -103,6 +115,14 @@ const DropDown = ({ }, [menuIsOpen, selectRef]); const openMenu = (open: boolean) => { + // If loadOptionsOnMount === false, fetch option list ourselves here + if (open && fetchOptions && !loadOptionsOnMount && !hasStartedPreload.current) { + hasStartedPreload.current = true; + setIsPreloading(true); + fetchOptions("") + .then(fetched => setPreloadedOptions(formatOptions(fetched, required))) + .finally(() => setIsPreloading(false)); + } if (handleMenuIsOpen !== undefined) { handleMenuIsOpen(open); } @@ -193,7 +213,9 @@ const DropDown = ({ overscanCount={4} />
- ) : null; + // react-select passes a single NoOptionsMessage/LoadingMessage node here (not an array) when + // there are no options to list, e.g. before the user has typed anything into an async dropdown. + ) : children; }, [itemHeight]); const filterOptions = (inputValue: string) => { @@ -249,7 +271,8 @@ const DropDown = ({ options, required, ) - : true, + : loadOptionsOnMount || (preloadedOptions ?? []), + isLoading: isPreloading, cacheOptions: true, loadOptions: fetchOptions ? loadOptionsAsync : loadOptions, placeholder: placeholder, diff --git a/src/components/shared/modals/ResourceDetailsAccessPolicyTab.tsx b/src/components/shared/modals/ResourceDetailsAccessPolicyTab.tsx index 2f6f074e25..13fc3f5bef 100644 --- a/src/components/shared/modals/ResourceDetailsAccessPolicyTab.tsx +++ b/src/components/shared/modals/ResourceDetailsAccessPolicyTab.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useRef } from "react"; import RenderMultiField from "../wizard/RenderMultiField"; import { Acl, @@ -17,8 +17,8 @@ import { } from "../../../utils/resourceUtils"; import { getUserInformation } from "../../../selectors/userInfoSelectors"; import { hasAccess } from "../../../utils/utils"; -import DropDown from "../DropDown"; -import { getAclTemplateText, handleTemplateChange, policiesFiltered, rolesFiltered } from "../../../utils/aclUtils"; +import DropDown, { DropDownOption } from "../DropDown"; +import { getAclTemplateText, handleTemplateChange, policiesFiltered } from "../../../utils/aclUtils"; import { useAppDispatch, useAppSelector } from "../../../store"; import { removeNotificationWizardForm, addNotification } from "../../../slices/notificationSlice"; import { useTranslation } from "react-i18next"; @@ -101,8 +101,10 @@ const ResourceDetailsAccessPolicyTab = ({ // shows, whether a resource has additional actions on top of normal read and write rights const [hasActions, setHasActions] = useState(false); - // list of possible roles - const [roles, setRoles] = useState([]); + // Whether per-role user info is sanitized (hidden) by this Opencast instance. Determines whether the roles + // list is split into a "Users" table and a "Groups & other roles" table, or shown as a single combined table. + // undefined until the initial, minimal check below has resolved. + const [isSanitize, setIsSanitize] = useState(undefined); // this state is used, because the policies should be read-only, if a transaction is currently being performed on a resource const [transactions, setTransactions] = useState({ readOnly: false }); @@ -122,7 +124,12 @@ const ResourceDetailsAccessPolicyTab = ({ setAclTemplates(responseTemplates); setAclActions(responseActions); setHasActions(responseActions.length > 0); - fetchRolesWithTarget("ACL").then(roles => setRoles(roles)); + // Fetch a single role just to read the isSanitize flag off it, rather than fetching every role. + fetchRolesWithTarget("ACL", { limit: 1 }).then(roles => { + if (roles.length > 0) { + setIsSanitize(roles[0].isSanitize); + } + }); if (fetchHasActiveTransactions) { const fetchTransactionResult = await dispatch(fetchHasActiveTransactions(resourceId)).then(unwrapResult); if (fetchTransactionResult.active !== undefined) { @@ -306,13 +313,13 @@ const ResourceDetailsAccessPolicyTab = ({ defaultUser={user} /> - {roles.length > 0 && !roles[0].isSanitize && + {isSanitize === false && <> {hasAccess(viewUsersAccessRole, user) && } @@ -329,7 +335,7 @@ const ResourceDetailsAccessPolicyTab = ({ } } - {roles.length > 0 && roles[0].isSanitize && + {isSanitize === true && <>
@@ -400,7 +404,7 @@ type AccessPolicyTabFormikProps = { export const AccessPolicyTable = ({ isUserTable, policiesFiltered, - rolesFilteredbyPolicies, + hasUser, header, firstColumnHeader, createLabel, @@ -408,12 +412,13 @@ export const AccessPolicyTable = ({ hasActions, transactions, aclActions, - roles, editAccessRole, }: { isUserTable: boolean policiesFiltered: TransformedAcl[] - rolesFilteredbyPolicies: Role[] + // If set, only search roles that do (true) or don't (false) resolve to a user account. + // undefined means no filter (single combined table, sanitized instances). + hasUser: boolean | undefined header?: ParseKeys firstColumnHeader: ParseKeys createLabel: ParseKeys, @@ -421,7 +426,6 @@ export const AccessPolicyTable = ({ hasActions: boolean transactions: { readOnly: boolean } aclActions: { id: string, value: string }[] - roles: Role[] editAccessRole: string }) => { const { t } = useTranslation(); @@ -435,11 +439,26 @@ export const AccessPolicyTable = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const dropdownOptions = useMemo(() => { - return roles.length > 0 - ? formatAclRolesForDropdown(rolesFilteredbyPolicies) - : []; - }, [roles, rolesFilteredbyPolicies]); + // Roles seen in the most recent search, keyed by name, so a selection can be enriched with its + // full Role (including .user) without having to hold or re-fetch the entire roles list. + const roleCacheRef = useRef>(new Map()); + + const fetchRoleOptions = async (inputValue: string): Promise[]> => { + let fetchedRoles = await fetchRolesWithTarget("ACL", { query: inputValue, limit: 50, hasUser }); + + if (aclDefaults) { + const prefixes = aclDefaults["display_role_filter_blacklist_prefixes"]; + fetchedRoles = fetchedRoles.filter(role => + !prefixes.some(prefix => role.name.startsWith(prefix)), + ); + } + + for (const role of fetchedRoles) { + roleCacheRef.current.set(role.name, role); + } + + return formatAclRolesForDropdown(fetchedRoles); + }; const createPolicy = (role: string, withUser: boolean): TransformedAcl => { const user = withUser ? { username: "", name: "", email: "" } : undefined; @@ -462,14 +481,6 @@ export const AccessPolicyTable = ({ return newRole; }; - // Filter available options by custom prefixes from the config - if (aclDefaults) { - const prefixes = aclDefaults["display_role_filter_blacklist_prefixes"]; - rolesFilteredbyPolicies = rolesFilteredbyPolicies.filter(role => - !prefixes.some(prefix => role.name.startsWith(prefix)), - ); - } - return ( <> {/* list of policy details and interface for changing them */} @@ -542,12 +553,13 @@ export const AccessPolicyTable = ({ { if (element) { - const matchingRole = roles.find(role => role.name === element.value); + const matchingRole = roleCacheRef.current.get(element.value); arrayHelpers.replace(formik.values.policies.findIndex(p => p === policy), { ...policy, role: element.value, diff --git a/src/components/users/partials/wizard/AclAccessPage.tsx b/src/components/users/partials/wizard/AclAccessPage.tsx index c591b7ac82..35d1d9363d 100644 --- a/src/components/users/partials/wizard/AclAccessPage.tsx +++ b/src/components/users/partials/wizard/AclAccessPage.tsx @@ -3,13 +3,12 @@ import { useTranslation } from "react-i18next"; import { FormikProps } from "formik"; import Notifications from "../../../shared/Notifications"; import { - Role, checkAcls, fetchAclActions, fetchAclTemplates, fetchRolesWithTarget, } from "../../../../slices/aclSlice"; -import { policiesFiltered, rolesFiltered } from "../../../../utils/aclUtils"; +import { policiesFiltered } from "../../../../utils/aclUtils"; import { useAppDispatch } from "../../../../store"; import { TransformedAcl } from "../../../../slices/aclDetailsSlice"; import { AccessPolicyTable, TemplateSelector } from "../../../shared/modals/ResourceDetailsAccessPolicyTab"; @@ -40,20 +39,22 @@ const AclAccessPage = ({ const [aclTemplates, setAclTemplates] = useState<{ id: string, value: string }[]>([]); const [aclActions, setAclActions] = useState<{ id: string, value: string }[]>([]); - const [roles, setRoles] = useState([]); + const [isSanitize, setIsSanitize] = useState(undefined); const [loading, setLoading] = useState(false); const editAccessRole = "ROLE_UI_SERIES_DETAILS_ACL_EDIT"; useEffect(() => { - // fetch data about roles, acl templates and actions from backend + // fetch data about acl templates and actions from backend async function fetchData() { setLoading(true); const [responseTemplates, responseActions, responseRoles] = await Promise.all([ - fetchAclTemplates(), fetchAclActions(), fetchRolesWithTarget("ACL")]); + fetchAclTemplates(), fetchAclActions(), fetchRolesWithTarget("ACL", { limit: 1 })]); setAclTemplates(responseTemplates); setAclActions(responseActions); - setRoles(responseRoles); + if (responseRoles.length > 0) { + setIsSanitize(responseRoles[0].isSanitize); + } setLoading(false); } @@ -83,12 +84,12 @@ const AclAccessPage = ({ aclTemplates={aclTemplates} /> - {roles.length > 0 && !roles[0].isSanitize && + {isSanitize === false && <> ({ hasActions={aclActions.length > 0} transactions={{ readOnly: false }} aclActions={aclActions} - roles={roles} editAccessRole={editAccessRole} /> ({ hasActions={aclActions.length > 0} transactions={{ readOnly: false }} aclActions={aclActions} - roles={roles} editAccessRole={editAccessRole} /> } - {roles.length > 0 && roles[0].isSanitize && + {isSanitize === true && <> 0} transactions={{ readOnly: false }} aclActions={aclActions} - roles={roles} editAccessRole={editAccessRole} />
diff --git a/src/slices/aclSlice.ts b/src/slices/aclSlice.ts index 2a69b19674..1a62f3de41 100644 --- a/src/slices/aclSlice.ts +++ b/src/slices/aclSlice.ts @@ -216,10 +216,18 @@ export const fetchAclTemplateByName = async (name: string) => { }; // fetch roles for select dialogs and access policy pages -export const fetchRolesWithTarget = async (target: string) => { +export const fetchRolesWithTarget = async (target: string, options?: { + query?: string, + limit?: number, + offset?: number, + hasUser?: boolean, // If set, only return roles that do (true) or don't (false) resolve to an actual user account. +}) => { const params = { - limit: -1, + limit: options?.limit ?? -1, + offset: options?.offset, target: target, + query: options?.query, + hasUser: options?.hasUser, }; const response = await axios.get("/admin-ng/acl/roles.json", { params: params }); From 5c3350d5c0660e15756a9a732ed530384fc6c78b Mon Sep 17 00:00:00 2001 From: Arnei Date: Wed, 7 Oct 2026 16:19:04 +0200 Subject: [PATCH 2/2] Load ACL roles page by page while scrolling Adds PagedDropDown for the role dropdowns, which loads roles in pages of 50 as the list is scrolled or searched, and reverts the DropDown changes it makes unnecessary. This commit was AI generated --- src/components/shared/DropDown.tsx | 29 +- src/components/shared/PagedDropDown.tsx | 285 ++++++++++++++++++ .../modals/ResourceDetailsAccessPolicyTab.tsx | 39 ++- .../adminui/languages/lang-en_US.json | 1 + 4 files changed, 315 insertions(+), 39 deletions(-) create mode 100644 src/components/shared/PagedDropDown.tsx diff --git a/src/components/shared/DropDown.tsx b/src/components/shared/DropDown.tsx index 42f2a1c92f..1f83acd0d6 100644 --- a/src/components/shared/DropDown.tsx +++ b/src/components/shared/DropDown.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { dropDownSpacingTheme, @@ -61,7 +61,6 @@ const DropDown = ({ optionHeight = 25, customCSS, fetchOptions, - loadOptionsOnMount = true, }: { ref?: React.RefObject, boolean, GroupBase>> | null> value: T @@ -88,23 +87,12 @@ const DropDown = ({ optionLineHeight?: string }, fetchOptions?: (inputValue: string) => Promise[]> - // Whether an async (fetchOptions-based) dropdown should eagerly fetch its default option list on - // mount, before the user has interacted with it. Set to false when many instances of this dropdown - // may be mounted at once (e.g. one per table row), to avoid firing one request per instance on - // render; the default option list is then fetched lazily, the first time this instance's menu is - // opened, instead of unconditionally on mount. - loadOptionsOnMount?: boolean }) => { const { t } = useTranslation(); const internalRef = useRef, boolean, GroupBase>> | null>(null); const selectRef = ref ?? internalRef; - // Holds the result of the one-off default-option fetch below, once it has completed. - const [preloadedOptions, setPreloadedOptions] = useState[] | undefined>(undefined); - const [isPreloading, setIsPreloading] = useState(false); - const hasStartedPreload = useRef(false); - const style = dropDownStyle(customCSS ?? {}); useEffect(() => { @@ -115,14 +103,6 @@ const DropDown = ({ }, [menuIsOpen, selectRef]); const openMenu = (open: boolean) => { - // If loadOptionsOnMount === false, fetch option list ourselves here - if (open && fetchOptions && !loadOptionsOnMount && !hasStartedPreload.current) { - hasStartedPreload.current = true; - setIsPreloading(true); - fetchOptions("") - .then(fetched => setPreloadedOptions(formatOptions(fetched, required))) - .finally(() => setIsPreloading(false)); - } if (handleMenuIsOpen !== undefined) { handleMenuIsOpen(open); } @@ -213,9 +193,7 @@ const DropDown = ({ overscanCount={4} />
- // react-select passes a single NoOptionsMessage/LoadingMessage node here (not an array) when - // there are no options to list, e.g. before the user has typed anything into an async dropdown. - ) : children; + ) : null; }, [itemHeight]); const filterOptions = (inputValue: string) => { @@ -271,8 +249,7 @@ const DropDown = ({ options, required, ) - : loadOptionsOnMount || (preloadedOptions ?? []), - isLoading: isPreloading, + : true, cacheOptions: true, loadOptions: fetchOptions ? loadOptionsAsync : loadOptions, placeholder: placeholder, diff --git a/src/components/shared/PagedDropDown.tsx b/src/components/shared/PagedDropDown.tsx new file mode 100644 index 0000000000..83a55602d4 --- /dev/null +++ b/src/components/shared/PagedDropDown.tsx @@ -0,0 +1,285 @@ +import { KeyboardEvent, useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import Select, { + components as SelectComponents, + GroupBase, + MenuListProps, + OptionProps, + ValueContainerProps, +} from "react-select"; +import CreatableSelect from "react-select/creatable"; +import { dropDownSpacingTheme, dropDownStyle } from "../../utils/componentStyles"; +import { DropDownOption } from "./DropDown"; + +// How long to wait after the user stops typing before searching. +const SEARCH_DEBOUNCE_MS = 300; + +// A page may consist only of options that the caller filters out. Look at this many pages in a row for something to +// show before giving up, so that a menu that could be scrolled is not left empty. +const MAX_CONSECUTIVE_EMPTY_PAGES = 10; + +export type DropDownPage = { + options: DropDownOption[] + // Offset to pass to fetch the page after this one, undefined if this was the last page. + nextOffset: number | undefined +} + +// How close to the end of the list, in pixels, counts as having reached it +const NEAR_BOTTOM_PX = 10; + +/** + * The list of options. Tells when its end is in view, and shows that there is more to come while it is loading. + * + * react-select's own onMenuScrollToBottom only reacts to the mouse wheel and touch, not to the keyboard or to + * dragging the scrollbar, so look at the scroll position instead. + */ +const PagedMenuList = ( + props: MenuListProps, boolean, GroupBase>>, +) => { + const { t } = useTranslation(); + // onNearBottom is passed to the select by us, and react-select hands all props on to its components + const { onNearBottom, isLoading } = props.selectProps as typeof props.selectProps & { onNearBottom?: () => void }; + const { innerRef } = props; + const element = useRef(null); + + // react-select needs the list element too, so pass it on + const setRefs = useCallback((node: HTMLDivElement | null) => { + element.current = node; + if (typeof innerRef === "function") { + innerRef(node); + } else if (innerRef) { + innerRef.current = node; + } + }, [innerRef]); + + const checkIfNearBottom = (list: HTMLDivElement | null) => { + if (list && list.scrollHeight - list.clientHeight - list.scrollTop <= NEAR_BOTTOM_PX) { + onNearBottom?.(); + } + }; + + // A list that is not long enough to scroll never gets scrolled, so look again whenever it changes + useEffect(() => { + checkIfNearBottom(element.current); + }); + + return ( + checkIfNearBottom(event.currentTarget) }} + > + {props.children} + {isLoading && props.options.length > 0 && +
+ {t("SELECT_LOADING")} +
+ } +
+ ); +}; + +/** + * The page of options that is being asked for. Everything that is loaded follows from this. + */ +type Request = { + query: string + offset: number +} + +/** + * What was loaded in answer to a request. The options are those of all pages loaded so far for the query. + */ +type Loaded = Request & { + options: DropDownOption[] + nextOffset: number | undefined +} + +/** + * A dropdown that loads its options from the server, a page at a time, as the user scrolls down the list or + * searches. The order of the options is the order in which the server returned them. + * + * Intended for lists that are too long to fetch at once. Other dropdowns should use DropDown. + */ +const PagedDropDown = ({ + value, + text, + fetchPage, + handleChange, + placeholder, + tabIndex = 0, + creatable = false, + disabled = false, + customCSS, +}: { + value: string + text: string + // Fetches the page of options that starts at the given offset, for the given search text. + fetchPage: (inputValue: string, offset: number) => Promise + handleChange: (option: {value: string, label: string} | null) => void + placeholder: string + tabIndex?: number + creatable?: boolean + disabled?: boolean + customCSS?: Parameters[0] +}) => { + const { t } = useTranslation(); + + // Always call the latest fetchPage, without having to start over whenever the caller passes a new function + const fetchPageRef = useRef(fetchPage); + useEffect(() => { + fetchPageRef.current = fetchPage; + }, [fetchPage]); + + const [menuIsOpen, setMenuIsOpen] = useState(false); + // What the user typed, and what is currently being asked for (which follows the input after a pause) + const [inputValue, setInputValue] = useState(""); + const [request, setRequest] = useState({ query: "", offset: 0 }); + const [loaded, setLoaded] = useState(undefined); + + const isLoading = menuIsOpen + && (loaded === undefined || loaded.query !== request.query || loaded.offset !== request.offset); + + // Search for what was typed, once the user pauses + useEffect(() => { + if (!menuIsOpen) { + return; + } + const timeout = setTimeout(() => { + setRequest(current => current.query === inputValue ? current : { query: inputValue, offset: 0 }); + }, SEARCH_DEBOUNCE_MS); + return () => clearTimeout(timeout); + }, [inputValue, menuIsOpen]); + + // Load what was asked for. If the request changes before the answer arrives, the answer is not wanted any more. + useEffect(() => { + if (!menuIsOpen) { + return; + } + + let isOutdated = false; + const load = async () => { + let options: DropDownOption[] = []; + let nextOffset: number | undefined = request.offset; + try { + for (let i = 0; i < MAX_CONSECUTIVE_EMPTY_PAGES && nextOffset !== undefined; i++) { + const page = await fetchPageRef.current(request.query, nextOffset); + options = options.concat(page.options); + nextOffset = page.nextOffset; + if (isOutdated || options.length > 0) { + break; + } + } + } catch { + // Show what there is, and do not try again + nextOffset = undefined; + } + if (isOutdated) { + return; + } + + setLoaded(previous => { + if (previous === undefined || previous.query !== request.query || request.offset === 0) { + return { ...request, options, nextOffset }; + } + const known = new Set(previous.options.map(option => option.value)); + return { + ...request, + options: previous.options.concat(options.filter(option => !known.has(option.value))), + nextOffset, + }; + }); + }; + void load(); + + return () => { + isOutdated = true; + }; + }, [menuIsOpen, request]); + + const onMenuOpen = () => { + // Fetch lazily: there can be a lot of these dropdowns on one page, so do not fetch before one is opened. + setLoaded(undefined); + setRequest({ query: "", offset: 0 }); + setMenuIsOpen(true); + }; + + const onInputChange = (newValue: string, meta: { action: string }) => { + // The input is reset when the menu closes, or an option is chosen + setInputValue(meta.action === "input-change" ? newValue : ""); + }; + + const onNearBottom = () => { + if (!isLoading && loaded?.nextOffset !== undefined) { + setRequest({ query: loaded.query, offset: loaded.nextOffset }); + } + }; + + const onKeyDown = (event: KeyboardEvent) => { + // Moving down from the last option wraps around to the first one. Do not do that while the list is still + // being filled, so that the user ends up at the new options instead. + if (event.key === "ArrowDown" && isLoading) { + event.preventDefault(); + } + }; + + /** + * Wrapper that adds the title attribute to options, which should result + * in a native tooltip that is intended to help with very long labels. + */ + const OptionWithTitle = useCallback(( + props: OptionProps, boolean, GroupBase>>, + ) => ( + + ), []); + + /** + * Same wrapper as above, but for the input field. + */ + const ValueContainerWithTitle = useCallback(( + props: ValueContainerProps, boolean, GroupBase>>, + ) => ( + + ), []); + + const commonProps = { + tabIndex: tabIndex, + theme: dropDownSpacingTheme, + styles: dropDownStyle(customCSS ?? {}), + isSearchable: true, + value: { value: value, label: text === "" ? placeholder : text }, + options: loaded?.options ?? [], + // The server does the searching + filterOption: null, + inputValue: inputValue, + onInputChange: onInputChange, + isLoading: isLoading, + placeholder: placeholder, + onChange: (element: unknown) => handleChange(element as { value: string, label: string } | null), + menuIsOpen: menuIsOpen, + onMenuOpen: onMenuOpen, + onMenuClose: () => setMenuIsOpen(false), + onNearBottom: onNearBottom, + onKeyDown: onKeyDown, + isDisabled: disabled, + menuPlacement: "auto" as const, + components: { + MenuList: PagedMenuList, + Option: OptionWithTitle, + ValueContainer: ValueContainerWithTitle, + }, + noOptionsMessage: () => t("SELECT_NO_MATCHING_RESULTS"), + loadingMessage: () => t("SELECT_LOADING"), + }; + + return creatable ? ( + + ) : ( +