Jake Vanderwerf
2026-09-04 32ce5c542c90be348895328078e392dde2052957
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
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 });
    }
    return (search) => addQueryArgs('/wp/v2/search', { search, type: 'post', subtype: field.post_type, per_page: 20 });
};
 
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 buildPath = searchPath(field);
    const ids = Array.isArray(value) ? value : value ? [value] : [];
 
    const [tokens, setTokens] = useState([]);   // display strings
    const [idByLabel, setIdByLabel] = useState({});
    const [options, setOptions] = useState({}); // label -> id, from last search
 
    // Resolve current IDs to labels on mount / value change
    useEffect(() => {
        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) => {
        if (!search) return;
        apiFetch({ path: buildPath(search) })
            .then((results) => {
                const map = {};
                results.forEach((item) => { map[labelOf(item, subtype)] = item.id; });
                setOptions(map);
            })
            .catch(() => {});
    }, [buildPath, subtype]);
 
    const onChangeTokens = (newTokens) => {
        setTokens(newTokens);
        const newIds = newTokens
            .map((label) => idByLabel[label] ?? options[label])
            .filter((id) => id !== undefined);
        setIdByLabel((prev) => ({ ...prev, ...options }));
 
        const isSingle = field.max === 1;
        if (isSingle) {
            onChange(newIds[newIds.length - 1] ?? 0);
        } else {
            onChange(field.max ? newIds.slice(0, field.max) : newIds);
        }
    };
 
    return (
        <FormTokenField
            label={field.label}
            help={field.description}
            value={tokens}
            suggestions={field.max === 1 && tokens.length ? [] : Object.keys(options)}
            onChange={onChangeTokens}
            onInputChange={onInputChange}
            maxLength={field.max || undefined}
            __experimentalExpandOnFocus
        />
    );
};
 
export default SelectorField;