import { useState, useEffect, useCallback } from '@wordpress/element'; import { FormTokenField } from '@wordpress/components'; import apiFetch from '@wordpress/api-fetch'; import { addQueryArgs } from '@wordpress/url'; const resolveSubtype = (field) => field.subtype || field.type; // 'taxonomy' | 'user' | 'post' const searchPath = (field) => { const subtype = resolveSubtype(field); if (subtype === 'user') { return (search) => addQueryArgs('/wp/v2/users', { search, per_page: 20 }); } if (subtype === 'taxonomy') { // Assumes taxonomy rest_base matches the (unprefixed) taxonomy slug. return (search) => addQueryArgs(`/wp/v2/${field.taxonomy}`, { search, per_page: 20, hide_empty: false }); } return (search) => addQueryArgs('/wp/v2/search', { search, type: 'post', subtype: field.post_type, per_page: 20 }); }; const createPayload = (field, subtype, name) => subtype === 'post' ? { title: name, status: 'publish', postType: field.post_type } : { name: name }; const labelOf = (item, subtype) => (subtype === 'user' ? item.name : item.title?.rendered ?? item.title ?? item.name); const SelectorField = ({ field, value, onChange }) => { const subtype = resolveSubtype(field); const canCreate = subtype === 'taxonomy'; const buildPath = searchPath(field); const ids = Array.isArray(value) ? value : value ? value.split(',') : []; const [tokens, setTokens] = useState([]); // display strings const [idByLabel, setIdByLabel] = useState({}); const [options, setOptions] = useState({}); // label -> id, from last search const [search, setSearch] = useState(''); const [creating, setCreating] = useState(false); const createLabel = search ? `Not found. Create "${search}"?` : null; // Resolve current IDs to labels on mount / value change useEffect(() => { onInputChange(''); let cancelled = false; if (!ids.length) { setTokens([]); return; } Promise.all(ids.map((id) => apiFetch({ path: buildPath('') + `&include=${id}` }))) .then((results) => { if (cancelled) return; const map = {}; const labels = []; results.flat().forEach((item) => { const label = labelOf(item, subtype); map[label] = item.id; labels.push(label); }); setIdByLabel((prev) => ({ ...prev, ...map })); setTokens(labels); }) .catch(() => {}); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(ids)]); const onInputChange = useCallback((search) => { setSearch(search); apiFetch({ path: buildPath(search) }) .then((results) => { const map = {}; results.forEach((item) => { map[labelOf(item, subtype)] = item.id; }); setOptions(map); }) .catch(() => {}); }, [buildPath, subtype]); const commitIds = (newIds) => { const isSingle = field.max === 1; if (isSingle) { onChange(newIds[newIds.length - 1] ?? 0); } else { onChange((field.max ? newIds.slice(0, field.max) : newIds).join(',')); } }; const onChangeTokens = (newTokens) => { const pendingCreate = newTokens.find((t) => t === createLabel); if (pendingCreate && canCreate && !creating) { setCreating(true); apiFetch({ path: `/wp/v2/${field.taxonomy}`, method: 'POST', data: createPayload(field, subtype, search) }) .then((item) => { const label = labelOf(item, subtype); const finalTokens = tokens.filter((t) => t !== createLabel).concat(label); const newIds = finalTokens .map((l) => idByLabel[l] ?? options[l] ?? (l === label ? item.id : undefined)) .filter((id) => id !== undefined); setIdByLabel((prev) => ({ ...prev, [label]: item.id })); setTokens(finalTokens); commitIds(newIds); }) .catch(() => {}) .finally(() => setCreating(false)); return; } const resolved = newTokens.filter( (label) => idByLabel[label] !== undefined || options[label] !== undefined ); const newIds = resolved.map((label) => idByLabel[label] ?? options[label]); setTokens(resolved); setIdByLabel((prev) => ({ ...prev, ...options })); commitIds(newIds); }; const noMatch = search && !Object.keys(options).some((l) => l.toLowerCase() === search.toLowerCase()); const suggestions = field.max === 1 && tokens.length ? [] : [...Object.keys(options), ...(canCreate && noMatch ? [createLabel] : [])]; return ( ); }; export default SelectorField;