UNPKG

@wordpress/editor

Version:
382 lines (381 loc) 11.7 kB
// packages/editor/src/components/post-taxonomies/hierarchical-term-selector.js import { __, _n, _x, sprintf } from "@wordpress/i18n"; import { memo, useDeferredValue, useEffect, useMemo, useState } from "@wordpress/element"; import { store as noticesStore } from "@wordpress/notices"; import { Button, CheckboxControl, TreeSelect, withFilters, SearchControl } from "@wordpress/components"; import { InputControl, Spinner, Stack } from "@wordpress/ui"; import { useDispatch, useSelect } from "@wordpress/data"; import { useDebounce, useEvent } from "@wordpress/compose"; import { store as coreStore, privateApis as coreDataPrivateApis } from "@wordpress/core-data"; import { speak } from "@wordpress/a11y"; import { decodeEntities } from "@wordpress/html-entities"; import { buildTermsTree } from "../../utils/terms.mjs"; import { normalizeTextString } from "../../utils/normalize-text-string.mjs"; import { store as editorStore } from "../../store/index.mjs"; import { unlock } from "../../lock-unlock.mjs"; import { jsx, jsxs } from "react/jsx-runtime"; var { RECEIVE_INTERMEDIATE_RESULTS } = unlock(coreDataPrivateApis); var DEFAULT_QUERY = { per_page: -1, orderby: "name", order: "asc", _fields: "id,name,parent", context: "view", [RECEIVE_INTERMEDIATE_RESULTS]: true }; var MIN_TERMS_COUNT_FOR_FILTER = 8; var EMPTY_ARRAY = []; var SPEAK_DEBOUNCE_MS = 500; function getResultCount(termsTree) { let count = 0; for (const term of termsTree) { count++; if (void 0 !== term.children) { count += getResultCount(term.children); } } return count; } var TermCheckbox = memo(function Checkbox({ id, name, checked, onToggle }) { return /* @__PURE__ */ jsx( CheckboxControl, { checked, onChange: () => onToggle(id), label: decodeEntities(name) } ); }); function TermRow({ term, selectedTerms, onToggle }) { return /* @__PURE__ */ jsxs("div", { className: "editor-post-taxonomies__hierarchical-terms-choice", children: [ /* @__PURE__ */ jsx( TermCheckbox, { id: term.id, name: term.name, checked: selectedTerms.has(term.id), onToggle } ), !!term.children.length && /* @__PURE__ */ jsx("div", { className: "editor-post-taxonomies__hierarchical-terms-subchoices", children: term.children.map((child) => /* @__PURE__ */ jsx( TermRow, { term: child, selectedTerms, onToggle }, child.id )) }) ] }); } function sortBySelected(termsTree, terms) { const selectedTerms = new Set(terms); const treeHasSelection = (termTree) => { if (selectedTerms.has(termTree.id)) { return true; } return !!termTree.children?.some(treeHasSelection); }; const selected = []; const unselected = []; for (const termTree of termsTree) { if (treeHasSelection(termTree)) { selected.push(termTree); } else { unselected.push(termTree); } } return [...selected, ...unselected]; } function findTerm(terms, parent, name) { return terms.find((term) => { return (!term.parent && !parent || parseInt(term.parent) === parseInt(parent)) && term.name.toLowerCase() === name.toLowerCase(); }); } function getFilterMatcher(filterValue) { const normalizedFilterValue = normalizeTextString(filterValue); const matchTermsForFilter = (originalTerm) => { if ("" === filterValue) { return originalTerm; } const term = { ...originalTerm }; if (term.children.length > 0) { term.children = term.children.map(matchTermsForFilter).filter((child) => child); } if (-1 !== normalizeTextString(term.name).indexOf( normalizedFilterValue ) || term.children.length > 0) { return term; } return false; }; return matchTermsForFilter; } function HierarchicalTermSelector({ slug }) { const [adding, setAdding] = useState(false); const [formName, setFormName] = useState(""); const [formParent, setFormParent] = useState(""); const [showForm, setShowForm] = useState(false); const [filterValue, setFilterValue] = useState(""); const deferredFilterValue = useDeferredValue(filterValue); const debouncedSpeak = useDebounce(speak, SPEAK_DEBOUNCE_MS); const { hasCreateAction, hasAssignAction, terms, loading, availableTerms, taxonomy } = useSelect( (select) => { const { getCurrentPost, getEditedPostAttribute } = select(editorStore); const { getEntityRecord, getEntityRecords, isResolving } = select(coreStore); const _taxonomy = getEntityRecord("root", "taxonomy", slug); const post = getCurrentPost(); return { hasCreateAction: _taxonomy ? !!post._links?.["wp:action-create-" + _taxonomy.rest_base] : false, hasAssignAction: _taxonomy ? !!post._links?.["wp:action-assign-" + _taxonomy.rest_base] : false, terms: _taxonomy ? getEditedPostAttribute(_taxonomy.rest_base) : EMPTY_ARRAY, loading: isResolving("getEntityRecords", [ "taxonomy", slug, DEFAULT_QUERY ]), availableTerms: getEntityRecords("taxonomy", slug, DEFAULT_QUERY) || EMPTY_ARRAY, taxonomy: _taxonomy }; }, [slug] ); const { editPost } = useDispatch(editorStore); const { saveEntityRecord } = useDispatch(coreStore); const { createErrorNotice } = useDispatch(noticesStore); const selectedTerms = useMemo(() => new Set(terms), [terms]); const availableTermsTree = useMemo( () => sortBySelected(buildTermsTree(availableTerms), terms), // Remove `terms` from the dependency list to avoid reordering every time // checking or unchecking a term. [availableTerms] ); const shownTerms = useMemo(() => { if ("" === deferredFilterValue) { return availableTermsTree; } return availableTermsTree.map(getFilterMatcher(deferredFilterValue)).filter((term) => term); }, [availableTermsTree, deferredFilterValue]); const resultCount = getResultCount(shownTerms); useEffect(() => { if ("" === deferredFilterValue) { return; } debouncedSpeak( sprintf( /* translators: %d: number of results. */ _n("%d result found.", "%d results found.", resultCount), resultCount ), "polite" ); return () => debouncedSpeak.cancel(); }, [resultCount, deferredFilterValue, debouncedSpeak]); const onUpdateTerms = (termIds) => { editPost({ [taxonomy.rest_base]: termIds }); }; const onToggleTerm = useEvent((termId) => { const id = parseInt(termId, 10); onUpdateTerms( selectedTerms.has(id) ? terms.filter((term) => term !== id) : [...terms, id] ); }); if (!hasAssignAction) { return null; } const addTerm = (term) => { return saveEntityRecord("taxonomy", slug, term, { throwOnError: true }); }; const onChangeFormName = (value) => { setFormName(value); }; const onChangeFormParent = (parentId) => { setFormParent(parentId); }; const onToggleForm = () => { setShowForm(!showForm); }; const onAddTerm = async (event) => { event.preventDefault(); if (formName === "" || adding) { return; } const existingTerm = findTerm(availableTerms, formParent, formName); if (existingTerm) { if (!terms.some((term) => term === existingTerm.id)) { onUpdateTerms([...terms, existingTerm.id]); } setFormName(""); setFormParent(""); return; } setAdding(true); let newTerm; try { newTerm = await addTerm({ name: formName, parent: formParent ? formParent : void 0 }); } catch (error) { createErrorNotice(error.message, { type: "snackbar" }); return; } const defaultName = slug === "category" ? __("Category") : __("Term"); const termAddedMessage = sprintf( /* translators: %s: term name. */ _x("%s added", "term"), taxonomy?.labels?.singular_name ?? defaultName ); speak(termAddedMessage, "assertive"); setAdding(false); setFormName(""); setFormParent(""); onUpdateTerms([...terms, newTerm.id]); }; const labelWithFallback = (labelProperty, fallbackIsCategory, fallbackIsNotCategory) => taxonomy?.labels?.[labelProperty] ?? (slug === "category" ? fallbackIsCategory : fallbackIsNotCategory); const newTermButtonLabel = labelWithFallback( "add_new_item", __("Add Category"), __("Add Term") ); const newTermLabel = labelWithFallback( "new_item_name", __("Add Category"), __("Add Term") ); const parentSelectLabel = labelWithFallback( "parent_item", __("Parent Category"), __("Parent Term") ); const noParentOption = `— ${parentSelectLabel} —`; const newTermSubmitLabel = newTermButtonLabel; const filterLabel = taxonomy?.labels?.search_items ?? __("Search Terms"); const groupLabel = taxonomy?.name ?? __("Terms"); const showFilter = availableTerms.length >= MIN_TERMS_COUNT_FOR_FILTER; return /* @__PURE__ */ jsxs(Stack, { direction: "column", gap: "lg", children: [ showFilter && !loading && /* @__PURE__ */ jsx( SearchControl, { label: filterLabel, placeholder: filterLabel, value: filterValue, onChange: setFilterValue } ), loading && /* @__PURE__ */ jsx( Stack, { justify: "center", style: { // Match SearchControl height to prevent layout shift. height: "40px" }, children: /* @__PURE__ */ jsx(Spinner, {}) } ), /* @__PURE__ */ jsx( "div", { className: "editor-post-taxonomies__hierarchical-terms-list", tabIndex: "0", role: "group", "aria-label": groupLabel, children: shownTerms.map((term) => /* @__PURE__ */ jsx( TermRow, { term, selectedTerms, onToggle: onToggleTerm }, term.id )) } ), !loading && hasCreateAction && /* @__PURE__ */ jsx( Button, { __next40pxDefaultSize: true, onClick: onToggleForm, className: "editor-post-taxonomies__hierarchical-terms-add", "aria-expanded": showForm, variant: "link", children: newTermButtonLabel } ), showForm && /* @__PURE__ */ jsx("form", { onSubmit: onAddTerm, children: /* @__PURE__ */ jsxs(Stack, { direction: "column", gap: "lg", children: [ /* @__PURE__ */ jsx( InputControl, { className: "editor-post-taxonomies__hierarchical-terms-input", label: newTermLabel, value: formName, onValueChange: onChangeFormName, required: true } ), !!availableTerms.length && /* @__PURE__ */ jsx( TreeSelect, { label: parentSelectLabel, noOptionLabel: noParentOption, onChange: onChangeFormParent, selectedId: formParent, tree: availableTermsTree } ), /* @__PURE__ */ jsx( Button, { __next40pxDefaultSize: true, variant: "secondary", type: "submit", className: "editor-post-taxonomies__hierarchical-terms-submit", children: newTermSubmitLabel } ) ] }) }) ] }); } var hierarchical_term_selector_default = withFilters("editor.PostTaxonomyType")( HierarchicalTermSelector ); export { HierarchicalTermSelector, hierarchical_term_selector_default as default, findTerm, getFilterMatcher, sortBySelected }; //# sourceMappingURL=hierarchical-term-selector.mjs.map