canopycms 0.4.0 → 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 +66 -42
- package/dist/ai/generate.d.ts +3 -0
- package/dist/ai/generate.js +12 -29
- package/dist/ai/handler.js +1 -0
- package/dist/ai/types.d.ts +3 -9
- 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 +131 -38
- package/dist/cli/generate-ai-content.js +75 -32
- 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 +39 -0
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +33 -0
- 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 +5 -3
- package/dist/entry-schema.js +2 -1
- 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/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
|
@@ -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>;
|
|
@@ -1,27 +1,37 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useId } from 'react';
|
|
3
3
|
import { TagsInput } from '@mantine/core';
|
|
4
|
+
import { listFullReason } from '../copy.js';
|
|
4
5
|
import { LabelledInput } from './FieldLabel.js';
|
|
5
|
-
/**
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* `maxTags` for a TagsInput holding `count` items: never below `count`, since a paste cuts the list
|
|
8
|
+
* to `maxTags` and would drop stored items without a word.
|
|
9
|
+
*/
|
|
10
|
+
export const tagCap = (count, maxItems) => maxItems === undefined ? undefined : Math.max(count, maxItems);
|
|
11
|
+
/** The field's description, ended as a sentence, then `hint`. */
|
|
12
|
+
export const withHint = (description, hint) => {
|
|
7
13
|
const own = description?.trim();
|
|
8
14
|
if (!own)
|
|
9
|
-
return
|
|
10
|
-
return `${/[.!?]$/.test(own) ? own : `${own}.`}
|
|
15
|
+
return hint;
|
|
16
|
+
return `${/[.!?]$/.test(own) ? own : `${own}.`} ${hint}`;
|
|
11
17
|
};
|
|
12
18
|
/**
|
|
13
19
|
* Editor for `type: 'string', list: true` fields.
|
|
14
20
|
*
|
|
15
21
|
* Uses Mantine's TagsInput: type + Enter adds an item, each item renders as a
|
|
16
22
|
* 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.
|
|
23
|
+
* that Enter adds, after the field's own description, or at `maxItems` why it adds no more.
|
|
18
24
|
*/
|
|
19
|
-
export const StringListField = ({ id, label, description, value, onChange, dataCanopyField, readOnly = false, }) => {
|
|
25
|
+
export const StringListField = ({ id, label, description, value, onChange, dataCanopyField, readOnly = false, maxItems, }) => {
|
|
20
26
|
const generatedId = useId();
|
|
21
27
|
const inputId = id ?? generatedId;
|
|
22
28
|
return (_jsx(LabelledInput, { label: label, inputId: inputId, canopyPath: dataCanopyField, children: _jsx(TagsInput, { id: inputId,
|
|
23
|
-
// TagsInput gives no sign that Enter adds a typed item
|
|
24
|
-
description: readOnly
|
|
29
|
+
// TagsInput gives no sign that Enter adds a typed item, nor why it stops at `maxTags`.
|
|
30
|
+
description: readOnly
|
|
31
|
+
? description
|
|
32
|
+
: withHint(description, maxItems !== undefined && value.length >= maxItems
|
|
33
|
+
? listFullReason(maxItems)
|
|
34
|
+
: 'Press Enter to add.'), maxTags: tagCap(value.length, maxItems), value: value, size: "sm", readOnly: readOnly, onChange: (next) => {
|
|
25
35
|
if (!readOnly)
|
|
26
36
|
onChange(next);
|
|
27
37
|
},
|
|
@@ -9,7 +9,11 @@ export interface ItemExpansion {
|
|
|
9
9
|
* Which rows of a collapsible list are open, by list-item key (`useListItemKeys`), so a row keeps
|
|
10
10
|
* its state through moves, and through a Remove and its Undo. A list inside a row that moves
|
|
11
11
|
* remounts and starts over. A list's first rows start open as `EXPAND_ALL_UP_TO` says; a row added
|
|
12
|
-
* later (Add, Duplicate) starts open. `
|
|
13
|
-
*
|
|
12
|
+
* later (Add, Duplicate) starts open. With `rowsStartOpen: false`, every row starts shut, for a
|
|
13
|
+
* list whose collapsed rows already say all it holds. `resetKey` names the entry being edited: a
|
|
14
|
+
* new one starts over. The form's own Expand all and Collapse all (`FormExpansionContext`) reach
|
|
15
|
+
* every list.
|
|
14
16
|
*/
|
|
15
|
-
export declare function useItemExpansion(itemKeys: readonly string[], resetKey: string
|
|
17
|
+
export declare function useItemExpansion(itemKeys: readonly string[], resetKey: string, { rowsStartOpen }?: {
|
|
18
|
+
rowsStartOpen?: boolean;
|
|
19
|
+
}): ItemExpansion;
|
|
@@ -6,10 +6,12 @@ const EXPAND_ALL_UP_TO = 3;
|
|
|
6
6
|
* Which rows of a collapsible list are open, by list-item key (`useListItemKeys`), so a row keeps
|
|
7
7
|
* its state through moves, and through a Remove and its Undo. A list inside a row that moves
|
|
8
8
|
* remounts and starts over. A list's first rows start open as `EXPAND_ALL_UP_TO` says; a row added
|
|
9
|
-
* later (Add, Duplicate) starts open. `
|
|
10
|
-
*
|
|
9
|
+
* later (Add, Duplicate) starts open. With `rowsStartOpen: false`, every row starts shut, for a
|
|
10
|
+
* list whose collapsed rows already say all it holds. `resetKey` names the entry being edited: a
|
|
11
|
+
* new one starts over. The form's own Expand all and Collapse all (`FormExpansionContext`) reach
|
|
12
|
+
* every list.
|
|
11
13
|
*/
|
|
12
|
-
export function useItemExpansion(itemKeys, resetKey) {
|
|
14
|
+
export function useItemExpansion(itemKeys, resetKey, { rowsStartOpen = true } = {}) {
|
|
13
15
|
const [toggled, setToggled] = useState(() => ({
|
|
14
16
|
resetKey,
|
|
15
17
|
open: new Map(),
|
|
@@ -27,7 +29,7 @@ export function useItemExpansion(itemKeys, resetKey) {
|
|
|
27
29
|
itemKeys.forEach((key, idx) => {
|
|
28
30
|
if (defaults.current.open.has(key))
|
|
29
31
|
return;
|
|
30
|
-
defaults.current.open.set(key, firstPopulation ? idx === 0 || itemKeys.length <= EXPAND_ALL_UP_TO : true);
|
|
32
|
+
defaults.current.open.set(key, rowsStartOpen && (firstPopulation ? idx === 0 || itemKeys.length <= EXPAND_ALL_UP_TO : true));
|
|
31
33
|
});
|
|
32
34
|
if (itemKeys.length > 0)
|
|
33
35
|
defaults.current.started = true;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Keys for the items of object lists and
|
|
3
|
-
* was first shown with, so
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Keys for the items of object lists, block lists and reference lists. An object, or a string,
|
|
3
|
+
* listed once keeps the key it was first shown with, so a move carries its row's state along, an
|
|
4
|
+
* append remounts no item, a removal never hands a crashed item's boundary to the next one, and an
|
|
5
|
+
* item put back by Undo (the same object, the same string) gets its key back. A list whose length
|
|
6
|
+
* is unchanged (an edited item, a repointed reference, the saved copy of the same items) keeps keys
|
|
7
|
+
* by position. One instance serves every list a component renders, told apart by `listPath`.
|
|
7
8
|
*/
|
|
8
9
|
export declare function useListItemKeys(): (items: readonly unknown[], listPath: string) => string[];
|
|
@@ -1,50 +1,82 @@
|
|
|
1
1
|
import { useRef } from 'react';
|
|
2
2
|
/**
|
|
3
|
-
* Keys for the items of object lists and
|
|
4
|
-
* was first shown with, so
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* Keys for the items of object lists, block lists and reference lists. An object, or a string,
|
|
4
|
+
* listed once keeps the key it was first shown with, so a move carries its row's state along, an
|
|
5
|
+
* append remounts no item, a removal never hands a crashed item's boundary to the next one, and an
|
|
6
|
+
* item put back by Undo (the same object, the same string) gets its key back. A list whose length
|
|
7
|
+
* is unchanged (an edited item, a repointed reference, the saved copy of the same items) keeps keys
|
|
8
|
+
* by position. One instance serves every list a component renders, told apart by `listPath`.
|
|
8
9
|
*/
|
|
9
10
|
export function useListItemKeys() {
|
|
10
11
|
const listItemKeys = useRef(new WeakMap());
|
|
12
|
+
// Strings can't key a WeakMap, and one id may sit in two lists, so each list keeps its own.
|
|
13
|
+
const stringKeys = useRef(new Map());
|
|
11
14
|
const lastListKeys = useRef(new Map());
|
|
15
|
+
const lastListItems = useRef(new Map());
|
|
12
16
|
const nextListItemKey = useRef(0);
|
|
13
17
|
return (items, listPath) => {
|
|
14
18
|
const previous = lastListKeys.current.get(listPath);
|
|
19
|
+
const previousItems = lastListItems.current.get(listPath);
|
|
20
|
+
let ownStrings = stringKeys.current.get(listPath);
|
|
21
|
+
if (!ownStrings) {
|
|
22
|
+
ownStrings = new Map();
|
|
23
|
+
stringKeys.current.set(listPath, ownStrings);
|
|
24
|
+
}
|
|
15
25
|
const occurrences = new Map();
|
|
16
26
|
for (const item of items) {
|
|
17
|
-
if (typeof item === 'object' && item !== null) {
|
|
27
|
+
if ((typeof item === 'object' && item !== null) || typeof item === 'string') {
|
|
18
28
|
occurrences.set(item, (occurrences.get(item) ?? 0) + 1);
|
|
19
29
|
}
|
|
20
30
|
}
|
|
21
|
-
// Only an
|
|
22
|
-
const single = (item) => typeof item === 'object' && item !== null
|
|
31
|
+
// Only an item listed once has an identity; a repeated one is keyed by position.
|
|
32
|
+
const single = (item) => ((typeof item === 'object' && item !== null) || typeof item === 'string') &&
|
|
33
|
+
occurrences.get(item) === 1;
|
|
34
|
+
const sameLength = previous?.length === items.length;
|
|
35
|
+
const ownKey = (item) => {
|
|
36
|
+
if (typeof item !== 'string')
|
|
37
|
+
return listItemKeys.current.get(item);
|
|
38
|
+
// A string new to a list of unchanged length took an item's place (a repoint), so it takes
|
|
39
|
+
// that position's key rather than one it held before.
|
|
40
|
+
if (sameLength && !previousItems?.includes(item))
|
|
41
|
+
return undefined;
|
|
42
|
+
return ownStrings.get(item);
|
|
43
|
+
};
|
|
23
44
|
const claimed = new Set();
|
|
24
45
|
const keys = items.map((item) => {
|
|
25
|
-
const own = single(item) ?
|
|
46
|
+
const own = single(item) ? ownKey(item) : undefined;
|
|
26
47
|
// Two objects can hold one key, an edited copy having inherited its original's.
|
|
27
48
|
if (own === undefined || claimed.has(own))
|
|
28
49
|
return undefined;
|
|
29
50
|
claimed.add(own);
|
|
30
51
|
return own;
|
|
31
52
|
});
|
|
32
|
-
// The rest inherit their position's key, never one an
|
|
53
|
+
// The rest inherit their position's key, never one an item present here holds.
|
|
33
54
|
const resolved = keys.map((key, idx) => {
|
|
34
55
|
if (key !== undefined)
|
|
35
56
|
return key;
|
|
36
|
-
let next =
|
|
57
|
+
let next = sameLength ? previous?.[idx] : undefined;
|
|
37
58
|
if (next === undefined || claimed.has(next)) {
|
|
38
59
|
nextListItemKey.current += 1;
|
|
39
60
|
next = `item-${nextListItemKey.current}`;
|
|
40
61
|
}
|
|
41
62
|
claimed.add(next);
|
|
42
63
|
const item = items[idx];
|
|
43
|
-
if (single(item))
|
|
44
|
-
|
|
64
|
+
if (single(item)) {
|
|
65
|
+
if (typeof item === 'string') {
|
|
66
|
+
// The string it replaced gives the key up, so that string returns with a key of its own.
|
|
67
|
+
for (const [held, key] of ownStrings)
|
|
68
|
+
if (key === next)
|
|
69
|
+
ownStrings.delete(held);
|
|
70
|
+
ownStrings.set(item, next);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
listItemKeys.current.set(item, next);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
45
76
|
return next;
|
|
46
77
|
});
|
|
47
78
|
lastListKeys.current.set(listPath, resolved);
|
|
79
|
+
lastListItems.current.set(listPath, items);
|
|
48
80
|
return resolved;
|
|
49
81
|
};
|
|
50
82
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { type ComboboxItem } from '@mantine/core';
|
|
3
|
+
import type { ReferenceOption } from './use-reference-options.js';
|
|
4
|
+
/** The badge on a picked or offered entry whose own form hides it from search. */
|
|
5
|
+
export declare const HIDDEN_FROM_SEARCH = "Hidden from search";
|
|
6
|
+
/** `inOption` lets a highlighted option's fill recolour it (theme.tsx), so it stays legible. */
|
|
7
|
+
export declare const HiddenFromSearchBadge: React.FC<{
|
|
8
|
+
inOption?: boolean;
|
|
9
|
+
}>;
|
|
10
|
+
/**
|
|
11
|
+
* A reference Select's `renderOption`: the label, with the badge on a hidden entry. Draws the
|
|
12
|
+
* check mark itself, since a custom option replaces Mantine's.
|
|
13
|
+
*/
|
|
14
|
+
export declare function referenceOptionRenderer(options: readonly ReferenceOption[]): ({ option, checked, }: {
|
|
15
|
+
option: ComboboxItem;
|
|
16
|
+
checked?: boolean;
|
|
17
|
+
}) => import("react/jsx-runtime").JSX.Element;
|
|
18
|
+
/** A pick whose target is hidden from search, as the note names it. */
|
|
19
|
+
export interface HiddenPick {
|
|
20
|
+
label: string;
|
|
21
|
+
fieldLabel: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* One sentence per noindex field label, since entry types can label that field differently:
|
|
25
|
+
* "‘A’ has “Hide from search engines” turned on, so pages that list only visible entries won't
|
|
26
|
+
* show it."
|
|
27
|
+
* @internal Exported for tests.
|
|
28
|
+
*/
|
|
29
|
+
export declare function hiddenPickSentences(picks: readonly HiddenPick[]): string[];
|
|
30
|
+
/**
|
|
31
|
+
* The yellow note about hidden picks: above a list, under its heading, or under a single
|
|
32
|
+
* field's input. It warns rather than filters: an editor may be lining up an entry that is
|
|
33
|
+
* about to go public.
|
|
34
|
+
*/
|
|
35
|
+
export declare const HiddenPicksNote: React.FC<{
|
|
36
|
+
picks: readonly HiddenPick[];
|
|
37
|
+
}>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Alert, Badge, Group } from '@mantine/core';
|
|
4
|
+
import { IconAlertTriangle, IconCheck } from '@tabler/icons-react';
|
|
5
|
+
/** The badge on a picked or offered entry whose own form hides it from search. */
|
|
6
|
+
export const HIDDEN_FROM_SEARCH = 'Hidden from search';
|
|
7
|
+
/** `inOption` lets a highlighted option's fill recolour it (theme.tsx), so it stays legible. */
|
|
8
|
+
export const HiddenFromSearchBadge = ({ inOption }) => (_jsx(Badge, { variant: "light", color: "yellow", size: "sm", tt: "none", style: { flexShrink: 0 }, ...(inOption && { 'data-canopy-option-badge': true }), children: HIDDEN_FROM_SEARCH }));
|
|
9
|
+
/**
|
|
10
|
+
* A reference Select's `renderOption`: the label, with the badge on a hidden entry. Draws the
|
|
11
|
+
* check mark itself, since a custom option replaces Mantine's.
|
|
12
|
+
*/
|
|
13
|
+
export function referenceOptionRenderer(options) {
|
|
14
|
+
const hiddenValues = new Set(options.flatMap((o) => (o.noindex ? [o.value] : [])));
|
|
15
|
+
return function ReferenceOptionLabel({ option, checked, }) {
|
|
16
|
+
const hidden = hiddenValues.has(option.value);
|
|
17
|
+
return (_jsxs(Group, { gap: 6, wrap: "nowrap", w: "100%", children: [_jsx(IconCheck, { "aria-hidden": true, size: 14, style: { visibility: checked ? 'visible' : 'hidden' } }), _jsx("span", { style: { flex: 1, minWidth: 0 }, children: option.label }), hidden && _jsx(HiddenFromSearchBadge, { inOption: true })] }));
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const quoted = (label) => `‘${label}’`;
|
|
21
|
+
const listNames = (names) => names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
22
|
+
/**
|
|
23
|
+
* One sentence per noindex field label, since entry types can label that field differently:
|
|
24
|
+
* "‘A’ has “Hide from search engines” turned on, so pages that list only visible entries won't
|
|
25
|
+
* show it."
|
|
26
|
+
* @internal Exported for tests.
|
|
27
|
+
*/
|
|
28
|
+
export function hiddenPickSentences(picks) {
|
|
29
|
+
const byField = new Map();
|
|
30
|
+
for (const pick of picks) {
|
|
31
|
+
byField.set(pick.fieldLabel, [...(byField.get(pick.fieldLabel) ?? []), quoted(pick.label)]);
|
|
32
|
+
}
|
|
33
|
+
return [...byField].map(([fieldLabel, names]) => {
|
|
34
|
+
const one = names.length === 1;
|
|
35
|
+
return (`${listNames(names)} ${one ? 'has' : 'have'} “${fieldLabel}” turned on, so pages ` +
|
|
36
|
+
`that list only visible entries won't show ${one ? 'it' : 'them'}.`);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The yellow note about hidden picks: above a list, under its heading, or under a single
|
|
41
|
+
* field's input. It warns rather than filters: an editor may be lining up an entry that is
|
|
42
|
+
* about to go public.
|
|
43
|
+
*/
|
|
44
|
+
export const HiddenPicksNote = ({ picks }) => {
|
|
45
|
+
if (picks.length === 0)
|
|
46
|
+
return null;
|
|
47
|
+
return (_jsx(Alert, { color: "yellow", icon: _jsx(IconAlertTriangle, { size: 16 }), p: "xs", "data-testid": "reference-hidden-note", children: hiddenPickSentences(picks).map((sentence) => (_jsx("div", { children: sentence }, sentence))) }));
|
|
48
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ReferenceOptionSort } from '../../config/index.js';
|
|
2
|
+
/** An entry a reference field can point at. */
|
|
3
|
+
export interface ReferenceOption {
|
|
4
|
+
value: string;
|
|
5
|
+
label: string;
|
|
6
|
+
/** The target is hidden from search; `fieldLabel` names the setting on that entry's form. */
|
|
7
|
+
noindex?: {
|
|
8
|
+
fieldLabel: string;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/** The id a stored reference holds: the id itself, or a resolved read's `{ id, … }`. */
|
|
12
|
+
export declare const extractId: (val: unknown) => string;
|
|
13
|
+
/** Label for a set value the options omit (a restricted, missing or unlisted target). */
|
|
14
|
+
export declare function unlistedReferenceLabel(value: unknown, id: string): string;
|
|
15
|
+
export interface ReferenceOptionsQuery {
|
|
16
|
+
/** Used as given, with no fetch. */
|
|
17
|
+
staticOptions?: ReferenceOption[];
|
|
18
|
+
collections?: string[];
|
|
19
|
+
entryTypes?: string[];
|
|
20
|
+
displayField: string;
|
|
21
|
+
branch: string;
|
|
22
|
+
/** The order the endpoint returns options in. Default: by label. */
|
|
23
|
+
optionSort?: ReferenceOptionSort;
|
|
24
|
+
}
|
|
25
|
+
export interface ReferenceOptions {
|
|
26
|
+
options: ReferenceOption[];
|
|
27
|
+
loading: boolean;
|
|
28
|
+
error: string | null;
|
|
29
|
+
retry: () => void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The entries a reference field offers: `staticOptions` as given, else one request per field to
|
|
33
|
+
* the reference-options endpoint, made again only when the query or the API client changes, or on
|
|
34
|
+
* `retry`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function useReferenceOptions({ staticOptions, collections, entryTypes, displayField, branch, optionSort, }: ReferenceOptionsQuery): ReferenceOptions;
|