@dxos/react-ui-form 0.8.4-main.3a94e84 → 0.8.4-main.406dc2a

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.
Files changed (118) hide show
  1. package/dist/lib/browser/index.mjs +816 -605
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node-esm/index.mjs +816 -605
  5. package/dist/lib/node-esm/index.mjs.map +4 -4
  6. package/dist/lib/node-esm/meta.json +1 -1
  7. package/dist/types/src/components/ControlSection/ControlSection.d.ts +2 -2
  8. package/dist/types/src/components/ControlSection/ControlSection.d.ts.map +1 -1
  9. package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts +81 -5
  10. package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts.map +1 -1
  11. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +2 -2
  12. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  13. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +76 -5
  14. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
  15. package/dist/types/src/components/Form/ArrayField.d.ts +2 -2
  16. package/dist/types/src/components/Form/ArrayField.d.ts.map +1 -1
  17. package/dist/types/src/components/Form/Defaults.d.ts +5 -5
  18. package/dist/types/src/components/Form/Defaults.d.ts.map +1 -1
  19. package/dist/types/src/components/Form/Form.d.ts +7 -6
  20. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  21. package/dist/types/src/components/Form/Form.stories.d.ts +129 -32
  22. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  23. package/dist/types/src/components/Form/FormActions.d.ts.map +1 -1
  24. package/dist/types/src/components/Form/FormContent.d.ts +17 -11
  25. package/dist/types/src/components/Form/FormContent.d.ts.map +1 -1
  26. package/dist/types/src/components/Form/FormContext.d.ts +3 -3
  27. package/dist/types/src/components/Form/FormContext.d.ts.map +1 -1
  28. package/dist/types/src/components/Form/Input.d.ts +5 -2
  29. package/dist/types/src/components/Form/Input.d.ts.map +1 -1
  30. package/dist/types/src/components/Form/RefField.d.ts +15 -5
  31. package/dist/types/src/components/Form/RefField.d.ts.map +1 -1
  32. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts +1 -1
  33. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts.map +1 -1
  34. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts +1 -1
  35. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts.map +1 -1
  36. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts +76 -4
  37. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +1 -1
  38. package/dist/types/src/components/Form/custom/TupleInput.d.ts +1 -1
  39. package/dist/types/src/components/Form/factory.d.ts +1 -1
  40. package/dist/types/src/components/Form/factory.d.ts.map +1 -1
  41. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +41 -0
  42. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -0
  43. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +82 -0
  44. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -0
  45. package/dist/types/src/components/ObjectPicker/index.d.ts +2 -0
  46. package/dist/types/src/components/ObjectPicker/index.d.ts.map +1 -0
  47. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +19 -5
  48. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  49. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +79 -7
  50. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  51. package/dist/types/src/components/index.d.ts +1 -1
  52. package/dist/types/src/components/index.d.ts.map +1 -1
  53. package/dist/types/src/components/testing.d.ts +6 -3
  54. package/dist/types/src/components/testing.d.ts.map +1 -1
  55. package/dist/types/src/hooks/useForm.d.ts +3 -3
  56. package/dist/types/src/hooks/useForm.d.ts.map +1 -1
  57. package/dist/types/src/hooks/useQueryRefOptions.d.ts +9 -3
  58. package/dist/types/src/hooks/useQueryRefOptions.d.ts.map +1 -1
  59. package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts.map +1 -1
  60. package/dist/types/src/translations.d.ts +11 -5
  61. package/dist/types/src/translations.d.ts.map +1 -1
  62. package/dist/types/src/util/field.d.ts +1 -1
  63. package/dist/types/src/util/field.d.ts.map +1 -1
  64. package/dist/types/src/util/formatting.d.ts +1 -1
  65. package/dist/types/src/util/formatting.d.ts.map +1 -1
  66. package/dist/types/src/util/refs.d.ts +1 -1
  67. package/dist/types/src/util/refs.d.ts.map +1 -1
  68. package/dist/types/src/util/schema.d.ts +1 -1
  69. package/dist/types/src/util/schema.d.ts.map +1 -1
  70. package/dist/types/tsconfig.tsbuildinfo +1 -1
  71. package/package.json +41 -36
  72. package/src/components/ControlSection/ControlSection.stories.tsx +11 -14
  73. package/src/components/ControlSection/ControlSection.tsx +26 -12
  74. package/src/components/FieldEditor/FieldEditor.stories.tsx +14 -14
  75. package/src/components/FieldEditor/FieldEditor.test.tsx +3 -2
  76. package/src/components/FieldEditor/FieldEditor.tsx +26 -18
  77. package/src/components/Form/ArrayField.tsx +12 -11
  78. package/src/components/Form/Defaults.tsx +22 -20
  79. package/src/components/Form/Form.stories.tsx +135 -99
  80. package/src/components/Form/Form.tsx +27 -15
  81. package/src/components/Form/FormActions.tsx +2 -1
  82. package/src/components/Form/FormContent.tsx +121 -39
  83. package/src/components/Form/FormContext.tsx +8 -8
  84. package/src/components/Form/Input.tsx +5 -2
  85. package/src/components/Form/RefField.tsx +111 -41
  86. package/src/components/Form/custom/GeoPointInput.tsx +29 -21
  87. package/src/components/Form/custom/SelectOptionsInput.stories.tsx +11 -11
  88. package/src/components/Form/custom/SelectOptionsInput.tsx +30 -31
  89. package/src/components/Form/custom/TupleInput.tsx +2 -2
  90. package/src/components/Form/factory.tsx +2 -2
  91. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +207 -0
  92. package/src/components/ObjectPicker/ObjectPicker.tsx +161 -0
  93. package/src/components/ObjectPicker/index.ts +5 -0
  94. package/src/components/ViewEditor/ViewEditor.stories.tsx +121 -47
  95. package/src/components/ViewEditor/ViewEditor.test.tsx +11 -13
  96. package/src/components/ViewEditor/ViewEditor.tsx +278 -218
  97. package/src/components/index.ts +1 -2
  98. package/src/components/testing.tsx +15 -9
  99. package/src/hooks/useForm.ts +11 -6
  100. package/src/hooks/useQueryRefOptions.ts +15 -6
  101. package/src/hooks/useRefQueryLookupHandler.ts +3 -3
  102. package/src/translations.ts +13 -6
  103. package/src/util/field.ts +9 -19
  104. package/src/util/formatting.ts +1 -1
  105. package/src/util/refs.ts +2 -2
  106. package/src/util/schema.ts +2 -4
  107. package/dist/types/src/components/deprecated/Form.d.ts +0 -15
  108. package/dist/types/src/components/deprecated/Form.d.ts.map +0 -1
  109. package/dist/types/src/components/deprecated/Form.stories.d.ts +0 -13
  110. package/dist/types/src/components/deprecated/Form.stories.d.ts.map +0 -1
  111. package/dist/types/src/components/deprecated/FormInput.d.ts +0 -16
  112. package/dist/types/src/components/deprecated/FormInput.d.ts.map +0 -1
  113. package/dist/types/src/components/deprecated/index.d.ts +0 -2
  114. package/dist/types/src/components/deprecated/index.d.ts.map +0 -1
  115. package/src/components/deprecated/Form.stories.tsx +0 -62
  116. package/src/components/deprecated/Form.tsx +0 -119
  117. package/src/components/deprecated/FormInput.tsx +0 -71
  118. package/src/components/deprecated/index.ts +0 -5
