@colixsystems/widget-sdk 0.246.0 → 0.247.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,247 @@
1
+ // The ONE implementation behind the `<PeoplePicker>` SDK primitive: a
2
+ // searchable `<Select>` over the workspace's users, its groups, or one
3
+ // table's records, read through the SDK's own directory and datastore hooks.
4
+ // The platform bindings (`people-picker.js` / `people-picker.native.js`)
5
+ // differ only in which host's `<Select>` and `<Avatar>` they pass.
6
+ //
7
+ // Nothing is fetched until the sheet first opens; each pause in typing asks
8
+ // the server again. A saved choice the page does not hold is looked up once
9
+ // by id, so the trigger names it and never shows the raw id.
10
+
11
+ import React from "react";
12
+ import {
13
+ useDatastoreQuery,
14
+ useDatastoreRecord,
15
+ useDatastoreSchema,
16
+ useDirectory,
17
+ useGroups,
18
+ } from "./hooks.js";
19
+ import {
20
+ answersQuery,
21
+ lookupableIds,
22
+ offeredRows,
23
+ pageQuery,
24
+ pickedIds,
25
+ pickedLabels,
26
+ pickerSource,
27
+ queueLookups,
28
+ recordLabelColumn,
29
+ rememberRows,
30
+ rowLabel,
31
+ settleLookup,
32
+ unresolvedIds,
33
+ } from "./people-picker-logic.js";
34
+
35
+ const h = React.createElement;
36
+ const NO_ROWS = Object.freeze([]);
37
+ const NO_LOOKUPS = Object.freeze({ pending: [], next: 0 });
38
+
39
+ function serialize(query) {
40
+ try {
41
+ return JSON.stringify(query);
42
+ } catch (_e) {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Bind the shared people picker to one host's `<Select>` and `<Avatar>`.
49
+ *
50
+ * @param {{View: any, Select: any, Avatar: any}} rn
51
+ */
52
+ export function makePeoplePicker(rn) {
53
+ const { View, Select, Avatar } = rn;
54
+
55
+ // Hands one hook result up once it answers the current query.
56
+ function useReport(onReport, query, rows, loading, error, sent) {
57
+ const queryKey = serialize(query);
58
+ const last = React.useRef(null);
59
+ React.useEffect(() => {
60
+ const now = { queryKey, rows, error, loading };
61
+ if (!answersQuery(last.current, now)) return;
62
+ last.current = now;
63
+ onReport({ rows: Array.isArray(rows) ? rows : NO_ROWS, loading, error: error || null, query: sent });
64
+ // eslint-disable-next-line react-hooks/exhaustive-deps
65
+ }, [queryKey, rows, loading, error, sent]);
66
+ }
67
+
68
+ function UsersPage({ query, sent, onReport }) {
69
+ const { users, loading, error } = useDirectory(query);
70
+ useReport(onReport, query, users, loading, error, sent);
71
+ return null;
72
+ }
73
+
74
+ function GroupsPage({ query, sent, onReport }) {
75
+ const { groups, loading, error } = useGroups(query);
76
+ useReport(onReport, query, groups, loading, error, sent);
77
+ return null;
78
+ }
79
+
80
+ function RecordsPage({ table, query, sent, onReport }) {
81
+ const { data, loading, fetching, error } = useDatastoreQuery(table, query);
82
+ useReport(onReport, query, data, loading || fetching, error, sent);
83
+ return null;
84
+ }
85
+
86
+ // A lookup reports once it settles; a failed one names nothing.
87
+ function useResolved(onResolved, lookup, settled, rows) {
88
+ React.useEffect(() => {
89
+ if (settled) onResolved(lookup, rows || NO_ROWS);
90
+ // eslint-disable-next-line react-hooks/exhaustive-deps
91
+ }, [settled]);
92
+ }
93
+
94
+ function UsersLookup({ lookup, onResolved }) {
95
+ const { users, loading, error } = useDirectory({ ids: lookup.ids, limit: lookup.ids.length });
96
+ useResolved(onResolved, lookup, !loading, error ? null : users);
97
+ return null;
98
+ }
99
+
100
+ function GroupsLookup({ lookup, onResolved }) {
101
+ const { groups, loading, error } = useGroups({ ids: lookup.ids, limit: lookup.ids.length });
102
+ useResolved(onResolved, lookup, !loading, error ? null : groups);
103
+ return null;
104
+ }
105
+
106
+ // The datastore list filters no system `id`, so each saved record is read alone.
107
+ function RecordLookup({ table, lookup, onResolved }) {
108
+ const { data, loading, error } = useDatastoreRecord(table, lookup.ids[0]);
109
+ useResolved(onResolved, lookup, !loading, error || !data ? null : [data]);
110
+ return null;
111
+ }
112
+
113
+ const LOOKUPS = { users: UsersLookup, groups: GroupsLookup, records: RecordLookup };
114
+
115
+ function avatarFor(source, row, label, avatarColumn) {
116
+ if (source === "records") {
117
+ if (!avatarColumn) return undefined;
118
+ const fileId = typeof row[avatarColumn] === "string" ? row[avatarColumn] : null;
119
+ return h(Avatar, { name: label, fileId, seed: String(row.id), size: "sm" });
120
+ }
121
+ return h(Avatar, {
122
+ name: label,
123
+ seed: String(row.id),
124
+ size: "sm",
125
+ shape: source === "groups" ? "rounded" : "circle",
126
+ });
127
+ }
128
+
129
+ // The row's name is its text; the picture only decorates it.
130
+ function decoration(node) {
131
+ if (!node) return undefined;
132
+ return h(
133
+ View,
134
+ {
135
+ "aria-hidden": true,
136
+ importantForAccessibility: "no-hide-descendants",
137
+ accessibilityElementsHidden: true,
138
+ },
139
+ node,
140
+ );
141
+ }
142
+
143
+ function SourcePicker(props) {
144
+ const {
145
+ source,
146
+ table,
147
+ labelColumn,
148
+ avatarColumn,
149
+ groupId,
150
+ filter,
151
+ excludeIds,
152
+ pageSize,
153
+ showAvatars = true,
154
+ unknownText = "Unknown",
155
+ loadingText = "Loading…",
156
+ multiple = false,
157
+ value,
158
+ values,
159
+ forwardedRef,
160
+ ...rest
161
+ } = props;
162
+ const [sent, setSent] = React.useState(null);
163
+ const [page, setPage] = React.useState({ rows: NO_ROWS, loading: false, error: null, query: null });
164
+ const [knownRows, setKnownRows] = React.useState(() => new Map());
165
+ const [attempted, setAttempted] = React.useState(() => new Set());
166
+ const [lookups, setLookups] = React.useState(NO_LOOKUPS);
167
+
168
+ const how = { source, labelColumn, unknownText };
169
+ const nameOf = (row) => rowLabel(row, how);
170
+ const toOption = (row) => {
171
+ const label = nameOf(row);
172
+ return {
173
+ value: row.id,
174
+ label,
175
+ ...(showAvatars ? { leading: decoration(avatarFor(source, row, label, avatarColumn)) } : null),
176
+ };
177
+ };
178
+
179
+ const onReport = React.useCallback((result) => {
180
+ setPage(result);
181
+ if (result.rows.length) setKnownRows((own) => rememberRows(own, result.rows));
182
+ }, []);
183
+ const onResolved = React.useCallback((lookup, rows) => {
184
+ setKnownRows((own) => rememberRows(own, rows));
185
+ setAttempted((own) => new Set([...own, ...lookup.ids.map(String)]));
186
+ setLookups((own) => settleLookup(own, lookup.key));
187
+ }, []);
188
+
189
+ // A record source with no table bound yet has nothing to read.
190
+ const readable = source !== "records" || (typeof table === "string" && table !== "");
191
+ const ids = pickedIds({ multiple, value, values });
192
+ const missing = readable ? unresolvedIds(ids, knownRows, attempted) : [];
193
+ const { lookup, unknown } = lookupableIds(missing, source);
194
+ const named = unknown.length ? new Set([...attempted, ...unknown.map(String)]) : attempted;
195
+ const query = sent === null || !readable ? null : pageQuery(source, { query: sent, pageSize, groupId, filter });
196
+ const options = offeredRows(page.rows, excludeIds).map(toOption);
197
+ const selectedOptions = pickedLabels(ids, { knownRows, attempted: named, nameOf, loadingText, unknownText }).map(
198
+ (own) => (own.row ? toOption(own.row) : own),
199
+ );
200
+
201
+ const lookupKey = lookup.join(",");
202
+ React.useEffect(() => {
203
+ if (lookup.length) setLookups((own) => queueLookups(own, lookup, source === "records" ? 1 : undefined));
204
+ // eslint-disable-next-line react-hooks/exhaustive-deps
205
+ }, [lookupKey]);
206
+
207
+ const pages = { users: UsersPage, groups: GroupsPage, records: RecordsPage };
208
+ const Lookup = LOOKUPS[source];
209
+
210
+ return h(
211
+ React.Fragment,
212
+ null,
213
+ h(Select, {
214
+ ...rest,
215
+ ref: forwardedRef,
216
+ multiple,
217
+ value,
218
+ values,
219
+ options,
220
+ selectedOptions,
221
+ onSearch: setSent,
222
+ loading: sent !== null && (page.query !== sent || page.loading),
223
+ optionsError: !!page.error,
224
+ loadingText,
225
+ }),
226
+ query ? h(pages[source], { table, query, sent, onReport }) : null,
227
+ ...lookups.pending.map((own) => h(Lookup, { key: own.key, table, lookup: own, onResolved })),
228
+ );
229
+ }
230
+
231
+ // A record source names rows by a column, so it waits for the table's schema.
232
+ function RecordsPicker(props) {
233
+ const { schema } = useDatastoreSchema(props.table || null);
234
+ return h(SourcePicker, { ...props, labelColumn: recordLabelColumn(schema, props.labelColumn) });
235
+ }
236
+
237
+ const PeoplePicker = React.forwardRef(function PeoplePicker(props, ref) {
238
+ const source = pickerSource(props.source);
239
+ const own = { ...props, source, forwardedRef: ref };
240
+ // Changing the source starts a fresh picker rather than mixing rows.
241
+ if (source === "records") return h(RecordsPicker, { ...own, key: `records:${props.table}` });
242
+ return h(SourcePicker, { ...own, key: source });
243
+ });
244
+
245
+ PeoplePicker.displayName = "PeoplePicker";
246
+ return PeoplePicker;
247
+ }
@@ -0,0 +1,11 @@
1
+ // `<PeoplePicker>` (web). Binds ./people-picker-view.js to the web `<Select>`
2
+ // and `<Avatar>`; see ./primitives.js for why not the bare specifier.
3
+
4
+ import * as ReactNative from "react-native-web";
5
+ import { makePeoplePicker } from "./people-picker-view.js";
6
+ import { Select } from "./select.js";
7
+ import { Avatar } from "./detail-parts.js";
8
+
9
+ const { View } = ReactNative;
10
+
11
+ export const PeoplePicker = makePeoplePicker({ View, Select, Avatar });
@@ -0,0 +1,9 @@
1
+ // `<PeoplePicker>` (native). Binds ./people-picker-view.js to the native
2
+ // `<Select>` and `<Avatar>`; only the imports differ from ./people-picker.js.
3
+
4
+ import { View } from "react-native";
5
+ import { makePeoplePicker } from "./people-picker-view.js";
6
+ import { Select } from "./select.native.js";
7
+ import { Avatar } from "./detail-parts.native.js";
8
+
9
+ export const PeoplePicker = makePeoplePicker({ View, Select, Avatar });
@@ -0,0 +1,150 @@
1
+ // The searchable `<Select>` rules that need no renderer: when a typed query
2
+ // is sent, which answer is still wanted, what the sheet shows meanwhile, and
3
+ // the labels it keeps for picked values the current page no longer holds.
4
+ // Both hosts share them, and the node suite tests them.
5
+
6
+ import { normaliseThemeComponents } from "./theme-components.js";
7
+
8
+ /** How long typing must pause before the query is sent, in ms. */
9
+ export const DEFAULT_SEARCH_DEBOUNCE_MS = 250;
10
+
11
+ /** A typed query as it is sent: trimmed, never null. */
12
+ export function normaliseQuery(text) {
13
+ return typeof text === "string" ? text.trim() : "";
14
+ }
15
+
16
+ /**
17
+ * A trailing debounce: `push` restarts the wait, and only the last text
18
+ * pushed is delivered, once typing pauses. `cancel` drops a pending one.
19
+ *
20
+ * @param {number|(() => number)} delay — ms, or a getter read at each push
21
+ * so a changed delay applies to the next pause; below 0 counts as 0.
22
+ * @param {(query: string) => void} deliver
23
+ * @param {{setTimeout: Function, clearTimeout: Function}} [timers]
24
+ */
25
+ export function createDebouncer(delay, deliver, timers = globalThis) {
26
+ const waitFor = () => {
27
+ const ms = typeof delay === "function" ? delay() : delay;
28
+ return Number.isFinite(ms) && ms > 0 ? ms : 0;
29
+ };
30
+ let pending = null;
31
+ const cancel = () => {
32
+ if (pending !== null) timers.clearTimeout(pending);
33
+ pending = null;
34
+ };
35
+ return {
36
+ push(text) {
37
+ cancel();
38
+ pending = timers.setTimeout(() => {
39
+ pending = null;
40
+ deliver(normaliseQuery(text));
41
+ }, waitFor());
42
+ },
43
+ cancel,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Numbers each request so only the latest one's answer is used; an answer to
49
+ * a query the person has already typed past is dropped.
50
+ */
51
+ export function createRequestGate() {
52
+ let latest = 0;
53
+ return {
54
+ begin: () => ++latest,
55
+ isCurrent: (ticket) => ticket === latest,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * The options whose label holds the query, case-insensitively; all of them
61
+ * for an empty query. For a list that is searched where it is held.
62
+ *
63
+ * @param {Array<{label: unknown}>} options
64
+ * @param {string} query
65
+ */
66
+ export function filterOptions(options, query) {
67
+ const list = Array.isArray(options) ? options : [];
68
+ const needle = normaliseQuery(query).toLocaleLowerCase();
69
+ if (!needle) return list;
70
+ return list.filter((option) =>
71
+ String(option && option.label != null ? option.label : "")
72
+ .toLocaleLowerCase()
73
+ .includes(needle),
74
+ );
75
+ }
76
+
77
+ /**
78
+ * What the sheet's body shows: a spinner while the answer is pending, the
79
+ * error, the "nothing matches" note for a query with no result, the empty
80
+ * note for no options at all, else the list.
81
+ *
82
+ * @param {{loading?: boolean, error?: boolean, query?: string, count: number}} state
83
+ * @returns {"loading"|"error"|"noResults"|"empty"|"list"}
84
+ */
85
+ export function sheetBody({ loading, error, query, count }) {
86
+ if (loading) return "loading";
87
+ if (error) return "error";
88
+ if (count > 0) return "list";
89
+ return normaliseQuery(query) ? "noResults" : "empty";
90
+ }
91
+
92
+ // 1 and "1" are two values; the key keeps them apart.
93
+ function valueKey(value) {
94
+ return `${typeof value}:${value}`;
95
+ }
96
+
97
+ /**
98
+ * A copy of `known` (value key → option) holding every option in `lists`,
99
+ * later lists winning. A searchable select remembers each option it has
100
+ * shown, so a picked one keeps its label after a search drops it.
101
+ *
102
+ * @param {Map<string, object>} known
103
+ * @param {...Array<{value: string|number}>} lists
104
+ */
105
+ export function rememberOptions(known, ...lists) {
106
+ const next = new Map(known instanceof Map ? known : []);
107
+ for (const list of lists) {
108
+ for (const option of Array.isArray(list) ? list : []) {
109
+ if (option && (typeof option.value === "string" || typeof option.value === "number")) {
110
+ next.set(valueKey(option.value), option);
111
+ }
112
+ }
113
+ }
114
+ return next;
115
+ }
116
+
117
+ /** The remembered option for one value, or undefined. */
118
+ export function knownOption(known, value) {
119
+ return known instanceof Map ? known.get(valueKey(value)) : undefined;
120
+ }
121
+
122
+ /**
123
+ * The remembered options for `values`, in their order; a value nothing has
124
+ * named is left out rather than shown by its raw value.
125
+ *
126
+ * @param {Map<string, object>} known
127
+ * @param {Array<string|number>} values
128
+ */
129
+ export function knownOptions(known, values) {
130
+ return (Array.isArray(values) ? values : [])
131
+ .map((value) => knownOption(known, value))
132
+ .filter(Boolean);
133
+ }
134
+
135
+ /**
136
+ * The row size a searchable sheet takes from the theme's `list` scope: only
137
+ * the values the theme sets, so an unthemed list keeps the option row's own.
138
+ *
139
+ * @param {object} theme — the resolved workspace theme.
140
+ * @returns {{minHeight?: number, paddingHorizontal?: number}}
141
+ */
142
+ export function searchRowOverrides(theme) {
143
+ const components = theme && typeof theme === "object" ? theme.components : undefined;
144
+ const list = normaliseThemeComponents({ list: components && components.list }).list || {};
145
+ const out = {};
146
+ // A row stays a 44px target however small the theme draws list rows.
147
+ if (Number.isFinite(list.rowMinHeight)) out.minHeight = Math.max(44, list.rowMinHeight);
148
+ if (Number.isFinite(list.rowPadding)) out.paddingHorizontal = list.rowPadding;
149
+ return out;
150
+ }