@dxos/react-ui-form 0.10.0 → 0.11.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/dist/lib/index.mjs +4064 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/lib/translations.mjs +77 -0
- package/dist/lib/translations.mjs.map +1 -0
- package/dist/lib/types.mjs +0 -0
- package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +1 -0
- package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/Card.stories.d.ts +88 -0
- package/dist/types/src/components/Form/Card.stories.d.ts.map +1 -0
- package/dist/types/src/components/Form/Form.d.ts +7 -7
- package/dist/types/src/components/Form/Form.stories.d.ts +21 -0
- package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.theme.d.ts +6 -0
- package/dist/types/src/components/Form/Form.theme.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormControls.d.ts +7 -7
- package/dist/types/src/components/Form/FormControls.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/FormField.d.ts +1 -1
- package/dist/types/src/components/Form/FormField/FormField.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/FormRow.d.ts +17 -3
- package/dist/types/src/components/Form/FormField/FormRow.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.d.ts +0 -6
- package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.stories.d.ts +1 -0
- package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/ArrayField/default-value.d.ts +7 -0
- package/dist/types/src/components/Form/FormField/fields/ArrayField/default-value.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormField/fields/ArrayField/index.d.ts +1 -0
- package/dist/types/src/components/Form/FormField/fields/ArrayField/index.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/BooleanField/BooleanField.stories.d.ts +1 -0
- package/dist/types/src/components/Form/FormField/fields/BooleanField/BooleanField.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/DateField/DateField.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/DateField/DateField.stories.d.ts +1 -0
- package/dist/types/src/components/Form/FormField/fields/DateField/DateField.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/GeoPointField/GeoPointField.stories.d.ts +1 -0
- package/dist/types/src/components/Form/FormField/fields/GeoPointField/GeoPointField.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/MarkdownField/MarkdownField.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/MarkdownField/MarkdownField.stories.d.ts +1 -0
- package/dist/types/src/components/Form/FormField/fields/MarkdownField/MarkdownField.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.d.ts +0 -11
- package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.stories.d.ts +1 -0
- package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/NumberField/index.d.ts +1 -0
- package/dist/types/src/components/Form/FormField/fields/NumberField/index.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/NumberField/numeric-constraints.d.ts +12 -0
- package/dist/types/src/components/Form/FormField/fields/NumberField/numeric-constraints.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormField/fields/PasswordField/PasswordField.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/PasswordField/PasswordField.stories.d.ts +1 -0
- package/dist/types/src/components/Form/FormField/fields/PasswordField/PasswordField.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/RefField/RefField.d.ts +1 -11
- package/dist/types/src/components/Form/FormField/fields/RefField/RefField.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/RefField/RefField.stories.d.ts +1 -0
- package/dist/types/src/components/Form/FormField/fields/RefField/RefField.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/RefField/find-ref-option.d.ts +13 -0
- package/dist/types/src/components/Form/FormField/fields/RefField/find-ref-option.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormField/fields/RefField/index.d.ts +1 -0
- package/dist/types/src/components/Form/FormField/fields/RefField/index.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/SelectField/SelectField.d.ts +1 -13
- package/dist/types/src/components/Form/FormField/fields/SelectField/SelectField.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/SelectField/SelectField.stories.d.ts +1 -0
- package/dist/types/src/components/Form/FormField/fields/SelectField/SelectField.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/SelectField/create-select-field.d.ts +15 -0
- package/dist/types/src/components/Form/FormField/fields/SelectField/create-select-field.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormField/fields/SelectField/index.d.ts +1 -0
- package/dist/types/src/components/Form/FormField/fields/SelectField/index.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/SelectOptionField/SelectOptionField.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/SelectOptionField/SelectOptionField.stories.d.ts +1 -0
- package/dist/types/src/components/Form/FormField/fields/SelectOptionField/SelectOptionField.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/TextAreaField/TextAreaField.stories.d.ts +1 -0
- package/dist/types/src/components/Form/FormField/fields/TextAreaField/TextAreaField.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/TextField/TextField.stories.d.ts +1 -0
- package/dist/types/src/components/Form/FormField/fields/TextField/TextField.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/TupleField/TupleField.stories.d.ts +1 -0
- package/dist/types/src/components/Form/FormField/fields/TupleField/TupleField.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormFieldSet/FormFieldSetContainer.d.ts +1 -1
- package/dist/types/src/components/Form/FormFieldSet/FormFieldSetContainer.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormLayout/FormLayout.d.ts +0 -27
- package/dist/types/src/components/Form/FormLayout/FormLayout.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormLayout/FormLayout.stories.d.ts +1 -0
- package/dist/types/src/components/Form/FormLayout/FormLayout.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormLayout/index.d.ts +1 -0
- package/dist/types/src/components/Form/FormLayout/index.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormLayout/resolve-layout-field.d.ts +29 -0
- package/dist/types/src/components/Form/FormLayout/resolve-layout-field.d.ts.map +1 -0
- package/dist/types/src/components/ObjectForm/ObjectForm.d.ts +10 -1
- package/dist/types/src/components/ObjectForm/ObjectForm.d.ts.map +1 -1
- package/dist/types/src/components/ObjectForm/ObjectForm.stories.d.ts +2 -0
- package/dist/types/src/components/ObjectForm/ObjectForm.stories.d.ts.map +1 -1
- package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +8 -23
- package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
- package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +1 -0
- package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -1
- package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts +1 -1
- package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts.map +1 -1
- package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts +1 -0
- package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts.map +1 -1
- package/dist/types/src/components/ObjectTree/ObjectTree.stories.d.ts +1 -0
- package/dist/types/src/components/ObjectTree/ObjectTree.stories.d.ts.map +1 -1
- package/dist/types/src/components/RefEditor/RefEditor.d.ts.map +1 -1
- package/dist/types/src/components/RefEditor/RefEditor.stories.d.ts +2 -0
- package/dist/types/src/components/RefEditor/RefEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/RefEditor/ref-editor-extension.d.ts +9 -8
- package/dist/types/src/components/RefEditor/ref-editor-extension.d.ts.map +1 -1
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +1 -0
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
- package/dist/types/src/hooks/useFormHandler.d.ts +6 -2
- package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -1
- package/dist/types/src/hooks/useFormHandler.test.d.ts +2 -0
- package/dist/types/src/hooks/useFormHandler.test.d.ts.map +1 -0
- package/dist/types/src/testing/TestLayout.d.ts +1 -1
- package/dist/types/src/testing/TestLayout.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +1 -0
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/src/types.d.ts +5 -0
- package/dist/types/src/types.d.ts.map +1 -1
- package/dist/types/src/util/index.d.ts +0 -1
- package/dist/types/src/util/index.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +39 -42
- package/src/components/Form/Card.stories.tsx +93 -0
- package/src/components/Form/Form.stories.tsx +95 -3
- package/src/components/Form/Form.theme.ts +14 -8
- package/src/components/Form/Form.tsx +2 -2
- package/src/components/Form/FormControls.tsx +32 -13
- package/src/components/Form/FormField/FormField.tsx +11 -3
- package/src/components/Form/FormField/FormRow.tsx +20 -1
- package/src/components/Form/FormField/fields/ArrayField/ArrayField.tsx +3 -46
- package/src/components/Form/FormField/fields/ArrayField/default-value.ts +49 -0
- package/src/components/Form/FormField/fields/ArrayField/index.ts +2 -0
- package/src/components/Form/FormField/fields/ComboboxField/ComboboxField.tsx +12 -2
- package/src/components/Form/FormField/fields/DateField/DateField.tsx +10 -3
- package/src/components/Form/FormField/fields/MarkdownField/MarkdownField.stories.tsx +1 -1
- package/src/components/Form/FormField/fields/MarkdownField/MarkdownField.tsx +2 -12
- package/src/components/Form/FormField/fields/NumberField/NumberField.test.ts +1 -1
- package/src/components/Form/FormField/fields/NumberField/NumberField.tsx +1 -36
- package/src/components/Form/FormField/fields/NumberField/index.ts +2 -0
- package/src/components/Form/FormField/fields/NumberField/numeric-constraints.ts +43 -0
- package/src/components/Form/FormField/fields/PasswordField/PasswordField.tsx +1 -0
- package/src/components/Form/FormField/fields/RefField/InlineRefField.tsx +2 -2
- package/src/components/Form/FormField/fields/RefField/RefField.test.ts +1 -1
- package/src/components/Form/FormField/fields/RefField/RefField.tsx +3 -45
- package/src/components/Form/FormField/fields/RefField/find-ref-option.ts +56 -0
- package/src/components/Form/FormField/fields/RefField/index.ts +2 -0
- package/src/components/Form/FormField/fields/SelectField/SelectField.tsx +2 -67
- package/src/components/Form/FormField/fields/SelectField/create-select-field.tsx +80 -0
- package/src/components/Form/FormField/fields/SelectField/index.ts +2 -0
- package/src/components/Form/FormField/fields/SelectOptionField/SelectOptionField.tsx +2 -1
- package/src/components/Form/FormFieldSet/FormFieldSetContainer.tsx +11 -9
- package/src/components/Form/FormLayout/FormLayout.test.ts +1 -1
- package/src/components/Form/FormLayout/FormLayout.tsx +1 -86
- package/src/components/Form/FormLayout/index.ts +2 -0
- package/src/components/Form/FormLayout/resolve-layout-field.ts +98 -0
- package/src/components/ObjectForm/ObjectForm.stories.tsx +93 -3
- package/src/components/ObjectForm/ObjectForm.tsx +22 -4
- package/src/components/ObjectPicker/ObjectPicker.stories.tsx +1 -1
- package/src/components/ObjectPicker/ObjectPicker.tsx +3 -3
- package/src/components/ObjectProperties/ObjectProperties.stories.tsx +1 -1
- package/src/components/ObjectProperties/ObjectProperties.tsx +7 -1
- package/src/components/ObjectTree/ObjectTree.stories.tsx +1 -1
- package/src/components/RefEditor/RefEditor.stories.tsx +9 -1
- package/src/components/RefEditor/RefEditor.tsx +42 -6
- package/src/components/RefEditor/ref-editor-extension.ts +168 -91
- package/src/components/ViewEditor/ViewEditor.stories.tsx +1 -1
- package/src/components/ViewEditor/ViewEditor.tsx +9 -9
- package/src/hooks/useFormHandler.test.ts +85 -0
- package/src/hooks/useFormHandler.ts +118 -31
- package/src/translations.ts +1 -0
- package/src/types.ts +5 -0
- package/src/util/index.ts +0 -1
- package/dist/lib/browser/index.mjs +0 -4675
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/browser/translations.mjs +0 -87
- package/dist/lib/browser/translations.mjs.map +0 -7
- package/dist/lib/node-esm/index.mjs +0 -4677
- package/dist/lib/node-esm/index.mjs.map +0 -7
- package/dist/lib/node-esm/meta.json +0 -1
- package/dist/lib/node-esm/translations.mjs +0 -89
- package/dist/lib/node-esm/translations.mjs.map +0 -7
- package/dist/types/src/util/formatting.d.ts +0 -15
- package/dist/types/src/util/formatting.d.ts.map +0 -1
- package/src/util/formatting.ts +0 -171
|
@@ -7,7 +7,7 @@ import { type EditorView } from '@codemirror/view';
|
|
|
7
7
|
import React, { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
8
8
|
|
|
9
9
|
import { type Database, Filter, Obj, type Type } from '@dxos/echo';
|
|
10
|
-
import { useQuery } from '@dxos/react
|
|
10
|
+
import { useQuery } from '@dxos/echo-react';
|
|
11
11
|
import { type ThemedClassName, useThemeContext, useTranslation } from '@dxos/react-ui';
|
|
12
12
|
import {
|
|
13
13
|
Editor,
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
type UseEditorMenuProps,
|
|
18
18
|
} from '@dxos/react-ui-editor';
|
|
19
19
|
import { createBasicExtensions, createThemeExtensions, insertAtCursor, keymap } from '@dxos/ui-editor';
|
|
20
|
-
import { getHashHue } from '@dxos/ui-theme';
|
|
20
|
+
import { getHashHue, mx } from '@dxos/ui-theme';
|
|
21
21
|
|
|
22
22
|
import { translationKey } from '#translations';
|
|
23
23
|
|
|
@@ -86,6 +86,7 @@ export const RefEditor = forwardRef<EditorController, RefEditorProps>(
|
|
|
86
86
|
placeholder: placeholderProp,
|
|
87
87
|
activateOnTyping,
|
|
88
88
|
numItems = 8,
|
|
89
|
+
classNames,
|
|
89
90
|
...props
|
|
90
91
|
},
|
|
91
92
|
forwardedRef,
|
|
@@ -131,7 +132,13 @@ export const RefEditor = forwardRef<EditorController, RefEditorProps>(
|
|
|
131
132
|
|
|
132
133
|
const getMenu = useCallback<NonNullable<UseEditorMenuProps['getMenu']>>(
|
|
133
134
|
async ({ text }) => {
|
|
134
|
-
|
|
135
|
+
// With `activateOnTyping` (no `@` trigger) an empty query would otherwise match every object
|
|
136
|
+
// and anchor the popover at the viewport origin (no range to position against). Show nothing
|
|
137
|
+
// until the user types. An explicit `@` trigger still browses (its `text` is '@', not empty).
|
|
138
|
+
if (!text) {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
const query = text.replace(/^@/, '').toLowerCase();
|
|
135
142
|
const matchesQuery = (object: Obj.Unknown) =>
|
|
136
143
|
!query ||
|
|
137
144
|
getObjectLabel(object).toLowerCase().includes(query) ||
|
|
@@ -199,7 +206,17 @@ export const RefEditor = forwardRef<EditorController, RefEditorProps>(
|
|
|
199
206
|
const extensions = useMemo<Extension[]>(
|
|
200
207
|
() => [
|
|
201
208
|
createBasicExtensions({ readOnly: readonly, lineWrapping: false }),
|
|
202
|
-
createThemeExtensions({
|
|
209
|
+
createThemeExtensions({
|
|
210
|
+
themeMode,
|
|
211
|
+
slots: {
|
|
212
|
+
editor: {
|
|
213
|
+
className: 'w-full',
|
|
214
|
+
},
|
|
215
|
+
scroller: {
|
|
216
|
+
className: 'scrollbar-none',
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
}),
|
|
203
220
|
refEditor({ mode, match, getRef: (id) => refsRef.current.get(id) }),
|
|
204
221
|
Prec.highest(
|
|
205
222
|
keymap.of([
|
|
@@ -225,6 +242,26 @@ export const RefEditor = forwardRef<EditorController, RefEditorProps>(
|
|
|
225
242
|
return true;
|
|
226
243
|
},
|
|
227
244
|
},
|
|
245
|
+
{
|
|
246
|
+
key: 'Space',
|
|
247
|
+
// In email mode, a space after a bare address commits it (a tag only forms once
|
|
248
|
+
// committed). A partial `Name <…` segment still needs spaces, so only commit — and
|
|
249
|
+
// consume the space — when the whole in-progress segment is an address.
|
|
250
|
+
run: (view) => {
|
|
251
|
+
if (mode !== 'email') {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
const { head } = view.state.selection.main;
|
|
255
|
+
const line = view.state.doc.lineAt(head);
|
|
256
|
+
const before = line.text.slice(0, head - line.from);
|
|
257
|
+
const segment = before.slice(before.lastIndexOf(',') + 1).trim();
|
|
258
|
+
if ((match ?? EMAIL_REGEX).test(segment)) {
|
|
259
|
+
insertAtCursor(view, head, ', ');
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
return false;
|
|
263
|
+
},
|
|
264
|
+
},
|
|
228
265
|
]),
|
|
229
266
|
),
|
|
230
267
|
],
|
|
@@ -245,9 +282,8 @@ export const RefEditor = forwardRef<EditorController, RefEditorProps>(
|
|
|
245
282
|
ref={composedRef}
|
|
246
283
|
>
|
|
247
284
|
<Editor.View
|
|
248
|
-
// TOOD(burdon): Use same style as react-ui Input.
|
|
249
|
-
classNames='border border-input-separator rounded-xs px-2'
|
|
250
285
|
{...props}
|
|
286
|
+
classNames={mx('flex items-center dx-input h-[2rem]', classNames)}
|
|
251
287
|
initialValue={value}
|
|
252
288
|
selectionEnd
|
|
253
289
|
/>
|
|
@@ -2,7 +2,14 @@
|
|
|
2
2
|
// Copyright 2026 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
EditorState,
|
|
7
|
+
type Extension,
|
|
8
|
+
RangeSetBuilder,
|
|
9
|
+
StateEffect,
|
|
10
|
+
StateField,
|
|
11
|
+
type TransactionSpec,
|
|
12
|
+
} from '@codemirror/state';
|
|
6
13
|
import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view';
|
|
7
14
|
|
|
8
15
|
import { Domino } from '@dxos/ui';
|
|
@@ -25,14 +32,6 @@ export type RefInfo = {
|
|
|
25
32
|
*/
|
|
26
33
|
export type RefEditorMode = 'ref' | 'email';
|
|
27
34
|
|
|
28
|
-
export type RefEditorExtensionOptions = {
|
|
29
|
-
mode?: RefEditorMode;
|
|
30
|
-
/** Resolve an object id referenced as `@<id>` to display info. */
|
|
31
|
-
getRef?: (id: string) => RefInfo | undefined;
|
|
32
|
-
/** Pluggable raw-token matcher (e.g. {@link EMAIL_REGEX}); matching tokens render as neutral tags. */
|
|
33
|
-
match?: RegExp;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
35
|
/** Matches an object reference token (`@<object id>`). */
|
|
37
36
|
export const REF_REGEX = /^@([A-Za-z0-9]+)$/;
|
|
38
37
|
|
|
@@ -51,6 +50,15 @@ export const formatMailbox = (name: string | undefined, email: string): string =
|
|
|
51
50
|
*/
|
|
52
51
|
export const refEditorRedecorate = StateEffect.define<null>();
|
|
53
52
|
|
|
53
|
+
export type RefEditorExtensionOptions = {
|
|
54
|
+
mode?: RefEditorMode;
|
|
55
|
+
/** Resolve an object id referenced as `@<id>` to display info. */
|
|
56
|
+
getRef?: (id: string) => RefInfo | undefined;
|
|
57
|
+
/** Pluggable raw-token matcher (e.g. {@link EMAIL_REGEX}); matching tokens render as neutral tags. */
|
|
58
|
+
match?: RegExp;
|
|
59
|
+
colorize?: boolean;
|
|
60
|
+
};
|
|
61
|
+
|
|
54
62
|
/**
|
|
55
63
|
* CodeMirror extension rendering reference-editor tokens as tags. Tags are atomic: the cursor
|
|
56
64
|
* cannot enter them and deletion removes the whole token.
|
|
@@ -60,7 +68,44 @@ export const refEditorRedecorate = StateEffect.define<null>();
|
|
|
60
68
|
* - `'email'` mode: RFC 5322 mailboxes render as tags labelled with the display name (falling
|
|
61
69
|
* back to the address); the trailing comma separator is hidden inside the tag's atomic range.
|
|
62
70
|
*/
|
|
63
|
-
export const refEditor = ({ mode = 'ref', getRef, match }: RefEditorExtensionOptions = {}): Extension => {
|
|
71
|
+
export const refEditor = ({ mode = 'ref', getRef, match, colorize }: RefEditorExtensionOptions = {}): Extension => {
|
|
72
|
+
// Forward declaration: the field is defined below but the widgets' delete affordance (built inside
|
|
73
|
+
// the field's `create`/`update`) needs to read it back at click time.
|
|
74
|
+
let decoField: StateField<DecorationSet>;
|
|
75
|
+
|
|
76
|
+
/** Delete the tag whose delete affordance (`node`) was clicked, keeping the delimited list well-formed. */
|
|
77
|
+
const removeToken = (view: EditorView, node: HTMLElement) => {
|
|
78
|
+
// Tags render in document order, so the button's index among all delete affordances selects the
|
|
79
|
+
// matching decoration. (A replace-widget's inner DOM has no reliable document position to query.)
|
|
80
|
+
const index = [...view.contentDOM.querySelectorAll('button[aria-label="Remove"]')].indexOf(node);
|
|
81
|
+
if (index < 0) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const cursor = view.state.field(decoField).iter();
|
|
85
|
+
let range: { from: number; to: number } | undefined;
|
|
86
|
+
for (let current = 0; cursor.value; current++, cursor.next()) {
|
|
87
|
+
if (current === index) {
|
|
88
|
+
range = { from: cursor.from, to: cursor.to };
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!range) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const doc = view.state.doc.toString();
|
|
97
|
+
let { from } = range;
|
|
98
|
+
const { to } = range;
|
|
99
|
+
if (mode === 'email' && !/,\s*$/.test(doc.slice(from, to))) {
|
|
100
|
+
// A last segment carries no trailing ", "; drop the one left dangling by the previous segment.
|
|
101
|
+
const dangling = doc.slice(0, from).match(/,\s*$/);
|
|
102
|
+
if (dangling) {
|
|
103
|
+
from -= dangling[0].length;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
view.dispatch({ changes: { from, to }, selection: { anchor: from } });
|
|
107
|
+
};
|
|
108
|
+
|
|
64
109
|
const buildRefDecorations = (state: EditorState) => {
|
|
65
110
|
const deco = new RangeSetBuilder<Decoration>();
|
|
66
111
|
const text = state.doc.toString();
|
|
@@ -78,9 +123,12 @@ export const refEditor = ({ mode = 'ref', getRef, match }: RefEditorExtensionOpt
|
|
|
78
123
|
deco.add(
|
|
79
124
|
from,
|
|
80
125
|
to,
|
|
81
|
-
Decoration.
|
|
82
|
-
widget: new RefWidget(
|
|
83
|
-
|
|
126
|
+
Decoration.replace({
|
|
127
|
+
widget: new RefWidget(
|
|
128
|
+
ref.label,
|
|
129
|
+
ref.hue ?? (colorize ? getHashHue(refMatch[1]) : undefined),
|
|
130
|
+
removeToken,
|
|
131
|
+
),
|
|
84
132
|
}),
|
|
85
133
|
);
|
|
86
134
|
}
|
|
@@ -91,9 +139,8 @@ export const refEditor = ({ mode = 'ref', getRef, match }: RefEditorExtensionOpt
|
|
|
91
139
|
deco.add(
|
|
92
140
|
from,
|
|
93
141
|
to,
|
|
94
|
-
Decoration.
|
|
95
|
-
widget: new
|
|
96
|
-
atomic: true,
|
|
142
|
+
Decoration.replace({
|
|
143
|
+
widget: new RefWidget(token, undefined, removeToken),
|
|
97
144
|
}),
|
|
98
145
|
);
|
|
99
146
|
}
|
|
@@ -117,6 +164,12 @@ export const refEditor = ({ mode = 'ref', getRef, match }: RefEditorExtensionOpt
|
|
|
117
164
|
if (!content) {
|
|
118
165
|
continue;
|
|
119
166
|
}
|
|
167
|
+
// Only tag committed segments (those with a trailing comma). The final segment being typed has
|
|
168
|
+
// no comma yet, so a valid-looking address stays plain text until the user commits it (comma,
|
|
169
|
+
// space, or Enter) — otherwise the tag would pop in mid-typing.
|
|
170
|
+
if (commaIndex === -1) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
120
173
|
const leading = segment.length - segment.trimStart().length;
|
|
121
174
|
const from = segmentMatch.index + leading;
|
|
122
175
|
const to = segmentMatch.index + segment.length;
|
|
@@ -126,18 +179,20 @@ export const refEditor = ({ mode = 'ref', getRef, match }: RefEditorExtensionOpt
|
|
|
126
179
|
deco.add(
|
|
127
180
|
from,
|
|
128
181
|
to,
|
|
129
|
-
Decoration.
|
|
130
|
-
widget: new RefWidget(
|
|
131
|
-
|
|
182
|
+
Decoration.replace({
|
|
183
|
+
widget: new RefWidget(
|
|
184
|
+
nameAddr[1] || nameAddr[2],
|
|
185
|
+
colorize ? getHashHue(nameAddr[2]) : undefined,
|
|
186
|
+
removeToken,
|
|
187
|
+
),
|
|
132
188
|
}),
|
|
133
189
|
);
|
|
134
190
|
} else if (addrRegex.test(content)) {
|
|
135
191
|
deco.add(
|
|
136
192
|
from,
|
|
137
193
|
to,
|
|
138
|
-
Decoration.
|
|
139
|
-
widget: new
|
|
140
|
-
atomic: true,
|
|
194
|
+
Decoration.replace({
|
|
195
|
+
widget: new RefWidget(content, undefined, removeToken),
|
|
141
196
|
}),
|
|
142
197
|
);
|
|
143
198
|
}
|
|
@@ -148,55 +203,105 @@ export const refEditor = ({ mode = 'ref', getRef, match }: RefEditorExtensionOpt
|
|
|
148
203
|
|
|
149
204
|
const buildDecorations = mode === 'email' ? buildEmailDecorations : buildRefDecorations;
|
|
150
205
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
206
|
+
decoField = StateField.define<DecorationSet>({
|
|
207
|
+
create: (state) => buildDecorations(state),
|
|
208
|
+
update: (deco, tr) => {
|
|
209
|
+
if (tr.docChanged || tr.effects.some((effect) => effect.is(refEditorRedecorate))) {
|
|
210
|
+
return buildDecorations(tr.state);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return deco;
|
|
214
|
+
},
|
|
215
|
+
provide: (field) => [
|
|
216
|
+
EditorView.decorations.from(field),
|
|
217
|
+
// Every tag is atomic: arrow motion skips the whole range and deletion removes the whole token.
|
|
218
|
+
EditorView.atomicRanges.of((view) => view.state.field(field)),
|
|
219
|
+
],
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const separator = mode === 'email' ? ', ' : ' ';
|
|
223
|
+
|
|
224
|
+
// Typing immediately before a tag would otherwise re-tokenize into it (e.g. `x` + `bob@e.com`
|
|
225
|
+
// parses as the single email `xbob@e.com`); inject the delimiter after the inserted text so the
|
|
226
|
+
// new characters begin their own token (and open a fresh completion) rather than joining the tag.
|
|
227
|
+
const separatorFilter = EditorState.transactionFilter.of((tr) => {
|
|
228
|
+
if (!tr.docChanged || !tr.isUserEvent('input')) {
|
|
229
|
+
return tr;
|
|
230
|
+
}
|
|
231
|
+
const tags = tr.startState.field(decoField, false);
|
|
232
|
+
if (!tags) {
|
|
233
|
+
return tr;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let extra: TransactionSpec | undefined;
|
|
237
|
+
tr.changes.iterChanges((fromA, toA, _fromB, toB, inserted) => {
|
|
238
|
+
if (extra || fromA !== toA) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const text = inserted.toString();
|
|
242
|
+
if (!text || /[\s,]$/.test(text)) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
// A tag starting exactly at the insertion point means the new text abuts it.
|
|
246
|
+
let abutsTag = false;
|
|
247
|
+
for (const cursor = tags.iter(); cursor.value; cursor.next()) {
|
|
248
|
+
if (cursor.from === fromA) {
|
|
249
|
+
abutsTag = true;
|
|
250
|
+
break;
|
|
158
251
|
}
|
|
252
|
+
if (cursor.from > fromA) {
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (abutsTag) {
|
|
257
|
+
extra = { changes: { from: toB, insert: separator }, sequential: true, selection: { anchor: toB } };
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
return extra ? [tr, extra] : tr;
|
|
262
|
+
});
|
|
159
263
|
|
|
160
|
-
|
|
161
|
-
},
|
|
162
|
-
provide: (field) => [
|
|
163
|
-
EditorView.decorations.from(field),
|
|
164
|
-
EditorView.atomicRanges.of((view) => {
|
|
165
|
-
const builder = new RangeSetBuilder<Decoration>();
|
|
166
|
-
const cursor = view.state.field(field).iter();
|
|
167
|
-
while (cursor.value) {
|
|
168
|
-
if (cursor.value.spec.atomic) {
|
|
169
|
-
builder.add(cursor.from, cursor.to, cursor.value);
|
|
170
|
-
}
|
|
171
|
-
cursor.next();
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
return builder.finish();
|
|
175
|
-
}),
|
|
176
|
-
],
|
|
177
|
-
}),
|
|
178
|
-
];
|
|
264
|
+
return [styles, decoField, separatorFilter];
|
|
179
265
|
};
|
|
180
266
|
|
|
181
|
-
|
|
267
|
+
/** Delete an atomic tag given the DOM node of its widget. */
|
|
268
|
+
type RemoveToken = (view: EditorView, node: HTMLElement) => void;
|
|
182
269
|
|
|
183
270
|
/**
|
|
184
|
-
*
|
|
271
|
+
* The outer span vertically aligns the tag with the surrounding line and adds a trailing gap so
|
|
272
|
+
* adjacent tags don't touch. The inner span carries the border; `overflow-hidden` clips the child
|
|
273
|
+
* fills to its radius (a square-cornered fill otherwise peeks past the rounded border).
|
|
185
274
|
*/
|
|
186
275
|
const container = (classNames: string, ...children: Domino<HTMLElement>[]) => {
|
|
187
276
|
const inner = Domino.of('span')
|
|
188
|
-
.classNames(mx('inline-flex
|
|
277
|
+
.classNames(mx('inline-flex items-stretch border rounded-xs overflow-hidden', classNames))
|
|
189
278
|
.append(...children);
|
|
190
|
-
return Domino.of('span').classNames('inline-flex
|
|
279
|
+
return Domino.of('span').classNames('inline-flex align-middle pe-1').append(inner).root;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/** Trailing `x` affordance that deletes the tag it belongs to. */
|
|
283
|
+
const deleteButton = (view: EditorView, remove: RemoveToken) => {
|
|
284
|
+
const button = Domino.of('button')
|
|
285
|
+
.classNames('flex items-center px-1 text-description hover:text-error cursor-pointer')
|
|
286
|
+
.attributes({ 'type': 'button', 'aria-label': 'Remove' })
|
|
287
|
+
.append(Domino.svg('ph--x--bold').classNames('shrink-0 w-3.5 h-3.5'))
|
|
288
|
+
// `mousedown` + preventDefault so clicking the affordance does not move the editor selection.
|
|
289
|
+
.on('mousedown', (event) => {
|
|
290
|
+
event.preventDefault();
|
|
291
|
+
remove(view, button.root);
|
|
292
|
+
});
|
|
293
|
+
return button;
|
|
191
294
|
};
|
|
192
295
|
|
|
193
296
|
/**
|
|
194
|
-
* Resolved reference (object reference or RFC 5322 mailbox), rendered as the object's label
|
|
297
|
+
* Resolved reference (object reference or RFC 5322 mailbox), rendered as the object's label with a
|
|
298
|
+
* trailing delete affordance.
|
|
195
299
|
*/
|
|
196
300
|
class RefWidget extends WidgetType {
|
|
197
301
|
constructor(
|
|
198
302
|
private readonly _label: string,
|
|
199
|
-
private readonly _hue: string,
|
|
303
|
+
private readonly _hue: string | undefined,
|
|
304
|
+
private readonly _remove: RemoveToken,
|
|
200
305
|
) {
|
|
201
306
|
super();
|
|
202
307
|
}
|
|
@@ -205,50 +310,22 @@ class RefWidget extends WidgetType {
|
|
|
205
310
|
return this._label === other._label && this._hue === other._hue;
|
|
206
311
|
}
|
|
207
312
|
|
|
208
|
-
override toDOM() {
|
|
209
|
-
const
|
|
210
|
-
return container(
|
|
211
|
-
border,
|
|
212
|
-
Domino.of('span').classNames(mx('flex items-center px-1 text-black text-xs', fill)).text('@'),
|
|
213
|
-
Domino.of('span')
|
|
214
|
-
.classNames(mx('flex items-center px-1 text-subdued text-sm rounded-r-[3px]', surface))
|
|
215
|
-
.text(this._label),
|
|
216
|
-
);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Raw token accepted by the pluggable matcher (e.g. an email address).
|
|
222
|
-
*/
|
|
223
|
-
class TokenWidget extends WidgetType {
|
|
224
|
-
constructor(private readonly _token: string) {
|
|
225
|
-
super();
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
override eq(other: this) {
|
|
229
|
-
return this._token === other._token;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
override toDOM() {
|
|
313
|
+
override toDOM(view: EditorView) {
|
|
314
|
+
const styles = this._hue ? getStyles(this._hue) : undefined;
|
|
233
315
|
return container(
|
|
234
|
-
'border-separator',
|
|
235
|
-
Domino.of('span').classNames('flex items-center
|
|
316
|
+
mx('border-separator rounded-sm', styles?.border),
|
|
317
|
+
Domino.of('span').classNames('flex items-center ps-2 pe-1').text(this._label),
|
|
318
|
+
deleteButton(view, this._remove),
|
|
236
319
|
);
|
|
237
320
|
}
|
|
238
321
|
}
|
|
239
322
|
|
|
240
323
|
const styles = EditorView.theme({
|
|
324
|
+
// Single-line editor: lay the line out as a centered flex row so text runs and the atomic tag
|
|
325
|
+
// widgets share a vertical center (avoids `vertical-align` baseline guesswork between the two).
|
|
241
326
|
'.cm-line': {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
// Match the standard Input block size (md density): 2.5rem, 2rem on pointer-fine devices.
|
|
245
|
-
'.cm-scroller': {
|
|
327
|
+
padding: '0',
|
|
328
|
+
display: 'flex',
|
|
246
329
|
alignItems: 'center',
|
|
247
|
-
minHeight: '2.5rem',
|
|
248
|
-
},
|
|
249
|
-
'@media (pointer: fine)': {
|
|
250
|
-
'.cm-scroller': {
|
|
251
|
-
minHeight: '2rem',
|
|
252
|
-
},
|
|
253
330
|
},
|
|
254
331
|
});
|
|
@@ -7,9 +7,9 @@ import * as Schema from 'effect/Schema';
|
|
|
7
7
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
8
8
|
|
|
9
9
|
import { DXN, EID, Filter, JsonSchema, Obj, Query, type QueryAST, Scope, Tag, Type, type View } from '@dxos/echo';
|
|
10
|
+
import { useQuery } from '@dxos/echo-react';
|
|
10
11
|
import { Format } from '@dxos/echo/Format';
|
|
11
12
|
import { type Mutable } from '@dxos/echo/Obj';
|
|
12
|
-
import { useQuery } from '@dxos/react-client/echo';
|
|
13
13
|
import { useClientStory, withClientProvider } from '@dxos/react-client/testing';
|
|
14
14
|
import { useAsyncEffect } from '@dxos/react-ui';
|
|
15
15
|
import { Loading, withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
@@ -23,9 +23,9 @@ import {
|
|
|
23
23
|
Type,
|
|
24
24
|
View,
|
|
25
25
|
} from '@dxos/echo';
|
|
26
|
+
import { useObject, useQuery } from '@dxos/echo-react';
|
|
26
27
|
import { SchemaEx } from '@dxos/effect';
|
|
27
28
|
import { invariant } from '@dxos/invariant';
|
|
28
|
-
import { useObject, useQuery } from '@dxos/react-client/echo';
|
|
29
29
|
import { Input, Message, type ThemedClassName, ToggleIconButton, useTranslation } from '@dxos/react-ui';
|
|
30
30
|
import { QueryForm, type QueryFormProps } from '@dxos/react-ui-components';
|
|
31
31
|
import { OrderedList } from '@dxos/react-ui-list';
|
|
@@ -107,7 +107,7 @@ export const ViewEditor = forwardRef<ProjectionModel | null, ViewEditorProps>(
|
|
|
107
107
|
projectionModel,
|
|
108
108
|
]);
|
|
109
109
|
|
|
110
|
-
const
|
|
110
|
+
const feedTarget = Match.value(view.query.ast).pipe(
|
|
111
111
|
Match.when({ type: 'from' }, ({ from }) => {
|
|
112
112
|
if (from._tag !== 'scope') {
|
|
113
113
|
return undefined;
|
|
@@ -125,16 +125,16 @@ export const ViewEditor = forwardRef<ProjectionModel | null, ViewEditorProps>(
|
|
|
125
125
|
const feeds = useQuery(db, Filter.type(Feed.Feed));
|
|
126
126
|
|
|
127
127
|
const targetRef = useMemo(() => {
|
|
128
|
-
if (!
|
|
128
|
+
if (!feedTarget) {
|
|
129
129
|
return undefined;
|
|
130
130
|
}
|
|
131
|
-
const targetEid = EID.tryParse(
|
|
131
|
+
const targetEid = EID.tryParse(feedTarget);
|
|
132
132
|
const feed = feeds.find((feed) => {
|
|
133
|
-
const feedEid = Feed.
|
|
133
|
+
const feedEid = Feed.getFeedUri(feed);
|
|
134
134
|
return feedEid != null && targetEid != null && EID.equals(feedEid, targetEid);
|
|
135
135
|
});
|
|
136
136
|
return feed ? Ref.fromURI(Entity.getURI(feed)) : undefined;
|
|
137
|
-
}, [
|
|
137
|
+
}, [feedTarget, feeds]);
|
|
138
138
|
|
|
139
139
|
const viewSchema = useMemo(() => {
|
|
140
140
|
const base = Schema.Struct({
|
|
@@ -179,13 +179,13 @@ export const ViewEditor = forwardRef<ProjectionModel | null, ViewEditorProps>(
|
|
|
179
179
|
const handleUpdate = useCallback(
|
|
180
180
|
(values: any) => {
|
|
181
181
|
const targetValue = values.target;
|
|
182
|
-
let
|
|
182
|
+
let feedDxn: EID.EID | undefined;
|
|
183
183
|
|
|
184
184
|
if (Ref.isRef(targetValue)) {
|
|
185
185
|
const feedUri = targetValue.uri;
|
|
186
186
|
const feed = feeds.find((feed) => Obj.getURI(feed) === feedUri);
|
|
187
187
|
if (feed) {
|
|
188
|
-
|
|
188
|
+
feedDxn = Feed.getFeedUri(feed);
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
|
|
@@ -197,7 +197,7 @@ export const ViewEditor = forwardRef<ProjectionModel | null, ViewEditorProps>(
|
|
|
197
197
|
? Query.select(Filter.type(EID.isEID(values.query) ? (values.query as EID.EID) : DXN.make(values.query)))
|
|
198
198
|
.ast
|
|
199
199
|
: JSON.parse(JSON.stringify(values.query));
|
|
200
|
-
onQueryChanged?.(query,
|
|
200
|
+
onQueryChanged?.(query, feedDxn);
|
|
201
201
|
},
|
|
202
202
|
[onQueryChanged, mode, feeds],
|
|
203
203
|
);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { act, renderHook } from '@testing-library/react';
|
|
6
|
+
import * as Schema from 'effect/Schema';
|
|
7
|
+
import { describe, test } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import { useFormHandler } from './useFormHandler';
|
|
10
|
+
|
|
11
|
+
const schema = Schema.mutable(
|
|
12
|
+
Schema.Struct({
|
|
13
|
+
name: Schema.NonEmptyString,
|
|
14
|
+
city: Schema.String,
|
|
15
|
+
}),
|
|
16
|
+
);
|
|
17
|
+
type Values = Schema.Schema.Type<typeof schema>;
|
|
18
|
+
|
|
19
|
+
// Only the AST `_tag` matters to `onValueChange` (it special-cases numbers), so a plain string AST is enough here.
|
|
20
|
+
const stringAst = Schema.String.ast;
|
|
21
|
+
|
|
22
|
+
describe('useFormHandler reactive buffering', () => {
|
|
23
|
+
test('adopts external changes to fields the user is not editing', ({ expect }) => {
|
|
24
|
+
const { result, rerender } = renderHook(({ values }) => useFormHandler<Values>({ schema, values }), {
|
|
25
|
+
initialProps: { values: { name: 'Alice', city: 'NYC' } as Partial<Values> },
|
|
26
|
+
});
|
|
27
|
+
expect(result.current.getValue(['city'])).toBe('NYC');
|
|
28
|
+
|
|
29
|
+
act(() => rerender({ values: { name: 'Alice', city: 'LA' } }));
|
|
30
|
+
expect(result.current.getValue(['city'])).toBe('LA');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('holds an invalid intermediate edit without snapping back while the source updates another field', ({
|
|
34
|
+
expect,
|
|
35
|
+
}) => {
|
|
36
|
+
const { result, rerender } = renderHook(({ values }) => useFormHandler<Values>({ schema, values }), {
|
|
37
|
+
initialProps: { values: { name: 'Alice', city: 'NYC' } as Partial<Values> },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Type an invalid (empty) name. A parent that gates on validity would not persist it, so the source is unchanged.
|
|
41
|
+
act(() => result.current.onValueChange(['name'], stringAst, ''));
|
|
42
|
+
expect(result.current.getValue(['name'])).toBe('');
|
|
43
|
+
expect(result.current.isValid).toBe(false);
|
|
44
|
+
|
|
45
|
+
// The source updates a different field; the invalid draft must survive and the other field must update.
|
|
46
|
+
act(() => rerender({ values: { name: 'Alice', city: 'LA' } }));
|
|
47
|
+
expect(result.current.getValue(['name'])).toBe('');
|
|
48
|
+
expect(result.current.getValue(['city'])).toBe('LA');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('reconciles an edited field once the source catches up to the committed value', ({ expect }) => {
|
|
52
|
+
const { result, rerender } = renderHook(({ values }) => useFormHandler<Values>({ schema, values }), {
|
|
53
|
+
initialProps: { values: { name: 'Alice', city: 'NYC' } as Partial<Values> },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// A valid edit is held locally until the source reflects it.
|
|
57
|
+
act(() => result.current.onValueChange(['name'], stringAst, 'Bob'));
|
|
58
|
+
expect(result.current.getValue(['name'])).toBe('Bob');
|
|
59
|
+
|
|
60
|
+
// Source now carries the committed value: the field reconciles and becomes valid again.
|
|
61
|
+
act(() => rerender({ values: { name: 'Bob', city: 'NYC' } }));
|
|
62
|
+
expect(result.current.getValue(['name'])).toBe('Bob');
|
|
63
|
+
expect(result.current.isValid).toBe(true);
|
|
64
|
+
|
|
65
|
+
// A subsequent external change to that same field is adopted (it is no longer treated as a pending edit).
|
|
66
|
+
act(() => rerender({ values: { name: 'Carol', city: 'NYC' } }));
|
|
67
|
+
expect(result.current.getValue(['name'])).toBe('Carol');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('keeps an in-progress edit even when the source changes that same field (no snap-back)', ({ expect }) => {
|
|
71
|
+
const { result, rerender } = renderHook(({ values }) => useFormHandler<Values>({ schema, values }), {
|
|
72
|
+
initialProps: { values: { name: 'Alice', city: 'NYC' } as Partial<Values> },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Start editing a field...
|
|
76
|
+
act(() => result.current.onValueChange(['name'], stringAst, 'Bob'));
|
|
77
|
+
expect(result.current.getValue(['name'])).toBe('Bob');
|
|
78
|
+
|
|
79
|
+
// ...the source changes that same field concurrently: the in-progress edit is kept (last-writer-wins on commit),
|
|
80
|
+
// while an un-edited field still adopts the source. Wholesale object swaps are handled by remounting the form.
|
|
81
|
+
act(() => rerender({ values: { name: 'Dave', city: 'SF' } }));
|
|
82
|
+
expect(result.current.getValue(['name'])).toBe('Bob');
|
|
83
|
+
expect(result.current.getValue(['city'])).toBe('SF');
|
|
84
|
+
});
|
|
85
|
+
});
|