@@ -2,269 +2,329 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { Schema } from 'effect';
6
- import React, { useCallback, useMemo, useState } from 'react';
5
+ import * as Array from 'effect/Array';
6
+ import * as Match from 'effect/Match';
7
+ import * as Option from 'effect/Option';
8
+ import * as Schema from 'effect/Schema';
9
+ import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useState } from 'react';
7
10
 
8
- import { type SchemaRegistry } from '@dxos/echo-db';
9
- import { EchoSchema, Format, type JsonProp, isMutable, toJsonSchema } from '@dxos/echo-schema';
11
+ import { QueryAST } from '@dxos/echo';
12
+ import { EchoSchema, Format, type JsonProp, isMutable, toJsonSchema } from '@dxos/echo/internal';
13
+ import { Filter, Query, type SchemaRegistry } from '@dxos/echo-db';
10
14
  import { invariant } from '@dxos/invariant';
11
- import { Callout, IconButton, type ThemedClassName, useTranslation } from '@dxos/react-ui';
15
+ import { Callout, IconButton, Input, type ThemedClassName, useTranslation } from '@dxos/react-ui';
16
+ import { QueryForm, type QueryFormProps } from '@dxos/react-ui-components';
12
17
  import { List } from '@dxos/react-ui-list';
13
18
  import { cardSpacing } from '@dxos/react-ui-stack';
