@dxos/react-ui-form 0.8.4-main.c4373fc → 0.8.4-main.d05539e30a
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/LICENSE +102 -5
- package/README.md +0 -1
- package/dist/lib/browser/index.mjs +2747 -2324
- 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 +2747 -2324
- 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 +8 -7
- package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
- package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +68 -60
- package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.d.ts +109 -26
- package/dist/types/src/components/Form/Form.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.stories.d.ts +114 -137
- package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField.d.ts +43 -0
- package/dist/types/src/components/Form/FormField.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormFieldComponent.d.ts +79 -0
- package/dist/types/src/components/Form/FormFieldComponent.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts +9 -0
- package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormFieldSet.d.ts +15 -0
- package/dist/types/src/components/Form/FormFieldSet.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/ArrayField.d.ts +18 -0
- package/dist/types/src/components/Form/fields/ArrayField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/BooleanField.d.ts +4 -0
- package/dist/types/src/components/Form/fields/BooleanField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/GeoPointField.d.ts +5 -0
- package/dist/types/src/components/Form/fields/GeoPointField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/MarkdownField.d.ts +11 -0
- package/dist/types/src/components/Form/fields/MarkdownField.d.ts.map +1 -0
- 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 +4 -0
- package/dist/types/src/components/Form/fields/NumberField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/RefField.d.ts +22 -0
- package/dist/types/src/components/Form/fields/RefField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/SelectField.d.ts +10 -0
- package/dist/types/src/components/Form/fields/SelectField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/SelectOptionField.d.ts +5 -0
- package/dist/types/src/components/Form/fields/SelectOptionField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts +84 -0
- package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/TextAreaField.d.ts +4 -0
- package/dist/types/src/components/Form/fields/TextAreaField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/TextField.d.ts +4 -0
- package/dist/types/src/components/Form/fields/TextField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/TupleField.d.ts +6 -0
- package/dist/types/src/components/Form/fields/TupleField.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/index.d.ts +12 -0
- package/dist/types/src/components/Form/fields/index.d.ts.map +1 -0
- package/dist/types/src/components/Form/index.d.ts +2 -4
- package/dist/types/src/components/Form/index.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 +37 -10
- package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
- package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +65 -59
- 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 +51 -0
- package/dist/types/src/components/Settings/Settings.d.ts.map +1 -0
- package/dist/types/src/components/Settings/Settings.stories.d.ts +84 -0
- package/dist/types/src/components/Settings/Settings.stories.d.ts.map +1 -0
- 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 +4 -0
- package/dist/types/src/components/Settings/index.d.ts.map +1 -0
- package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +15 -16
- package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +70 -64
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/index.d.ts +3 -1
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/components/testing.d.ts +4 -6
- package/dist/types/src/components/testing.d.ts.map +1 -1
- package/dist/types/src/hooks/index.d.ts +2 -3
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useFormHandler.d.ts +82 -0
- package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -0
- package/dist/types/src/hooks/useKeyHandler.d.ts +7 -0
- package/dist/types/src/hooks/useKeyHandler.d.ts.map +1 -0
- 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 +65 -60
- 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 +3 -3
- package/dist/types/src/util/field.d.ts.map +1 -1
- package/dist/types/src/util/formatting.d.ts +2 -2
- package/dist/types/src/util/formatting.d.ts.map +1 -1
- package/dist/types/src/util/index.d.ts +2 -1
- package/dist/types/src/util/index.d.ts.map +1 -1
- package/dist/types/src/util/properties.d.ts +13 -0
- package/dist/types/src/util/properties.d.ts.map +1 -0
- package/dist/types/src/util/properties.test.d.ts +2 -0
- package/dist/types/src/util/properties.test.d.ts.map +1 -0
- package/dist/types/src/util/refs.d.ts +2 -2
- package/dist/types/src/util/refs.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +57 -51
- package/src/components/FieldEditor/FieldEditor.stories.tsx +44 -25
- package/src/components/FieldEditor/FieldEditor.test.tsx +0 -1
- package/src/components/FieldEditor/FieldEditor.tsx +76 -79
- package/src/components/Form/AUDIT.md +17 -0
- package/src/components/Form/Form.stories.tsx +139 -251
- package/src/components/Form/Form.tsx +375 -106
- package/src/components/Form/FormField.tsx +306 -0
- package/src/components/Form/FormFieldComponent.stories.tsx +65 -0
- package/src/components/Form/FormFieldComponent.tsx +174 -0
- package/src/components/Form/FormFieldSet.tsx +144 -0
- package/src/components/Form/README.md +3 -0
- package/src/components/Form/fields/ArrayField.tsx +155 -0
- package/src/components/Form/fields/BooleanField.tsx +22 -0
- package/src/components/Form/fields/GeoPointField.tsx +121 -0
- package/src/components/Form/fields/MarkdownField.stories.tsx +206 -0
- package/src/components/Form/fields/MarkdownField.tsx +168 -0
- package/src/components/Form/fields/NumberField.tsx +39 -0
- package/src/components/Form/fields/RefField.tsx +229 -0
- package/src/components/Form/fields/SelectField.tsx +65 -0
- package/src/components/Form/{custom/SelectOptionsInput.stories.tsx → fields/SelectOptionField.stories.tsx} +23 -18
- package/src/components/Form/{custom/SelectOptionsInput.tsx → fields/SelectOptionField.tsx} +30 -24
- package/src/components/Form/fields/TextAreaField.tsx +38 -0
- package/src/components/Form/fields/TextField.tsx +38 -0
- package/src/components/Form/{custom/TupleInput.tsx → fields/TupleField.tsx} +16 -9
- package/src/components/Form/fields/index.ts +15 -0
- package/src/components/Form/index.ts +3 -4
- package/src/components/ObjectForm/ObjectForm.tsx +114 -0
- package/src/components/{ControlSection → ObjectForm}/index.ts +1 -1
- package/src/components/ObjectPicker/ObjectPicker.stories.tsx +50 -45
- package/src/components/ObjectPicker/ObjectPicker.tsx +127 -86
- package/src/components/ObjectProperties/ObjectProperties.stories.tsx +362 -0
- package/src/components/ObjectProperties/ObjectProperties.test.tsx +101 -0
- package/src/components/ObjectProperties/ObjectProperties.tsx +148 -0
- package/src/components/ObjectProperties/index.ts +5 -0
- package/src/components/Settings/AUDIT.md +51 -0
- package/src/components/Settings/Settings.stories.tsx +104 -0
- package/src/components/Settings/Settings.tsx +80 -0
- 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 +7 -0
- package/src/components/ViewEditor/ViewEditor.stories.tsx +73 -54
- package/src/components/ViewEditor/ViewEditor.test.tsx +8 -7
- package/src/components/ViewEditor/ViewEditor.tsx +304 -269
- package/src/components/index.ts +3 -1
- package/src/components/testing.tsx +24 -11
- package/src/hooks/index.ts +2 -3
- package/src/hooks/useFormHandler.ts +380 -0
- package/src/hooks/useKeyHandler.tsx +37 -0
- package/src/index.ts +0 -4
- package/src/translations.ts +70 -65
- package/src/util/field-type.ts +60 -0
- package/src/util/field.ts +25 -25
- package/src/util/formatting.ts +22 -22
- package/src/util/index.ts +2 -1
- package/src/util/properties.test.ts +63 -0
- package/src/util/properties.ts +29 -0
- package/src/util/refs.ts +16 -18
- package/dist/types/src/components/ControlSection/ControlSection.d.ts +0 -23
- package/dist/types/src/components/ControlSection/ControlSection.d.ts.map +0 -1
- package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts +0 -84
- package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts.map +0 -1
- package/dist/types/src/components/ControlSection/index.d.ts +0 -2
- package/dist/types/src/components/ControlSection/index.d.ts.map +0 -1
- package/dist/types/src/components/Form/ArrayField.d.ts +0 -16
- package/dist/types/src/components/Form/ArrayField.d.ts.map +0 -1
- package/dist/types/src/components/Form/Defaults.d.ts +0 -14
- package/dist/types/src/components/Form/Defaults.d.ts.map +0 -1
- package/dist/types/src/components/Form/FormActions.d.ts +0 -9
- package/dist/types/src/components/Form/FormActions.d.ts.map +0 -1
- package/dist/types/src/components/Form/FormContent.d.ts +0 -63
- package/dist/types/src/components/Form/FormContent.d.ts.map +0 -1
- package/dist/types/src/components/Form/FormContext.d.ts +0 -23
- package/dist/types/src/components/Form/FormContext.d.ts.map +0 -1
- package/dist/types/src/components/Form/Input.d.ts +0 -29
- package/dist/types/src/components/Form/Input.d.ts.map +0 -1
- package/dist/types/src/components/Form/RefField.d.ts +0 -21
- package/dist/types/src/components/Form/RefField.d.ts.map +0 -1
- package/dist/types/src/components/Form/custom/GeoPointInput.d.ts +0 -4
- package/dist/types/src/components/Form/custom/GeoPointInput.d.ts.map +0 -1
- package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts +0 -4
- package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts.map +0 -1
- package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts +0 -79
- package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +0 -1
- package/dist/types/src/components/Form/custom/TupleInput.d.ts +0 -6
- package/dist/types/src/components/Form/custom/TupleInput.d.ts.map +0 -1
- package/dist/types/src/components/Form/custom/index.d.ts +0 -4
- package/dist/types/src/components/Form/custom/index.d.ts.map +0 -1
- package/dist/types/src/components/Form/factory.d.ts +0 -8
- package/dist/types/src/components/Form/factory.d.ts.map +0 -1
- package/dist/types/src/hooks/useForm.d.ts +0 -67
- package/dist/types/src/hooks/useForm.d.ts.map +0 -1
- package/dist/types/src/hooks/useQueryRefOptions.d.ts +0 -28
- package/dist/types/src/hooks/useQueryRefOptions.d.ts.map +0 -1
- package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts +0 -8
- package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts.map +0 -1
- package/dist/types/src/util/schema.d.ts +0 -3
- package/dist/types/src/util/schema.d.ts.map +0 -1
- package/src/components/ControlSection/ControlSection.stories.tsx +0 -38
- package/src/components/ControlSection/ControlSection.tsx +0 -153
- package/src/components/Form/ArrayField.tsx +0 -125
- package/src/components/Form/Defaults.tsx +0 -205
- package/src/components/Form/FormActions.tsx +0 -54
- package/src/components/Form/FormContent.tsx +0 -322
- package/src/components/Form/FormContext.tsx +0 -110
- package/src/components/Form/Input.tsx +0 -54
- package/src/components/Form/RefField.tsx +0 -269
- package/src/components/Form/custom/GeoPointInput.tsx +0 -80
- package/src/components/Form/custom/index.ts +0 -7
- package/src/components/Form/factory.tsx +0 -31
- package/src/hooks/useForm.ts +0 -295
- package/src/hooks/useQueryRefOptions.ts +0 -59
- package/src/hooks/useRefQueryLookupHandler.ts +0 -39
- package/src/util/schema.ts +0 -12
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2023 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
|
+
import * as Schema from 'effect/Schema';
|
|
7
|
+
import React, { useEffect } from 'react';
|
|
8
|
+
import { expect, userEvent, within } from 'storybook/test';
|
|
9
|
+
|
|
10
|
+
import { Annotation, Filter, Obj, Ref, Tag, Type } from '@dxos/echo';
|
|
11
|
+
import { FormInputAnnotation, LabelAnnotation } from '@dxos/echo/internal';
|
|
12
|
+
import { useQuery } from '@dxos/react-client/echo';
|
|
13
|
+
import { useClientStory, withClientProvider } from '@dxos/react-client/testing';
|
|
14
|
+
import { Panel } from '@dxos/react-ui';
|
|
15
|
+
import { Loading, withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
16
|
+
import { FactoryAnnotation, type FactoryFn } from '@dxos/schema';
|
|
17
|
+
import { Pipeline } from '@dxos/types';
|
|
18
|
+
|
|
19
|
+
import { translations } from '#translations';
|
|
20
|
+
|
|
21
|
+
import { OBJECT_PROPERTIES_DEBUG_SYMBOL } from '../testing';
|
|
22
|
+
import { ObjectProperties } from './ObjectProperties';
|
|
23
|
+
|
|
24
|
+
//
|
|
25
|
+
// Test-only schemas exercising a non-Tag ref-array. `Article.authors` is the
|
|
26
|
+
// case the generic create flow currently fails on: the picker creates an
|
|
27
|
+
// Author in the DB but never wires the new Ref into the array slot.
|
|
28
|
+
//
|
|
29
|
+
|
|
30
|
+
const Author = Schema.Struct({
|
|
31
|
+
name: Schema.String,
|
|
32
|
+
}).pipe(
|
|
33
|
+
Type.object({
|
|
34
|
+
typename: 'org.dxos.test.author',
|
|
35
|
+
version: '0.1.0',
|
|
36
|
+
}),
|
|
37
|
+
LabelAnnotation.set(['name']),
|
|
38
|
+
Annotation.IconAnnotation.set({ icon: 'ph--user--regular', hue: 'blue' }),
|
|
39
|
+
);
|
|
40
|
+
type Author = Schema.Schema.Type<typeof Author>;
|
|
41
|
+
|
|
42
|
+
const Article = Schema.Struct({
|
|
43
|
+
title: Schema.String.pipe(Schema.optional),
|
|
44
|
+
authors: Schema.Array(Ref.Ref(Author)),
|
|
45
|
+
}).pipe(
|
|
46
|
+
Type.object({
|
|
47
|
+
typename: 'org.dxos.test.article',
|
|
48
|
+
version: '0.1.0',
|
|
49
|
+
}),
|
|
50
|
+
LabelAnnotation.set(['title']),
|
|
51
|
+
Annotation.IconAnnotation.set({ icon: 'ph--article--regular', hue: 'green' }),
|
|
52
|
+
);
|
|
53
|
+
type Article = Schema.Schema.Type<typeof Article>;
|
|
54
|
+
|
|
55
|
+
//
|
|
56
|
+
// `Note` mirrors the `Subscription.Feed` shape: a required `Ref` field
|
|
57
|
+
// (`backing`) is hidden from the form via `FormInputAnnotation.set(false)`,
|
|
58
|
+
// so `Obj.make(Note, values)` from the picker would reject because the form
|
|
59
|
+
// values can't satisfy the schema. A `FactoryAnnotation` constructs the
|
|
60
|
+
// backing object and links it at create time. `cursor` is an optional hidden
|
|
61
|
+
// field included to exercise the same path for non-required hidden fields.
|
|
62
|
+
//
|
|
63
|
+
const NoteBacking = Schema.Struct({}).pipe(Type.object({ typename: 'org.dxos.test.note-backing', version: '0.1.0' }));
|
|
64
|
+
type NoteBacking = Schema.Schema.Type<typeof NoteBacking>;
|
|
65
|
+
|
|
66
|
+
const Note = Schema.Struct({
|
|
67
|
+
title: Schema.String,
|
|
68
|
+
cursor: Schema.String.pipe(FormInputAnnotation.set(false), Schema.optional),
|
|
69
|
+
backing: Ref.Ref(NoteBacking).pipe(FormInputAnnotation.set(false)),
|
|
70
|
+
}).pipe(
|
|
71
|
+
Type.object({
|
|
72
|
+
typename: 'org.dxos.test.note',
|
|
73
|
+
version: '0.1.0',
|
|
74
|
+
}),
|
|
75
|
+
LabelAnnotation.set(['title']),
|
|
76
|
+
Annotation.IconAnnotation.set({ icon: 'ph--note--regular', hue: 'amber' }),
|
|
77
|
+
FactoryAnnotation.set(((values: any) =>
|
|
78
|
+
Obj.make(Note, { ...values, backing: Ref.make(Obj.make(NoteBacking, {})) })) as FactoryFn),
|
|
79
|
+
);
|
|
80
|
+
type Note = Schema.Schema.Type<typeof Note>;
|
|
81
|
+
|
|
82
|
+
const Notebook = Schema.Struct({
|
|
83
|
+
name: Schema.String.pipe(Schema.optional),
|
|
84
|
+
notes: Schema.Array(Ref.Ref(Note)),
|
|
85
|
+
}).pipe(
|
|
86
|
+
Type.object({
|
|
87
|
+
typename: 'org.dxos.test.notebook',
|
|
88
|
+
version: '0.1.0',
|
|
89
|
+
}),
|
|
90
|
+
LabelAnnotation.set(['name']),
|
|
91
|
+
Annotation.IconAnnotation.set({ icon: 'ph--notebook--regular', hue: 'amber' }),
|
|
92
|
+
);
|
|
93
|
+
type Notebook = Schema.Schema.Type<typeof Notebook>;
|
|
94
|
+
|
|
95
|
+
//
|
|
96
|
+
// Stories.
|
|
97
|
+
//
|
|
98
|
+
|
|
99
|
+
export type ObjectPropertiesDebug = {
|
|
100
|
+
/** Live database the story is rendering against. */
|
|
101
|
+
db: ReturnType<typeof useClientStory>['space'] extends infer S ? (S extends { db: infer D } ? D : never) : never;
|
|
102
|
+
/** Object whose properties are being edited. */
|
|
103
|
+
object: Obj.Unknown;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const PipelineStory = () => {
|
|
107
|
+
const { space } = useClientStory();
|
|
108
|
+
const [object] = useQuery(space?.db, Filter.type(Pipeline.Pipeline));
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (typeof window !== 'undefined' && space?.db && object) {
|
|
112
|
+
(window as any)[OBJECT_PROPERTIES_DEBUG_SYMBOL] = { db: space.db, object } satisfies ObjectPropertiesDebug;
|
|
113
|
+
}
|
|
114
|
+
}, [space, object]);
|
|
115
|
+
|
|
116
|
+
if (!object) {
|
|
117
|
+
return <Loading />;
|
|
118
|
+
}
|
|
119
|
+
return (
|
|
120
|
+
<Panel.Root>
|
|
121
|
+
<Panel.Content asChild>
|
|
122
|
+
<ObjectProperties object={object} />
|
|
123
|
+
</Panel.Content>
|
|
124
|
+
</Panel.Root>
|
|
125
|
+
);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const ArticleStory = () => {
|
|
129
|
+
const { space } = useClientStory();
|
|
130
|
+
const [object] = useQuery(space?.db, Filter.type(Article));
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (typeof window !== 'undefined' && space?.db && object) {
|
|
134
|
+
(window as any)[OBJECT_PROPERTIES_DEBUG_SYMBOL] = { db: space.db, object } satisfies ObjectPropertiesDebug;
|
|
135
|
+
}
|
|
136
|
+
}, [space, object]);
|
|
137
|
+
|
|
138
|
+
if (!object) {
|
|
139
|
+
return <Loading />;
|
|
140
|
+
}
|
|
141
|
+
return (
|
|
142
|
+
<Panel.Root>
|
|
143
|
+
<Panel.Content asChild>
|
|
144
|
+
<ObjectProperties object={object} />
|
|
145
|
+
</Panel.Content>
|
|
146
|
+
</Panel.Root>
|
|
147
|
+
);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const meta = {
|
|
151
|
+
title: 'ui/react-ui-form/ObjectProperties',
|
|
152
|
+
component: ObjectProperties as any,
|
|
153
|
+
decorators: [withTheme(), withLayout({ layout: 'column' })],
|
|
154
|
+
parameters: {
|
|
155
|
+
layout: 'fullscreen',
|
|
156
|
+
translations,
|
|
157
|
+
},
|
|
158
|
+
} satisfies Meta<typeof PipelineStory>;
|
|
159
|
+
|
|
160
|
+
export default meta;
|
|
161
|
+
|
|
162
|
+
type Story = StoryObj<typeof meta>;
|
|
163
|
+
|
|
164
|
+
const pipelineDecorators = [
|
|
165
|
+
withClientProvider({
|
|
166
|
+
createIdentity: true,
|
|
167
|
+
createSpace: true,
|
|
168
|
+
types: [Pipeline.Pipeline, Tag.Tag],
|
|
169
|
+
onCreateSpace: async ({ space }) => {
|
|
170
|
+
space.db.add(Pipeline.make());
|
|
171
|
+
space.db.add(Tag.make({ label: 'Tag 1' }));
|
|
172
|
+
space.db.add(Tag.make({ label: 'Tag 2' }));
|
|
173
|
+
space.db.add(Tag.make({ label: 'Tag 3' }));
|
|
174
|
+
},
|
|
175
|
+
}),
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
const articleDecorators = [
|
|
179
|
+
withClientProvider({
|
|
180
|
+
createIdentity: true,
|
|
181
|
+
createSpace: true,
|
|
182
|
+
types: [Article, Author, Tag.Tag],
|
|
183
|
+
onCreateSpace: async ({ space }) => {
|
|
184
|
+
space.db.add(Obj.make(Article, { title: 'Untitled article', authors: [] }));
|
|
185
|
+
},
|
|
186
|
+
}),
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
const NotebookStory = () => {
|
|
190
|
+
const { space } = useClientStory();
|
|
191
|
+
const [object] = useQuery(space?.db, Filter.type(Notebook));
|
|
192
|
+
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (typeof window !== 'undefined' && space?.db && object) {
|
|
195
|
+
(window as any)[OBJECT_PROPERTIES_DEBUG_SYMBOL] = { db: space.db, object } satisfies ObjectPropertiesDebug;
|
|
196
|
+
}
|
|
197
|
+
}, [space, object]);
|
|
198
|
+
|
|
199
|
+
if (!object) {
|
|
200
|
+
return <Loading />;
|
|
201
|
+
}
|
|
202
|
+
return (
|
|
203
|
+
<Panel.Root>
|
|
204
|
+
<Panel.Content asChild>
|
|
205
|
+
<ObjectProperties object={object} />
|
|
206
|
+
</Panel.Content>
|
|
207
|
+
</Panel.Root>
|
|
208
|
+
);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const notebookDecorators = [
|
|
212
|
+
withClientProvider({
|
|
213
|
+
createIdentity: true,
|
|
214
|
+
createSpace: true,
|
|
215
|
+
types: [Notebook, Note, NoteBacking, Tag.Tag],
|
|
216
|
+
onCreateSpace: async ({ space }) => {
|
|
217
|
+
space.db.add(Obj.make(Notebook, { name: 'Untitled notebook', notes: [] }));
|
|
218
|
+
},
|
|
219
|
+
}),
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Default Pipeline form — manual exploration. Includes the Tags picker (which
|
|
224
|
+
* is the only ref-array path that currently works end-to-end, by virtue of
|
|
225
|
+
* `Tag.Tag` being special-cased in `ObjectProperties.handleCreate`).
|
|
226
|
+
*/
|
|
227
|
+
export const Default: Story = {
|
|
228
|
+
render: PipelineStory,
|
|
229
|
+
decorators: pipelineDecorators,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Tag-creation play. Walks the inline create flow against the Tags picker and
|
|
234
|
+
* leaves the magazine in the post-Save state for assertions to inspect via
|
|
235
|
+
* `OBJECT_PROPERTIES_DEBUG_SYMBOL`.
|
|
236
|
+
*
|
|
237
|
+
* Expected end-state (currently passes):
|
|
238
|
+
* - new Tag.Tag exists in the DB with `label === "PinnedTag"`
|
|
239
|
+
* - `Obj.getMeta(object).tags` includes its DXN.
|
|
240
|
+
*/
|
|
241
|
+
export const CreateTagPlay: Story = {
|
|
242
|
+
render: PipelineStory,
|
|
243
|
+
decorators: pipelineDecorators,
|
|
244
|
+
play: async ({ canvasElement }) => {
|
|
245
|
+
const canvas = within(canvasElement);
|
|
246
|
+
const body = within(document.body);
|
|
247
|
+
|
|
248
|
+
// Tags is `Schema.Array(Ref.Ref(Tag.Tag))` and starts empty, so it renders
|
|
249
|
+
// as an "Add item" button — same shape as any other ref-array. Click it
|
|
250
|
+
// to materialise an empty slot, then open the slot's picker.
|
|
251
|
+
const addButton = await canvas.findByRole('button', { name: /add/i }, { timeout: 10_000 });
|
|
252
|
+
await userEvent.click(addButton);
|
|
253
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
254
|
+
|
|
255
|
+
const comboboxes = await canvas.findAllByRole('combobox', undefined, { timeout: 10_000 });
|
|
256
|
+
await expect(comboboxes.length).toBeGreaterThan(0);
|
|
257
|
+
await userEvent.click(comboboxes[0]);
|
|
258
|
+
|
|
259
|
+
const search = await body.findByPlaceholderText(/search/i, undefined, { timeout: 5000 });
|
|
260
|
+
await userEvent.type(search, 'PinnedTag');
|
|
261
|
+
|
|
262
|
+
// The Tag picker localises the create option as "Add tag" (no text suffix
|
|
263
|
+
// — the translation has no `{{text}}` placeholder), so match on /add/.
|
|
264
|
+
const createOption = await body.findByRole('option', { name: /add/i }, { timeout: 3000 });
|
|
265
|
+
await userEvent.click(createOption);
|
|
266
|
+
|
|
267
|
+
const form = await body.findByTestId('create-referenced-object-form', undefined, { timeout: 5000 });
|
|
268
|
+
const labelInput = await within(form).findByLabelText(/^label$/i);
|
|
269
|
+
await userEvent.clear(labelInput);
|
|
270
|
+
await userEvent.type(labelInput, 'PinnedTag');
|
|
271
|
+
await userEvent.click(await within(form).findByTestId('save-button'));
|
|
272
|
+
|
|
273
|
+
// Allow async state updates to flush before the test asserts.
|
|
274
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Ref-array creation play. Walks Add → Open → Create → Save against
|
|
280
|
+
* `Article.authors`. After the fix, the new Author should be persisted AND
|
|
281
|
+
* wired into `article.authors[0]`. Today it persists to the DB but the array
|
|
282
|
+
* slot stays empty.
|
|
283
|
+
*/
|
|
284
|
+
export const CreateRefArrayPlay: Story = {
|
|
285
|
+
render: ArticleStory,
|
|
286
|
+
decorators: articleDecorators,
|
|
287
|
+
play: async ({ canvasElement }) => {
|
|
288
|
+
const canvas = within(canvasElement);
|
|
289
|
+
const body = within(document.body);
|
|
290
|
+
|
|
291
|
+
// ObjectProperties extends Article with the BaseSchema's `tags` array, so
|
|
292
|
+
// the form has two "Add item" buttons (Tags + Authors). Pick the last one
|
|
293
|
+
// — Authors comes after Tags in the rendered order.
|
|
294
|
+
const addButtons = await canvas.findAllByRole('button', { name: /add/i }, { timeout: 10_000 });
|
|
295
|
+
await userEvent.click(addButtons[addButtons.length - 1]);
|
|
296
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
297
|
+
|
|
298
|
+
// Open the empty slot's combobox.
|
|
299
|
+
const comboboxes = await canvas.findAllByRole('combobox', undefined, { timeout: 10_000 });
|
|
300
|
+
await expect(comboboxes.length).toBeGreaterThan(0);
|
|
301
|
+
await userEvent.click(comboboxes[0]);
|
|
302
|
+
|
|
303
|
+
const search = await body.findByPlaceholderText(/search/i, undefined, { timeout: 5000 });
|
|
304
|
+
await userEvent.type(search, 'Ada Lovelace');
|
|
305
|
+
|
|
306
|
+
// Author has no `createOptionLabel` → option falls back to "Create".
|
|
307
|
+
const createOption = await body.findByRole('option', { name: /create/i }, { timeout: 3000 });
|
|
308
|
+
await userEvent.click(createOption);
|
|
309
|
+
|
|
310
|
+
const form = await body.findByTestId('create-referenced-object-form', undefined, { timeout: 5000 });
|
|
311
|
+
const nameInput = await within(form).findByLabelText(/^name$/i);
|
|
312
|
+
await userEvent.clear(nameInput);
|
|
313
|
+
await userEvent.type(nameInput, 'Ada Lovelace');
|
|
314
|
+
await userEvent.click(await within(form).findByTestId('save-button'));
|
|
315
|
+
|
|
316
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Ref-array creation against a schema with a hidden required field. `Note`
|
|
322
|
+
* mirrors the shape of `Subscription.Feed`: the form omits `backing` (a
|
|
323
|
+
* required `Ref` annotated `FormInputAnnotation.set(false)`) but the schema
|
|
324
|
+
* requires it. Without `omitHiddenFormFields` + `FactoryAnnotation`, the
|
|
325
|
+
* form's validator would reject Save and the popover would never close.
|
|
326
|
+
*
|
|
327
|
+
* Expected end-state after the fix:
|
|
328
|
+
* - new Note exists in DB with a synthesised `backing` ref
|
|
329
|
+
* - `notebook.notes[0]` references the new Note
|
|
330
|
+
* - the create form is gone
|
|
331
|
+
*/
|
|
332
|
+
export const CreateHiddenFieldPlay: Story = {
|
|
333
|
+
render: NotebookStory,
|
|
334
|
+
decorators: notebookDecorators,
|
|
335
|
+
play: async ({ canvasElement }) => {
|
|
336
|
+
const canvas = within(canvasElement);
|
|
337
|
+
const body = within(document.body);
|
|
338
|
+
|
|
339
|
+
// Two "Add item" buttons (Tags + Notes) — Notes comes second.
|
|
340
|
+
const addButtons = await canvas.findAllByRole('button', { name: /add/i }, { timeout: 10_000 });
|
|
341
|
+
await userEvent.click(addButtons[addButtons.length - 1]);
|
|
342
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
343
|
+
|
|
344
|
+
const comboboxes = await canvas.findAllByRole('combobox', undefined, { timeout: 10_000 });
|
|
345
|
+
await expect(comboboxes.length).toBeGreaterThan(0);
|
|
346
|
+
await userEvent.click(comboboxes[0]);
|
|
347
|
+
|
|
348
|
+
const search = await body.findByPlaceholderText(/search/i, undefined, { timeout: 5000 });
|
|
349
|
+
await userEvent.type(search, 'Ideas');
|
|
350
|
+
|
|
351
|
+
const createOption = await body.findByRole('option', { name: /create/i }, { timeout: 3000 });
|
|
352
|
+
await userEvent.click(createOption);
|
|
353
|
+
|
|
354
|
+
const form = await body.findByTestId('create-referenced-object-form', undefined, { timeout: 5000 });
|
|
355
|
+
const titleInput = await within(form).findByLabelText(/^title$/i);
|
|
356
|
+
await userEvent.clear(titleInput);
|
|
357
|
+
await userEvent.type(titleInput, 'Ideas');
|
|
358
|
+
await userEvent.click(await within(form).findByTestId('save-button'));
|
|
359
|
+
|
|
360
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
361
|
+
},
|
|
362
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { composeStories } from '@storybook/react';
|
|
6
|
+
import { cleanup } from '@testing-library/react';
|
|
7
|
+
import { afterEach, describe, expect, test } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import { Filter, Obj, Tag } from '@dxos/echo';
|
|
10
|
+
|
|
11
|
+
import { OBJECT_PROPERTIES_DEBUG_SYMBOL } from '../testing';
|
|
12
|
+
import * as stories from './ObjectProperties.stories';
|
|
13
|
+
import { type ObjectPropertiesDebug } from './ObjectProperties.stories';
|
|
14
|
+
|
|
15
|
+
const { CreateTagPlay, CreateRefArrayPlay, CreateHiddenFieldPlay } = composeStories(stories);
|
|
16
|
+
|
|
17
|
+
const getDebug = (): ObjectPropertiesDebug => {
|
|
18
|
+
const debug = (window as any)[OBJECT_PROPERTIES_DEBUG_SYMBOL] as ObjectPropertiesDebug | undefined;
|
|
19
|
+
expect(debug).toBeDefined();
|
|
20
|
+
return debug!;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe('ObjectProperties — inline create flow', () => {
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
cleanup();
|
|
26
|
+
delete (window as any)[OBJECT_PROPERTIES_DEBUG_SYMBOL];
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('Tag.Tag: Create + Save persists tag and assigns it to the object', { timeout: 30_000 }, async () => {
|
|
30
|
+
await CreateTagPlay.run();
|
|
31
|
+
const { db, object } = getDebug();
|
|
32
|
+
|
|
33
|
+
// The new Tag should exist in the DB.
|
|
34
|
+
const tags = (await db.query(Filter.type(Tag.Tag)).run()) as Tag.Tag[];
|
|
35
|
+
const created = tags.find((tag) => tag.label === 'PinnedTag');
|
|
36
|
+
expect(created, 'new Tag.Tag with label "PinnedTag" should be in the database').toBeDefined();
|
|
37
|
+
|
|
38
|
+
// And `object.meta.tags` should contain a DXN that resolves to that tag.
|
|
39
|
+
// Compare by the DXN's object-id tail since refs may be stored as relative
|
|
40
|
+
// (`dxn:echo:@:<id>`) or absolute (`dxn:echo:<spaceId>:<id>`) forms.
|
|
41
|
+
const meta = Obj.getMeta(object);
|
|
42
|
+
const createdId = (created as any).id as string;
|
|
43
|
+
const matched = (meta.tags ?? []).some((dxn) => dxn.endsWith(`:${createdId}`));
|
|
44
|
+
expect(matched, "meta.tags should contain a DXN ending with the new tag's id").toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('non-Tag ref-array: Create + Save assigns the new ref to the array slot', { timeout: 30_000 }, async () => {
|
|
48
|
+
await CreateRefArrayPlay.run();
|
|
49
|
+
const { db, object } = getDebug();
|
|
50
|
+
|
|
51
|
+
// The new Author should exist in the DB.
|
|
52
|
+
const authors = (await db.query(Filter.typename('org.dxos.test.author' as any)).run()) as any[];
|
|
53
|
+
const created = authors.find((author: any) => author.name === 'Ada Lovelace');
|
|
54
|
+
expect(created, 'new Author with name "Ada Lovelace" should be in the database').toBeDefined();
|
|
55
|
+
|
|
56
|
+
// And the article's authors array should contain a Ref to it. This is the
|
|
57
|
+
// assertion that fails before the fix — handleCreate persists the object
|
|
58
|
+
// but never wires the new ref into the array slot.
|
|
59
|
+
const article = object as any;
|
|
60
|
+
expect(article.authors ?? [], 'article.authors should contain a single Ref').toHaveLength(1);
|
|
61
|
+
// Compare by DXN tail to be agnostic to relative vs absolute Ref form.
|
|
62
|
+
const createdId = (created as any).id as string;
|
|
63
|
+
expect(
|
|
64
|
+
article.authors[0]?.dxn?.toString().endsWith(`:${createdId}`),
|
|
65
|
+
'article.authors[0] should reference the newly-created Author',
|
|
66
|
+
).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test(
|
|
70
|
+
'hidden required field via FactoryAnnotation: Create + Save closes form and assigns ref',
|
|
71
|
+
{ timeout: 30_000 },
|
|
72
|
+
async () => {
|
|
73
|
+
await CreateHiddenFieldPlay.run();
|
|
74
|
+
const { db, object } = getDebug();
|
|
75
|
+
|
|
76
|
+
// The new Note should exist in the DB with the synthesised backing ref.
|
|
77
|
+
const notes = (await db.query(Filter.typename('org.dxos.test.note' as any)).run()) as any[];
|
|
78
|
+
const created = notes.find((note: any) => note.title === 'Ideas');
|
|
79
|
+
expect(created, 'new Note with title "Ideas" should be in the database').toBeDefined();
|
|
80
|
+
expect(
|
|
81
|
+
(created as any).backing,
|
|
82
|
+
'FactoryAnnotation should have populated the hidden `backing` ref',
|
|
83
|
+
).toBeDefined();
|
|
84
|
+
|
|
85
|
+
// The notebook's notes array should reference the new Note.
|
|
86
|
+
const notebook = object as any;
|
|
87
|
+
expect(notebook.notes ?? [], 'notebook.notes should contain a single Ref').toHaveLength(1);
|
|
88
|
+
const createdId = (created as any).id as string;
|
|
89
|
+
expect(
|
|
90
|
+
notebook.notes[0]?.dxn?.toString().endsWith(`:${createdId}`),
|
|
91
|
+
'notebook.notes[0] should reference the newly-created Note',
|
|
92
|
+
).toBe(true);
|
|
93
|
+
|
|
94
|
+
// The picker's create form should be gone.
|
|
95
|
+
expect(
|
|
96
|
+
document.querySelector('[data-testid="create-referenced-object-form"]'),
|
|
97
|
+
'create form should be dismissed after a successful save',
|
|
98
|
+
).toBeNull();
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Function from 'effect/Function';
|
|
6
|
+
import * as Option from 'effect/Option';
|
|
7
|
+
import * as Schema from 'effect/Schema';
|
|
8
|
+
import React, { type PropsWithChildren, useCallback, useMemo } from 'react';
|
|
9
|
+
|
|
10
|
+
import { DXN, Obj, Ref, Tag, Type } from '@dxos/echo';
|
|
11
|
+
import { useSchema } from '@dxos/echo-react';
|
|
12
|
+
import { type JsonPath, splitJsonPath } from '@dxos/echo/internal';
|
|
13
|
+
import { invariant } from '@dxos/invariant';
|
|
14
|
+
import { HuePicker } from '@dxos/react-ui-pickers';
|
|
15
|
+
import { FactoryAnnotation } from '@dxos/schema';
|
|
16
|
+
import { composable, composableProps } from '@dxos/ui-theme';
|
|
17
|
+
import { isNonNullable } from '@dxos/util';
|
|
18
|
+
|
|
19
|
+
import { translationKey } from '#translations';
|
|
20
|
+
|
|
21
|
+
import { Form, type FormFieldMap, omitId } from '../Form';
|
|
22
|
+
|
|
23
|
+
export type ObjectPropertiesProps = PropsWithChildren<{ object: Obj.Unknown }>;
|
|
24
|
+
|
|
25
|
+
// TODO(wittjosiah): Reconcile w/ ObjectForm.
|
|
26
|
+
export const ObjectProperties = composable<HTMLDivElement, ObjectPropertiesProps>(
|
|
27
|
+
({ children, object, ...props }, forwardedRef) => {
|
|
28
|
+
const db = Obj.getDatabase(object);
|
|
29
|
+
const meta = Obj.getMeta(object);
|
|
30
|
+
const tags = (meta.tags ?? []).map((tag) => db?.makeRef(DXN.parse(tag))).filter(isNonNullable);
|
|
31
|
+
const values = useMemo(() => ({ tags, ...object }), [object, tags]);
|
|
32
|
+
|
|
33
|
+
// Obj.getSchema fails for database-registered (dynamic) schemas due to DXN mismatch;
|
|
34
|
+
// useSchema queries by typename which correctly resolves both static and dynamic schemas.
|
|
35
|
+
const typename = Obj.getTypename(object) ?? undefined;
|
|
36
|
+
const schemaFromRegistry = useSchema(db, typename);
|
|
37
|
+
const rawSchema = Obj.getSchema(object) ?? schemaFromRegistry;
|
|
38
|
+
|
|
39
|
+
const formSchema = useMemo(() => {
|
|
40
|
+
return Function.pipe(
|
|
41
|
+
rawSchema,
|
|
42
|
+
Option.fromNullable,
|
|
43
|
+
Option.map((schema) => omitId(BaseSchema.pipe(Schema.extend(schema)))),
|
|
44
|
+
Option.getOrUndefined,
|
|
45
|
+
);
|
|
46
|
+
}, [rawSchema]);
|
|
47
|
+
|
|
48
|
+
// Persist a newly-created object referenced by one of the form's ref
|
|
49
|
+
// fields and return it. The calling RefField wires the new Ref into its
|
|
50
|
+
// own slot's form value; for the BaseSchema `tags` array, the resulting
|
|
51
|
+
// form change is then synced back to `Obj.getMeta(object).tags` by
|
|
52
|
+
// `handleChange` below — so Tag.Tag follows the same generic path as any
|
|
53
|
+
// other ref-array field, no type-specific branching required here.
|
|
54
|
+
//
|
|
55
|
+
// Schemas whose required structure can't be produced by `Obj.make(schema,
|
|
56
|
+
// values)` alone (e.g. types with a required ref to a backing object) can
|
|
57
|
+
// declare a `FactoryAnnotation` to take over construction.
|
|
58
|
+
const handleCreate = useCallback(
|
|
59
|
+
(schema: Type.AnyEntity, values: any): Obj.Unknown => {
|
|
60
|
+
invariant(db);
|
|
61
|
+
invariant(Type.isObjectSchema(schema));
|
|
62
|
+
const factory = Option.getOrUndefined(FactoryAnnotation.get(schema));
|
|
63
|
+
const newObject = factory ? (factory(values) as Obj.Unknown) : Obj.make(schema, values);
|
|
64
|
+
return db.add(newObject) as Obj.Unknown;
|
|
65
|
+
},
|
|
66
|
+
[db],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// TODO(wittjosiah): Use FormRootProps type.
|
|
70
|
+
const handleChange = useCallback(
|
|
71
|
+
(
|
|
72
|
+
{ tags, ...values }: Schema.Schema.Type<typeof formSchema>,
|
|
73
|
+
{ isValid, changed }: { isValid: boolean; changed: Record<JsonPath, boolean> },
|
|
74
|
+
) => {
|
|
75
|
+
if (!isValid) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const changedPaths = Object.keys(changed).filter((path) => changed[path as JsonPath]) as JsonPath[];
|
|
80
|
+
|
|
81
|
+
// Handle tags separately using Obj.update.
|
|
82
|
+
const hasTagsChange = changedPaths.some((path) => splitJsonPath(path)[0] === 'tags');
|
|
83
|
+
if (hasTagsChange) {
|
|
84
|
+
Obj.update(object, (object) => {
|
|
85
|
+
Obj.getMeta(object).tags = tags?.map((tag: Ref.Ref<Tag.Tag>) => tag.dxn.toString()) ?? [];
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Handle other property changes.
|
|
90
|
+
const nonTagPaths = changedPaths.filter((path) => splitJsonPath(path)[0] !== 'tags');
|
|
91
|
+
if (nonTagPaths.length > 0) {
|
|
92
|
+
Obj.update(object, () => {
|
|
93
|
+
for (const path of nonTagPaths) {
|
|
94
|
+
const parts = splitJsonPath(path);
|
|
95
|
+
const value = Obj.getValue(values as any, parts);
|
|
96
|
+
Obj.setValue(object, parts, value);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
[object],
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
if (!formSchema) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<Form.Root
|
|
110
|
+
schema={formSchema}
|
|
111
|
+
defaultValues={values as any}
|
|
112
|
+
createTypename={Type.getTypename(Tag.Tag)}
|
|
113
|
+
createOptionIcon='ph--plus--regular'
|
|
114
|
+
createOptionLabel={['add-tag.label', { ns: translationKey }]}
|
|
115
|
+
createInitialValuePath='label'
|
|
116
|
+
createFieldMap={createFieldMap}
|
|
117
|
+
db={db}
|
|
118
|
+
onValuesChanged={handleChange}
|
|
119
|
+
onCreate={handleCreate}
|
|
120
|
+
>
|
|
121
|
+
<Form.Viewport {...composableProps(props)} ref={forwardedRef}>
|
|
122
|
+
<Form.Content>
|
|
123
|
+
<Form.FieldSet />
|
|
124
|
+
{children}
|
|
125
|
+
</Form.Content>
|
|
126
|
+
</Form.Viewport>
|
|
127
|
+
</Form.Root>
|
|
128
|
+
);
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const createFieldMap: FormFieldMap = {
|
|
133
|
+
hue: ({ type, label, layout, getValue, onValueChange }) => {
|
|
134
|
+
const handleChange = useCallback((nextHue: string) => onValueChange(type, nextHue), [onValueChange, type]);
|
|
135
|
+
const handleReset = useCallback(() => onValueChange(type, undefined), [onValueChange, type]);
|
|
136
|
+
return (
|
|
137
|
+
<>
|
|
138
|
+
{layout !== 'inline' && <Form.Label label={label} />}
|
|
139
|
+
<HuePicker value={getValue()} onChange={handleChange} onReset={handleReset} />
|
|
140
|
+
</>
|
|
141
|
+
);
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// TODO(wittjosiah): Would be nice to control order when extending so this isn't always first/last.
|
|
146
|
+
const BaseSchema = Schema.Struct({
|
|
147
|
+
tags: Schema.Array(Ref.Ref(Tag.Tag)).pipe(Schema.optional),
|
|
148
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Plugin Settings Audit
|
|
2
|
+
|
|
3
|
+
Tracks which plugin settings panels use schema-driven `Settings.FieldSet` and which still hand-roll their controls.
|
|
4
|
+
|
|
5
|
+
The target shape for a plugin settings panel is:
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
<SettingsForm.Viewport>
|
|
9
|
+
<SettingsForm.Section title={t('settings.title', { ns: meta.id })}>
|
|
10
|
+
<SettingsForm.FieldSet
|
|
11
|
+
readonly={!onSettingsChange}
|
|
12
|
+
schema={Settings.Settings}
|
|
13
|
+
values={settings}
|
|
14
|
+
onValuesChanged={(values) => onSettingsChange?.(() => values)}
|
|
15
|
+
/>
|
|
16
|
+
</SettingsForm.Section>
|
|
17
|
+
</SettingsForm.Viewport>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Field labels and descriptions come from `.annotations({ title, description })` on each schema field. Use `fieldMap` for runtime-driven option lists (model pickers, etc.). Use the `visible` callback to gate fields on environment or sibling values.
|
|
21
|
+
|
|
22
|
+
## FieldSet adopters
|
|
23
|
+
|
|
24
|
+
| Plugin | Notes |
|
|
25
|
+
| ---------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
26
|
+
| `plugin-assistant` | Nested `modelDefaults` struct; `fieldMap` provides per-provider model selectors via `createSelectField`. |
|
|
27
|
+
| `plugin-crx` | Two booleans. |
|
|
28
|
+
| `plugin-deck` | Five booleans; `enableNativeRedirect` hidden via `visible` when `isSocket`. |
|
|
29
|
+
| `plugin-inbox` | Single boolean. |
|
|
30
|
+
| `plugin-markdown` | Original reference adopter. `snippets` text field gated via `visible`/`fieldMap`. |
|
|
31
|
+
| `plugin-meeting` | Single boolean. |
|
|
32
|
+
| `plugin-observability` | Boolean; info `Message.Root` banner kept above `FieldSet`. |
|
|
33
|
+
| `plugin-presenter` | Single boolean. |
|
|
34
|
+
| `plugin-sample` | Single boolean. |
|
|
35
|
+
| `plugin-script` | Mixed: auth `Button` retained as a manual `SettingsForm.Item`; `editorInputMode` rendered by `FieldSet`. |
|
|
36
|
+
| `plugin-sketch` | Boolean + `gridType` literal union (rendered as a Select — was previously a Switch). |
|
|
37
|
+
|
|
38
|
+
## Skipped
|
|
39
|
+
|
|
40
|
+
| Plugin | Reason |
|
|
41
|
+
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
42
|
+
| `plugin-debug` | Action buttons (download diagnostics, download logs, repair), `Toast`, and a storage-adapter selector wired to `client.config` rather than the plugin settings atom. |
|
|
43
|
+
| `plugin-space` | Renders a list of spaces with per-space "open settings" buttons — not bound to any schema field. |
|
|
44
|
+
| `plugin-spacetime` | Section is currently empty; nothing to render. |
|
|
45
|
+
| `plugin-thread` | Settings schema is `Schema.Struct({})` — nothing to render. |
|
|
46
|
+
|
|
47
|
+
## When to extend `SettingsFieldSet` vs. add a custom field
|
|
48
|
+
|
|
49
|
+
- **Extend `SettingsFieldSet`** when the rendering rule applies generally to a schema construct (e.g. nested structs recursing into sibling fields, literal unions becoming Selects). Keep auto-detection in `detectFieldType`.
|
|
50
|
+
- **Use `fieldMap`** when the schema can't fully describe the rendering — runtime-driven option lists, custom inputs, or override of the auto-rendered control. Use the typed form `SettingsFieldMap<T>` (or `satisfies SettingsFieldMap<Settings>`) so the entries are checked against the values shape.
|
|
51
|
+
- **Drop back to manual `SettingsForm.Item`** when the panel needs UI that isn't bound to a settings field at all (action buttons, info banners, lists of external entities). These coexist as siblings of `FieldSet` inside the same `Section`.
|