From db79be5780859a6fc4f5c8480813b3cb94884cba Mon Sep 17 00:00:00 2001 From: Arnei Date: Tue, 22 Jul 2025 13:52:53 +0200 Subject: [PATCH 1/2] Make event metadata load faster by fetching series options asynchronously If you have many series in your Opencast, any modal that displays event metadata can take several seconds to load. This patch aims at pushing the loading time back into millisecond terrritory no matter how many series there are. The problem is that when fetching event metadata from the backend, the backend also fetch all series options, which can take too long. So we change the backend to not do that, and change the frontend to fetch the series options separately. ideally we could also do this for other metadata fields with options (notably contributors and presenters), but the endpoint lacks the necessary filter capabilities. --- src/components/shared/DropDown.tsx | 52 +++++---- .../modals/ResourceDetailsAccessPolicyTab.tsx | 5 - src/components/shared/wizard/RenderField.tsx | 101 +++++++++++++++--- src/utils/resourceUtils.ts | 21 ++++ 4 files changed, 136 insertions(+), 43 deletions(-) diff --git a/src/components/shared/DropDown.tsx b/src/components/shared/DropDown.tsx index fa0539104c..83421c81d2 100644 --- a/src/components/shared/DropDown.tsx +++ b/src/components/shared/DropDown.tsx @@ -45,7 +45,7 @@ const DropDown = ({ ref?: React.RefObject> | null> value: T text: string, - options: DropDownOption[], + options?: DropDownOption[], required: boolean, handleChange: (option: {value: T, label: string} | null) => void placeholder: string @@ -66,7 +66,7 @@ const DropDown = ({ optionPaddingTop?: number, optionLineHeight?: string }, - fetchOptions?: () => { label: string, value: string}[] + fetchOptions?: (inputValue: string) => Promise<{ label: string, value: string }[]> }) => { const { t } = useTranslation(); @@ -157,14 +157,29 @@ const DropDown = ({ ) : null; }; + const filterOptions = (inputValue: string) => { + if (options) { + return options.filter(option => + option.label.toLowerCase().includes(inputValue.toLowerCase()), + ); + } + return []; + }; + + const loadOptionsAsync = (inputValue: string, callback: (options: DropDownOption[]) => void) => { + setTimeout(async () => { + callback(formatOptions( + fetchOptions ? await fetchOptions(inputValue) : filterOptions(inputValue), + required, + )); + }, 1000); + }; + const loadOptions = ( inputValue: string, callback: (options: DropDownOption[]) => void, ) => { - callback(formatOptions( - fetchOptions ? fetchOptions() : options, - required, - )); + callback(formatOptions(filterOptions(inputValue), required)); }; @@ -176,10 +191,14 @@ const DropDown = ({ autoFocus: autoFocus, isSearchable: true, value: { value: value, label: text === "" ? placeholder : text }, - options: formatOptions( - options, - required, - ), + defaultOptions: options + ? formatOptions( + options, + required, + ) + : true, + cacheOptions: true, + loadOptions: fetchOptions ? loadOptionsAsync : loadOptions, placeholder: placeholder, onChange: element => handleChange(element as {value: T, label: string}), menuIsOpen: menuIsOpen, @@ -191,31 +210,18 @@ const DropDown = ({ // @ts-expect-error: React-Select typing does not account for the typing of option it itself requires components: { MenuList }, - filterOption: createFilter({ ignoreAccents: false }), // To improve performance on filtering }; return creatable ? ( ) : ( t("SELECT_NO_MATCHING_RESULTS")} - cacheOptions - defaultOptions={formatOptions( - options, - required, - )} - loadOptions={loadOptions} /> ); }; diff --git a/src/components/shared/modals/ResourceDetailsAccessPolicyTab.tsx b/src/components/shared/modals/ResourceDetailsAccessPolicyTab.tsx index 77bc8be567..cdc78708d9 100644 --- a/src/components/shared/modals/ResourceDetailsAccessPolicyTab.tsx +++ b/src/components/shared/modals/ResourceDetailsAccessPolicyTab.tsx @@ -543,11 +543,6 @@ export const AccessPolicyTable = ({ ? formatAclRolesForDropdown(rolesFilteredbyPolicies) : [] } - fetchOptions={() => - roles.length > 0 - ? formatAclRolesForDropdown(rolesFilteredbyPolicies) - : [] - } required={true} creatable={true} handleChange={element => { diff --git a/src/components/shared/wizard/RenderField.tsx b/src/components/shared/wizard/RenderField.tsx index 00b4fbe5c0..0f77616fba 100644 --- a/src/components/shared/wizard/RenderField.tsx +++ b/src/components/shared/wizard/RenderField.tsx @@ -1,8 +1,8 @@ -import React, { useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import DatePicker from "react-datepicker"; import cn from "classnames"; -import { getMetadataCollectionFieldName } from "../../../utils/resourceUtils"; +import { getMetadataCollectionFieldName, transformListProvider } from "../../../utils/resourceUtils"; import { getCurrentLanguageInformation } from "../../../utils/utils"; import DropDown from "../DropDown"; import { parseISO } from "date-fns"; @@ -10,6 +10,7 @@ import { FieldProps } from "formik"; import { MetadataField } from "../../../slices/eventSlice"; import { GroupBase, SelectInstance } from "react-select"; import TextareaAutosize from "react-textarea-autosize"; +import axios from "axios"; /** * This component renders an editable field for single values depending on the type of the corresponding metadata @@ -65,7 +66,7 @@ const RenderField = ({ )} {metadataField.type === "text" && !!metadataField.collection && - metadataField.collection.length > 0 && ( + ( void ref: React.RefObject>> -}) => { +}) +const EditableSingleSelect = (props: EditableSingleSelectProps) => { const { t } = useTranslation(); + const { + field, + metadataField, + text, + form: { setFieldValue }, + isFirstField, + focused, + setFocused, + ref, + } = props; + + if (metadataField.id === "isPartOf") { + return ; + } + return ( { + const { t } = useTranslation(); + + const [label, setLabel] = useState(""); + + useEffect(() => { + // The metadata catalog only contains the field value, so we need to fetch the label ourselves + const fetchLabelById = async () => { + if (field.value) { + const res = await axios.get<{ [key: string]: string }>(`/admin-ng/resources/SERIES.WRITE_ONLY.json?limit=1&filter=textFilter:${field.value}`); + const data = res.data; + const transformedData = transformListProvider(data); + if (transformedData.length > 0) { + setLabel(transformedData[0].label); + } + } + }; + fetchLabelById(); + }, [field.value]); + + // Fetch collection + const fetchOptions = async (inputValue: string) => { + const res = await axios.get<{ [key: string]: string }>(`/admin-ng/resources/SERIES.WRITE_ONLY.json?filter=textFilter:*${inputValue}*`); + const data = res.data; + return transformListProvider(data); + }; + + return ( + element && setFieldValue(field.name, element.value)} + placeholder={focused + ? `-- ${t("SELECT_NO_OPTION_SELECTED")} --` + : `${t("SELECT_NO_OPTION_SELECTED")}` + } + customCSS={{ isMetadataStyle: focused ? false : true }} + handleMenuIsOpen={(open: boolean) => setFocused(open)} + openMenuOnFocus + autoFocus={isFirstField} + skipTranslate={!metadataField.translatable} + /> + ); +}; + export default RenderField; diff --git a/src/utils/resourceUtils.ts b/src/utils/resourceUtils.ts index ce72afa7dc..ba6d007bab 100644 --- a/src/utils/resourceUtils.ts +++ b/src/utils/resourceUtils.ts @@ -177,6 +177,27 @@ export const transformMetadataFields = (metadata: MetadataField[]) => { return metadata; }; +export const transformListProvider = (collection: { [key: string]: string }) => { + return Object.entries(collection) + .map(([key, value]) => { + if (isJson(value)) { + // TODO: Handle JSON parsing errors + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const collectionParsed: { [key: string]: string } = JSON.parse(value); + return { + label: collectionParsed.label || value, + value: key, + ...collectionParsed, + }; + } else { + return { + label: value, + value: key, + }; + } + }); +}; + // transform metadata catalog for update via post request export const transformMetadataForUpdate = (catalog: MetadataCatalog, values: { [key: string]: MetadataCatalog["fields"][0]["value"] }) => { const fields: MetadataCatalog["fields"] = []; From 59f7ae0d398d62c3dfaa1b704639afa22e7b1f05 Mon Sep 17 00:00:00 2001 From: Arnei Date: Fri, 8 Aug 2025 11:30:28 +0200 Subject: [PATCH 2/2] Fix series not loading Apparently our textFilter is now automatically treated as wildcard search and the "*" we used to trigger wildcard search are now breaking it, so we can just remove them. --- src/components/shared/wizard/RenderField.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/shared/wizard/RenderField.tsx b/src/components/shared/wizard/RenderField.tsx index 0f77616fba..b728cd7750 100644 --- a/src/components/shared/wizard/RenderField.tsx +++ b/src/components/shared/wizard/RenderField.tsx @@ -366,7 +366,7 @@ const EditableSingleSelectSeries = ({ // Fetch collection const fetchOptions = async (inputValue: string) => { - const res = await axios.get<{ [key: string]: string }>(`/admin-ng/resources/SERIES.WRITE_ONLY.json?filter=textFilter:*${inputValue}*`); + const res = await axios.get<{ [key: string]: string }>(`/admin-ng/resources/SERIES.WRITE_ONLY.json?filter=textFilter:${inputValue}`); const data = res.data; return transformListProvider(data); };