14
- import { ghostHover, inputTextLabel, mx } from '@dxos/react-ui-theme';
15
- import { FieldSchema, type FieldType, VIEW_FIELD_LIMIT, type DataType, ProjectionModel } from '@dxos/schema';
19
+ import { inputTextLabel, mx, subtleHover } from '@dxos/react-ui-theme';
20
+ import {
21
+ type DataType,
22
+ FieldSchema,
23
+ type FieldType,
24
+ ProjectionModel,
25
+ VIEW_FIELD_LIMIT,
26
+ getTypenameFromQuery,
27
+ } from '@dxos/schema';
16
28
 
17
29
  import { translationKey } from '../../translations';
18
30
  import { FieldEditor } from '../FieldEditor';
19
- import { Form, type FormProps } from '../Form';
31
+ import { Form, type FormProps, type InputComponent, InputHeader, type InputProps } from '../Form';
20
32
 
21
- const grid = 'grid grid-cols-[32px_1fr_32px_32px] min-bs-[2.5rem]';
22
-
23
- const ViewMetaSchema = Schema.Struct({
24
- typename: Format.URL.annotations({
25
- title: 'Typename',
26
- }),
27
- }).pipe(Schema.mutable);
28
-
29
- type ViewMetaType = Schema.Schema.Type<typeof ViewMetaSchema>;
33
+ const listGrid = 'grid grid-cols-[min-content_1fr_min-content_min-content_min-content]';
34
+ const listItemGrid = 'grid grid-cols-subgrid col-span-5';
30
35
 
31
36
  export type ViewEditorProps = ThemedClassName<
32
37
  {
33
38
  schema: Schema.Schema.AnyNoContext;
34
39
  view: DataType.View;
40
+ mode?: 'schema' | 'query';
35
41
  registry?: SchemaRegistry;
36
42
  readonly?: boolean;
37
43
  showHeading?: boolean;
38
- onTypenameChanged?: (typename: string) => void;
44
+ onQueryChanged?: (query: QueryAST.Query, target?: string) => void;
39
45
  onDelete?: (fieldId: string) => void;
40
- } & Pick<FormProps<any>, 'outerSpacing'>
46
+ } & Pick<FormProps<any>, 'outerSpacing'> &
47
+ Pick<QueryFormProps, 'types' | 'tags'>
41
48
  >;
42
49
 
43
50
  /**
44
51
  * Schema-based object form.
45
52
  */
