@dxos/react-ui-form 0.8.4-main.d05673bc65 → 0.8.4-main.dfabb4ec29
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/browser/index.mjs +1004 -612
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/translations.mjs +82 -0
- package/dist/lib/browser/translations.mjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +1004 -612
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/lib/node-esm/translations.mjs +84 -0
- package/dist/lib/node-esm/translations.mjs.map +7 -0
- package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
- package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +63 -61
- package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.d.ts +40 -12
- package/dist/types/src/components/Form/Form.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.stories.d.ts +76 -74
- package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField.d.ts +11 -5
- package/dist/types/src/components/Form/FormField.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormFieldComponent.d.ts +8 -7
- package/dist/types/src/components/Form/FormFieldComponent.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormFieldSet.d.ts +5 -9
- package/dist/types/src/components/Form/FormFieldSet.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/ArrayField.d.ts +4 -4
- package/dist/types/src/components/Form/fields/ArrayField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/BooleanField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/GeoPointField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/MarkdownField.d.ts +8 -1
- package/dist/types/src/components/Form/fields/MarkdownField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts +84 -0
- package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/NumberField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/RefField.d.ts +12 -6
- package/dist/types/src/components/Form/fields/RefField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/SelectField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/SelectOptionField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts +63 -61
- package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/TextAreaField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/TextField.d.ts +1 -1
- package/dist/types/src/components/Form/fields/TextField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/TupleField.d.ts.map +1 -1
- package/dist/types/src/components/ObjectForm/ObjectForm.d.ts +9 -0
- package/dist/types/src/components/ObjectForm/ObjectForm.d.ts.map +1 -0
- package/dist/types/src/components/ObjectForm/index.d.ts +2 -0
- package/dist/types/src/components/ObjectForm/index.d.ts.map +1 -0
- package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +26 -4
- package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
- package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +63 -61
- package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -1
- package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts +9 -0
- package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts.map +1 -0
- package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts +127 -0
- package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts.map +1 -0
- package/dist/types/src/components/ObjectProperties/ObjectProperties.test.d.ts +2 -0
- package/dist/types/src/components/ObjectProperties/ObjectProperties.test.d.ts.map +1 -0
- package/dist/types/src/components/ObjectProperties/index.d.ts +2 -0
- package/dist/types/src/components/ObjectProperties/index.d.ts.map +1 -0
- package/dist/types/src/components/Settings/Settings.d.ts +28 -33
- package/dist/types/src/components/Settings/Settings.d.ts.map +1 -1
- package/dist/types/src/components/Settings/Settings.stories.d.ts +64 -61
- package/dist/types/src/components/Settings/Settings.stories.d.ts.map +1 -1
- package/dist/types/src/components/Settings/SettingsFieldSet.d.ts +43 -0
- package/dist/types/src/components/Settings/SettingsFieldSet.d.ts.map +1 -0
- package/dist/types/src/components/Settings/SettingsItem.d.ts +16 -0
- package/dist/types/src/components/Settings/SettingsItem.d.ts.map +1 -0
- package/dist/types/src/components/Settings/SettingsSelectField.d.ts +18 -0
- package/dist/types/src/components/Settings/SettingsSelectField.d.ts.map +1 -0
- package/dist/types/src/components/Settings/index.d.ts +2 -0
- package/dist/types/src/components/Settings/index.d.ts.map +1 -1
- package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +2 -2
- package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +65 -63
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/index.d.ts +2 -0
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/components/testing.d.ts +4 -4
- package/dist/types/src/components/testing.d.ts.map +1 -1
- package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -1
- package/dist/types/src/hooks/useKeyHandler.d.ts +2 -1
- package/dist/types/src/hooks/useKeyHandler.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +0 -2
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +63 -61
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/src/util/field-type.d.ts +19 -0
- package/dist/types/src/util/field-type.d.ts.map +1 -0
- package/dist/types/src/util/field.d.ts.map +1 -1
- package/dist/types/src/util/formatting.d.ts.map +1 -1
- package/dist/types/src/util/index.d.ts +1 -0
- package/dist/types/src/util/index.d.ts.map +1 -1
- package/dist/types/src/util/properties.d.ts.map +1 -1
- package/dist/types/src/util/refs.d.ts +1 -0
- package/dist/types/src/util/refs.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +42 -40
- package/src/components/FieldEditor/FieldEditor.stories.tsx +2 -2
- package/src/components/FieldEditor/FieldEditor.test.tsx +0 -1
- package/src/components/FieldEditor/FieldEditor.tsx +4 -3
- package/src/components/Form/AUDIT.md +17 -0
- package/src/components/Form/Form.stories.tsx +23 -36
- package/src/components/Form/Form.tsx +77 -48
- package/src/components/Form/FormField.tsx +27 -15
- package/src/components/Form/FormFieldComponent.tsx +5 -3
- package/src/components/Form/FormFieldSet.tsx +98 -77
- package/src/components/Form/fields/ArrayField.tsx +14 -8
- package/src/components/Form/fields/GeoPointField.tsx +8 -7
- package/src/components/Form/fields/MarkdownField.stories.tsx +206 -0
- package/src/components/Form/fields/MarkdownField.tsx +135 -45
- package/src/components/Form/fields/RefField.tsx +70 -35
- package/src/components/Form/fields/SelectOptionField.stories.tsx +8 -6
- package/src/components/Form/fields/SelectOptionField.tsx +10 -10
- package/src/components/Form/fields/TextField.tsx +2 -2
- package/src/components/Form/fields/TupleField.tsx +1 -1
- package/src/components/ObjectForm/ObjectForm.tsx +114 -0
- package/src/components/ObjectForm/index.ts +5 -0
- package/src/components/ObjectPicker/ObjectPicker.stories.tsx +9 -7
- package/src/components/ObjectPicker/ObjectPicker.tsx +92 -65
- package/src/components/ObjectProperties/ObjectProperties.stories.tsx +362 -0
- package/src/components/ObjectProperties/ObjectProperties.test.tsx +101 -0
- package/src/components/ObjectProperties/ObjectProperties.tsx +141 -0
- package/src/components/ObjectProperties/index.ts +5 -0
- package/src/components/Settings/AUDIT.md +52 -0
- package/src/components/Settings/Settings.stories.tsx +69 -21
- package/src/components/Settings/Settings.tsx +33 -183
- package/src/components/Settings/SettingsFieldSet.tsx +257 -0
- package/src/components/Settings/SettingsItem.tsx +58 -0
- package/src/components/Settings/SettingsSelectField.tsx +59 -0
- package/src/components/Settings/index.ts +2 -0
- package/src/components/ViewEditor/ViewEditor.stories.tsx +10 -10
- package/src/components/ViewEditor/ViewEditor.test.tsx +0 -1
- package/src/components/ViewEditor/ViewEditor.tsx +15 -15
- package/src/components/index.ts +2 -0
- package/src/components/testing.tsx +19 -11
- package/src/hooks/useKeyHandler.tsx +7 -7
- package/src/index.ts +0 -4
- package/src/translations.ts +66 -63
- package/src/util/field-type.ts +60 -0
- package/src/util/index.ts +1 -0
- package/src/util/refs.ts +7 -3
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
|
+
import * as Schema from 'effect/Schema';
|
|
7
|
+
import React, { useMemo } from 'react';
|
|
8
|
+
import { expect, userEvent } from 'storybook/test';
|
|
9
|
+
|
|
10
|
+
import { Filter, Format, Obj, Ref } from '@dxos/echo';
|
|
11
|
+
import { useQuery } from '@dxos/react-client/echo';
|
|
12
|
+
import { useClientStory, withClientProvider } from '@dxos/react-client/testing';
|
|
13
|
+
import { Panel } from '@dxos/react-ui';
|
|
14
|
+
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
15
|
+
import { Text } from '@dxos/schema';
|
|
16
|
+
|
|
17
|
+
import { translations } from '#translations';
|
|
18
|
+
|
|
19
|
+
import { TestLayout } from '../../testing';
|
|
20
|
+
import { Form } from '../Form';
|
|
21
|
+
|
|
22
|
+
//
|
|
23
|
+
// String-backed markdown.
|
|
24
|
+
//
|
|
25
|
+
|
|
26
|
+
const StringSchema = Schema.Struct({
|
|
27
|
+
notes: Schema.String.pipe(Format.FormatAnnotation.set(Format.TypeFormat.Markdown)).annotations({ title: 'Notes' }),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const StringStory = () => (
|
|
31
|
+
<TestLayout>
|
|
32
|
+
<Form.Root schema={StringSchema} defaultValues={{ notes: '# Hello\n\nMarkdown text *here*.' }}>
|
|
33
|
+
<Form.Viewport>
|
|
34
|
+
<Form.Content>
|
|
35
|
+
<Form.FieldSet />
|
|
36
|
+
</Form.Content>
|
|
37
|
+
</Form.Viewport>
|
|
38
|
+
</Form.Root>
|
|
39
|
+
</TestLayout>
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
//
|
|
43
|
+
// Ref<Text>-backed markdown.
|
|
44
|
+
//
|
|
45
|
+
|
|
46
|
+
const TextNoteHolder = Schema.Struct({
|
|
47
|
+
instructions: Ref.Ref(Text.Text)
|
|
48
|
+
.pipe(Format.FormatAnnotation.set(Format.TypeFormat.Markdown))
|
|
49
|
+
.annotations({ title: 'Instructions' }),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const RefStory = () => {
|
|
53
|
+
const { space } = useClientStory();
|
|
54
|
+
const [text] = useQuery(space?.db, Filter.type(Text.Text));
|
|
55
|
+
const values = useMemo(() => (text ? { instructions: Ref.make(text) } : undefined), [text]);
|
|
56
|
+
|
|
57
|
+
if (!space || !values) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<TestLayout>
|
|
63
|
+
<Panel.Root>
|
|
64
|
+
<Panel.Content asChild>
|
|
65
|
+
<Form.Root schema={TextNoteHolder} values={values as any} db={space.db}>
|
|
66
|
+
<Form.Viewport>
|
|
67
|
+
<Form.Content>
|
|
68
|
+
<Form.FieldSet />
|
|
69
|
+
</Form.Content>
|
|
70
|
+
</Form.Viewport>
|
|
71
|
+
</Form.Root>
|
|
72
|
+
</Panel.Content>
|
|
73
|
+
</Panel.Root>
|
|
74
|
+
</TestLayout>
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
//
|
|
79
|
+
// Ref<Text> empty — shows the create button.
|
|
80
|
+
//
|
|
81
|
+
|
|
82
|
+
const EmptyRefSchema = Schema.Struct({
|
|
83
|
+
instructions: Schema.optional(
|
|
84
|
+
Ref.Ref(Text.Text)
|
|
85
|
+
.pipe(Format.FormatAnnotation.set(Format.TypeFormat.Markdown))
|
|
86
|
+
.annotations({ title: 'Instructions' }),
|
|
87
|
+
),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const EmptyRefStory = () => {
|
|
91
|
+
const { space } = useClientStory();
|
|
92
|
+
if (!space) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<TestLayout>
|
|
98
|
+
<Panel.Root>
|
|
99
|
+
<Panel.Content asChild>
|
|
100
|
+
<Form.Root schema={EmptyRefSchema} defaultValues={{}} db={space.db}>
|
|
101
|
+
<Form.Viewport>
|
|
102
|
+
<Form.Content>
|
|
103
|
+
<Form.FieldSet />
|
|
104
|
+
</Form.Content>
|
|
105
|
+
</Form.Viewport>
|
|
106
|
+
</Form.Root>
|
|
107
|
+
</Panel.Content>
|
|
108
|
+
</Panel.Root>
|
|
109
|
+
</TestLayout>
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const meta = {
|
|
114
|
+
title: 'ui/react-ui-form/MarkdownField',
|
|
115
|
+
decorators: [withTheme(), withLayout({ layout: 'column' })],
|
|
116
|
+
parameters: {
|
|
117
|
+
layout: 'fullscreen',
|
|
118
|
+
translations,
|
|
119
|
+
},
|
|
120
|
+
} satisfies Meta;
|
|
121
|
+
|
|
122
|
+
export default meta;
|
|
123
|
+
|
|
124
|
+
type Story = StoryObj<typeof meta>;
|
|
125
|
+
|
|
126
|
+
export const StringBacked: Story = {
|
|
127
|
+
render: () => <StringStory />,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Regression test: keystrokes into a string-backed markdown field must not
|
|
131
|
+
// blur the editor. Previously the EditorView was destroyed and recreated on
|
|
132
|
+
// every change because `onChange` was a useTextEditor dep and form callbacks
|
|
133
|
+
// are unstable across renders.
|
|
134
|
+
export const StringBackedKeepsFocus: Story = {
|
|
135
|
+
render: () => (
|
|
136
|
+
<TestLayout>
|
|
137
|
+
<Form.Root schema={StringSchema} defaultValues={{ notes: '' }}>
|
|
138
|
+
<Form.Viewport>
|
|
139
|
+
<Form.Content>
|
|
140
|
+
<Form.FieldSet />
|
|
141
|
+
</Form.Content>
|
|
142
|
+
</Form.Viewport>
|
|
143
|
+
</Form.Root>
|
|
144
|
+
</TestLayout>
|
|
145
|
+
),
|
|
146
|
+
play: async ({ canvasElement }) => {
|
|
147
|
+
const waitFor = async <T,>(predicate: () => T | null | undefined, message: string): Promise<T> => {
|
|
148
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
149
|
+
const value = predicate();
|
|
150
|
+
if (value) {
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
154
|
+
}
|
|
155
|
+
throw new Error(message);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const editorRoot = await waitFor(
|
|
159
|
+
() => canvasElement.querySelector<HTMLElement>('.cm-editor'),
|
|
160
|
+
'CodeMirror editor root (.cm-editor) not found',
|
|
161
|
+
);
|
|
162
|
+
const editorContent = editorRoot.querySelector<HTMLElement>('.cm-content')!;
|
|
163
|
+
await expect(editorContent).toBeVisible();
|
|
164
|
+
|
|
165
|
+
// Focus editor and wait for focus to settle inside the editor root.
|
|
166
|
+
editorContent.focus();
|
|
167
|
+
await waitFor(() => (editorRoot.contains(document.activeElement) ? true : null), 'Editor did not receive focus');
|
|
168
|
+
|
|
169
|
+
// Type several characters; focus must be preserved inside the editor root
|
|
170
|
+
// after every keystroke. Regression: previously the EditorView was
|
|
171
|
+
// destroyed and recreated on every change, which detached the focused
|
|
172
|
+
// contentDOM and blurred the input.
|
|
173
|
+
const phrase = 'hello world';
|
|
174
|
+
for (const ch of phrase) {
|
|
175
|
+
await userEvent.keyboard(ch);
|
|
176
|
+
await expect(editorRoot.contains(document.activeElement)).toBe(true);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await expect(editorContent).toHaveTextContent(phrase);
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export const RefPopulated: Story = {
|
|
184
|
+
render: () => <RefStory />,
|
|
185
|
+
decorators: [
|
|
186
|
+
withClientProvider({
|
|
187
|
+
createIdentity: true,
|
|
188
|
+
createSpace: true,
|
|
189
|
+
types: [Text.Text],
|
|
190
|
+
onCreateSpace: async ({ space }) => {
|
|
191
|
+
space.db.add(Obj.make(Text.Text, { content: '# Magazine brief\n\nLong-form text describing what to gather.' }));
|
|
192
|
+
},
|
|
193
|
+
}),
|
|
194
|
+
],
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export const RefEmpty: Story = {
|
|
198
|
+
render: () => <EmptyRefStory />,
|
|
199
|
+
decorators: [
|
|
200
|
+
withClientProvider({
|
|
201
|
+
createIdentity: true,
|
|
202
|
+
createSpace: true,
|
|
203
|
+
types: [Text.Text],
|
|
204
|
+
}),
|
|
205
|
+
],
|
|
206
|
+
};
|
|
@@ -2,77 +2,167 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import
|
|
5
|
+
import { useAtomValue } from '@effect-atom/atom-react';
|
|
6
|
+
import React, { useCallback, useMemo } from 'react';
|
|
6
7
|
|
|
7
|
-
import {
|
|
8
|
+
import { type Database, Obj, Ref } from '@dxos/echo';
|
|
9
|
+
import { AtomRef } from '@dxos/echo-atom';
|
|
10
|
+
import { createDocAccessor } from '@dxos/echo-db';
|
|
11
|
+
import { Button, Icon, Input, useTranslation } from '@dxos/react-ui';
|
|
12
|
+
import { Editor, useBasicMarkdownExtensions } from '@dxos/react-ui-editor';
|
|
13
|
+
import { Text } from '@dxos/schema';
|
|
14
|
+
import { createDataExtensions } from '@dxos/ui-editor';
|
|
15
|
+
|
|
16
|
+
import { translationKey } from '#translations';
|
|
8
17
|
|
|
9
18
|
import { type FormFieldComponentProps, FormFieldLabel } from '../FormFieldComponent';
|
|
10
19
|
|
|
20
|
+
const editorClassNames =
|
|
21
|
+
'transition-colors bg-input-surface focus-within:bg-focus-surface border border-separator rounded-xs p-1 px-2';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Form field that edits a markdown value in a CodeMirror editor.
|
|
25
|
+
* Supports two backings:
|
|
26
|
+
* - `Schema.String` annotated with `Format.TypeFormat.Markdown`: edited as plain text via `getValue`/`onValueChange`.
|
|
27
|
+
* - `Ref<Text>` annotated with `Format.TypeFormat.Markdown`: edited in-place via an Automerge doc accessor.
|
|
28
|
+
* When the ref is empty and a database is available, a create button is shown to allocate a new Text.
|
|
29
|
+
*/
|
|
11
30
|
export const MarkdownField = ({
|
|
12
31
|
type,
|
|
13
32
|
readonly,
|
|
14
33
|
label,
|
|
15
34
|
placeholder,
|
|
16
35
|
layout,
|
|
36
|
+
db,
|
|
17
37
|
getStatus,
|
|
18
38
|
getValue,
|
|
19
39
|
onValueChange,
|
|
20
|
-
onBlur,
|
|
21
40
|
}: FormFieldComponentProps) => {
|
|
22
41
|
const { status, error } = getStatus();
|
|
23
|
-
const
|
|
42
|
+
const isRef = Ref.isRefType(type);
|
|
43
|
+
const value = getValue();
|
|
24
44
|
|
|
25
|
-
|
|
26
|
-
|
|
45
|
+
if ((readonly || layout === 'static') && value == null) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
27
48
|
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}, []);
|
|
49
|
+
const renderEditor = () => {
|
|
50
|
+
if (isRef) {
|
|
51
|
+
const reference = value as Ref.Ref<any> | undefined;
|
|
52
|
+
if (reference) {
|
|
53
|
+
return <RefMarkdownEditor reference={reference} placeholder={placeholder} />;
|
|
54
|
+
}
|
|
35
55
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}, [adjustHeight]);
|
|
56
|
+
if (readonly) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
40
59
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
adjustHeight();
|
|
44
|
-
}, [getValue(), adjustHeight]);
|
|
60
|
+
return <CreateTextButton db={db} disabled={!db} onCreate={(ref) => onValueChange(type, ref)} />;
|
|
61
|
+
}
|
|
45
62
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
63
|
+
return (
|
|
64
|
+
<StringMarkdownEditor
|
|
65
|
+
value={(value as string | undefined) ?? ''}
|
|
66
|
+
placeholder={placeholder}
|
|
67
|
+
readonly={!!readonly}
|
|
68
|
+
onChange={(next) => onValueChange(type, next)}
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
};
|
|
50
72
|
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
73
|
+
const renderStatic = () => {
|
|
74
|
+
if (isRef) {
|
|
75
|
+
const reference = value as Ref.Ref<any> | undefined;
|
|
76
|
+
return reference ? <RefStaticText reference={reference} /> : null;
|
|
77
|
+
}
|
|
78
|
+
return <p className='whitespace-pre-wrap'>{(value as string | undefined) ?? ''}</p>;
|
|
79
|
+
};
|
|
55
80
|
|
|
56
|
-
// TODO(burdon): Use Markdown Editor.
|
|
57
81
|
return (
|
|
58
82
|
<Input.Root validationValence={status}>
|
|
59
83
|
{layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} />}
|
|
60
|
-
{layout === 'static' ? (
|
|
61
|
-
<p>{value}</p>
|
|
62
|
-
) : (
|
|
63
|
-
<Input.TextArea
|
|
64
|
-
ref={textareaRef}
|
|
65
|
-
disabled={!!readonly}
|
|
66
|
-
placeholder={placeholder}
|
|
67
|
-
value={value ?? ''}
|
|
68
|
-
classNames={'min-h-auto max-h-40 overflow-auto'}
|
|
69
|
-
onChange={handleChange}
|
|
70
|
-
onBlur={onBlur}
|
|
71
|
-
style={{ resize: 'none' }}
|
|
72
|
-
spellCheck={false}
|
|
73
|
-
/>
|
|
74
|
-
)}
|
|
84
|
+
{layout === 'static' ? renderStatic() : renderEditor()}
|
|
75
85
|
{layout === 'full' && <Input.Validation>{error}</Input.Validation>}
|
|
76
86
|
</Input.Root>
|
|
77
87
|
);
|
|
78
88
|
};
|
|
89
|
+
|
|
90
|
+
/** Read-only static rendering for a `Ref<Text>` value: resolve the ref and print its content. */
|
|
91
|
+
const RefStaticText = ({ reference }: { reference: Ref.Ref<any> }) => {
|
|
92
|
+
const text = useAtomValue(useMemo(() => AtomRef.make(reference), [reference]));
|
|
93
|
+
if (!text?.content) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return <p className='whitespace-pre-wrap'>{text.content}</p>;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
type RefMarkdownEditorProps = {
|
|
100
|
+
reference: Ref.Ref<any>;
|
|
101
|
+
placeholder?: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const RefMarkdownEditor = ({ reference, placeholder }: RefMarkdownEditorProps) => {
|
|
105
|
+
const text = useAtomValue(useMemo(() => AtomRef.make(reference), [reference]));
|
|
106
|
+
const dataExtensions = useMemo(
|
|
107
|
+
() =>
|
|
108
|
+
text ? [createDataExtensions({ id: reference.dxn.toString(), text: createDocAccessor(text, ['content']) })] : [],
|
|
109
|
+
[text, reference],
|
|
110
|
+
);
|
|
111
|
+
const extensions = useBasicMarkdownExtensions({ placeholder, extensions: dataExtensions });
|
|
112
|
+
if (!text) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<Editor.Root>
|
|
118
|
+
<Editor.View classNames={editorClassNames} extensions={extensions} />
|
|
119
|
+
</Editor.Root>
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
type StringMarkdownEditorProps = {
|
|
124
|
+
value: string;
|
|
125
|
+
placeholder?: string;
|
|
126
|
+
readonly?: boolean;
|
|
127
|
+
onChange: (value: string) => void;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const StringMarkdownEditor = ({ value, placeholder, readonly, onChange }: StringMarkdownEditorProps) => {
|
|
131
|
+
const extensions = useBasicMarkdownExtensions({ placeholder });
|
|
132
|
+
const handleChange = useCallback((next: string) => onChange(next), [onChange]);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<Editor.Root>
|
|
136
|
+
<Editor.View
|
|
137
|
+
classNames={editorClassNames}
|
|
138
|
+
extensions={extensions}
|
|
139
|
+
value={value}
|
|
140
|
+
onChange={readonly ? undefined : handleChange}
|
|
141
|
+
/>
|
|
142
|
+
</Editor.Root>
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
type CreateTextButtonProps = {
|
|
147
|
+
db?: Database.Database;
|
|
148
|
+
disabled?: boolean;
|
|
149
|
+
onCreate: (ref: Ref.Ref<Text.Text>) => void;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const CreateTextButton = ({ db, disabled, onCreate }: CreateTextButtonProps) => {
|
|
153
|
+
const { t } = useTranslation(translationKey);
|
|
154
|
+
const handleClick = useCallback(() => {
|
|
155
|
+
if (!db) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const text = db.add(Obj.make(Text.Text, { content: '' }));
|
|
159
|
+
onCreate(Ref.make(text));
|
|
160
|
+
}, [db, onCreate]);
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<Button onClick={handleClick} disabled={disabled}>
|
|
164
|
+
<Icon icon='ph--plus--regular' size={4} />
|
|
165
|
+
<span>{t('create-text.label')}</span>
|
|
166
|
+
</Button>
|
|
167
|
+
);
|
|
168
|
+
};
|
|
@@ -2,23 +2,22 @@
|
|
|
2
2
|
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import '
|
|
6
|
-
|
|
7
|
-
import React, { useCallback, useMemo } from 'react';
|
|
5
|
+
import React, { useCallback, useMemo, useState } from 'react';
|
|
8
6
|
|
|
7
|
+
import '@dxos/lit-ui/dx-tag-picker.pcss';
|
|
9
8
|
import { type Database, Entity, Filter, Obj, Ref, Type } from '@dxos/echo';
|
|
9
|
+
import { useQuery, useSchema as defaultUseSchema } from '@dxos/echo-react';
|
|
10
10
|
import { ReferenceAnnotationId, type ReferenceAnnotationValue } from '@dxos/echo/internal';
|
|
11
|
-
import { useQuery, useSchema as useSchema$ } from '@dxos/echo-react';
|
|
12
11
|
import { findAnnotation } from '@dxos/effect';
|
|
13
12
|
import { DXN } from '@dxos/keys';
|
|
14
13
|
import { DxAnchor } from '@dxos/lit-ui/react';
|
|
15
14
|
import { Button, Icon, Input, useTranslation } from '@dxos/react-ui';
|
|
16
15
|
import { ParentLabelAnnotationId } from '@dxos/schema';
|
|
17
|
-
import { mx } from '@dxos/ui-theme';
|
|
18
16
|
|
|
19
|
-
import { translationKey } from '
|
|
17
|
+
import { translationKey } from '#translations';
|
|
18
|
+
|
|
20
19
|
import { ObjectPicker, type ObjectPickerContentProps, type RefOption } from '../../ObjectPicker';
|
|
21
|
-
import { omitId } from '../Form';
|
|
20
|
+
import { omitHiddenFormFields, omitId } from '../Form';
|
|
22
21
|
import { type FormFieldComponentProps, FormFieldLabel } from '../FormFieldComponent';
|
|
23
22
|
|
|
24
23
|
// TODO(burdon): Factor out.
|
|
@@ -34,7 +33,7 @@ const defaultGetOptions: NonNullable<RefFieldProps['getOptions']> = (results, {
|
|
|
34
33
|
return { id, label: label ?? id };
|
|
35
34
|
});
|
|
36
35
|
|
|
37
|
-
const
|
|
36
|
+
const defaultUseResults: NonNullable<RefFieldProps['useResults']> = (db, typename) =>
|
|
38
37
|
useQuery(
|
|
39
38
|
db,
|
|
40
39
|
typename
|
|
@@ -46,12 +45,22 @@ const defaultResultsHook: NonNullable<RefFieldProps['resultsHook']> = (db, typen
|
|
|
46
45
|
);
|
|
47
46
|
|
|
48
47
|
export type RefFieldProps = FormFieldComponentProps &
|
|
49
|
-
Pick<
|
|
48
|
+
Pick<
|
|
49
|
+
ObjectPickerContentProps,
|
|
50
|
+
'createOptionLabel' | 'createOptionIcon' | 'createInitialValuePath' | 'createFieldMap'
|
|
51
|
+
> & {
|
|
50
52
|
db?: Database.Database;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
+
// TODO(burdon): Replace hooks with callbacks.
|
|
54
|
+
useSchema?: (db?: Database.Database, typename?: string) => Type.AnyEntity;
|
|
55
|
+
useResults?: (db?: Database.Database, typename?: string) => Entity.Any[];
|
|
53
56
|
getOptions?: (objects: Entity.Any[], options?: { parentLabel?: boolean }) => RefOption[];
|
|
54
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Persist a newly-created object. Called after the user fills out the
|
|
59
|
+
* inline create form and clicks Save. Should add the object to the
|
|
60
|
+
* database and return it (sync or async). The returned object is then
|
|
61
|
+
* wired into this slot's form value as a Ref.
|
|
62
|
+
*/
|
|
63
|
+
onCreate?: (schema: Type.AnyEntity, values: any) => Obj.Unknown | Promise<Obj.Unknown> | undefined | void;
|
|
55
64
|
};
|
|
56
65
|
|
|
57
66
|
export const RefField = (props: RefFieldProps) => {
|
|
@@ -66,9 +75,10 @@ export const RefField = (props: RefFieldProps) => {
|
|
|
66
75
|
createOptionLabel,
|
|
67
76
|
createOptionIcon,
|
|
68
77
|
createInitialValuePath,
|
|
78
|
+
createFieldMap,
|
|
69
79
|
db,
|
|
70
|
-
|
|
71
|
-
|
|
80
|
+
useSchema = defaultUseSchema,
|
|
81
|
+
useResults = defaultUseResults,
|
|
72
82
|
getOptions = defaultGetOptions,
|
|
73
83
|
onCreate,
|
|
74
84
|
onValueChange,
|
|
@@ -89,42 +99,63 @@ export const RefField = (props: RefFieldProps) => {
|
|
|
89
99
|
|
|
90
100
|
const handleGetValue = useCallback(() => {
|
|
91
101
|
const formValue = getValue();
|
|
102
|
+
// Match form-value Refs against options by the bare object id (last DXN
|
|
103
|
+
// segment), not by full DXN string. Ref.make uses
|
|
104
|
+
// `DXN.fromLocalObjectId(id)` (`dxn:echo:@:<id>`), but
|
|
105
|
+
// `Entity.getDXN(obj)` on a registered object produces the space-scoped
|
|
106
|
+
// form (`dxn:echo:<spaceId>:<id>`). String-comparing the two never
|
|
107
|
+
// matches, so the just-created Ref's option lookup fails and the slot
|
|
108
|
+
// displays as empty even though the underlying form value IS set.
|
|
109
|
+
const dxnToObjectId = (dxn: string): string => dxn.split(':').pop() ?? dxn;
|
|
92
110
|
|
|
93
111
|
const unknownToRefOption = (value: unknown) => {
|
|
94
112
|
const isRef = Ref.isRef(value);
|
|
95
113
|
if (isRef || isRefSnapshot(value)) {
|
|
96
114
|
const dxnString = isRef ? value.dxn.toString() : value['/'];
|
|
97
|
-
const
|
|
115
|
+
const objectId = dxnToObjectId(dxnString);
|
|
116
|
+
const matchingOption = options.find((option) => dxnToObjectId(option.id) === objectId);
|
|
98
117
|
if (matchingOption) {
|
|
99
118
|
return matchingOption;
|
|
100
119
|
}
|
|
101
120
|
}
|
|
121
|
+
|
|
102
122
|
return undefined;
|
|
103
123
|
};
|
|
104
124
|
|
|
105
125
|
return unknownToRefOption(formValue);
|
|
106
126
|
}, [options, getValue]);
|
|
107
127
|
|
|
108
|
-
const handleUpdate = useCallback(
|
|
109
|
-
(id: string | undefined) => {
|
|
110
|
-
const item = options.find((option) => option.id === id);
|
|
111
|
-
const ref = item ? Ref.fromDXN(DXN.parse(item.id)) : undefined;
|
|
112
|
-
onValueChange(type, ref);
|
|
113
|
-
},
|
|
114
|
-
[options, type, onValueChange],
|
|
115
|
-
);
|
|
116
|
-
|
|
117
128
|
const item = handleGetValue();
|
|
118
129
|
const selectedIds = useMemo(() => (item ? [item.id] : []), [item]);
|
|
119
130
|
const createSchema = useSchema(db, typename);
|
|
120
131
|
|
|
132
|
+
// Lift the popover open state so we can dismiss it after a successful create.
|
|
133
|
+
const [open, setOpen] = useState(false);
|
|
134
|
+
|
|
121
135
|
const handleCreate = useCallback(
|
|
122
|
-
(values: any) => {
|
|
123
|
-
if (createSchema
|
|
124
|
-
|
|
136
|
+
async (values: any) => {
|
|
137
|
+
if (!createSchema || !onCreate) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const newObject = await onCreate(createSchema, values);
|
|
141
|
+
if (newObject) {
|
|
142
|
+
// Wire the newly-created object into this slot's form value. ArrayField
|
|
143
|
+
// owns the array; this RefField represents a single slot, so writing a
|
|
144
|
+
// Ref via `onValueChange` populates that slot.
|
|
145
|
+
onValueChange(type, Ref.make(newObject));
|
|
125
146
|
}
|
|
147
|
+
setOpen(false);
|
|
148
|
+
},
|
|
149
|
+
[createSchema, onCreate, type, onValueChange],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const handleUpdate = useCallback(
|
|
153
|
+
(id: string | undefined) => {
|
|
154
|
+
const item = options.find((option) => option.id === id);
|
|
155
|
+
const ref = item ? Ref.fromDXN(DXN.parse(item.id)) : undefined;
|
|
156
|
+
onValueChange(type, ref);
|
|
126
157
|
},
|
|
127
|
-
[
|
|
158
|
+
[options, type, onValueChange],
|
|
128
159
|
);
|
|
129
160
|
|
|
130
161
|
const handleSelect = useCallback(
|
|
@@ -148,25 +179,25 @@ export const RefField = (props: RefFieldProps) => {
|
|
|
148
179
|
<div>
|
|
149
180
|
{readonly ? (
|
|
150
181
|
!item ? (
|
|
151
|
-
<p className=
|
|
182
|
+
<p className='text-description mb-2'>{t('empty-readonly-ref-field.label')}</p>
|
|
152
183
|
) : (
|
|
153
184
|
<DxAnchor key={item.id} dxn={item.id} rootclassname='me-1'>
|
|
154
185
|
{item.label}
|
|
155
186
|
</DxAnchor>
|
|
156
187
|
)
|
|
157
188
|
) : (
|
|
158
|
-
<ObjectPicker.Root>
|
|
189
|
+
<ObjectPicker.Root open={open} onOpenChange={setOpen}>
|
|
159
190
|
<ObjectPicker.Trigger asChild classNames='p-0'>
|
|
160
191
|
{item ? (
|
|
161
|
-
<div className='flex gap-
|
|
192
|
+
<div className='flex gap-form-gap w-full'>
|
|
162
193
|
<Input.Root key={item.id}>
|
|
163
194
|
<Input.TextInput value={item.label} readOnly classNames='w-full' />
|
|
164
195
|
</Input.Root>
|
|
165
196
|
</div>
|
|
166
197
|
) : (
|
|
167
|
-
<Button classNames='w-full text-start gap-
|
|
168
|
-
<div
|
|
169
|
-
<span className='
|
|
198
|
+
<Button classNames='w-full text-start gap-form-gap'>
|
|
199
|
+
<div className='grow overflow-hidden'>
|
|
200
|
+
<span className='truncate text-description'>{placeholder ?? t('ref-field.placeholder')}</span>
|
|
170
201
|
</div>
|
|
171
202
|
<Icon size={3} icon='ph--caret-down--bold' />
|
|
172
203
|
</Button>
|
|
@@ -177,10 +208,14 @@ export const RefField = (props: RefFieldProps) => {
|
|
|
177
208
|
classNames='dx-card-popover-width'
|
|
178
209
|
options={options}
|
|
179
210
|
selectedIds={selectedIds}
|
|
180
|
-
|
|
211
|
+
// Strip hidden (`FormInputAnnotation.set(false)`) fields so the
|
|
212
|
+
// form's validator doesn't reject required-but-hidden fields
|
|
213
|
+
// such as backing-object refs supplied by a `FactoryAnnotation`.
|
|
214
|
+
createSchema={createSchema && omitHiddenFormFields(omitId(createSchema))}
|
|
181
215
|
createOptionLabel={createOptionLabel}
|
|
182
216
|
createOptionIcon={createOptionIcon}
|
|
183
217
|
createInitialValuePath={createInitialValuePath}
|
|
218
|
+
createFieldMap={createFieldMap}
|
|
184
219
|
onCreate={handleCreate}
|
|
185
220
|
onSelect={handleSelect}
|
|
186
221
|
/>
|
|
@@ -9,10 +9,10 @@ import React, { useState } from 'react';
|
|
|
9
9
|
import { SelectOption } from '@dxos/echo/internal';
|
|
10
10
|
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
11
11
|
|
|
12
|
-
import { translations } from '
|
|
12
|
+
import { translations } from '#translations';
|
|
13
|
+
|
|
13
14
|
import { TestLayout } from '../../testing';
|
|
14
15
|
import { Form } from '../Form';
|
|
15
|
-
|
|
16
16
|
import { SelectOptionField } from './SelectOptionField';
|
|
17
17
|
|
|
18
18
|
const schema = Schema.Struct({
|
|
@@ -37,10 +37,12 @@ const DefaultStory = () => {
|
|
|
37
37
|
fieldMap={{ options: SelectOptionField }}
|
|
38
38
|
onSave={(values) => setValues(values)}
|
|
39
39
|
>
|
|
40
|
-
<Form.
|
|
41
|
-
<Form.
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
<Form.Viewport>
|
|
41
|
+
<Form.Content>
|
|
42
|
+
<Form.FieldSet />
|
|
43
|
+
<Form.Actions />
|
|
44
|
+
</Form.Content>
|
|
45
|
+
</Form.Viewport>
|
|
44
46
|
</Form.Root>
|
|
45
47
|
</TestLayout>
|
|
46
48
|
);
|