@tristan2828/ui-foundation 3.11.0 → 3.13.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/README.md +1 -1
- package/conventions/docs/add-an-entity.md +113 -16
- package/conventions/docs/cell-patterns.md +176 -6
- package/conventions/docs/entity-plan-template.md +31 -4
- package/dist/api/gateway/errors.js +7 -1
- package/dist/components/app/edit-in-place-store.d.ts +76 -0
- package/dist/components/app/edit-in-place-store.js +159 -0
- package/dist/components/app/edit-in-place.d.ts +88 -0
- package/dist/components/app/edit-in-place.js +10 -0
- package/dist/components/app/editable-value.d.ts +23 -0
- package/dist/components/app/editable-value.js +176 -0
- package/dist/components/app/entity-view.d.ts +15 -2
- package/dist/components/app/entity-view.js +24 -8
- package/dist/components/app/milkdown-editor.d.ts +2 -0
- package/dist/components/app/milkdown-editor.js +221 -0
- package/dist/components/app/multi-reference.d.ts +5 -1
- package/dist/components/app/multi-reference.js +2 -2
- package/dist/components/app/rich-text-editor.d.ts +20 -0
- package/dist/components/app/rich-text-editor.js +25 -0
- package/dist/components/ui/field.js +2 -2
- package/dist/hooks/use-record-update.d.ts +71 -0
- package/dist/hooks/use-record-update.js +148 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/lib/markdown-merge.d.ts +19 -0
- package/dist/lib/markdown-merge.js +195 -0
- package/dist/testing/index.d.ts +18 -1
- package/dist/testing/index.js +21 -2
- package/package.json +5 -2
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// The rich-text editor behind RichTextEditor, loaded only when one opens
|
|
3
|
+
// (rich-text-editor.tsx imports this lazily): Milkdown, a ProseMirror
|
|
4
|
+
// editor whose Markdown goes in and out through remark, the same parser
|
|
5
|
+
// <Markdown> renders with. Why Milkdown, and what it costs:
|
|
6
|
+
// docs/ARCHITECTURE.md "Decisions".
|
|
7
|
+
//
|
|
8
|
+
// What this file adds to Milkdown's own presets:
|
|
9
|
+
// - Saves go through mergeMarkdown: an untouched document, and every block
|
|
10
|
+
// the person didn't change, saves back as originally written.
|
|
11
|
+
// - No HTML in, ever. The preset's "preserve empty line" plugin is left
|
|
12
|
+
// out: it writes an empty paragraph as a raw `<br />`. Raw HTML already
|
|
13
|
+
// in the text shows as its characters (an uneditable chip, Milkdown's
|
|
14
|
+
// html node), and HTML pasted as such a chip becomes plain text.
|
|
15
|
+
// - Headings render from <h3>, like <Markdown>: the page owns <h1> and
|
|
16
|
+
// <h2>. A task-list item renders a real checkbox that toggles it.
|
|
17
|
+
// - Edited blocks are written in the document's own bullet and rule marks.
|
|
18
|
+
import { useEffect, useRef } from 'react';
|
|
19
|
+
import { defaultValueCtx, Editor, editorViewCtx, editorViewOptionsCtx, remarkStringifyOptionsCtx, rootCtx, } from '@milkdown/kit/core';
|
|
20
|
+
import { history } from '@milkdown/kit/plugin/history';
|
|
21
|
+
import { commands, inputRules, keymap, markInputRules, plugins, remarkPreserveEmptyLinePlugin, schema, } from '@milkdown/kit/preset/commonmark';
|
|
22
|
+
import { gfm } from '@milkdown/kit/preset/gfm';
|
|
23
|
+
import { Fragment, Slice } from '@milkdown/kit/prose/model';
|
|
24
|
+
import { Plugin } from '@milkdown/kit/prose/state';
|
|
25
|
+
import { $prose, getMarkdown } from '@milkdown/kit/utils';
|
|
26
|
+
import { cn } from 'cn';
|
|
27
|
+
import { MarkdownMergeError, markdownStyle, mergeMarkdown } from '../../lib/markdown-merge.js';
|
|
28
|
+
// The commonmark preset, minus the plugin that writes `<br />`. A $remark
|
|
29
|
+
// plugin is a pair (its options and the plugin), spread into `plugins`.
|
|
30
|
+
const LEFT_OUT = remarkPreserveEmptyLinePlugin;
|
|
31
|
+
const COMMONMARK = [
|
|
32
|
+
...schema,
|
|
33
|
+
...inputRules,
|
|
34
|
+
...markInputRules,
|
|
35
|
+
...commands,
|
|
36
|
+
...keymap,
|
|
37
|
+
...plugins.filter((plugin) => !LEFT_OUT.includes(plugin)),
|
|
38
|
+
];
|
|
39
|
+
// Raw HTML pasted in as Milkdown's html chip becomes the plain text it
|
|
40
|
+
// shows, so a paste can never add markup to the Markdown.
|
|
41
|
+
function htmlToText(fragment) {
|
|
42
|
+
const nodes = [];
|
|
43
|
+
fragment.forEach((node) => {
|
|
44
|
+
if (node.type.name === 'html') {
|
|
45
|
+
const value = String(node.attrs.value ?? '');
|
|
46
|
+
if (value)
|
|
47
|
+
nodes.push(node.type.schema.text(value));
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
nodes.push(node.copy(htmlToText(node.content)));
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
return Fragment.fromArray(nodes);
|
|
54
|
+
}
|
|
55
|
+
function headingView(node) {
|
|
56
|
+
const dom = document.createElement(`h${Math.min(Number(node.attrs.level) + 2, 6)}`);
|
|
57
|
+
return {
|
|
58
|
+
dom,
|
|
59
|
+
contentDOM: dom,
|
|
60
|
+
update: (next) => next.type === node.type && next.attrs.level === node.attrs.level,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
// A task-list item: its checkbox toggles `checked`. Any other list item
|
|
64
|
+
// renders as Milkdown's own.
|
|
65
|
+
function taskItemView(node, view, getPos) {
|
|
66
|
+
const dom = document.createElement('li');
|
|
67
|
+
const box = document.createElement('input');
|
|
68
|
+
box.type = 'checkbox';
|
|
69
|
+
box.contentEditable = 'false';
|
|
70
|
+
box.tabIndex = -1;
|
|
71
|
+
box.setAttribute('aria-label', 'Done');
|
|
72
|
+
const contentDOM = document.createElement('div');
|
|
73
|
+
dom.append(box, contentDOM);
|
|
74
|
+
dom.dataset.itemType = 'task';
|
|
75
|
+
let current = node;
|
|
76
|
+
const render = () => {
|
|
77
|
+
box.checked = Boolean(current.attrs.checked);
|
|
78
|
+
dom.dataset.checked = String(Boolean(current.attrs.checked));
|
|
79
|
+
};
|
|
80
|
+
render();
|
|
81
|
+
box.addEventListener('mousedown', (event) => event.preventDefault());
|
|
82
|
+
// The browser's own toggle is left to happen (cancelling a checkbox's
|
|
83
|
+
// click puts its old state back after this handler), and the document
|
|
84
|
+
// follows it.
|
|
85
|
+
box.addEventListener('click', () => {
|
|
86
|
+
const pos = getPos();
|
|
87
|
+
if (pos === undefined || !view.editable)
|
|
88
|
+
return;
|
|
89
|
+
view.dispatch(view.state.tr.setNodeMarkup(pos, undefined, { ...current.attrs, checked: !current.attrs.checked }));
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
dom,
|
|
93
|
+
contentDOM,
|
|
94
|
+
update: (next) => {
|
|
95
|
+
if (next.type !== current.type || next.attrs.checked == null)
|
|
96
|
+
return false;
|
|
97
|
+
current = next;
|
|
98
|
+
render();
|
|
99
|
+
return true;
|
|
100
|
+
},
|
|
101
|
+
stopEvent: (event) => event.target === box,
|
|
102
|
+
ignoreMutation: (mutation) => mutation.target === box,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
|
|
106
|
+
const rootRef = useRef(null);
|
|
107
|
+
const editorRef = useRef(null);
|
|
108
|
+
// The latest props, for the editor's callbacks, which are set up once.
|
|
109
|
+
const latest = useRef({ onChange, onProblem, readOnly });
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
latest.current = { onChange, onProblem, readOnly };
|
|
112
|
+
});
|
|
113
|
+
const attributes = {
|
|
114
|
+
'aria-multiline': 'true',
|
|
115
|
+
'aria-readonly': String(readOnly),
|
|
116
|
+
class: 'outline-none',
|
|
117
|
+
};
|
|
118
|
+
if (id)
|
|
119
|
+
attributes.id = id;
|
|
120
|
+
if (ariaLabel)
|
|
121
|
+
attributes['aria-label'] = ariaLabel;
|
|
122
|
+
if (ariaLabelledBy)
|
|
123
|
+
attributes['aria-labelledby'] = ariaLabelledBy;
|
|
124
|
+
if (ariaDescribedBy)
|
|
125
|
+
attributes['aria-describedby'] = ariaDescribedBy;
|
|
126
|
+
if (ariaInvalid)
|
|
127
|
+
attributes['aria-invalid'] = 'true';
|
|
128
|
+
const attributesKey = JSON.stringify(attributes);
|
|
129
|
+
// The editor is made once, from the Markdown it opened with.
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
const root = rootRef.current;
|
|
132
|
+
if (!root)
|
|
133
|
+
return;
|
|
134
|
+
const original = defaultValue;
|
|
135
|
+
let baseline = null;
|
|
136
|
+
let cancelled = false;
|
|
137
|
+
const emit = () => {
|
|
138
|
+
const editor = editorRef.current;
|
|
139
|
+
if (!editor || baseline === null)
|
|
140
|
+
return;
|
|
141
|
+
const current = editor.action(getMarkdown());
|
|
142
|
+
try {
|
|
143
|
+
const merged = mergeMarkdown(original, baseline, current);
|
|
144
|
+
latest.current.onProblem?.(null);
|
|
145
|
+
latest.current.onChange(merged);
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (!(error instanceof MarkdownMergeError))
|
|
149
|
+
throw error;
|
|
150
|
+
latest.current.onProblem?.(error.message);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
const changes = $prose(() => new Plugin({
|
|
154
|
+
view: () => ({
|
|
155
|
+
update: (view, previous) => {
|
|
156
|
+
if (!view.state.doc.eq(previous.doc))
|
|
157
|
+
emit();
|
|
158
|
+
},
|
|
159
|
+
}),
|
|
160
|
+
props: {
|
|
161
|
+
transformPasted: (slice) => new Slice(htmlToText(slice.content), slice.openStart, slice.openEnd),
|
|
162
|
+
},
|
|
163
|
+
}));
|
|
164
|
+
const make = Editor.make()
|
|
165
|
+
.config((ctx) => {
|
|
166
|
+
ctx.set(rootCtx, root);
|
|
167
|
+
ctx.set(defaultValueCtx, original);
|
|
168
|
+
ctx.update(remarkStringifyOptionsCtx, (options) => ({ ...options, ...markdownStyle(original) }));
|
|
169
|
+
ctx.update(editorViewOptionsCtx, (options) => ({
|
|
170
|
+
...options,
|
|
171
|
+
editable: () => !latest.current.readOnly,
|
|
172
|
+
attributes,
|
|
173
|
+
nodeViews: {
|
|
174
|
+
heading: headingView,
|
|
175
|
+
list_item: (node, view, getPos) => node.attrs.checked == null ? undefined : taskItemView(node, view, getPos),
|
|
176
|
+
},
|
|
177
|
+
}));
|
|
178
|
+
})
|
|
179
|
+
.use(COMMONMARK)
|
|
180
|
+
.use(gfm)
|
|
181
|
+
.use(history)
|
|
182
|
+
.use(changes);
|
|
183
|
+
void make.create().then((editor) => {
|
|
184
|
+
if (cancelled) {
|
|
185
|
+
void editor.destroy();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
editorRef.current = editor;
|
|
189
|
+
baseline = editor.action(getMarkdown());
|
|
190
|
+
if (autoFocus)
|
|
191
|
+
editor.action((ctx) => ctx.get(editorViewCtx).focus());
|
|
192
|
+
});
|
|
193
|
+
return () => {
|
|
194
|
+
cancelled = true;
|
|
195
|
+
const editor = editorRef.current;
|
|
196
|
+
editorRef.current = null;
|
|
197
|
+
if (editor)
|
|
198
|
+
void editor.destroy();
|
|
199
|
+
root.replaceChildren();
|
|
200
|
+
};
|
|
201
|
+
// Made once per mount: later changes to defaultValue are ignored, as
|
|
202
|
+
// with any uncontrolled input. Attributes and read-only follow below.
|
|
203
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
204
|
+
}, []);
|
|
205
|
+
// Read-only and the ARIA attributes follow their props.
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
const editor = editorRef.current;
|
|
208
|
+
if (!editor)
|
|
209
|
+
return;
|
|
210
|
+
editor.action((ctx) => {
|
|
211
|
+
const view = ctx.get(editorViewCtx);
|
|
212
|
+
view.setProps({ editable: () => !readOnly, attributes: JSON.parse(attributesKey) });
|
|
213
|
+
});
|
|
214
|
+
}, [readOnly, attributesKey]);
|
|
215
|
+
return (_jsx("div", { ref: rootRef, "data-slot": "rich-text-editor", className: cn('min-h-24 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-2 type-body text-foreground break-words transition-colors', 'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', 'has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/20', 'has-[[aria-readonly=true]]:text-muted-foreground',
|
|
216
|
+
// The same type roles <Markdown> uses, so text reads the same
|
|
217
|
+
// being edited as being read.
|
|
218
|
+
'[&_.ProseMirror]:flex [&_.ProseMirror]:min-h-20 [&_.ProseMirror]:flex-col [&_.ProseMirror]:gap-3', '[&_h3]:type-section-title [&_h4]:type-section-title [&_h5]:type-label [&_h6]:type-label', '[&_ul]:flex [&_ul]:list-disc [&_ul]:flex-col [&_ul]:gap-1 [&_ul]:pl-6', '[&_ol]:flex [&_ol]:list-decimal [&_ol]:flex-col [&_ol]:gap-1 [&_ol]:pl-6', '[&_li[data-item-type=task]]:-ml-6 [&_li[data-item-type=task]]:flex [&_li[data-item-type=task]]:list-none [&_li[data-item-type=task]]:items-start [&_li[data-item-type=task]]:gap-2', '[&_li[data-item-type=task]>input]:mt-1 [&_li[data-item-type=task]>input]:size-4 [&_li[data-item-type=task]>input]:accent-primary', '[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-4', '[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground', '[&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:font-mono', '[&_:not(pre)>code]:rounded [&_:not(pre)>code]:bg-muted [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:font-mono', '[&_hr]:border-border', '[&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:type-label',
|
|
219
|
+
// Raw HTML from the text: shown as its characters, never as markup.
|
|
220
|
+
'[&_span[data-type=html]]:rounded [&_span[data-type=html]]:bg-muted [&_span[data-type=html]]:px-1 [&_span[data-type=html]]:font-mono [&_span[data-type=html]]:text-foreground', className) }));
|
|
221
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
export declare function MultiReference<Id extends string | number>({ options, value, onValueChange, getLabel, onSearchChange, id, placeholder, emptyText, 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, className, }: {
|
|
2
|
+
export declare function MultiReference<Id extends string | number>({ options, value, onValueChange, getLabel, onSearchChange, id, placeholder, emptyText, 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, 'aria-describedby': ariaDescribedBy, readOnly, className, }: {
|
|
3
3
|
/** The records the current search returned, in display order. */
|
|
4
4
|
options: readonly {
|
|
5
5
|
id: Id;
|
|
@@ -23,5 +23,9 @@ export declare function MultiReference<Id extends string | number>({ options, va
|
|
|
23
23
|
/** For use without a visible label (e.g. a table toolbar filter). */
|
|
24
24
|
'aria-label'?: string;
|
|
25
25
|
'aria-invalid'?: boolean;
|
|
26
|
+
/** The id of a line under the control (an error, a saving status). */
|
|
27
|
+
'aria-describedby'?: string;
|
|
28
|
+
/** Shown, not changeable: editing in place, while the save is in flight. */
|
|
29
|
+
readOnly?: boolean;
|
|
26
30
|
className?: string;
|
|
27
31
|
}): React.JSX.Element;
|
|
@@ -17,11 +17,11 @@ import * as React from 'react';
|
|
|
17
17
|
import { Combobox, ComboboxChip, ComboboxChips, ComboboxChipsInput, ComboboxContent, ComboboxEmpty, ComboboxItem, ComboboxList, ComboboxValue, useComboboxAnchor, } from '../ui/combobox.js';
|
|
18
18
|
/** Shown on a chip whose name hasn't loaded yet, instead of a bare id. */
|
|
19
19
|
const PENDING_LABEL = '…';
|
|
20
|
-
export function MultiReference({ options, value, onValueChange, getLabel, onSearchChange, id, placeholder, emptyText = 'No matches.', 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, className, }) {
|
|
20
|
+
export function MultiReference({ options, value, onValueChange, getLabel, onSearchChange, id, placeholder, emptyText = 'No matches.', 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, 'aria-describedby': ariaDescribedBy, readOnly = false, className, }) {
|
|
21
21
|
const anchor = useComboboxAnchor();
|
|
22
22
|
const label = (option) => getLabel(option) ?? PENDING_LABEL;
|
|
23
23
|
return (_jsxs(Combobox, { multiple: true, autoHighlight: true,
|
|
24
24
|
// Ids, not {id,label} objects: Base UI compares items to the value
|
|
25
25
|
// with Object.is, so the items must have the value's own shape.
|
|
26
|
-
items: options.map((option) => option.id), value: value, onValueChange: (next) => onValueChange(next), onInputValueChange: onSearchChange, itemToStringLabel: label, filter: null, children: [_jsx(ComboboxChips, { ref: anchor, className: className, children: _jsx(ComboboxValue, { children: (selected) => (_jsxs(React.Fragment, { children: [selected.map((option) => (_jsx(ComboboxChip, { children: label(option) }, option))), _jsx(ComboboxChipsInput, { id: id, "aria-label": ariaLabel, "aria-invalid": ariaInvalid, placeholder: selected.length === 0 ? placeholder : undefined })] })) }) }), _jsxs(ComboboxContent, { anchor: anchor, children: [_jsx(ComboboxEmpty, { children: emptyText }), _jsx(ComboboxList, { children: (option) => (_jsx(ComboboxItem, { value: option, children: label(option) }, option)) })] })] }));
|
|
26
|
+
items: options.map((option) => option.id), value: value, onValueChange: (next) => onValueChange(next), onInputValueChange: onSearchChange, itemToStringLabel: label, filter: null, readOnly: readOnly, children: [_jsx(ComboboxChips, { ref: anchor, className: className, children: _jsx(ComboboxValue, { children: (selected) => (_jsxs(React.Fragment, { children: [selected.map((option) => (_jsx(ComboboxChip, { children: label(option) }, option))), _jsx(ComboboxChipsInput, { id: id, "aria-label": ariaLabel, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy, placeholder: selected.length === 0 ? placeholder : undefined })] })) }) }), _jsxs(ComboboxContent, { anchor: anchor, children: [_jsx(ComboboxEmpty, { children: emptyText }), _jsx(ComboboxList, { children: (option) => (_jsx(ComboboxItem, { value: option, children: label(option) }, option)) })] })] }));
|
|
27
27
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type RichTextEditorProps = {
|
|
2
|
+
/** The editable element's id, for a label's `htmlFor` or a test. */
|
|
3
|
+
id?: string;
|
|
4
|
+
/** The Markdown it opens with. Read once, when it mounts (uncontrolled). */
|
|
5
|
+
defaultValue: string;
|
|
6
|
+
/** The Markdown to save, after every edit: untouched blocks as originally written. */
|
|
7
|
+
onChange: (markdown: string) => void;
|
|
8
|
+
/** Why the edit can't be saved as it stands, or `null` once it can. */
|
|
9
|
+
onProblem?: (message: string | null) => void;
|
|
10
|
+
/** Shown but not editable (while a save is in flight). */
|
|
11
|
+
readOnly?: boolean;
|
|
12
|
+
autoFocus?: boolean;
|
|
13
|
+
className?: string;
|
|
14
|
+
/** The field's label. A rich-text area must have one: this, or `aria-labelledby`. */
|
|
15
|
+
'aria-label'?: string;
|
|
16
|
+
'aria-labelledby'?: string;
|
|
17
|
+
'aria-describedby'?: string;
|
|
18
|
+
'aria-invalid'?: boolean;
|
|
19
|
+
};
|
|
20
|
+
export declare function RichTextEditor(props: RichTextEditorProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Long text written as Markdown, edited as formatted text where it's read
|
|
3
|
+
// (Notion-style), not as Markdown syntax in a textarea. The control for a
|
|
4
|
+
// `long text` field marked Markdown, on the form and in place on the view.
|
|
5
|
+
//
|
|
6
|
+
// It stays Markdown: it opens from Markdown and reports Markdown. Opening
|
|
7
|
+
// and saving round-trip exactly: an untouched document comes back byte for
|
|
8
|
+
// byte, and an edited one keeps every block the person didn't change as it
|
|
9
|
+
// was written (src/lib/markdown-merge.ts). If an edit can't be saved
|
|
10
|
+
// without rewriting another block, `onProblem` says so and `onChange`
|
|
11
|
+
// isn't called: the draft stays on screen, nothing is saved in its place.
|
|
12
|
+
//
|
|
13
|
+
// Raw HTML in the text shows as its characters, as <Markdown> shows it,
|
|
14
|
+
// and nothing pasted or typed can add markup.
|
|
15
|
+
//
|
|
16
|
+
// The editor (Milkdown) is loaded on first use, so a page that only reads
|
|
17
|
+
// Markdown never downloads it. Until it arrives, a placeholder the size of
|
|
18
|
+
// the field shows.
|
|
19
|
+
import { lazy, Suspense } from 'react';
|
|
20
|
+
import { cn } from 'cn';
|
|
21
|
+
import { Skeleton } from '../ui/skeleton.js';
|
|
22
|
+
const MilkdownEditor = lazy(() => import('./milkdown-editor.js'));
|
|
23
|
+
export function RichTextEditor(props) {
|
|
24
|
+
return (_jsx(Suspense, { fallback: _jsxs("div", { "data-slot": "rich-text-editor", "data-state": "loading", className: cn('flex min-h-24 w-full flex-col gap-2 rounded-lg border border-input px-2.5 py-2', props.className), children: [_jsx(Skeleton, { className: "h-4 w-3/4" }), _jsx(Skeleton, { className: "h-4 w-1/2" })] }), children: _jsx(MilkdownEditor, { ...props }) }));
|
|
25
|
+
}
|
|
@@ -13,7 +13,7 @@ function FieldLegend({ className, variant = "legend", ...props }) {
|
|
|
13
13
|
function FieldGroup({ className, ...props }) {
|
|
14
14
|
return (_jsx("div", { "data-slot": "field-group", className: cn("group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4", className), ...props }));
|
|
15
15
|
}
|
|
16
|
-
const fieldVariants = cva("group/field flex w-full gap-2 data-[invalid=true]:text-destructive", {
|
|
16
|
+
const fieldVariants = cva("group/field flex w-full gap-2 data-[invalid=true]:text-destructive-text", {
|
|
17
17
|
variants: {
|
|
18
18
|
orientation: {
|
|
19
19
|
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
|
@@ -62,6 +62,6 @@ function FieldError({ className, children, errors, ...props }) {
|
|
|
62
62
|
if (!content) {
|
|
63
63
|
return null;
|
|
64
64
|
}
|
|
65
|
-
return (_jsx("div", { role: "alert", "data-slot": "field-error", className: cn("text-sm font-normal text-destructive", className), ...props, children: content }));
|
|
65
|
+
return (_jsx("div", { role: "alert", "data-slot": "field-error", className: cn("text-sm font-normal text-destructive-text", className), ...props, children: content }));
|
|
66
66
|
}
|
|
67
67
|
export { Field, FieldLabel, FieldDescription, FieldError, FieldGroup, FieldLegend, FieldSeparator, FieldSet, FieldContent, FieldTitle, };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type MutationOptions, type QueryClient, type QueryKey } from '@tanstack/react-query';
|
|
2
|
+
import type { AppError } from '../api/contracts.js';
|
|
3
|
+
type RecordId = string | number;
|
|
4
|
+
/**
|
|
5
|
+
* What one save changes: the fields to send (`{ status: 'done' }`), or a
|
|
6
|
+
* function building them from the record (`(task) => ({ checklist: ... })`).
|
|
7
|
+
* Use the function when the new value depends on the current one, like a
|
|
8
|
+
* whole list with one item changed: it is applied to the record on screen
|
|
9
|
+
* at once, and again to the server's latest record when the request is
|
|
10
|
+
* sent, so a change made while another is saving builds on it.
|
|
11
|
+
*/
|
|
12
|
+
export type RecordChange<TRecord, TUpdate> = TUpdate | ((record: TRecord) => TUpdate);
|
|
13
|
+
export type RecordUpdateOptions<TRecord extends {
|
|
14
|
+
id: RecordId;
|
|
15
|
+
}, TUpdate extends Partial<TRecord>> = {
|
|
16
|
+
/** The record's id: list items with this id are the ones updated. */
|
|
17
|
+
id: TRecord['id'];
|
|
18
|
+
/** The record's detail query key, the one its view reads. */
|
|
19
|
+
detailKey: QueryKey;
|
|
20
|
+
/** The prefix every list query of the entity starts with; each holds a `Page<TRecord>`. */
|
|
21
|
+
listsKey: QueryKey;
|
|
22
|
+
/** The gateway's PATCH: sends `update`, resolves with the saved record, rejects with an `AppError`. */
|
|
23
|
+
update: (id: TRecord['id'], update: TUpdate) => Promise<TRecord>;
|
|
24
|
+
/** What a refusal's toast calls the record: "Couldn't update <name>: <reason>". */
|
|
25
|
+
name: (record: TRecord) => string;
|
|
26
|
+
/**
|
|
27
|
+
* Show the change before the server answers (default `true`, a quick
|
|
28
|
+
* action). `false` for editing in place: the value shows as saved only
|
|
29
|
+
* once it is, and until then the field holds it.
|
|
30
|
+
*/
|
|
31
|
+
optimistic?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Toast a refusal (default `true`). `false` when the control shows the
|
|
34
|
+
* reason itself (editing in place): `mutateAsync` rejects with the
|
|
35
|
+
* `AppError`, and `refusalReason` words it.
|
|
36
|
+
*/
|
|
37
|
+
toastOnError?: boolean;
|
|
38
|
+
};
|
|
39
|
+
type Entry<TRecord, TUpdate> = {
|
|
40
|
+
change: RecordChange<TRecord, TUpdate>;
|
|
41
|
+
optimistic: boolean;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Why a save was refused, in the server's words: for a 422, the message
|
|
45
|
+
* on a field the save sent (the first field error otherwise), else the
|
|
46
|
+
* `AppError`'s message.
|
|
47
|
+
*/
|
|
48
|
+
export declare function refusalReason(error: AppError, sent?: object): string;
|
|
49
|
+
/**
|
|
50
|
+
* The mutation options behind `useRecordUpdate`, for a given QueryClient.
|
|
51
|
+
* Exported for tests and for code outside React; screens use the hook.
|
|
52
|
+
*/
|
|
53
|
+
export declare function recordUpdateMutationOptions<TRecord extends {
|
|
54
|
+
id: RecordId;
|
|
55
|
+
}, TUpdate extends Partial<TRecord>>(queryClient: QueryClient, { id, detailKey, listsKey, update, name, optimistic, toastOnError }: RecordUpdateOptions<TRecord, TUpdate>): MutationOptions<TRecord, AppError, RecordChange<TRecord, TUpdate>, {
|
|
56
|
+
entry: Entry<TRecord, TUpdate>;
|
|
57
|
+
}>;
|
|
58
|
+
/**
|
|
59
|
+
* Saves a record's fields on their own, optimistically, wherever the
|
|
60
|
+
* record is cached (see the top of this file). Call it once per control,
|
|
61
|
+
* with the entity's query keys and its gateway PATCH, then
|
|
62
|
+
* `mutate({ field: value })` or `mutate((record) => ({ field: ... }))`.
|
|
63
|
+
* `isPending` is that control's own save in flight. A refusal rolls back
|
|
64
|
+
* and shows a toast by itself.
|
|
65
|
+
*/
|
|
66
|
+
export declare function useRecordUpdate<TRecord extends {
|
|
67
|
+
id: RecordId;
|
|
68
|
+
}, TUpdate extends Partial<TRecord>>(options: RecordUpdateOptions<TRecord, TUpdate>): import("@tanstack/react-query").UseMutationResult<TRecord, AppError, RecordChange<TRecord, TUpdate>, {
|
|
69
|
+
entry: Entry<TRecord, TUpdate>;
|
|
70
|
+
}>;
|
|
71
|
+
export {};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// One record's fields saved on their own, straight from the screen showing
|
|
2
|
+
// them (a quick action on the view, a switch in a table row), instead of
|
|
3
|
+
// through the form. A PATCH of what changed, optimistic everywhere the
|
|
4
|
+
// record is cached: its detail query (what the view shows) and every list
|
|
5
|
+
// page holding it. The piece any save-without-the-form reuses.
|
|
6
|
+
//
|
|
7
|
+
// - **Shown at once.** The change is applied to the cached record before
|
|
8
|
+
// the request goes out, so the screen moves when the user does.
|
|
9
|
+
// - **The server's record wins.** When the save answers, the cache takes
|
|
10
|
+
// the record the server returned, not the one sent, so a server-side
|
|
11
|
+
// effect (a computed field, a flag one change clears) shows at once.
|
|
12
|
+
// - **Rapid changes don't lose each other.** Saves of one record run one
|
|
13
|
+
// at a time (a TanStack mutation scope per record), and each request is
|
|
14
|
+
// built when it is sent, from the server's latest record. Ticking two
|
|
15
|
+
// checklist items quickly sends the whole list twice, and the second
|
|
16
|
+
// request already holds the first tick. A change can be a function of
|
|
17
|
+
// the record for exactly this: `(record) => ({ checklist: ... })`.
|
|
18
|
+
// - **A refusal rolls back only itself.** The cache goes back to the
|
|
19
|
+
// server's latest record with every change still waiting re-applied on
|
|
20
|
+
// top, and a toast gives the server's reason (the field's 422 message,
|
|
21
|
+
// or the AppError's).
|
|
22
|
+
// - **The lists refetch once the last save settles**, so a filter on the
|
|
23
|
+
// field drops the row once the server agrees.
|
|
24
|
+
//
|
|
25
|
+
// Editing in place uses the same save with `optimistic: false` and
|
|
26
|
+
// `toastOnError: false`: nothing changes on screen until the server
|
|
27
|
+
// agrees, and a refusal comes back to the field (`mutateAsync` rejects)
|
|
28
|
+
// instead of a toast.
|
|
29
|
+
import { hashKey, useMutation, useQueryClient, } from '@tanstack/react-query';
|
|
30
|
+
import { toast } from 'sonner';
|
|
31
|
+
const states = new WeakMap();
|
|
32
|
+
function recordStates(queryClient) {
|
|
33
|
+
let map = states.get(queryClient);
|
|
34
|
+
if (!map)
|
|
35
|
+
states.set(queryClient, (map = new Map()));
|
|
36
|
+
return map;
|
|
37
|
+
}
|
|
38
|
+
function resolve(change, record) {
|
|
39
|
+
return typeof change === 'function' ? change(record) : change;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Why a save was refused, in the server's words: for a 422, the message
|
|
43
|
+
* on a field the save sent (the first field error otherwise), else the
|
|
44
|
+
* `AppError`'s message.
|
|
45
|
+
*/
|
|
46
|
+
export function refusalReason(error, sent) {
|
|
47
|
+
const fieldErrors = Object.entries(error.fieldErrors ?? {});
|
|
48
|
+
const fields = Object.keys(sent ?? {});
|
|
49
|
+
const onSentField = fieldErrors.find(([key]) => fields.some((field) => key === field || key.startsWith(`${field}.`)));
|
|
50
|
+
return (onSentField ?? fieldErrors[0])?.[1][0] ?? error.message;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The mutation options behind `useRecordUpdate`, for a given QueryClient.
|
|
54
|
+
* Exported for tests and for code outside React; screens use the hook.
|
|
55
|
+
*/
|
|
56
|
+
export function recordUpdateMutationOptions(queryClient, { id, detailKey, listsKey, update, name, optimistic = true, toastOnError = true }) {
|
|
57
|
+
const key = hashKey(detailKey);
|
|
58
|
+
const lists = { queryKey: listsKey };
|
|
59
|
+
const map = recordStates(queryClient);
|
|
60
|
+
// The record as the caches hold it now: the detail, else a list's copy.
|
|
61
|
+
function cached() {
|
|
62
|
+
const detail = queryClient.getQueryData(detailKey);
|
|
63
|
+
if (detail)
|
|
64
|
+
return detail;
|
|
65
|
+
for (const [, page] of queryClient.getQueriesData(lists)) {
|
|
66
|
+
const item = page?.items.find((record) => record.id === id);
|
|
67
|
+
if (item)
|
|
68
|
+
return item;
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
// Writes confirmed + pending changes to the detail (when it's cached)
|
|
73
|
+
// and to every list page holding the record.
|
|
74
|
+
function render(state) {
|
|
75
|
+
if (!state.confirmed)
|
|
76
|
+
return;
|
|
77
|
+
const shown = state.pending.reduce((record, entry) => (entry.optimistic ? { ...record, ...resolve(entry.change, record) } : record), state.confirmed);
|
|
78
|
+
queryClient.setQueryData(detailKey, (detail) => detail && shown);
|
|
79
|
+
queryClient.setQueriesData(lists, (page) => page && { ...page, items: page.items.map((record) => (record.id === id ? shown : record)) });
|
|
80
|
+
}
|
|
81
|
+
// A save has settled: drop its change and show what's left on top of
|
|
82
|
+
// the server's latest record. After the last one, refetch the lists.
|
|
83
|
+
function settle(state, entry) {
|
|
84
|
+
state.pending = state.pending.filter((pending) => pending !== entry);
|
|
85
|
+
render(state);
|
|
86
|
+
if (state.pending.length === 0) {
|
|
87
|
+
map.delete(key);
|
|
88
|
+
void queryClient.invalidateQueries(lists);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
// One save of this record at a time: the next waits for the last
|
|
93
|
+
// one's answer, so its request is built on the server's latest record.
|
|
94
|
+
scope: { id: `record-update:${key}` },
|
|
95
|
+
mutationFn: (change) => {
|
|
96
|
+
const confirmed = map.get(key)?.confirmed ?? cached();
|
|
97
|
+
if (!confirmed)
|
|
98
|
+
return Promise.reject(new Error('The record to update is not loaded'));
|
|
99
|
+
return update(id, resolve(change, confirmed));
|
|
100
|
+
},
|
|
101
|
+
onMutate: async (change) => {
|
|
102
|
+
let state = map.get(key);
|
|
103
|
+
if (!state)
|
|
104
|
+
map.set(key, (state = { confirmed: undefined, pending: [] }));
|
|
105
|
+
const entry = { change, optimistic };
|
|
106
|
+
state.pending.push(entry);
|
|
107
|
+
// A refetch landing now would overwrite the change on screen.
|
|
108
|
+
await Promise.all([
|
|
109
|
+
queryClient.cancelQueries({ queryKey: detailKey }),
|
|
110
|
+
queryClient.cancelQueries(lists),
|
|
111
|
+
]);
|
|
112
|
+
// The first change of a burst: the caches still hold the server's record.
|
|
113
|
+
state.confirmed ??= cached();
|
|
114
|
+
render(state);
|
|
115
|
+
return { entry };
|
|
116
|
+
},
|
|
117
|
+
onError: (error, _change, context) => {
|
|
118
|
+
const state = map.get(key);
|
|
119
|
+
if (!state || !context)
|
|
120
|
+
return;
|
|
121
|
+
// What was sent: the change built on the record it was sent against.
|
|
122
|
+
const record = state.confirmed;
|
|
123
|
+
const sent = record && resolve(context.entry.change, record);
|
|
124
|
+
settle(state, context.entry);
|
|
125
|
+
if (toastOnError)
|
|
126
|
+
toast.error(`Couldn't update ${record ? name(record) : 'this record'}: ${refusalReason(error, sent)}`);
|
|
127
|
+
},
|
|
128
|
+
onSuccess: (saved, _change, context) => {
|
|
129
|
+
const state = map.get(key);
|
|
130
|
+
if (!state || !context)
|
|
131
|
+
return;
|
|
132
|
+
state.confirmed = saved;
|
|
133
|
+
settle(state, context.entry);
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Saves a record's fields on their own, optimistically, wherever the
|
|
139
|
+
* record is cached (see the top of this file). Call it once per control,
|
|
140
|
+
* with the entity's query keys and its gateway PATCH, then
|
|
141
|
+
* `mutate({ field: value })` or `mutate((record) => ({ field: ... }))`.
|
|
142
|
+
* `isPending` is that control's own save in flight. A refusal rolls back
|
|
143
|
+
* and shows a toast by itself.
|
|
144
|
+
*/
|
|
145
|
+
export function useRecordUpdate(options) {
|
|
146
|
+
const queryClient = useQueryClient();
|
|
147
|
+
return useMutation(recordUpdateMutationOptions(queryClient, options));
|
|
148
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ export { AppShell, type AppShellProps, type NavItem } from './components/app/app
|
|
|
2
2
|
export { DataTable, type DataTableProps, type SortingState } from './components/app/data-table.js';
|
|
3
3
|
export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
|
|
4
4
|
export { EntityView, type EntityViewField, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
|
|
5
|
+
export { editInPlace, type EditControlProps, type EditInPlace, type EditInPlaceOptions, type EditKind, } from './components/app/edit-in-place.js';
|
|
6
|
+
export { EditableValue, type EditableValueProps } from './components/app/editable-value.js';
|
|
5
7
|
export { ErrorState } from './components/app/error-state.js';
|
|
6
8
|
export { FoundationProviders } from './components/app/foundation-providers.js';
|
|
7
9
|
export { Markdown, type MarkdownProps } from './components/app/markdown.js';
|
|
@@ -10,6 +12,7 @@ export { MultiChoice } from './components/app/multi-choice.js';
|
|
|
10
12
|
export { MultiReference } from './components/app/multi-reference.js';
|
|
11
13
|
export { ListEditor } from './components/app/list-editor.js';
|
|
12
14
|
export { PasswordInput } from './components/app/password-input.js';
|
|
15
|
+
export { RichTextEditor, type RichTextEditorProps } from './components/app/rich-text-editor.js';
|
|
13
16
|
export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
14
17
|
export { ThemeProvider } from './components/theme-provider.js';
|
|
15
18
|
export { LoginRoute, type LoginRouteProps } from './routes/login.js';
|
|
@@ -22,5 +25,6 @@ export { createQueryClient } from './api/query-client.js';
|
|
|
22
25
|
export { useTableUrlState } from './hooks/use-table-url-state.js';
|
|
23
26
|
export type { TableView, UrlChanges } from './hooks/table-url-changes.js';
|
|
24
27
|
export { useDebouncedValue } from './hooks/use-debounced-value.js';
|
|
28
|
+
export { useRecordUpdate, type RecordChange, type RecordUpdateOptions, } from './hooks/use-record-update.js';
|
|
25
29
|
export { useIsMobile } from './hooks/use-mobile.js';
|
|
26
30
|
export { cn } from './lib/utils.js';
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,8 @@ export { AppShell } from './components/app/app-shell.js';
|
|
|
7
7
|
export { DataTable } from './components/app/data-table.js';
|
|
8
8
|
export { EntityForm } from './components/app/entity-form.js';
|
|
9
9
|
export { EntityView, } from './components/app/entity-view.js';
|
|
10
|
+
export { editInPlace, } from './components/app/edit-in-place.js';
|
|
11
|
+
export { EditableValue } from './components/app/editable-value.js';
|
|
10
12
|
export { ErrorState } from './components/app/error-state.js';
|
|
11
13
|
export { FoundationProviders } from './components/app/foundation-providers.js';
|
|
12
14
|
export { Markdown } from './components/app/markdown.js';
|
|
@@ -15,6 +17,7 @@ export { MultiChoice } from './components/app/multi-choice.js';
|
|
|
15
17
|
export { MultiReference } from './components/app/multi-reference.js';
|
|
16
18
|
export { ListEditor } from './components/app/list-editor.js';
|
|
17
19
|
export { PasswordInput } from './components/app/password-input.js';
|
|
20
|
+
export { RichTextEditor } from './components/app/rich-text-editor.js';
|
|
18
21
|
export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
19
22
|
export { ThemeProvider } from './components/theme-provider.js';
|
|
20
23
|
// Screens outside the app shell
|
|
@@ -27,6 +30,7 @@ export { createQueryClient } from './api/query-client.js';
|
|
|
27
30
|
// Hooks
|
|
28
31
|
export { useTableUrlState } from './hooks/use-table-url-state.js';
|
|
29
32
|
export { useDebouncedValue } from './hooks/use-debounced-value.js';
|
|
33
|
+
export { useRecordUpdate, } from './hooks/use-record-update.js';
|
|
30
34
|
export { useIsMobile } from './hooks/use-mobile.js';
|
|
31
35
|
// Utilities
|
|
32
36
|
export { cn } from './lib/utils.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare class MarkdownMergeError extends Error {
|
|
2
|
+
constructor();
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* The Markdown to save: `original` with the edits that turned `baseline`
|
|
6
|
+
* (the editor's serialization as loaded) into `current` (its serialization
|
|
7
|
+
* now), every unchanged top-level block kept as `original` wrote it.
|
|
8
|
+
* Throws `MarkdownMergeError` rather than save something that means other
|
|
9
|
+
* than `current`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function mergeMarkdown(original: string, baseline: string, current: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* The document's own marks, for writing a block the person edited the way
|
|
14
|
+
* the rest is written: its bullet (`-`, `*`, `+`) and its thematic break.
|
|
15
|
+
*/
|
|
16
|
+
export declare function markdownStyle(markdown: string): {
|
|
17
|
+
bullet: '-' | '*' | '+';
|
|
18
|
+
rule: '-' | '*' | '_';
|
|
19
|
+
};
|