46
- export const ViewEditor = ({
47
- classNames,
48
- schema,
49
- view,
50
- registry,
51
- readonly: _readonly,
52
- showHeading = false,
53
- onTypenameChanged,
54
- onDelete,
55
- outerSpacing = true,
56
- }: ViewEditorProps) => {
57
- const { t } = useTranslation(translationKey);
58
- const projection = useMemo(() => {
59
- // Use reactive and mutable version of json schema when schema is mutable.
60
- const jsonSchema = schema instanceof EchoSchema ? schema.jsonSchema : toJsonSchema(schema);
61
- return new ProjectionModel(jsonSchema, view.projection);
62
- }, [schema, view.projection]);
63
- const [field, setField] = useState<FieldType>();
64
- const readonly = _readonly || !isMutable(schema);
53
+ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
54
+ (
55
+ {
56
+ classNames,
57
+ schema,
58
+ view,
59
+ mode = 'schema',
60
+ registry,
61
+ readonly,
62
+ showHeading = false,
63
+ types,
64
+ tags,
65
+ onQueryChanged,
66
+ onDelete,
67
+ outerSpacing = true,
68
+ },
69
+ forwardedRef,
70
+ ) => {
71
+ const schemaReadonly = !isMutable(schema);
72
+ const { t } = useTranslation(translationKey);
73
+ const projectionModel = useMemo(() => {
74
+ // Use reactive and mutable version of json schema when schema is mutable.
75
+ const jsonSchema = schema instanceof EchoSchema ? schema.jsonSchema : toJsonSchema(schema);
76
+ return new ProjectionModel(jsonSchema, view.projection);
77
+ }, [schema, JSON.stringify(view.projection)]);
78
+ useImperativeHandle(forwardedRef, () => projectionModel, [projectionModel]);
79
+ const [expandedField, setExpandedField] = useState<FieldType['id']>();
80
+
81
+ const queueTarget = Match.value(view.query.ast).pipe(
82
+ Match.when({ type: 'options' }, ({ options }) => {
83
+ return Option.fromNullable(options.queues).pipe(
84
+ Option.flatMap((queues) => Array.head(queues)),
85
+ Option.getOrUndefined,
86
+ );
87
+ }),
88
+ Match.orElse(() => undefined),
89
+ );
65
90
 
66
- // TODO(burdon): Should be reactive.
67
- const viewValues = useMemo(() => {
68
- return {
69
- // TODO(burdon): Need to warn user of possible consequences of editing.
70
- // TODO(burdon): Settings should have domain name owned by user.
71
- typename: view.query.typename,
72
- };
73
- }, [view.query.typename]);
91
+ const viewSchema = useMemo(() => {
92
+ const base = Schema.Struct({
93
+ query:
94
+ mode === 'schema'
95
+ ? Format.URL.annotations({ title: 'Record type' })
96
+ : QueryAST.Query.annotations({ title: 'Query' }),
97
+ });
74
98
 
75
- const handleSelect = useCallback(
76
- (field: FieldType) => {
77
- if (readonly) {
78
- return;
99
+ if (mode === 'query') {
100
+ return Schema.Struct({
101
+ name: Schema.optional(Schema.String.annotations({ title: 'Name' })),
102
+ ...base.fields,
103
+ target: Schema.optional(Schema.String.annotations({ title: 'Target Queue' })),
104
+ }).pipe(Schema.mutable);
79
105
  }
80
- setField((f) => (f === field ? undefined : field));
81
- },
82
- [readonly],
83
- );
84
106
 
85
- // TODO(burdon): Check if mutable; variant of useCallback that return undefined if readonly?
107
+ return base.pipe(Schema.mutable);
108
+ }, [mode]);
86
109
 
87
- const handleAdd = useCallback(() => {
88
- invariant(!readonly);
89
- const field = projection.createFieldProjection();
90
- setField(field);
91
- }, [schema, projection, readonly]);
110
+ // TODO(burdon): Need to warn user of possible consequences of editing.
111
+ // TODO(burdon): Settings should have domain name owned by user.
112
+ const viewValues = useMemo(
113
+ () => ({
114
+ name: view.name,
115
+ query: mode === 'schema' ? getTypenameFromQuery(view.query.ast) : view.query.ast,
116
+ target: queueTarget,
117
+ }),
118
+ [view.name, mode, view.query.ast, queueTarget],
119
+ );
92
120
 
93
- const handleUpdate = useCallback(
94
- ({ typename }: ViewMetaType) => {
95
- invariant(!readonly);
96
- requestAnimationFrame(() => {
97
- if (view.query.typename !== typename && !readonly) {
98
- onTypenameChanged?.(typename);
99
- }
100
- });
101
- },
102
- [view.query.typename, onTypenameChanged, readonly],
103
- );
121
+ const handleToggleField = useCallback(
122
+ (field: FieldType) => {
123
+ setExpandedField((prevExpandedFieldId) => (prevExpandedFieldId === field.id ? undefined : field.id));
124
+ },
125
+ [readonly],
126
+ );
104
127
 
105
- const handleDelete = useCallback(
106
- (fieldId: string) => {
128
+ // TODO(burdon): Check if mutable; variant of useCallback that return undefined if readonly?
129
+
130
+ const handleAdd = useCallback(() => {
107
131
  invariant(!readonly);
108
- if (fieldId === field?.id) {
109
- setField(undefined);
110
- }
132
+ const field = projectionModel.createFieldProjection();
133
+ setExpandedField(field.id);
134
+ }, [schema, projectionModel, readonly]);
111
135
 
112
- onDelete?.(fieldId);
113
- },
114
- [field, onDelete, readonly],
115
- );
136
+ const handleUpdate = useCallback(
137
+ (values: any) => {
138
+ invariant(!readonly);
139
+ requestAnimationFrame(() => {
140
+ if ('name' in values && view.name !== values.name) {
141
+ view.name = values.name;
142
+ return;
143
+ }
116
144
 
117
- const handleMove = useCallback(
118
- (fromIndex: number, toIndex: number) => {
119
- invariant(!readonly);
120
- // NOTE(ZaymonFC): Using arrayMove here causes a race condition with the kanban model.
121
- const fields = [...view.projection.fields];
122
- const [moved] = fields.splice(fromIndex, 1);
123
- fields.splice(toIndex, 0, moved);
124
- view.projection.fields = fields;
125
- },
126
- [view.projection.fields, readonly],
127
- );
145
+ const query = mode === 'schema' ? Query.select(Filter.typename(values.query)).ast : values.query;
146
+ onQueryChanged?.(query, values.target);
147
+ });
148
+ },
149
+ [onQueryChanged, readonly, view, queueTarget, mode],
150
+ );
128
151
 
129
- const handleClose = useCallback(() => setField(undefined), []);
152
+ const handleDelete = useCallback(
153
+ (fieldId: string) => {
154
+ invariant(!readonly);
155
+ if (fieldId === expandedField) {
156
+ setExpandedField(undefined);
157
+ }
130
158
 
131
- const hiddenProperties = projection.getHiddenProperties();
159
+ onDelete?.(fieldId);
160
+ },
161
+ [expandedField, onDelete, readonly],
162
+ );
132
163
 
133
- const handleHide = useCallback(
134
- (fieldId: string) => {
135
- setField(undefined);
136
- projection.hideFieldProjection(fieldId);
137
- },
138
- [projection],
139
- );
164
+ const handleMove = useCallback(
165
+ (fromIndex: number, toIndex: number) => {
166
+ invariant(!readonly);
167
+ // NOTE(ZaymonFC): Using arrayMove here causes a race condition with the kanban model.
168
+ const fields = [...view.projection.fields];
169
+ const [moved] = fields.splice(fromIndex, 1);
170
+ fields.splice(toIndex, 0, moved);
171
+ view.projection.fields = fields;
172
+ },
173
+ [view.projection.fields, readonly],
174
+ );
140
175
 
141
- const handleShow = useCallback(
142
- (property: string) => {
143
- setField(undefined);
144
- projection.showFieldProjection(property as JsonProp);
145
- },
146
- [projection],
147
- );
176
+ const handleClose = useCallback(() => setExpandedField(undefined), []);
148
177
 
149
- return (
150
- <div role='none' className={mx(classNames)}>
151
- {readonly && (
152
- <div role='none' className={mx('is-full', outerSpacing && 'plb-cardSpacingBlock pli-cardSpacingInline')}>
153
- <Callout.Root valence='info'>
154
- <Callout.Title>{t('system schema description')}</Callout.Title>
155
- </Callout.Root>
156
- </div>
157
- )}
178
+ const handleHide = useCallback(
179
+ (fieldId: string) => {
180
+ setExpandedField(undefined);
181
+ projectionModel.hideFieldProjection(fieldId);
182
+ },
183
+ [projectionModel],
184
+ );
158
185
 
159
- {/* TODO(burdon): Is the form read-only or just the schema? */}
160
- {/* TODO(burdon): Readonly fields should take up the same space as editable fields (just be ghosted). */}
161
- <Form<ViewMetaType>
162
- autoSave
163
- schema={ViewMetaSchema}
164
- values={viewValues}
165
- readonly={readonly}
166
- onSave={handleUpdate}
167
- outerSpacing={outerSpacing}
168
- />
186
+ const handleShow = useCallback(
187
+ (property: string) => {
188
+ setExpandedField(undefined);
189
+ projectionModel.showFieldProjection(property as JsonProp);
190
+ },
191
+ [projectionModel],
192
+ );
169
193
 
170
- <div role='none' className={outerSpacing ? cardSpacing : 'mlb-cardSpacingBlock'}>
171
- <label className={mx(inputTextLabel)}>{t('fields label')}</label>
194
+ const custom = useMemo(() => (mode === 'query' ? customFields({ types, tags }) : undefined), [types, tags, mode]);
172
195
 
173
- <List.Root<FieldType>
174
- items={view.projection.fields}
175
- isItem={Schema.is(FieldSchema)}
176
- getId={(field) => field.id}
177
- readonly={readonly}
178
- onMove={readonly ? undefined : handleMove}
179
- >
180
- {({ items: fields }) => (
181
- <>
182
- {showHeading && (
183
- <div role='heading' className={grid}>
184
- <div />
185
- <div className='flex items-center text-sm'>{t('field path label')}</div>
186
- </div>
187
- )}
196
+ return (
197
+ <div role='none' className={mx(classNames)}>
198
+ {schemaReadonly && mode === 'schema' && (
199
+ <Callout.Root valence='info' classNames={['mlb-cardSpacingBlock', outerSpacing && 'mli-cardSpacingInline']}>
200
+ <Callout.Title>{t('system schema description')}</Callout.Title>
201
+ </Callout.Root>
202
+ )}
188
203
 
189
- <div role='list' className='flex flex-col is-full'>
190
- {fields?.map((field) => (
191
- <List.Item<FieldType>
192
- key={field.id}
193
- item={field}
194
- classNames={mx(grid, ghostHover, 'overflow-hidden', !readonly && 'cursor-pointer')}
195
- >
196
- <List.ItemDragHandle />
197
- <List.ItemTitle onClick={() => handleSelect(field)}>{field.path}</List.ItemTitle>
198
- <List.ItemButton
199
- data-testid='hide-field-button'
200
- icon='ph--eye-slash--regular'
201
- // TDOO(burdon): Is this the correct test?
202
- disabled={view.projection.fields.length <= 1}
203
- onClick={() => handleHide(field.id)}
204
- />
205
- {/* TODO(burdon): Remove unless implement undo. */}
206
- {!readonly && (
207
- <List.ItemDeleteButton
208
- disabled={view.projection.fields.length <= 1}
209
- onClick={() => handleDelete(field.id)}
210
- />
211
- )}
212
- </List.Item>
213
- ))}
214
- </div>
215
- </>
216
- )}
217
- </List.Root>
204
+ {/* TODO(burdon): Is the form read-only or just the schema? */}
205
+ {/* TODO(burdon): Readonly fields should take up the same space as editable fields (just be ghosted). */}
206
+ <Form<Schema.Schema.Type<typeof viewSchema>>
207
+ autoSave
208
+ schema={viewSchema}
209
+ values={viewValues}
210
+ readonly={readonly ? 'disabled-input' : false}
211
+ onSave={handleUpdate}
212
+ outerSpacing={outerSpacing}
213
+ Custom={custom}
214
+ />
218
215
 
219
- {hiddenProperties.length > 0 && (
220
- <>
221
- <label className={mx(inputTextLabel)}>{t('hidden fields label')}</label>
216
+ <div role='none' className={outerSpacing ? cardSpacing : 'mlb-cardSpacingBlock'}>
217
+ <h2 className={mx(inputTextLabel)}>{t('fields label')}</h2>
222
218
 
223
- <List.Root<string>
224
- items={hiddenProperties}
225
- isItem={(item): item is string => typeof item === 'string'}
226
- getId={(property) => property}
227
- >
228
- {({ items: properties }) => (
229
- <div role='list' className='flex flex-col is-full'>
230
- {properties?.map((property) => (
231
- <List.Item<string>
232
- key={property}
233
- item={property}
234
- classNames={mx(grid, ghostHover, !readonly && 'cursor-pointer')}
235
- >
236
- <List.ItemDragHandle disabled />
237
- <List.ItemTitle>{property}</List.ItemTitle>
238
- <List.ItemButton
239
- icon='ph--eye--regular'
240
- onClick={() => handleShow(property)}
241
- data-testid='show-field-button'
242
- />
243
- </List.Item>
244
- ))}
219
+ <List.Root<FieldType>
220
+ items={view.projection.fields}
221
+ isItem={Schema.is(FieldSchema)}
222
+ getId={(field) => field.id}
223
+ onMove={readonly ? undefined : handleMove}
224
+ readonly={readonly}
225
+ >
226
+ {({ items: fields }) => (
227
+ <>
228
+ {showHeading && <h3 className='text-sm'>{t('field path label')}</h3>}
229
+ <div role='list' className={listGrid}>
230
+ {fields?.map((field) => {
231
+ const hidden = field.visible === false;
232
+ return (
233
+ <List.Item<FieldType>
234
+ key={field.id}
235
+ item={field}
236
+ classNames={listItemGrid}
237
+ aria-expanded={expandedField === field.id}
238
+ >
239
+ <div
240
+ role='none'
241
+ className={mx(subtleHover, listItemGrid, 'rounded-sm cursor-pointer min-bs-10')}
242
+ >
243
+ <List.ItemDragHandle disabled={readonly} />
244
+ <List.ItemTitle
245
+ classNames={hidden && 'text-subdued'}
246
+ onClick={() => handleToggleField(field)}
247
+ >
248
+ {field.path}
249
+ </List.ItemTitle>
250
+ <List.ItemButton
251
+ label={t(hidden ? 'show field label' : 'hide field label')}
252
+ data-testid={hidden ? 'show-field-button' : 'hide-field-button'}
253
+ icon={hidden ? 'ph--eye-closed--regular' : 'ph--eye--regular'}
254
+ autoHide={false}
255
+ disabled={readonly || (!hidden && projectionModel.fields.length <= 1)}
256
+ onClick={() => (hidden ? handleShow(field.path) : handleHide(field.id))}
257
+ />
258
+ {mode === 'schema' && (
259
+ <>
260
+ <List.ItemDeleteButton
261
+ label={t('delete field label')}
262
+ autoHide={false}
263
+ disabled={readonly || schemaReadonly || view.projection.fields.length <= 1}
264
+ onClick={() => handleDelete(field.id)}
265
+ data-testid='field.delete'
266
+ />
267
+ <IconButton
268
+ iconOnly
269
+ variant='ghost'
270
+ label={t('toggle expand label', { ns: 'os' })}
271
+ icon={
272
+ expandedField === field.id ? 'ph--caret-down--regular' : 'ph--caret-right--regular'
273
+ }
274
+ onClick={() => handleToggleField(field)}
275
+ />
276
+ </>
277
+ )}
278
+ </div>
279
+ {expandedField === field.id && mode === 'schema' && (
280
+ <div role='none' className='col-span-5 mbs-1 mbe-1 border border-separator rounded-md'>
281
+ <FieldEditor
282
+ readonly={readonly || schemaReadonly ? 'disabled-input' : false}
283
+ projection={projectionModel}
284
+ field={field}
285
+ registry={registry}
286
+ onSave={handleClose}
287
+ />
288
+ </div>
289
+ )}
290
+ </List.Item>
291
+ );
292
+ })}
245
293
  </div>
246
- )}
247
- </List.Root>
248
- </>
294
+ </>
295
+ )}
296
+ </List.Root>
297
+ </div>
298
+
299
+ {!readonly && !expandedField && mode === 'schema' && (
300
+ <div role='none' className={outerSpacing ? cardSpacing : 'mlb-cardSpacingBlock'}>
301
+ <IconButton
302
+ icon='ph--plus--regular'
303
+ label={t('button add property')}
304
+ onClick={readonly ? undefined : handleAdd}
305
+ // TODO(burdon): Show field limit in ux (not tooltip).
306
+ disabled={view.projection.fields.length >= VIEW_FIELD_LIMIT}
307
+ classNames='is-full'
308
+ />
309
+ </div>
249
310
  )}
250
311
  </div>
312
+ );
313
+ },
314
+ );
251
315
 
252
- {field && (
253
- <FieldEditor key={field.id} projection={projection} field={field} registry={registry} onSave={handleClose} />
254
- )}
316
+ const customFields = ({ types, tags }: Pick<ViewEditorProps, 'types' | 'tags'>): Record<string, InputComponent> => ({
317
+ query: (props: InputProps) => {
318
+ const handleChange = useCallback(
319
+ (query: Query.Any) => props.onValueChange('object', query.ast),
320
+ [props.onValueChange],
321
+ );
255
322
 
256
- {!readonly && !field && (
257
- <div role='none' className={outerSpacing ? cardSpacing : 'mlb-cardSpacingBlock'}>
258
- <IconButton
259
- icon='ph--plus--regular'
260
- label={t('button add property')}
261
- onClick={readonly ? undefined : handleAdd}
262
- // TODO(burdon): Show field limit in ux (not tooltip).
263
- disabled={view.projection.fields.length >= VIEW_FIELD_LIMIT}
264
- classNames='flex is-full'
265
- />
266
- </div>
267
- )}
268
- </div>
269
- );
270
- };
323
+ return (
324
+ <Input.Root>
325
+ <InputHeader label={props.label} />
326
+ <QueryForm initialQuery={props.getValue()} types={types} tags={tags} onChange={handleChange} />
327
+ </Input.Root>
328
+ );
329
+ },
330
+ });
@@ -2,9 +2,8 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- export * from './deprecated';
6
-
7
5
  export * from './ControlSection';
8
6
  export * from './FieldEditor';
9
7
  export * from './Form';
8
+ export * from './ObjectPicker';
10
9
  export * from './ViewEditor';
@@ -9,16 +9,22 @@ import { Card } from '@dxos/react-ui-stack';
9
9
  import { JsonFilter } from '@dxos/react-ui-syntax-highlighter';
10
10
  import { mx, textBlockWidth } from '@dxos/react-ui-theme';
11
11
 
12
- export const TestLayout = ({ classNames, json, children }: ThemedClassName<PropsWithChildren<{ json?: any }>>) => (
13
- <div className='w-full h-full grid grid-cols-[1fr_1fr] gap-2 overflow-hidden'>
14
- <div className={mx('p-4 justify-center overflow-y-auto', classNames)}>{children}</div>
15
- <JsonFilter data={json} classNames='w-full text-xs' testId='debug' />
16
- </div>
17
- );
12
+ type TestLayoutProps = ThemedClassName<PropsWithChildren<{ json?: any }>>;
18
13
 
19
- export const TestPanel = ({ classNames, children }: ThemedClassName<PropsWithChildren>) => (
20
- <Card.StaticRoot classNames={[textBlockWidth, classNames]}>{children}</Card.StaticRoot>
21
- );
14
+ export const TestLayout = ({ classNames, children, json }: TestLayoutProps) => {
15
+ return (
16
+ <div className='is-full bs-full grid grid-cols-[1fr_1fr] gap-2 overflow-hidden'>
17
+ <div className={mx('p-4 justify-center overflow-y-auto', classNames)}>{children}</div>
18
+ <JsonFilter data={json} classNames='is-full text-xs' testId='debug' />
19
+ </div>
20
+ );
21
+ };
22
+
23
+ type TestPanelProps = ThemedClassName<PropsWithChildren>;
24
+
25
+ export const TestPanel = ({ classNames, children }: TestPanelProps) => {
26
+ return <Card.StaticRoot classNames={[textBlockWidth, classNames]}>{children}</Card.StaticRoot>;
27
+ };
22
28
 
23
29
  // Symbol for accessing debug objects in tests.
24
30
  export const VIEW_EDITOR_DEBUG_SYMBOL = Symbol('viewEditorDebug');
@@ -2,14 +2,15 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { SchemaAST, type Schema } from 'effect';
5
+ import type * as Schema from 'effect/Schema';
6
+ import * as SchemaAST from 'effect/SchemaAST';
6
7
  import { useCallback, useEffect, useMemo, useState } from 'react';
7
8
 
8
- import { type BaseObject, getValue, setValue } from '@dxos/echo-schema';
9
- import { type SimpleType, type JsonPath, createJsonPath, fromEffectValidationPath } from '@dxos/effect';
9
+ import { type BaseObject, getValue, setValue } from '@dxos/echo/internal';
10
+ import { type JsonPath, type SimpleType, createJsonPath, fromEffectValidationPath } from '@dxos/effect';
10
11
  import { invariant } from '@dxos/invariant';
11
12
  import { log } from '@dxos/log';
12
- import { validateSchema, type ValidationError } from '@dxos/schema';
13
+ import { type ValidationError, validateSchema } from '@dxos/schema';
13
14
  import { type MaybePromise } from '@dxos/util';
14
15
 
15
16
  /**
@@ -26,6 +27,8 @@ export type FormHandler<T extends BaseObject> = {
26
27
  changed: Record<JsonPath, boolean>;
27
28
  canSave: boolean;
28
29
  formIsValid: boolean;
30
+
31
+ // TODO(burdon): Return onSave.
29
32
  handleSave: () => void;
30
33
 
31
34
  //
@@ -201,6 +204,7 @@ export const useForm = <T extends BaseObject>({
201
204
  [values],
202
205
  );
203
206
 
207
+ // TODO(wittjosiah): This is causing the entire form to re-render on every change.
204
208
  const onValueChange = useCallback<FormHandler<T>['onValueChange']>(
205
209
  (path: (string | number)[], type: SimpleType, value: any) => {
206
210
  const jsonPath = createJsonPath(path);
@@ -216,12 +220,13 @@ export const useForm = <T extends BaseObject>({
216
220
 
217
221
  const newValues = { ...setValue(values, jsonPath, parsedValue) };
218
222
  setValues(newValues);
219
- setChanged((prev) => ({ ...prev, [jsonPath]: true }));
223
+ const newChanged = { ...changed, [jsonPath]: true };
224
+ setChanged(newChanged);
220
225
  onValuesChanged?.(newValues);
221
226
 
222
227
  const isValid = validate(newValues);
223
228
  if (isValid && onValid) {
224
- onValid(newValues, { changed });
229
+ onValid(newValues, { changed: newChanged });
225
230
  }
226
231
  },
227
232
  [values, onValuesChanged, validate, onValid, changed],