diff --git a/src/components/Dropdown/asyncDropdown/AsyncSearchTags.tsx b/src/components/Dropdown/asyncDropdown/AsyncSearchTags.tsx deleted file mode 100644 index f2397b99cf..0000000000 --- a/src/components/Dropdown/asyncDropdown/AsyncSearchTags.tsx +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Copyright (c) 2020-present, NDLA. - * - * This source code is licensed under the GPLv3 license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import { FieldInputProps, FormikHelpers } from "formik"; -import { useState, useEffect } from "react"; -import { useTranslation } from "react-i18next"; -//@ts-ignore -import { DropdownInput } from "@ndla/forms"; -import AsyncDropdown from "../../../components/Dropdown/asyncDropdown/AsyncDropdown"; -import { SearchResultBase } from "../../../interfaces"; - -export const formatTagToList = (newTag: string, existingTags: string[]): string[] => { - if (newTag.includes(",")) { - const tagList = newTag.split(",").map((tag) => tag.trim()); - const temp = [...existingTags, ...tagList]; - // Return unique list - return [...new Set(temp)]; - } else return [...existingTags, newTag.trim()]; -}; - -interface Props { - language: string; - initialTags: string[]; - field?: FieldInputProps; - form?: FormikHelpers; - fetchTags: (input: string, language: string) => Promise>; - updateValue?: (value: string[]) => void; - disableCreate?: boolean; - multiSelect?: boolean; -} - -interface TagWithTitle { - title: string; -} - -const AsyncSearchTags = ({ - language, - initialTags, - field, - form, - fetchTags, - updateValue, - multiSelect, - disableCreate, -}: Props) => { - const { t } = useTranslation(); - const convertToTagsWithTitle = (tagsWithoutTitle: string[]) => { - return tagsWithoutTitle.map((tag) => ({ title: tag })); - }; - - const [tags, setTags] = useState(initialTags); - - useEffect(() => { - setTags(initialTags); - }, [initialTags]); - - const searchForTags = async (inp: string) => { - const response = await fetchTags(inp, language); - const tagsWithTitle = convertToTagsWithTitle(response.results); - return { ...response, results: tagsWithTitle }; - }; - - const updateField = (newData: string[]) => { - setTags(newData || []); - if (form && field) { - form.setFieldTouched(field.name, true, true); - form.setFieldValue(field.name, newData || null, true); - } else if (updateValue) { - updateValue(newData); - } - }; - - const addTag = (tag: TagWithTitle) => { - if (tag && !tags.includes(tag.title)) { - const temp = [...tags, tag.title]; - updateField(temp); - } - }; - - const createNewTag = (newTag: string) => { - if (newTag && !tags.includes(newTag.trim())) { - const temp = formatTagToList(newTag, tags); - updateField(temp); - } - }; - - const removeTag = (tag: string) => { - const reduced_array = tags.filter((t) => t !== tag); - setTags(reduced_array); - updateField(reduced_array); - }; - - return ( - <> - - {({ selectedItems, value, removeItem, onBlur, onChange, onKeyDown }) => ( - - )} - - - ); -}; - -export default AsyncSearchTags; diff --git a/src/components/Dropdown/asyncDropdown/__tests__/csv-tag-input-test.ts b/src/components/Dropdown/asyncDropdown/__tests__/csv-tag-input-test.ts deleted file mode 100644 index 70930fbd6b..0000000000 --- a/src/components/Dropdown/asyncDropdown/__tests__/csv-tag-input-test.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) 2022-present, NDLA. - * - * This source code is licensed under the GPLv3 license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import { formatTagToList } from "../AsyncSearchTags"; - -test("csv/string input is correctly returned", () => { - expect(formatTagToList("tag2, tag3", ["tag1"])).toStrictEqual(["tag1", "tag2", "tag3"]); - expect(formatTagToList("tag2, tag1", ["tag1"])).toStrictEqual(["tag1", "tag2"]); - expect(formatTagToList("tag2, tag3, tag3", ["tag1"])).toStrictEqual(["tag1", "tag2", "tag3"]); - expect(formatTagToList("tag2", ["tag1"])).toStrictEqual(["tag1", "tag2"]); -}); diff --git a/src/components/Form/SearchTagsContent.tsx b/src/components/Form/SearchTagsContent.tsx new file mode 100644 index 0000000000..6da4350814 --- /dev/null +++ b/src/components/Form/SearchTagsContent.tsx @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2024-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { forwardRef } from "react"; +import { useTranslation } from "react-i18next"; +import { ComboboxContent, ComboboxContentProps, ComboboxPositioner, Spinner, Text } from "@ndla/primitives"; + +interface Props extends ComboboxContentProps { + isFetching: boolean; + hits: number; +} + +export const SearchTagsContent = forwardRef(({ isFetching, hits, children, ...props }) => { + const { t } = useTranslation(); + return ( + + + {isFetching ? : hits ? children : {t("dropdown.numberHits", { hits: 0 })}} + + + ); +}); diff --git a/src/components/Form/SearchTagsTagSelectorInput.tsx b/src/components/Form/SearchTagsTagSelectorInput.tsx new file mode 100644 index 0000000000..d8c606f898 --- /dev/null +++ b/src/components/Form/SearchTagsTagSelectorInput.tsx @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2024-present, NDLA. + * + * This source code is licensed under the GPLv3 license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { forwardRef } from "react"; +import { CloseLine } from "@ndla/icons/action"; +import { ArrowDownShortLine } from "@ndla/icons/common"; +import { IconButton, InputContainer } from "@ndla/primitives"; +import { HStack } from "@ndla/styled-system/jsx"; +import { + TagSelectorClearTrigger, + TagSelectorControl, + TagSelectorInput, + TagSelectorTrigger, + TagSelectorInputProps, +} from "@ndla/ui"; + +interface Props extends TagSelectorInputProps {} + +export const SearchTagsTagSelectorInput = forwardRef((props, ref) => { + return ( + + + + + + + + + + + + + + + + + + ); +}); diff --git a/src/components/SlateEditor/plugins/concept/ConceptModalContent.tsx b/src/components/SlateEditor/plugins/concept/ConceptModalContent.tsx index 401068e1e5..2b69850c4a 100644 --- a/src/components/SlateEditor/plugins/concept/ConceptModalContent.tsx +++ b/src/components/SlateEditor/plugins/concept/ConceptModalContent.tsx @@ -35,7 +35,6 @@ import { IConceptSearchResult, INewConcept, IUpdatedConcept, - ITagsSearchResult, IConceptSummary, } from "@ndla/types-backend/concept-api"; import { IArticle } from "@ndla/types-backend/draft-api"; @@ -53,7 +52,6 @@ interface Props { addConcept: (concept: IConceptSummary | IConcept) => void; concept?: IConcept; createConcept: (createdConcept: INewConcept) => Promise; - fetchSearchTags: (input: string, language: string) => Promise; handleRemove: () => void; onClose: () => void; locale: string; @@ -80,7 +78,6 @@ const ConceptModalContent = ({ updateConcept, createConcept, concept, - fetchSearchTags, conceptArticles, conceptType, }: Props) => { @@ -241,7 +238,6 @@ const ConceptModalContent = ({ subjects={subjects} upsertProps={upsertProps} language={locale} - fetchConceptTags={fetchSearchTags} concept={concept} conceptArticles={conceptArticles} initialTitle={selectedText} diff --git a/src/components/SlateEditor/plugins/concept/inline/InlineWrapper.tsx b/src/components/SlateEditor/plugins/concept/inline/InlineWrapper.tsx index dc3e95b366..2413d3f2e4 100644 --- a/src/components/SlateEditor/plugins/concept/inline/InlineWrapper.tsx +++ b/src/components/SlateEditor/plugins/concept/inline/InlineWrapper.tsx @@ -89,8 +89,10 @@ const InlineWrapper = (props: Props) => { const nodeText = Node.string(element).trim(); const [isEditing, setIsEditing] = useState(element.isFirstEdit); const locale = useArticleLanguage(); - const { concept, subjects, loading, fetchSearchTags, conceptArticles, createConcept, updateConcept } = - useFetchConceptData(parseInt(element.data.contentId), locale); + const { concept, subjects, loading, conceptArticles, createConcept, updateConcept } = useFetchConceptData( + parseInt(element.data.contentId), + locale, + ); const visualElementQuery = useConceptVisualElement(concept?.id!, concept?.visualElement?.visualElement!, locale, { enabled: !!concept?.id && !!concept?.visualElement?.visualElement.length, @@ -258,7 +260,6 @@ const InlineWrapper = (props: Props) => { subjects={subjects} handleRemove={handleRemove} selectedText={nodeText} - fetchSearchTags={fetchSearchTags} createConcept={createConcept} updateConcept={updateConcept} conceptArticles={conceptArticles} diff --git a/src/containers/AudioUploader/components/AudioMetaData.tsx b/src/containers/AudioUploader/components/AudioMetaData.tsx index 98a697e2ac..afe32b8dfc 100644 --- a/src/containers/AudioUploader/components/AudioMetaData.tsx +++ b/src/containers/AudioUploader/components/AudioMetaData.tsx @@ -6,33 +6,85 @@ * */ -import { FieldProps, useFormikContext } from "formik"; +import { useFormikContext } from "formik"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { createListCollection } from "@ark-ui/react"; +import { CheckLine } from "@ndla/icons/editor"; +import { + ComboboxItem, + ComboboxItemIndicator, + ComboboxItemText, + FieldErrorMessage, + FieldHelper, + FieldRoot, + Input, +} from "@ndla/primitives"; +import { TagSelectorLabel, TagSelectorRoot, useTagSelectorTranslations } from "@ndla/ui"; import { AudioFormikType } from "./AudioForm"; -import AsyncSearchTags from "../../../components/Dropdown/asyncDropdown/AsyncSearchTags"; -import FormikField from "../../../components/FormikField"; -import { fetchSearchTags } from "../../../modules/audio/audioApi"; +import { SearchTagsContent } from "../../../components/Form/SearchTagsContent"; +import { SearchTagsTagSelectorInput } from "../../../components/Form/SearchTagsTagSelectorInput"; +import { FormField } from "../../../components/FormField"; +import { useAudioSearchTags } from "../../../modules/audio/audioQueries"; +import useDebounce from "../../../util/useDebounce"; const AudioMetaData = () => { - const { - values: { language, tags }, - } = useFormikContext(); + const { values } = useFormikContext(); const { t } = useTranslation(); + const [inputQuery, setInputQuery] = useState(""); + const debouncedQuery = useDebounce(inputQuery, 300); + const tagSelectorTranslations = useTagSelectorTranslations(); + const searchTagsQuery = useAudioSearchTags( + { + input: debouncedQuery, + language: values.language, + }, + { + enabled: !!debouncedQuery.length, + placeholderData: (prev) => prev, + }, + ); + + const collection = useMemo(() => { + return createListCollection({ + items: searchTagsQuery.data?.results ?? [], + itemToValue: (item) => item, + itemToString: (item) => item, + }); + }, [searchTagsQuery.data?.results]); + return ( - <> - - {({ field, form }: FieldProps) => ( - - )} - - + + {({ field, meta, helpers }) => ( + + helpers.setValue(details.value)} + translations={tagSelectorTranslations} + inputValue={inputQuery} + onInputValueChange={(details) => setInputQuery(details.inputValue)} + > + {t("form.tags.label")} + {meta.error} + {t("form.tags.description")} + + + + + {collection.items.map((item) => ( + + {item} + + + + + ))} + + + + )} + ); }; diff --git a/src/containers/ConceptPage/ConceptForm/ConceptForm.tsx b/src/containers/ConceptPage/ConceptForm/ConceptForm.tsx index 0e96771b4b..f69773181c 100644 --- a/src/containers/ConceptPage/ConceptForm/ConceptForm.tsx +++ b/src/containers/ConceptPage/ConceptForm/ConceptForm.tsx @@ -9,13 +9,7 @@ import { Formik, FormikProps, FormikHelpers } from "formik"; import { useState, useMemo, useCallback } from "react"; import { useTranslation } from "react-i18next"; -import { - IConcept, - INewConcept, - IUpdatedConcept, - ITagsSearchResult, - IConceptSummary, -} from "@ndla/types-backend/concept-api"; +import { IConcept, INewConcept, IUpdatedConcept, IConceptSummary } from "@ndla/types-backend/concept-api"; import { IArticle } from "@ndla/types-backend/draft-api"; import { Node } from "@ndla/types-taxonomy"; import ConceptFormFooter from "./ConceptFormFooter"; @@ -49,7 +43,6 @@ interface Props { upsertProps: CreateProps | UpdateProps; concept?: IConcept; conceptChanged?: boolean; - fetchConceptTags: (input: string, language: string) => Promise; inModal: boolean; isNewlyCreated?: boolean; conceptArticles: IArticle[]; @@ -122,7 +115,6 @@ const conceptRules: RulesType = { const ConceptForm = ({ concept, conceptChanged, - fetchConceptTags, inModal, isNewlyCreated = false, onClose, @@ -235,12 +227,7 @@ const ConceptForm = ({ title={t("form.metadataSection")} hasError={!!(errors.tags || errors.metaImageAlt || errors.subjects)} > - + diff --git a/src/containers/ConceptPage/CreateConcept.tsx b/src/containers/ConceptPage/CreateConcept.tsx index be52a0b5a7..cff30628c1 100644 --- a/src/containers/ConceptPage/CreateConcept.tsx +++ b/src/containers/ConceptPage/CreateConcept.tsx @@ -23,7 +23,7 @@ interface Props { const CreateConcept = ({ inModal = false, addConceptInModal }: Props) => { const { t, i18n } = useTranslation(); const navigate = useNavigate(); - const { subjects, createConcept, fetchSearchTags, conceptArticles } = useFetchConceptData(undefined, i18n.language); + const { subjects, createConcept, conceptArticles } = useFetchConceptData(undefined, i18n.language); const onCreate = useCallback( async (createdConcept: INewConcept) => { @@ -44,7 +44,6 @@ const CreateConcept = ({ inModal = false, addConceptInModal }: Props) => { { const conceptId = Number(params.id) || undefined; const selectedLanguage = params.selectedLanguage as LocaleType; const { t } = useTranslation(); - const { concept, setConcept, fetchSearchTags, conceptArticles, loading, conceptChanged, subjects, updateConcept } = + const { concept, setConcept, conceptArticles, loading, conceptChanged, subjects, updateConcept } = useFetchConceptData(conceptId, selectedLanguage!); const { shouldTranslate, translate, translating } = useTranslateToNN(); @@ -75,7 +75,6 @@ const EditConcept = ({ isNewlyCreated }: Props) => { concept={concept} conceptArticles={conceptArticles} conceptChanged={conceptChanged || newLanguage} - fetchConceptTags={fetchSearchTags} isNewlyCreated={isNewlyCreated} upsertProps={{ onUpdate: (concept) => updateConcept(conceptId, concept), diff --git a/src/containers/ConceptPage/components/ConceptMetaData.tsx b/src/containers/ConceptPage/components/ConceptMetaData.tsx index 13fefa1b63..8790bd26c9 100644 --- a/src/containers/ConceptPage/components/ConceptMetaData.tsx +++ b/src/containers/ConceptPage/components/ConceptMetaData.tsx @@ -7,28 +7,64 @@ */ import { useFormikContext } from "formik"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { ITagsSearchResult } from "@ndla/types-backend/concept-api"; +import { createListCollection } from "@ark-ui/react"; +import { CheckLine } from "@ndla/icons/editor"; +import { + ComboboxItem, + ComboboxItemIndicator, + ComboboxItemText, + FieldErrorMessage, + FieldHelper, + FieldRoot, + Input, +} from "@ndla/primitives"; import { Node } from "@ndla/types-taxonomy"; +import { TagSelectorLabel, TagSelectorRoot, useTagSelectorTranslations } from "@ndla/ui"; import InlineImageSearch from "./InlineImageSearch"; -import AsyncSearchTags from "../../../components/Dropdown/asyncDropdown/AsyncSearchTags"; import MultiSelectDropdown from "../../../components/Dropdown/MultiSelectDropdown"; +import { SearchTagsContent } from "../../../components/Form/SearchTagsContent"; +import { SearchTagsTagSelectorInput } from "../../../components/Form/SearchTagsTagSelectorInput"; +import { FormField } from "../../../components/FormField"; import FormikField from "../../../components/FormikField"; +import { useConceptSearchTags } from "../../../modules/concept/conceptQueries"; +import useDebounce from "../../../util/useDebounce"; import { MetaImageSearch } from "../../FormikForm"; import { onSaveAsVisualElement } from "../../FormikForm/utils"; import { ConceptFormValues } from "../conceptInterfaces"; interface Props { subjects: Node[]; - fetchTags: (input: string, language: string) => Promise; inModal: boolean; language?: string; } -const ConceptMetaData = ({ subjects, fetchTags, inModal, language }: Props) => { +const ConceptMetaData = ({ subjects, inModal, language }: Props) => { const { t } = useTranslation(); const formikContext = useFormikContext(); + const tagSelectorTranslations = useTagSelectorTranslations(); const { values } = formikContext; + const [inputQuery, setInputQuery] = useState(""); + const debouncedQuery = useDebounce(inputQuery, 300); + const searchTagsQuery = useConceptSearchTags( + { + input: debouncedQuery, + language: values.language, + }, + { + enabled: !!debouncedQuery.length, + placeholderData: (prev) => prev, + }, + ); + + const collection = useMemo(() => { + return createListCollection({ + items: searchTagsQuery.data?.results ?? [], + itemToValue: (item) => item, + itemToString: (item) => item, + }); + }, [searchTagsQuery.data?.results]); return ( <> @@ -52,18 +88,37 @@ const ConceptMetaData = ({ subjects, fetchTags, inModal, language }: Props) => { {({ field }) => } - - {({ field, form }) => ( - + + {({ field, meta, helpers }) => ( + + helpers.setValue(details.value)} + translations={tagSelectorTranslations} + inputValue={inputQuery} + onInputValueChange={(details) => setInputQuery(details.inputValue)} + > + {t("form.tags.label")} + {meta.error} + {t("form.tags.description")} + + + + + {collection.items.map((item) => ( + + {item} + + + + + ))} + + + )} - + ); }; diff --git a/src/containers/FormikForm/MetaDataField.tsx b/src/containers/FormikForm/MetaDataField.tsx index 59980002b9..92d1c1f2e0 100644 --- a/src/containers/FormikForm/MetaDataField.tsx +++ b/src/containers/FormikForm/MetaDataField.tsx @@ -6,10 +6,18 @@ * */ -import { memo } from "react"; +import { memo, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { createListCollection } from "@ark-ui/react"; +import { CheckLine } from "@ndla/icons/editor"; import { + ComboboxItem, + ComboboxItemIndicator, + ComboboxItemText, + FieldErrorMessage, + FieldHelper, FieldRoot, + Input, RadioGroupItem, RadioGroupItemControl, RadioGroupItemHiddenInput, @@ -18,15 +26,18 @@ import { RadioGroupRoot, } from "@ndla/primitives"; import { IImageMetaInformationV3 } from "@ndla/types-backend/image-api"; +import { TagSelectorLabel, TagSelectorRoot, useTagSelectorTranslations } from "@ndla/ui"; import { MetaImageSearch } from "."; -import AsyncSearchTags from "../../components/Dropdown/asyncDropdown/AsyncSearchTags"; +import { SearchTagsContent } from "../../components/Form/SearchTagsContent"; +import { SearchTagsTagSelectorInput } from "../../components/Form/SearchTagsTagSelectorInput"; import { FormField } from "../../components/FormField"; import FormikField from "../../components/FormikField"; import { FormContent } from "../../components/FormikForm"; import PlainTextEditor from "../../components/SlateEditor/PlainTextEditor"; import { textTransformPlugin } from "../../components/SlateEditor/plugins/textTransform"; import { DRAFT_ADMIN_SCOPE } from "../../constants"; -import { fetchSearchTags } from "../../modules/draft/draftApi"; +import { useDraftSearchTags } from "../../modules/draft/draftQueries"; +import useDebounce from "../../util/useDebounce"; import { useSession } from "../Session/SessionProvider"; interface Props { @@ -40,22 +51,62 @@ const availabilityValues: string[] = ["everyone", "teacher"]; const MetaDataField = ({ articleLanguage, showCheckbox, checkboxAction }: Props) => { const { t } = useTranslation(); const { userPermissions } = useSession(); + const tagSelectorTranslations = useTagSelectorTranslations(); const plugins = [textTransformPlugin]; + const [inputQuery, setInputQuery] = useState(""); + const debouncedQuery = useDebounce(inputQuery, 300); + const searchTagsQuery = useDraftSearchTags( + { + input: debouncedQuery, + language: articleLanguage, + }, + { + enabled: !!debouncedQuery.length, + placeholderData: (prev) => prev, + }, + ); + + const collection = useMemo(() => { + return createListCollection({ + items: searchTagsQuery.data?.results ?? [], + itemToValue: (item) => item, + itemToString: (item) => item, + }); + }, [searchTagsQuery.data?.results]); return ( - - {({ field, form }) => ( - + + {({ field, meta, helpers }) => ( + + helpers.setValue(details.value)} + translations={tagSelectorTranslations} + inputValue={inputQuery} + onInputValueChange={(details) => setInputQuery(details.inputValue)} + > + {t("form.tags.label")} + {meta.error} + {t("form.tags.description")} + + + + + {collection.items.map((item) => ( + + {item} + + + + + ))} + + + )} - + {userPermissions?.includes(DRAFT_ADMIN_SCOPE) && ( {({ field, helpers }) => ( diff --git a/src/containers/FormikForm/formikConceptHooks.tsx b/src/containers/FormikForm/formikConceptHooks.tsx index 21cd43b994..322483d9d4 100644 --- a/src/containers/FormikForm/formikConceptHooks.tsx +++ b/src/containers/FormikForm/formikConceptHooks.tsx @@ -13,7 +13,6 @@ import { IArticle, IUserData } from "@ndla/types-backend/draft-api"; import { Node } from "@ndla/types-taxonomy"; import { LAST_UPDATED_SIZE, TAXONOMY_CUSTOM_FIELD_SUBJECT_FOR_CONCEPT } from "../../constants"; import * as conceptApi from "../../modules/concept/conceptApi"; -import { fetchSearchTags } from "../../modules/concept/conceptApi"; import { fetchDraft } from "../../modules/draft/draftApi"; import { useUpdateUserDataMutation, useUserData } from "../../modules/draft/draftQueries"; import { fetchNodes } from "../../modules/nodes/nodeApi"; @@ -100,7 +99,6 @@ export function useFetchConceptData(conceptId: number | undefined, locale: strin return { concept, createConcept, - fetchSearchTags, loading, setConcept: (concept: IConcept) => { setConcept(concept); diff --git a/src/containers/ImageUploader/components/ImageForm.tsx b/src/containers/ImageUploader/components/ImageForm.tsx index 3a70caa507..b73da08dab 100644 --- a/src/containers/ImageUploader/components/ImageForm.tsx +++ b/src/containers/ImageUploader/components/ImageForm.tsx @@ -221,7 +221,7 @@ const ImageForm = ({ - + diff --git a/src/containers/ImageUploader/components/ImageMetaData.tsx b/src/containers/ImageUploader/components/ImageMetaData.tsx index 25aec10626..59706b16bb 100644 --- a/src/containers/ImageUploader/components/ImageMetaData.tsx +++ b/src/containers/ImageUploader/components/ImageMetaData.tsx @@ -6,10 +6,18 @@ * */ -import { FieldProps } from "formik"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { createListCollection } from "@ark-ui/react"; +import { CheckLine } from "@ndla/icons/editor"; import { + ComboboxItem, + ComboboxItemIndicator, + ComboboxItemText, + FieldErrorMessage, + FieldHelper, FieldRoot, + Input, RadioGroupItem, RadioGroupItemControl, RadioGroupItemHiddenInput, @@ -18,14 +26,15 @@ import { RadioGroupRoot, } from "@ndla/primitives"; import { styled } from "@ndla/styled-system/jsx"; -import AsyncSearchTags from "../../../components/Dropdown/asyncDropdown/AsyncSearchTags"; +import { TagSelectorLabel, TagSelectorRoot, useTagSelectorTranslations } from "@ndla/ui"; +import { SearchTagsContent } from "../../../components/Form/SearchTagsContent"; +import { SearchTagsTagSelectorInput } from "../../../components/Form/SearchTagsTagSelectorInput"; import { FormField } from "../../../components/FormField"; -import FormikField from "../../../components/FormikField"; import { FormContent } from "../../../components/FormikForm"; -import { fetchSearchTags } from "../../../modules/image/imageApi"; +import { useImageSearchTags } from "../../../modules/image/imageQueries"; +import useDebounce from "../../../util/useDebounce"; interface Props { - imageTags: string[]; imageLanguage?: string; } @@ -40,22 +49,64 @@ const RadioGroupItemWrapper = styled("div", { const options = ["yes", "not-applicable", "no", "not-set"]; const defaultValue = "not-set"; -const ImageMetaData = ({ imageTags, imageLanguage }: Props) => { +const ImageMetaData = ({ imageLanguage }: Props) => { const { t } = useTranslation(); + const tagSelectorTranslations = useTagSelectorTranslations(); + const [inputQuery, setInputQuery] = useState(""); + const debouncedQuery = useDebounce(inputQuery, 300); + + const searchTagsQuery = useImageSearchTags( + { + input: debouncedQuery, + language: imageLanguage || "all", + }, + { + enabled: !!debouncedQuery.length, + placeholderData: (prev) => prev, + }, + ); + + const collection = useMemo(() => { + return createListCollection({ + items: searchTagsQuery.data?.results ?? [], + itemToValue: (item) => item, + itemToString: (item) => item, + }); + }, [searchTagsQuery.data?.results]); + return ( - - {({ field, form }: FieldProps) => ( - + + {({ field, meta, helpers }) => ( + + helpers.setValue(details.value)} + translations={tagSelectorTranslations} + inputValue={inputQuery} + onInputValueChange={(details) => setInputQuery(details.inputValue)} + > + {t("form.tags.label")} + {meta.error} + {t("form.tags.description")} + + + + + {collection.items.map((item) => ( + + {item} + + + + + ))} + + + )} - + {({ field, helpers }) => { return ( diff --git a/src/modules/audio/audioQueries.ts b/src/modules/audio/audioQueries.ts index 06bb0460a4..16c3759255 100644 --- a/src/modules/audio/audioQueries.ts +++ b/src/modules/audio/audioQueries.ts @@ -14,10 +14,11 @@ import { ISeries, ISeriesSearchParams, ISearchParams as IAudioSearchParams, + ITagsSearchResult, } from "@ndla/types-backend/audio-api"; -import { fetchAudio, fetchSeries, postSearchAudio, postSearchSeries } from "./audioApi"; +import { fetchAudio, fetchSearchTags, fetchSeries, postSearchAudio, postSearchSeries } from "./audioApi"; import { StringSort } from "../../containers/SearchPage/components/form/SearchForm"; -import { AUDIO, PODCAST_SERIES, SEARCH_AUDIO, SEARCH_SERIES } from "../../queryKeys"; +import { AUDIO, PODCAST_SERIES, SEARCH_AUDIO, AUDIO_SEARCH_TAGS, SEARCH_SERIES } from "../../queryKeys"; export interface UseAudio { id: number; @@ -29,6 +30,7 @@ export const audioQueryKeys = { search: (params?: Partial>) => [SEARCH_AUDIO, params] as const, podcastSeries: (params?: Partial) => [PODCAST_SERIES, params] as const, podcastSeriesSearch: (params?: Partial>) => [SEARCH_SERIES, params] as const, + audioSearchTags: (params?: Partial) => [AUDIO_SEARCH_TAGS, params] as const, }; export const useAudio = (params: UseAudio, options?: Partial>) => @@ -71,3 +73,16 @@ export const useSearchAudio = ( ...options, }); }; + +interface UseSearchTags { + input: string; + language: string; +} + +export const useAudioSearchTags = (params: UseSearchTags, options?: Partial>) => { + return useQuery({ + queryKey: audioQueryKeys.audioSearchTags(params), + queryFn: () => fetchSearchTags(params.input, params.language), + ...options, + }); +}; diff --git a/src/modules/concept/conceptQueries.ts b/src/modules/concept/conceptQueries.ts index e8dee81656..ab030d272c 100644 --- a/src/modules/concept/conceptQueries.ts +++ b/src/modules/concept/conceptQueries.ts @@ -7,11 +7,16 @@ */ import { useQuery, UseQueryOptions } from "@tanstack/react-query"; -import { IConcept, IDraftConceptSearchParams, IConceptSearchResult } from "@ndla/types-backend/concept-api"; -import { fetchConcept, fetchStatusStateMachine, postSearchConcepts } from "./conceptApi"; +import { + IConcept, + IDraftConceptSearchParams, + IConceptSearchResult, + ITagsSearchResult, +} from "@ndla/types-backend/concept-api"; +import { fetchConcept, fetchSearchTags, fetchStatusStateMachine, postSearchConcepts } from "./conceptApi"; import { StringSort } from "../../containers/SearchPage/components/form/SearchForm"; import { ConceptStatusStateMachineType } from "../../interfaces"; -import { CONCEPT, CONCEPT_STATE_MACHINE, SEARCH_CONCEPTS } from "../../queryKeys"; +import { CONCEPT, CONCEPT_SEARCH_TAGS, CONCEPT_STATE_MACHINE, SEARCH_CONCEPTS } from "../../queryKeys"; export interface UseConcept { id: number; @@ -22,6 +27,7 @@ export const conceptQueryKeys = { concept: (params?: Partial) => [CONCEPT, params] as const, searchConcepts: (params?: Partial>) => [SEARCH_CONCEPTS, params] as const, statusStateMachine: [CONCEPT_STATE_MACHINE] as const, + conceptSearchTags: (params?: Partial) => [CONCEPT_SEARCH_TAGS, params] as const, }; export const useConcept = (params: UseConcept, options?: Partial>) => { @@ -50,3 +56,16 @@ export const useConceptStateMachine = (options?: Partial>) => { + return useQuery({ + queryKey: conceptQueryKeys.conceptSearchTags(params), + queryFn: () => fetchSearchTags(params.input, params.language), + ...options, + }); +}; diff --git a/src/modules/draft/draftQueries.ts b/src/modules/draft/draftQueries.ts index 0cc813429f..ca9cf6f625 100644 --- a/src/modules/draft/draftQueries.ts +++ b/src/modules/draft/draftQueries.ts @@ -7,7 +7,14 @@ */ import { useMutation, useQuery, useQueryClient, UseQueryOptions } from "@tanstack/react-query"; -import { ILicense, IArticle, IUserData, IUpdatedUserData, ISearchResult } from "@ndla/types-backend/draft-api"; +import { + ILicense, + IArticle, + IUserData, + IUpdatedUserData, + ISearchResult, + ITagsSearchResult, +} from "@ndla/types-backend/draft-api"; import { fetchDraft, fetchLicenses, @@ -16,10 +23,19 @@ import { updateUserData, searchAllDrafts, fetchDraftHistory, + fetchSearchTags, } from "./draftApi"; import { DraftSearchQuery } from "./draftApiInterfaces"; import { DraftStatusStateMachineType } from "../../interfaces"; -import { DRAFT, DRAFT_STATUS_STATE_MACHINE, LICENSES, USER_DATA, SEARCH_DRAFTS, DRAFT_HISTORY } from "../../queryKeys"; +import { + DRAFT, + DRAFT_STATUS_STATE_MACHINE, + LICENSES, + USER_DATA, + SEARCH_DRAFTS, + DRAFT_HISTORY, + DRAFT_SEARCH_TAGS, +} from "../../queryKeys"; export interface UseDraft { id: number; @@ -39,6 +55,7 @@ export const draftQueryKeys = { licenses: [LICENSES] as const, userData: [USER_DATA] as const, statusStateMachine: (params?: Partial) => [DRAFT_STATUS_STATE_MACHINE, params] as const, + draftSearchTags: (params?: Partial) => [DRAFT_SEARCH_TAGS, params] as const, }; draftQueryKeys.draft({ id: 1 }); @@ -129,3 +146,16 @@ export const useDraftStatusStateMachine = ( ...options, }); }; + +export interface UseSearchTags { + input: string; + language: string; +} + +export const useDraftSearchTags = (params: UseSearchTags, options?: Partial>) => { + return useQuery({ + queryKey: draftQueryKeys.draftSearchTags(params), + queryFn: () => fetchSearchTags(params.input, params.language), + ...options, + }); +}; diff --git a/src/modules/image/imageQueries.ts b/src/modules/image/imageQueries.ts index 36c8747ae4..bd42cf1cae 100644 --- a/src/modules/image/imageQueries.ts +++ b/src/modules/image/imageQueries.ts @@ -12,10 +12,11 @@ import { ISearchParams, IImageMetaInformationV3, ISearchParams as IImageSearchParams, + ITagsSearchResult, } from "@ndla/types-backend/image-api"; -import { fetchImage, postSearchImages } from "./imageApi"; +import { fetchImage, fetchSearchTags, postSearchImages } from "./imageApi"; import { StringSort } from "../../containers/SearchPage/components/form/SearchForm"; -import { IMAGE, SEARCH_IMAGES } from "../../queryKeys"; +import { IMAGE, IMAGE_SEARCH_TAGS, SEARCH_IMAGES } from "../../queryKeys"; export interface UseImage { id: number; @@ -25,6 +26,7 @@ export interface UseImage { export const imageQueryKeys = { image: (params?: Partial) => [IMAGE, params] as const, search: (params?: Partial>) => [SEARCH_IMAGES, params] as const, + imageSearchTags: (params?: Partial) => [IMAGE_SEARCH_TAGS, params] as const, }; export const useImage = (params: UseImage, options?: Partial>) => @@ -44,3 +46,16 @@ export const useSearchImages = ( ...options, }); }; + +interface UseSearchTags { + input: string; + language: string; +} + +export const useImageSearchTags = (params: UseSearchTags, options?: Partial>) => { + return useQuery({ + queryKey: imageQueryKeys.imageSearchTags(params), + queryFn: () => fetchSearchTags(params.input, params.language), + ...options, + }); +}; diff --git a/src/queryKeys.ts b/src/queryKeys.ts index 057f13857b..31561872ba 100644 --- a/src/queryKeys.ts +++ b/src/queryKeys.ts @@ -15,6 +15,7 @@ export const SEARCH_SUBJECT_STATS = "searchSubjectStats"; export const MYNDLA_RESOURCE_STATS = "myNdlaResourceStats"; export const CONCEPT_STATE_MACHINE = "conceptStateMachine"; +export const CONCEPT_SEARCH_TAGS = "conceptSearchTags"; export const AUTH0_USERS = "auth0Users"; export const AUTH0_EDITORS = "auth0Editors"; @@ -23,8 +24,10 @@ export const AUTH0_RESPONSIBLES = "auth0Responsibles"; export const CONCEPT = "concept"; export const IMAGE = "image"; +export const IMAGE_SEARCH_TAGS = "imageSearchTags"; export const AUDIO = "audio"; +export const AUDIO_SEARCH_TAGS = "audioSearchTags"; export const AUDIO_EMBED = "audioEmbed"; export const BRIGHTCOVE_EMBED = "brightcoveEmbed"; @@ -38,6 +41,7 @@ export const SEARCH_DRAFTS = "searchDrafts"; export const LICENSES = "licenses"; export const USER_DATA = "userData"; export const DRAFT_STATUS_STATE_MACHINE = "draftStatusStateMachine"; +export const DRAFT_SEARCH_TAGS = "draftSearchTags"; export const RESOURCE_TYPE = "resourceType"; export const RESOURCE_TYPES = "resourceTypes";