canopycms 0.3.3 → 0.5.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.
- package/CHANGELOG.md +91 -41
- package/dist/ai/generate.d.ts +3 -0
- package/dist/ai/generate.js +23 -33
- package/dist/ai/handler.js +1 -0
- package/dist/ai/types.d.ts +6 -11
- package/dist/api/reference-options.d.ts +2 -5
- package/dist/api/reference-options.js +21 -2
- package/dist/api/schema.d.ts +4 -4
- package/dist/build/generate-ai-content.js +1 -0
- package/dist/cli/cli.js +176 -66
- package/dist/cli/generate-ai-content.js +122 -62
- package/dist/cli/init.js +29 -4
- package/dist/config/index.d.ts +1 -1
- package/dist/config/schemas/config.d.ts +74 -0
- package/dist/config/schemas/config.js +21 -0
- package/dist/config/schemas/field.d.ts +30 -0
- package/dist/config/schemas/field.js +22 -0
- package/dist/config/types.d.ts +47 -8
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +33 -0
- package/dist/content-store.d.ts +5 -4
- package/dist/content-store.js +12 -7
- package/dist/editor/FormRenderer.js +15 -13
- package/dist/editor/copy.d.ts +5 -0
- package/dist/editor/copy.js +5 -0
- package/dist/editor/fields/BlockField.d.ts +2 -0
- package/dist/editor/fields/BlockField.js +15 -8
- package/dist/editor/fields/ItemList.d.ts +21 -3
- package/dist/editor/fields/ItemList.js +16 -8
- package/dist/editor/fields/NumberListField.d.ts +2 -0
- package/dist/editor/fields/NumberListField.js +6 -2
- package/dist/editor/fields/ObjectListField.js +9 -3
- package/dist/editor/fields/ReferenceField.d.ts +6 -7
- package/dist/editor/fields/ReferenceField.js +28 -122
- package/dist/editor/fields/ReferenceListField.d.ts +41 -0
- package/dist/editor/fields/ReferenceListField.js +139 -0
- package/dist/editor/fields/SelectField.d.ts +2 -0
- package/dist/editor/fields/SelectField.js +6 -3
- package/dist/editor/fields/StringListField.d.ts +10 -1
- package/dist/editor/fields/StringListField.js +18 -8
- package/dist/editor/fields/item-expansion.d.ts +7 -3
- package/dist/editor/fields/item-expansion.js +6 -4
- package/dist/editor/fields/list-item-keys.d.ts +6 -5
- package/dist/editor/fields/list-item-keys.js +45 -13
- package/dist/editor/fields/reference-noindex.d.ts +37 -0
- package/dist/editor/fields/reference-noindex.js +48 -0
- package/dist/editor/fields/use-reference-options.d.ts +36 -0
- package/dist/editor/fields/use-reference-options.js +121 -0
- package/dist/editor/form-outline.d.ts +2 -2
- package/dist/editor/form-outline.js +4 -2
- package/dist/editor/theme.js +5 -0
- package/dist/entry-schema-registry.d.ts +1 -1
- package/dist/entry-schema-registry.js +3 -2
- package/dist/entry-schema.d.ts +21 -9
- package/dist/entry-schema.js +5 -2
- package/dist/reference-resolver.d.ts +18 -1
- package/dist/reference-resolver.js +175 -8
- package/dist/schema/schema-store.d.ts +2 -2
- package/dist/static/index.d.ts +8 -4
- package/dist/static/index.js +38 -13
- package/dist/static/seo.d.ts +7 -5
- package/dist/static/seo.js +13 -6
- package/dist/utils/body-field.d.ts +9 -1
- package/dist/utils/body-field.js +18 -1
- package/dist/validation/entry-validator.d.ts +5 -0
- package/dist/validation/entry-validator.js +12 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import React, { useEffect, useId, useRef, useState } from 'react';
|
|
4
|
-
import { ActionIcon, Box, Group, Menu, Paper, Stack, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
|
|
4
|
+
import { ActionIcon, Badge, Box, Group, Menu, Paper, Skeleton, Stack, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
|
|
5
5
|
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core';
|
|
6
6
|
import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable';
|
|
7
7
|
import { CSS } from '@dnd-kit/utilities';
|
|
@@ -17,10 +17,10 @@ const ROW_GAP = 8;
|
|
|
17
17
|
/**
|
|
18
18
|
* The rows of an object list or block list: each collapses to a one-line summary and carries a
|
|
19
19
|
* drag handle (pointer and keyboard, via dnd-kit) and a "…" menu with Move up, Move down,
|
|
20
|
-
* Duplicate and Remove. A read-only list keeps only collapsing. A collapsed row stays mounted
|
|
20
|
+
* Duplicate (when the list offers it) and Remove. A read-only list keeps only collapsing. A collapsed row stays mounted
|
|
21
21
|
* and opens when something asks to reveal a field inside it (`REVEAL_FIELD_EVENT`).
|
|
22
22
|
*/
|
|
23
|
-
export function ItemList({ items, itemKeys, path, readOnly, expansion, summarize, renderItem, describe, hasErrors, onMove, onDuplicate, onRemove, renderBetween, focusAfterRenderRef, 'data-testid': testId, }) {
|
|
23
|
+
export function ItemList({ items, itemKeys, path, readOnly, expansion, summarize, renderItem, describe, hasErrors, onMove, onDuplicate, onRemove, renderBetween, focusAfterRenderRef, announcement: callerAnnouncement, duplicateBlockedReason, 'data-testid': testId, }) {
|
|
24
24
|
const listRef = useRef(null);
|
|
25
25
|
const [announcement, setAnnouncement] = useState('');
|
|
26
26
|
// Constant: DndContext's hooks depend on the sensor count; read-only disables each row instead.
|
|
@@ -36,6 +36,12 @@ export function ItemList({ items, itemKeys, path, readOnly, expansion, summarize
|
|
|
36
36
|
if (row)
|
|
37
37
|
focusFieldTarget(row);
|
|
38
38
|
});
|
|
39
|
+
const [lastCallerAnnouncement, setLastCallerAnnouncement] = useState(callerAnnouncement);
|
|
40
|
+
if (callerAnnouncement !== lastCallerAnnouncement) {
|
|
41
|
+
setLastCallerAnnouncement(callerAnnouncement);
|
|
42
|
+
if (callerAnnouncement)
|
|
43
|
+
setAnnouncement(callerAnnouncement.message);
|
|
44
|
+
}
|
|
39
45
|
const titleAt = (index) => summarize(items[index], index).title;
|
|
40
46
|
const indexOf = (id) => itemKeys.indexOf(String(id));
|
|
41
47
|
const position = (index) => `position ${index + 1} of ${items.length}`;
|
|
@@ -63,7 +69,7 @@ export function ItemList({ items, itemKeys, path, readOnly, expansion, summarize
|
|
|
63
69
|
setAnnouncement(`${titleAt(from)} moved to ${position(to)}.`);
|
|
64
70
|
};
|
|
65
71
|
const duplicate = (index) => {
|
|
66
|
-
onDuplicate(index);
|
|
72
|
+
onDuplicate?.(index);
|
|
67
73
|
setAnnouncement(`${titleAt(index)} duplicated, position ${index + 2} of ${items.length + 1}.`);
|
|
68
74
|
};
|
|
69
75
|
const remove = (index) => {
|
|
@@ -74,10 +80,10 @@ export function ItemList({ items, itemKeys, path, readOnly, expansion, summarize
|
|
|
74
80
|
return (_jsxs("div", { ref: listRef, "data-item-list": true, "data-testid": testId, children: [_jsx(DndContext, { sensors: sensors, collisionDetection: closestCenter, onDragEnd: handleDragEnd, accessibility: { announcements }, children: _jsx(SortableContext, { items: [...itemKeys], strategy: verticalListSortingStrategy, children: items.map((item, idx) => {
|
|
75
81
|
const key = itemKeys[idx];
|
|
76
82
|
const summary = summarize(item, idx);
|
|
77
|
-
return (_jsxs(React.Fragment, { children: [renderBetween ? renderBetween(idx) : idx > 0 && _jsx(Box, { h: ROW_GAP, "aria-hidden": true }), _jsx(ItemRow, { rowKey: key, canopyPath: formatCanopyPath([...path, idx]), summary: summary, description: describe?.(item), hasErrors: hasErrors?.(idx) ?? false, expanded: expansion.isExpanded(key), onExpandedChange: (open) => expansion.setExpanded(key, open), readOnly: readOnly, isFirst: idx === 0, isLast: idx === items.length - 1, onMoveUp: () => move(idx, idx - 1), onMoveDown: () => move(idx, idx + 1), onDuplicate: () => duplicate(idx), onRemove: () => remove(idx), children: renderItem(item, idx) })] }, key));
|
|
83
|
+
return (_jsxs(React.Fragment, { children: [renderBetween ? renderBetween(idx) : idx > 0 && _jsx(Box, { h: ROW_GAP, "aria-hidden": true }), _jsx(ItemRow, { rowKey: key, canopyPath: formatCanopyPath([...path, idx]), summary: summary, description: describe?.(item), hasErrors: hasErrors?.(idx) ?? false, expanded: expansion.isExpanded(key), onExpandedChange: (open) => expansion.setExpanded(key, open), readOnly: readOnly, isFirst: idx === 0, isLast: idx === items.length - 1, onMoveUp: () => move(idx, idx - 1), onMoveDown: () => move(idx, idx + 1), onDuplicate: onDuplicate ? () => duplicate(idx) : undefined, duplicateBlockedReason: duplicateBlockedReason, onRemove: () => remove(idx), children: renderItem(item, idx) })] }, key));
|
|
78
84
|
}) }) }), _jsx(VisuallyHidden, { "aria-live": "polite", children: announcement })] }));
|
|
79
85
|
}
|
|
80
|
-
const ItemRow = ({ rowKey, canopyPath, summary, description, hasErrors, expanded, onExpandedChange, readOnly, isFirst, isLast, onMoveUp, onMoveDown, onDuplicate, onRemove, children, }) => {
|
|
86
|
+
const ItemRow = ({ rowKey, canopyPath, summary, description, hasErrors, expanded, onExpandedChange, readOnly, isFirst, isLast, onMoveUp, onMoveDown, onDuplicate, duplicateBlockedReason, onRemove, children, }) => {
|
|
81
87
|
const baseId = useId();
|
|
82
88
|
const bodyId = `${baseId}-body`;
|
|
83
89
|
const rowRef = useRef(null);
|
|
@@ -111,7 +117,7 @@ const ItemRow = ({ rowKey, canopyPath, summary, description, hasErrors, expanded
|
|
|
111
117
|
requestAnimationFrame(() => menuTargetRef.current?.focus());
|
|
112
118
|
};
|
|
113
119
|
// Rows can share a title (two sections of one template), so names carry the second value too.
|
|
114
|
-
const name = summary.
|
|
120
|
+
const name = [summary.title, summary.secondary, summary.badge?.label].filter(Boolean).join(', ');
|
|
115
121
|
const actionsLabel = `Actions for ${name}`;
|
|
116
122
|
return (_jsxs(Paper, { ref: (node) => {
|
|
117
123
|
rowRef.current = node;
|
|
@@ -126,5 +132,7 @@ const ItemRow = ({ rowKey, canopyPath, summary, description, hasErrors, expanded
|
|
|
126
132
|
flexShrink: 0,
|
|
127
133
|
transform: expanded ? 'rotate(90deg)' : undefined,
|
|
128
134
|
transition: 'transform 120ms ease',
|
|
129
|
-
} }),
|
|
135
|
+
} }), summary.pending ? (
|
|
136
|
+
// About the height of the title's line, which replaces it.
|
|
137
|
+
_jsx(Skeleton, { height: 14, width: "40%", my: 3, "data-testid": "item-row-title-pending" })) : (_jsx(Text, { size: "sm", fw: 500, truncate: "end", style: summary.secondary ? { flexShrink: 0, maxWidth: '60%' } : { minWidth: 0 }, children: summary.title })), summary.secondary && (_jsx(Text, { size: "sm", c: "dimmed", truncate: "end", style: { minWidth: 0 }, children: summary.secondary })), summary.badge && !summary.pending && (_jsx(Badge, { variant: "light", color: summary.badge.color, size: "sm", tt: "none", style: { flexShrink: 0 }, children: summary.badge.label })), hasErrors && !expanded && (_jsxs(Group, { gap: 2, wrap: "nowrap", ml: "auto", style: { flexShrink: 0 }, children: [_jsx(IconAlertCircle, { size: 14, color: "var(--canopy-danger-text)", "aria-hidden": true }), _jsx(Text, { size: "xs", c: "var(--canopy-danger-text)", children: "Has errors" })] }))] }) }), !readOnly && (_jsxs(Menu, { position: "bottom-end", withinPortal: true, shadow: "sm", width: 220, children: [_jsx(Tooltip, { label: actionsLabel, openDelay: 500, children: _jsx(Menu.Target, { children: _jsx(ActionIcon, { ref: menuTargetRef, variant: "subtle", color: "gray", "aria-label": actionsLabel, "data-testid": "item-row-menu", children: _jsx(IconDots, { size: 16, "aria-hidden": true }) }) }) }), _jsxs(Menu.Dropdown, { children: [_jsx(ReasonedMenuItem, { blockedReason: isFirst ? 'Already first' : undefined, onClick: thenFocusMenu(onMoveUp), "data-testid": "item-row-move-up", children: "Move up" }), _jsx(ReasonedMenuItem, { blockedReason: isLast ? 'Already last' : undefined, onClick: thenFocusMenu(onMoveDown), "data-testid": "item-row-move-down", children: "Move down" }), onDuplicate && (_jsx(ReasonedMenuItem, { blockedReason: duplicateBlockedReason, onClick: thenFocusMenu(onDuplicate), "data-testid": "item-row-duplicate", children: "Duplicate" })), _jsx(Menu.Divider, {}), _jsx(DestructiveMenuItem, { onClick: onRemove, "data-testid": "item-row-remove", children: EDITOR_ACTIONS.remove })] })] }))] }), _jsx(Box, { id: bodyId, hidden: !expanded, children: _jsxs(Stack, { gap: "sm", px: "sm", pb: "sm", pt: 4, children: [description && _jsx(FieldDescription, { baseId: baseId, description: description }), children] }) })] }));
|
|
130
138
|
};
|
|
@@ -8,6 +8,8 @@ export interface NumberListFieldProps {
|
|
|
8
8
|
dataCanopyField?: string;
|
|
9
9
|
/** Shows the value without accepting edits. */
|
|
10
10
|
readOnly?: boolean;
|
|
11
|
+
/** The most items the list holds; at or over it, nothing adds and the hint says why. */
|
|
12
|
+
maxItems?: number;
|
|
11
13
|
}
|
|
12
14
|
/**
|
|
13
15
|
* Editor for `type: 'number', list: true` fields.
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useId, useState } from 'react';
|
|
3
3
|
import { TagsInput } from '@mantine/core';
|
|
4
|
+
import { listFullReason } from '../copy.js';
|
|
4
5
|
import { LabelledInput } from './FieldLabel.js';
|
|
6
|
+
import { tagCap, withHint } from './StringListField.js';
|
|
5
7
|
/**
|
|
6
8
|
* A tag is a number only if it is non-blank AND parses finitely.
|
|
7
9
|
*
|
|
@@ -28,11 +30,13 @@ const parseTag = (tag) => {
|
|
|
28
30
|
* on Enter indistinguishably from being accepted, with nothing to tell a
|
|
29
31
|
* typo from a broken field — so rejected text is named in the error slot.
|
|
30
32
|
*/
|
|
31
|
-
export const NumberListField = ({ id, label, description, value, onChange, dataCanopyField, readOnly = false, }) => {
|
|
33
|
+
export const NumberListField = ({ id, label, description, value, onChange, dataCanopyField, readOnly = false, maxItems, }) => {
|
|
32
34
|
const generatedId = useId();
|
|
33
35
|
const inputId = id ?? generatedId;
|
|
34
36
|
const [rejected, setRejected] = useState([]);
|
|
35
|
-
return (_jsx(LabelledInput, { label: label, inputId: inputId, canopyPath: dataCanopyField, children: _jsx(TagsInput, { id: inputId, description:
|
|
37
|
+
return (_jsx(LabelledInput, { label: label, inputId: inputId, canopyPath: dataCanopyField, children: _jsx(TagsInput, { id: inputId, description: !readOnly && maxItems !== undefined && value.length >= maxItems
|
|
38
|
+
? withHint(description, listFullReason(maxItems))
|
|
39
|
+
: description, maxTags: tagCap(value.length, maxItems), value: value.map(String), size: "sm", error: rejected.length > 0
|
|
36
40
|
? `Not ${rejected.length === 1 ? 'a number' : 'numbers'}: ${rejected.join(', ')}`
|
|
37
41
|
: undefined, readOnly: readOnly, onChange: (next) => {
|
|
38
42
|
if (readOnly)
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useRef } from 'react';
|
|
3
|
-
import { Button, Group, Paper, Stack, Text } from '@mantine/core';
|
|
3
|
+
import { Button, Group, Paper, Stack, Text, Tooltip } from '@mantine/core';
|
|
4
4
|
import { IconPlus } from '@tabler/icons-react';
|
|
5
5
|
import { isPlainRecord } from '../../validation/field-traversal.js';
|
|
6
6
|
import { ITEM_KEY, mintItemKey, withFreshItemKeys } from '../../item-keys.js';
|
|
7
7
|
import { formatCanopyPath } from '../canopy-path.js';
|
|
8
|
+
import { listFullReason } from '../copy.js';
|
|
8
9
|
import { useFieldCommentControl } from '../comments/field-comment-context.js';
|
|
9
10
|
import { groupDescriptionProps } from './FieldDescription.js';
|
|
10
11
|
import { FieldLabel } from './FieldLabel.js';
|
|
@@ -20,7 +21,12 @@ export const ObjectListField = ({ id, label, field, items, itemKeys, onChange, r
|
|
|
20
21
|
const commentControl = useFieldCommentControl(canopyPath);
|
|
21
22
|
const expansion = useItemExpansion(itemKeys, resetKey);
|
|
22
23
|
const focusAfterRenderRef = useRef(null);
|
|
24
|
+
const fullReason = field.maxItems !== undefined && items.length >= field.maxItems
|
|
25
|
+
? listFullReason(field.maxItems)
|
|
26
|
+
: undefined;
|
|
23
27
|
const insertAt = (index, item) => {
|
|
28
|
+
if (fullReason)
|
|
29
|
+
return;
|
|
24
30
|
focusAfterRenderRef.current = index;
|
|
25
31
|
onChange([...items.slice(0, index), item, ...items.slice(index)]);
|
|
26
32
|
};
|
|
@@ -38,7 +44,7 @@ export const ObjectListField = ({ id, label, field, items, itemKeys, onChange, r
|
|
|
38
44
|
...items.slice(0, idx + 1),
|
|
39
45
|
withFreshItemKeys(field, structuredClone(items[idx])),
|
|
40
46
|
...items.slice(idx + 1),
|
|
41
|
-
]), onRemove: (idx) => {
|
|
47
|
+
]), duplicateBlockedReason: fullReason, onRemove: (idx) => {
|
|
42
48
|
const item = items[idx];
|
|
43
49
|
onChange(items.filter((_, i) => i !== idx));
|
|
44
50
|
onRemoved?.(idx, item);
|
|
@@ -53,5 +59,5 @@ export const ObjectListField = ({ id, label, field, items, itemKeys, onChange, r
|
|
|
53
59
|
onChange(nextItems);
|
|
54
60
|
}, childPath) }, formatCanopyPath(childPath)));
|
|
55
61
|
});
|
|
56
|
-
} }), items.length === 0 && (_jsx(Text, { size: "sm", c: "dimmed", children: readOnly ? 'Nothing added.' : 'Nothing added yet.' })), !readOnly && (_jsx(Group, { children: _jsxs(Button, { variant: "light", size: "xs", leftSection: _jsx(IconPlus, { size: 14, "aria-hidden": true }), onClick: () => insertAt(items.length, { [ITEM_KEY]: mintItemKey() }), "data-testid": "object-list-add", children: ["Add ", field.itemLabel ?? 'item'] }) }))] }) }));
|
|
62
|
+
} }), items.length === 0 && (_jsx(Text, { size: "sm", c: "dimmed", children: readOnly ? 'Nothing added.' : 'Nothing added yet.' })), !readOnly && (_jsx(Group, { children: _jsx(Tooltip, { label: fullReason, disabled: !fullReason, multiline: true, maw: 280, children: _jsxs(Button, { variant: "light", size: "xs", leftSection: _jsx(IconPlus, { size: 14, "aria-hidden": true }), onClick: () => insertAt(items.length, { [ITEM_KEY]: mintItemKey() }), "data-disabled": fullReason ? true : undefined, "aria-disabled": fullReason ? true : undefined, "data-testid": "object-list-add", children: ["Add ", field.itemLabel ?? 'item'] }) }) }))] }) }));
|
|
57
63
|
};
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
import type { ReferenceOptionSort } from '../../config/index.js';
|
|
3
|
+
import { type ReferenceOption } from './use-reference-options.js';
|
|
2
4
|
/** @internal Exported for tests. */
|
|
3
|
-
export
|
|
4
|
-
value: string;
|
|
5
|
-
label: string;
|
|
6
|
-
}
|
|
5
|
+
export type { ReferenceOption };
|
|
7
6
|
export interface ReferenceFieldProps {
|
|
8
7
|
id?: string;
|
|
9
8
|
label?: string;
|
|
@@ -13,9 +12,9 @@ export interface ReferenceFieldProps {
|
|
|
13
12
|
entryTypes?: string[];
|
|
14
13
|
displayField?: string;
|
|
15
14
|
branch?: string;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
optionSort?: ReferenceOptionSort;
|
|
16
|
+
value: unknown;
|
|
17
|
+
onChange: (value: string) => void;
|
|
19
18
|
dataCanopyField?: string;
|
|
20
19
|
/** Shows the value without accepting edits. */
|
|
21
20
|
readOnly?: boolean;
|
|
@@ -1,136 +1,42 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import {
|
|
4
|
-
import { Alert, Button,
|
|
3
|
+
import { useId, useMemo } from 'react';
|
|
4
|
+
import { Alert, Button, Group, Select, Stack, Text, Loader } from '@mantine/core';
|
|
5
5
|
import { IconAlertCircle } from '@tabler/icons-react';
|
|
6
|
-
import { createApiClient } from '../../api/client.js';
|
|
7
|
-
import { useOptionalApiClient } from '../context/index.js';
|
|
8
|
-
import { getErrorMessage } from '../../utils/error.js';
|
|
9
6
|
import { useFieldCommentControl } from '../comments/field-comment-context.js';
|
|
10
7
|
import { FieldDescription, groupDescriptionProps } from './FieldDescription.js';
|
|
11
8
|
import { FieldLabel } from './FieldLabel.js';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
// ApiClientProvider (always true in the real Editor tree); `null` outside one
|
|
25
|
-
// (e.g. FormRenderer.stories.tsx), so the fetch effect falls back to a
|
|
26
|
-
// default-configured client.
|
|
27
|
-
const contextApiClient = useOptionalApiClient();
|
|
28
|
-
const hasCollections = !!collections && collections.length > 0;
|
|
29
|
-
const hasEntryTypes = !!entryTypes && entryTypes.length > 0;
|
|
30
|
-
const needsFetch = !staticOptions && (hasCollections || hasEntryTypes);
|
|
31
|
-
const [options, setOptions] = useState(staticOptions || []);
|
|
32
|
-
const [loading, setLoading] = useState(needsFetch);
|
|
33
|
-
const [error, setError] = useState(null);
|
|
34
|
-
const [retryCount, setRetryCount] = useState(0);
|
|
35
|
-
const extractId = (val) => {
|
|
36
|
-
if (typeof val === 'string')
|
|
37
|
-
return val;
|
|
38
|
-
if (val && typeof val === 'object' && 'id' in val && typeof val.id === 'string')
|
|
39
|
-
return val.id;
|
|
40
|
-
return '';
|
|
41
|
-
};
|
|
42
|
-
const normalizedValue = multiple
|
|
43
|
-
? Array.isArray(value)
|
|
44
|
-
? value.map(extractId)
|
|
45
|
-
: []
|
|
46
|
-
: extractId(value);
|
|
9
|
+
import { HiddenFromSearchBadge, HiddenPicksNote, referenceOptionRenderer, } from './reference-noindex.js';
|
|
10
|
+
import { extractId, unlistedReferenceLabel, useReferenceOptions, } from './use-reference-options.js';
|
|
11
|
+
export const ReferenceField = ({ id, label, description, options: staticOptions, collections, entryTypes, displayField = 'title', branch = 'main', optionSort, value, onChange, dataCanopyField, readOnly = false, }) => {
|
|
12
|
+
const { options, loading, error, retry } = useReferenceOptions({
|
|
13
|
+
staticOptions,
|
|
14
|
+
collections,
|
|
15
|
+
entryTypes,
|
|
16
|
+
displayField,
|
|
17
|
+
branch,
|
|
18
|
+
optionSort,
|
|
19
|
+
});
|
|
20
|
+
const normalizedValue = extractId(value);
|
|
47
21
|
// Otherwise Select shows a set value as empty, inviting an editor to repoint it.
|
|
48
22
|
const listed = new Set(options.map((option) => option.value));
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
const data = unlisted.size > 0 ? [...options, ...unlisted.values()] : options;
|
|
23
|
+
const data = normalizedValue && !listed.has(normalizedValue)
|
|
24
|
+
? [
|
|
25
|
+
...options,
|
|
26
|
+
{ value: normalizedValue, label: unlistedReferenceLabel(value, normalizedValue) },
|
|
27
|
+
]
|
|
28
|
+
: options;
|
|
57
29
|
const generatedId = useId();
|
|
58
30
|
const inputId = id ?? generatedId;
|
|
59
31
|
const commentControl = useFieldCommentControl(dataCanopyField);
|
|
60
|
-
// Track deps to detect changes and reset loading during render
|
|
61
|
-
const [prevFetchKey, setPrevFetchKey] = useState('');
|
|
62
|
-
const fetchKey = needsFetch
|
|
63
|
-
? `${collections?.join(',') ?? ''}:${entryTypes?.join(',') ?? ''}:${displayField}:${branch}`
|
|
64
|
-
: '';
|
|
65
|
-
if (fetchKey !== prevFetchKey) {
|
|
66
|
-
setPrevFetchKey(fetchKey);
|
|
67
|
-
if (needsFetch) {
|
|
68
|
-
setLoading(true);
|
|
69
|
-
setError(null);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
// Keyed on the derived `fetchKey`, not the raw `collections`/`entryTypes`
|
|
73
|
-
// arrays: the parent rebuilds those on every render (including every
|
|
74
|
-
// `refreshEntries()` after a save), so depending on them directly fired
|
|
75
|
-
// this fetch far more often than the inputs actually changed.
|
|
76
|
-
//
|
|
77
|
-
// `active` guards against a stale response: if the key changes again or the
|
|
78
|
-
// field unmounts before this request settles, the loser's `.then`/`.catch`/
|
|
79
|
-
// `.finally` must not overwrite state a newer request already owns.
|
|
80
|
-
useEffect(() => {
|
|
81
|
-
if (!needsFetch)
|
|
82
|
-
return;
|
|
83
|
-
let active = true;
|
|
84
|
-
const apiClient = contextApiClient ?? createApiClient();
|
|
85
|
-
const params = { branch, displayField };
|
|
86
|
-
if (collections && collections.length > 0)
|
|
87
|
-
params.collections = collections.join(',');
|
|
88
|
-
if (entryTypes && entryTypes.length > 0)
|
|
89
|
-
params.entryTypes = entryTypes.join(',');
|
|
90
|
-
apiClient.content
|
|
91
|
-
.getReferenceOptions(params)
|
|
92
|
-
.then((response) => {
|
|
93
|
-
if (!active)
|
|
94
|
-
return;
|
|
95
|
-
if (response.ok && response.data?.options) {
|
|
96
|
-
const mappedOptions = response.data.options.map((opt) => ({
|
|
97
|
-
value: opt.id,
|
|
98
|
-
label: opt.label,
|
|
99
|
-
}));
|
|
100
|
-
setOptions(mappedOptions);
|
|
101
|
-
}
|
|
102
|
-
else {
|
|
103
|
-
setError(response.error || 'Failed to load reference options');
|
|
104
|
-
}
|
|
105
|
-
})
|
|
106
|
-
.catch((err) => {
|
|
107
|
-
if (!active)
|
|
108
|
-
return;
|
|
109
|
-
console.error('Failed to load reference options:', err);
|
|
110
|
-
setError(getErrorMessage(err));
|
|
111
|
-
})
|
|
112
|
-
.finally(() => {
|
|
113
|
-
if (!active)
|
|
114
|
-
return;
|
|
115
|
-
setLoading(false);
|
|
116
|
-
});
|
|
117
|
-
return () => {
|
|
118
|
-
active = false;
|
|
119
|
-
};
|
|
120
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- collections/entryTypes/displayField/branch are captured via fetchKey; re-listing them here would defeat the point of keying on the derived string
|
|
121
|
-
}, [needsFetch, fetchKey, retryCount, contextApiClient]);
|
|
122
|
-
const handleRetry = () => {
|
|
123
|
-
setLoading(true);
|
|
124
|
-
setError(null);
|
|
125
|
-
setRetryCount((count) => count + 1);
|
|
126
|
-
};
|
|
127
32
|
const settled = !loading && !error;
|
|
33
|
+
// Stable between renders, as in ReferenceListField.
|
|
34
|
+
const renderOption = useMemo(() => referenceOptionRenderer(options), [options]);
|
|
35
|
+
const pickedOption = options.find((option) => option.value === normalizedValue);
|
|
36
|
+
const hidden = settled && pickedOption?.noindex ? pickedOption : undefined;
|
|
128
37
|
// One label row for every state, so the comment popover in it survives loading and Retry.
|
|
129
|
-
return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `reference-field-${dataCanopyField}`, ...(settled ? {} : groupDescriptionProps(inputId, description)), children: [_jsx(FieldLabel, { label: label, htmlFor: settled ? inputId : undefined, commentControl: commentControl }), loading ? (_jsxs(_Fragment, { children: [_jsx(FieldDescription, { baseId: inputId, description: description }), _jsx(Loader, { size: "sm", "data-testid": `reference-loading-${dataCanopyField}` })] })) : error ? (_jsxs(_Fragment, { children: [_jsx(FieldDescription, { baseId: inputId, description: description }), _jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", title: "Error", "data-testid": `reference-error-${dataCanopyField}`, children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { size: "sm", children: error }), _jsx(Button, { variant: "light", onClick:
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (!readOnly)
|
|
134
|
-
onChange(next ?? '');
|
|
135
|
-
}, searchable: true, clearable: true, placeholder: readOnly ? undefined : 'Select reference…', size: "sm" }))] }));
|
|
38
|
+
return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `reference-field-${dataCanopyField}`, ...(settled ? {} : groupDescriptionProps(inputId, description)), children: [_jsx(FieldLabel, { label: label, htmlFor: settled ? inputId : undefined, commentControl: commentControl }), loading ? (_jsxs(_Fragment, { children: [_jsx(FieldDescription, { baseId: inputId, description: description }), _jsx(Loader, { size: "sm", "data-testid": `reference-loading-${dataCanopyField}` })] })) : error ? (_jsxs(_Fragment, { children: [_jsx(FieldDescription, { baseId: inputId, description: description }), _jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", title: "Error", "data-testid": `reference-error-${dataCanopyField}`, children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { size: "sm", children: error }), _jsx(Button, { variant: "light", onClick: retry, "data-testid": `reference-retry-${dataCanopyField}`, children: "Retry" })] }) })] })) : (_jsxs(_Fragment, { children: [_jsx(Select, { id: inputId, "aria-label": label, description: description, data: data, renderOption: renderOption, inputContainer: (input) => hidden ? (_jsxs(Group, { gap: "xs", wrap: "nowrap", children: [_jsx("div", { style: { flex: 1, minWidth: 0 }, children: input }), _jsx(HiddenFromSearchBadge, {})] })) : (input), value: normalizedValue, readOnly: readOnly, rightSection: readOnly ? _jsx("span", {}) : undefined, onChange: (next) => {
|
|
39
|
+
if (!readOnly)
|
|
40
|
+
onChange(next ?? '');
|
|
41
|
+
}, searchable: true, clearable: true, placeholder: readOnly ? undefined : 'Select reference…', size: "sm" }), hidden?.noindex && (_jsx(HiddenPicksNote, { picks: [{ label: hidden.label, fieldLabel: hidden.noindex.fieldLabel }] }))] }))] }));
|
|
136
42
|
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { ReferenceOptionSort } from '../../config/index.js';
|
|
3
|
+
import { type ReferenceOption } from './use-reference-options.js';
|
|
4
|
+
export interface ReferenceListFieldProps {
|
|
5
|
+
/** Base for the description's id. */
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
required?: boolean;
|
|
10
|
+
/** Offered as given; without them, options load from the API. */
|
|
11
|
+
options?: ReferenceOption[];
|
|
12
|
+
collections?: string[];
|
|
13
|
+
entryTypes?: string[];
|
|
14
|
+
displayField?: string;
|
|
15
|
+
branch?: string;
|
|
16
|
+
optionSort?: ReferenceOptionSort;
|
|
17
|
+
/** The stored items: ids, or a resolved read's `{ id, … }`. */
|
|
18
|
+
value: readonly unknown[];
|
|
19
|
+
/** Always given ids. */
|
|
20
|
+
onChange: (next: string[]) => void;
|
|
21
|
+
path: Array<string | number>;
|
|
22
|
+
/** The list's canopy path, which picks its comment control. */
|
|
23
|
+
canopyPath: string;
|
|
24
|
+
readOnly?: boolean;
|
|
25
|
+
/** Names the entry being edited; another entry starts with every row shut again. */
|
|
26
|
+
resetKey: string;
|
|
27
|
+
/** The most picks the list holds; the Add control gives way to a note at it. */
|
|
28
|
+
maxItems?: number;
|
|
29
|
+
/** The validation error on item `index`, if any. */
|
|
30
|
+
itemError?: (index: number) => string | undefined;
|
|
31
|
+
/** Called after Remove takes item `index` out, so the caller can offer Undo. */
|
|
32
|
+
onRemoved?: (index: number, id: string, title: string) => void;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A `list: true` reference field: one collapsible row per pick, in stored order, reordered by drag
|
|
36
|
+
* or the row menu. A row's body repoints that pick in place; an Add control under the rows appends
|
|
37
|
+
* and keeps focus, so several picks are several choices in a row. Neither control offers an entry
|
|
38
|
+
* another row holds, so no edit makes a duplicate. Comments attach to the whole field: a string
|
|
39
|
+
* item carries no item key for a thread to follow through a move.
|
|
40
|
+
*/
|
|
41
|
+
export declare const ReferenceListField: React.FC<ReferenceListFieldProps>;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { Alert, Button, Paper, Select, Skeleton, Stack, Text } from '@mantine/core';
|
|
5
|
+
import { IconAlertCircle } from '@tabler/icons-react';
|
|
6
|
+
import { useFieldCommentControl } from '../comments/field-comment-context.js';
|
|
7
|
+
import { ExpandAllButton } from './ExpandAllButton.js';
|
|
8
|
+
import { groupDescriptionProps } from './FieldDescription.js';
|
|
9
|
+
import { FieldLabel } from './FieldLabel.js';
|
|
10
|
+
import { ItemList } from './ItemList.js';
|
|
11
|
+
import { useItemExpansion } from './item-expansion.js';
|
|
12
|
+
import { useListItemKeys } from './list-item-keys.js';
|
|
13
|
+
import { HIDDEN_FROM_SEARCH, HiddenPicksNote, referenceOptionRenderer, } from './reference-noindex.js';
|
|
14
|
+
import { extractId, unlistedReferenceLabel, useReferenceOptions, } from './use-reference-options.js';
|
|
15
|
+
const plural = (count, one, many) => (count === 1 ? one : many);
|
|
16
|
+
/**
|
|
17
|
+
* A `list: true` reference field: one collapsible row per pick, in stored order, reordered by drag
|
|
18
|
+
* or the row menu. A row's body repoints that pick in place; an Add control under the rows appends
|
|
19
|
+
* and keeps focus, so several picks are several choices in a row. Neither control offers an entry
|
|
20
|
+
* another row holds, so no edit makes a duplicate. Comments attach to the whole field: a string
|
|
21
|
+
* item carries no item key for a thread to follow through a move.
|
|
22
|
+
*/
|
|
23
|
+
export const ReferenceListField = ({ id, label, description, required, options: staticOptions, collections, entryTypes, displayField = 'title', branch = 'main', optionSort, value, onChange, path, canopyPath, readOnly = false, resetKey, maxItems, itemError, onRemoved, }) => {
|
|
24
|
+
const commentControl = useFieldCommentControl(canopyPath);
|
|
25
|
+
const { options, loading, error, retry } = useReferenceOptions({
|
|
26
|
+
staticOptions,
|
|
27
|
+
collections,
|
|
28
|
+
entryTypes,
|
|
29
|
+
displayField,
|
|
30
|
+
branch,
|
|
31
|
+
optionSort,
|
|
32
|
+
});
|
|
33
|
+
const ids = value.map(extractId);
|
|
34
|
+
const keysFor = useListItemKeys();
|
|
35
|
+
const itemKeys = keysFor(ids, 'references');
|
|
36
|
+
// A collapsed row already names its entry; it opens only to swap that entry.
|
|
37
|
+
const expansion = useItemExpansion(itemKeys, resetKey, { rowsStartOpen: false });
|
|
38
|
+
const [announcement, setAnnouncement] = useState();
|
|
39
|
+
const [addSearch, setAddSearch] = useState('');
|
|
40
|
+
// The pick that reaches `maxItems` unmounts the focused Add control; its note takes focus instead.
|
|
41
|
+
const capNoteRef = useRef(null);
|
|
42
|
+
const focusCapNoteRef = useRef(false);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!focusCapNoteRef.current)
|
|
45
|
+
return;
|
|
46
|
+
focusCapNoteRef.current = false;
|
|
47
|
+
capNoteRef.current?.focus();
|
|
48
|
+
});
|
|
49
|
+
const labels = new Map(options.map((option) => [option.value, option.label]));
|
|
50
|
+
// Until options settle, a title is unknown rather than unlisted, and after a failed load: either
|
|
51
|
+
// way the id is all there is to show (a Remove's Undo toast names it).
|
|
52
|
+
const titleOf = (index) => {
|
|
53
|
+
const itemId = ids[index];
|
|
54
|
+
if (!itemId)
|
|
55
|
+
return 'No entry picked';
|
|
56
|
+
return (labels.get(itemId) ??
|
|
57
|
+
(loading || error ? itemId : unlistedReferenceLabel(value[index], itemId)));
|
|
58
|
+
};
|
|
59
|
+
const picked = new Set(ids);
|
|
60
|
+
const hiddenField = new Map(options.flatMap((option) => option.noindex ? [[option.value, option.noindex.fieldLabel]] : []));
|
|
61
|
+
// Held back while options load: a stale list could name the wrong picks.
|
|
62
|
+
const hiddenPicks = loading || error
|
|
63
|
+
? []
|
|
64
|
+
: [...picked].flatMap((pick) => {
|
|
65
|
+
const fieldLabel = hiddenField.get(pick);
|
|
66
|
+
return fieldLabel ? [{ label: labels.get(pick) ?? pick, fieldLabel }] : [];
|
|
67
|
+
});
|
|
68
|
+
// Stable between renders: a new `renderOption` makes Mantine drop the option that typing
|
|
69
|
+
// highlighted, so Enter after a search would add nothing.
|
|
70
|
+
const renderOption = useMemo(() => referenceOptionRenderer(loading || error ? [] : options), [options, loading, error]);
|
|
71
|
+
const atCap = maxItems !== undefined && ids.length >= maxItems;
|
|
72
|
+
/** Row `index`'s choices: every option no other row holds, plus its own pick. */
|
|
73
|
+
const rowOptions = (index) => {
|
|
74
|
+
const own = ids[index];
|
|
75
|
+
const elsewhere = new Set(ids.filter((_, i) => i !== index));
|
|
76
|
+
const offered = options.filter((option) => option.value === own || !elsewhere.has(option.value));
|
|
77
|
+
return own && !labels.has(own) ? [...offered, { value: own, label: titleOf(index) }] : offered;
|
|
78
|
+
};
|
|
79
|
+
const emit = (next) => {
|
|
80
|
+
if (!readOnly)
|
|
81
|
+
onChange(next);
|
|
82
|
+
};
|
|
83
|
+
const add = (pick) => {
|
|
84
|
+
if (picked.has(pick) || atCap)
|
|
85
|
+
return;
|
|
86
|
+
const count = ids.length + 1;
|
|
87
|
+
setAnnouncement({
|
|
88
|
+
message: `${labels.get(pick) ?? pick} added, position ${count} of ${count}.`,
|
|
89
|
+
});
|
|
90
|
+
setAddSearch('');
|
|
91
|
+
if (maxItems !== undefined && count >= maxItems)
|
|
92
|
+
focusCapNoteRef.current = true;
|
|
93
|
+
emit([...ids, pick]);
|
|
94
|
+
};
|
|
95
|
+
const capNote = maxItems === undefined
|
|
96
|
+
? undefined
|
|
97
|
+
: ids.length > maxItems
|
|
98
|
+
? `${ids.length} picked, but at most ${maxItems} ${plural(maxItems, 'is', 'are')} allowed. Remove ${ids.length - maxItems} to save.`
|
|
99
|
+
: `${ids.length} of ${maxItems} picked. Open a row to swap its entry, or remove one to add another.`;
|
|
100
|
+
return (_jsx(Paper, { withBorder: true, p: "md", shadow: "xs", ...groupDescriptionProps(id, description), role: "group", "aria-label": label, "data-removal-focus": true, tabIndex: -1, "data-canopy-field": canopyPath, "data-testid": `reference-list-${canopyPath}`, children: _jsxs(Stack, { gap: "sm", children: [_jsx(FieldLabel, { label: label, required: required, description: description, descriptionBaseId: id, commentControl: commentControl, actions: ids.length > 1 && _jsx(ExpandAllButton, { expansion: expansion }) }), error && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", title: "Couldn't load the entries to pick from", "data-testid": "reference-list-error", children: _jsxs(Stack, { gap: 4, align: "flex-start", children: [_jsx(Text, { size: "sm", children: error }), _jsx(Button, { variant: "light", size: "xs", onClick: retry, children: "Retry" })] }) })), _jsx(HiddenPicksNote, { picks: hiddenPicks }), _jsx(ItemList, { items: value, itemKeys: itemKeys, path: path, readOnly: readOnly, expansion: expansion, announcement: announcement, summarize: (_, idx) => loading
|
|
101
|
+
? { title: 'Loading…', pending: true }
|
|
102
|
+
: {
|
|
103
|
+
title: titleOf(idx),
|
|
104
|
+
...(!error &&
|
|
105
|
+
hiddenField.has(ids[idx]) && {
|
|
106
|
+
badge: { label: HIDDEN_FROM_SEARCH, color: 'yellow' },
|
|
107
|
+
}),
|
|
108
|
+
}, hasErrors: itemError ? (idx) => itemError(idx) !== undefined : undefined, onMove: (from, to) => {
|
|
109
|
+
if (from === to || to < 0 || to >= ids.length)
|
|
110
|
+
return;
|
|
111
|
+
const next = [...ids];
|
|
112
|
+
const [moved] = next.splice(from, 1);
|
|
113
|
+
next.splice(to, 0, moved);
|
|
114
|
+
emit(next);
|
|
115
|
+
}, onRemove: (idx) => {
|
|
116
|
+
if (readOnly)
|
|
117
|
+
return;
|
|
118
|
+
const title = titleOf(idx);
|
|
119
|
+
emit(ids.filter((_, i) => i !== idx));
|
|
120
|
+
onRemoved?.(idx, ids[idx], title);
|
|
121
|
+
}, renderItem: (_, idx) => loading ? (_jsx(Skeleton, { height: 36 })) : (_jsx(Select, { label: "Entry", data: rowOptions(idx), renderOption: renderOption, value: ids[idx] || null, readOnly: readOnly, rightSection: readOnly ? _jsx("span", {}) : undefined, onChange: (next) => {
|
|
122
|
+
if (!next || next === ids[idx])
|
|
123
|
+
return;
|
|
124
|
+
const updated = [...ids];
|
|
125
|
+
updated[idx] = next;
|
|
126
|
+
emit(updated);
|
|
127
|
+
}, error: itemError?.(idx),
|
|
128
|
+
// No `selectFirstOptionOnChange` here: with the row's own title in the box every
|
|
129
|
+
// option shows, so Enter would repoint the row to the first.
|
|
130
|
+
searchable: true, allowDeselect: false, nothingFoundMessage: "No entries match", size: "sm", "data-testid": "reference-list-row-select" })) }), !loading && ids.length === 0 && (_jsx(Text, { size: "sm", c: "dimmed", children: readOnly ? 'Nothing picked.' : 'Nothing picked yet.' })), !readOnly &&
|
|
131
|
+
(atCap ? (_jsx(Text, { ref: capNoteRef, tabIndex: -1, size: "sm", c: "dimmed", "data-testid": "reference-list-cap", children: capNote })) : loading ? (_jsx(Skeleton, { height: 36, "data-testid": "reference-list-add-pending" })) : error ? null : (_jsx(Select, { "aria-label": `Add to ${label}`, placeholder: "Search to add an entry", data: options.filter((option) => !picked.has(option.value)), renderOption: renderOption, value: null, searchValue: addSearch, onSearchChange: setAddSearch, onChange: (next) => {
|
|
132
|
+
if (next)
|
|
133
|
+
add(next);
|
|
134
|
+
}, searchable: true,
|
|
135
|
+
// So typing part of a title and pressing Enter picks it.
|
|
136
|
+
selectFirstOptionOnChange: true, nothingFoundMessage: options.length > 0 && options.every((option) => picked.has(option.value))
|
|
137
|
+
? 'Every entry is already picked'
|
|
138
|
+
: 'No entries match', size: "sm", "data-testid": "reference-list-add" })))] }) }));
|
|
139
|
+
};
|
|
@@ -15,6 +15,8 @@ export interface SelectFieldProps {
|
|
|
15
15
|
dataCanopyField?: string;
|
|
16
16
|
/** Shows the value without accepting edits. */
|
|
17
17
|
readOnly?: boolean;
|
|
18
|
+
/** With `multiple`, the most values it holds; at it, the hint says why it takes no more. */
|
|
19
|
+
maxItems?: number;
|
|
18
20
|
}
|
|
19
21
|
export declare const SelectField: React.FC<SelectFieldProps>;
|
|
20
22
|
export {};
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useId } from 'react';
|
|
3
3
|
import { MultiSelect, Select, Stack, Text } from '@mantine/core';
|
|
4
|
+
import { listFullReason } from '../copy.js';
|
|
4
5
|
import { useFieldCommentControl } from '../comments/field-comment-context.js';
|
|
5
6
|
import { FieldLabel } from './FieldLabel.js';
|
|
6
|
-
export const SelectField = ({ id, label, description, options, value, onChange, multiple, placeholder = 'Select…', dataCanopyField, readOnly = false, }) => {
|
|
7
|
+
export const SelectField = ({ id, label, description, options, value, onChange, multiple, placeholder = 'Select…', dataCanopyField, readOnly = false, maxItems, }) => {
|
|
7
8
|
const normalizedValue = multiple
|
|
8
9
|
? Array.isArray(value)
|
|
9
10
|
? value
|
|
@@ -17,8 +18,10 @@ export const SelectField = ({ id, label, description, options, value, onChange,
|
|
|
17
18
|
return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, children: [_jsx(FieldLabel, { label: label, htmlFor: inputId, commentControl: commentControl }), multiple ? (_jsx(MultiSelect, { id: inputId, "aria-label": label, description: description, data: options, value: normalizedValue, readOnly: readOnly, rightSection: readOnly ? _jsx("span", {}) : undefined, onChange: (next) => {
|
|
18
19
|
if (!readOnly)
|
|
19
20
|
onChange(next);
|
|
20
|
-
}, searchable: true, placeholder: readOnly ? undefined : placeholder, size: "sm" })) : (_jsx(Select, { id: inputId, "aria-label": label, description: description, data: options, value: normalizedValue, readOnly: readOnly, rightSection: readOnly ? _jsx("span", {}) : undefined, onChange: (next) => {
|
|
21
|
+
}, searchable: true, maxValues: maxItems, placeholder: readOnly ? undefined : placeholder, size: "sm" })) : (_jsx(Select, { id: inputId, "aria-label": label, description: description, data: options, value: normalizedValue, readOnly: readOnly, rightSection: readOnly ? _jsx("span", {}) : undefined, onChange: (next) => {
|
|
21
22
|
if (!readOnly)
|
|
22
23
|
onChange(next ?? '');
|
|
23
|
-
}, searchable: true, clearable: true, placeholder: readOnly ? undefined : placeholder, size: "sm" })), multiple && !readOnly && (_jsx(Text, { size: "xs", c: "dimmed", children:
|
|
24
|
+
}, searchable: true, clearable: true, placeholder: readOnly ? undefined : placeholder, size: "sm" })), multiple && !readOnly && (_jsx(Text, { size: "xs", c: "dimmed", children: maxItems !== undefined && normalizedValue.length >= maxItems
|
|
25
|
+
? listFullReason(maxItems)
|
|
26
|
+
: 'Searchable multi-select; start typing to filter.' }))] }));
|
|
24
27
|
};
|
|
@@ -8,12 +8,21 @@ export interface StringListFieldProps {
|
|
|
8
8
|
dataCanopyField?: string;
|
|
9
9
|
/** Shows the value without accepting edits. */
|
|
10
10
|
readOnly?: boolean;
|
|
11
|
+
/** The most items the list holds; at or over it, nothing adds and the hint says why. */
|
|
12
|
+
maxItems?: number;
|
|
11
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* `maxTags` for a TagsInput holding `count` items: never below `count`, since a paste cuts the list
|
|
16
|
+
* to `maxTags` and would drop stored items without a word.
|
|
17
|
+
*/
|
|
18
|
+
export declare const tagCap: (count: number, maxItems: number | undefined) => number | undefined;
|
|
19
|
+
/** The field's description, ended as a sentence, then `hint`. */
|
|
20
|
+
export declare const withHint: (description: string | undefined, hint: string) => string;
|
|
12
21
|
/**
|
|
13
22
|
* Editor for `type: 'string', list: true` fields.
|
|
14
23
|
*
|
|
15
24
|
* Uses Mantine's TagsInput: type + Enter adds an item, each item renders as a
|
|
16
25
|
* removable pill, and Backspace on an empty input removes the last item. An editable list says
|
|
17
|
-
* that Enter adds, after the field's own description.
|
|
26
|
+
* that Enter adds, after the field's own description, or at `maxItems` why it adds no more.
|
|
18
27
|
*/
|
|
19
28
|
export declare const StringListField: React.FC<StringListFieldProps>;
|