@dxos/react-ui-form 0.8.4-main.f9ba587 → 0.8.4-main.fd6878d

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 (63) hide show
  1. package/dist/lib/browser/index.mjs +184 -171
  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 +184 -171
  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.stories.d.ts.map +1 -1
  8. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +4 -4
  9. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  10. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +2 -2
  11. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
  12. package/dist/types/src/components/Form/ArrayField.d.ts.map +1 -1
  13. package/dist/types/src/components/Form/Defaults.d.ts.map +1 -1
  14. package/dist/types/src/components/Form/Form.d.ts +2 -2
  15. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  16. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  17. package/dist/types/src/components/Form/FormActions.d.ts.map +1 -1
  18. package/dist/types/src/components/Form/FormContent.d.ts +1 -1
  19. package/dist/types/src/components/Form/FormContent.d.ts.map +1 -1
  20. package/dist/types/src/components/Form/FormContext.d.ts +1 -1
  21. package/dist/types/src/components/Form/FormContext.d.ts.map +1 -1
  22. package/dist/types/src/components/Form/RefField.d.ts +1 -1
  23. package/dist/types/src/components/Form/RefField.d.ts.map +1 -1
  24. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts.map +1 -1
  25. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +1 -1
  26. package/dist/types/src/components/Form/factory.d.ts.map +1 -1
  27. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +2 -2
  28. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  29. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +3 -3
  30. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  31. package/dist/types/src/components/deprecated/Form.d.ts +3 -3
  32. package/dist/types/src/components/deprecated/Form.d.ts.map +1 -1
  33. package/dist/types/src/components/deprecated/Form.stories.d.ts.map +1 -1
  34. package/dist/types/src/hooks/useForm.d.ts +1 -1
  35. package/dist/types/src/hooks/useForm.d.ts.map +1 -1
  36. package/dist/types/src/translations.d.ts +3 -2
  37. package/dist/types/src/translations.d.ts.map +1 -1
  38. package/dist/types/tsconfig.tsbuildinfo +1 -1
  39. package/package.json +30 -29
  40. package/src/components/ControlSection/ControlSection.stories.tsx +2 -1
  41. package/src/components/FieldEditor/FieldEditor.stories.tsx +16 -13
  42. package/src/components/FieldEditor/FieldEditor.test.tsx +14 -13
  43. package/src/components/FieldEditor/FieldEditor.tsx +11 -11
  44. package/src/components/Form/ArrayField.tsx +8 -7
  45. package/src/components/Form/Defaults.tsx +2 -1
  46. package/src/components/Form/Form.stories.tsx +4 -3
  47. package/src/components/Form/Form.tsx +4 -3
  48. package/src/components/Form/FormActions.tsx +2 -1
  49. package/src/components/Form/FormContent.tsx +6 -5
  50. package/src/components/Form/FormContext.tsx +5 -5
  51. package/src/components/Form/RefField.tsx +6 -5
  52. package/src/components/Form/custom/GeoPointInput.tsx +2 -2
  53. package/src/components/Form/custom/SelectOptionsInput.stories.tsx +3 -2
  54. package/src/components/Form/custom/SelectOptionsInput.tsx +28 -29
  55. package/src/components/Form/factory.tsx +1 -1
  56. package/src/components/ViewEditor/ViewEditor.stories.tsx +19 -12
  57. package/src/components/ViewEditor/ViewEditor.test.tsx +18 -15
  58. package/src/components/ViewEditor/ViewEditor.tsx +81 -81
  59. package/src/components/deprecated/Form.stories.tsx +6 -5
  60. package/src/components/deprecated/Form.tsx +6 -6
  61. package/src/hooks/useForm.ts +5 -3
  62. package/src/hooks/useRefQueryLookupHandler.ts +1 -1
  63. package/src/translations.ts +3 -2
@@ -2,19 +2,18 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import React, { type ChangeEvent, type KeyboardEvent, useCallback, useEffect, useState, useRef } from 'react';
5
+ import React, { type ChangeEvent, type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
6
6
 
7
7
  import { type SelectOption } from '@dxos/echo-schema';
8
8
  import { PublicKey } from '@dxos/keys';
9
- import { type ChromaticPalette, Icon, IconButton, Input, Tag, Toolbar, useTranslation } from '@dxos/react-ui';
9
+ import { type ChromaticPalette, IconButton, Input, Tag, useTranslation } from '@dxos/react-ui';
10
10
  import { List } from '@dxos/react-ui-list';
11
11
  import { HuePicker } from '@dxos/react-ui-pickers';
12
- import { hues, mx } from '@dxos/react-ui-theme';
12
+ import { hues, subtleHover } from '@dxos/react-ui-theme';
13
13
 
14
14
  import { translationKey } from '../../../translations';
15
15
  import { InputHeader, type InputProps } from '../Input';
16
16
 
17
- // TODO(ZaymonFC): Handle disabled.
18
17
  export const SelectOptionInput = ({ type, label, disabled, getStatus, getValue, onValueChange }: InputProps) => {
19
18
  const [selected, setSelectedId] = useState<string | null>(null);
20
19
  const [isNewOption, setIsNewOption] = useState(false);
@@ -115,7 +114,12 @@ export const SelectOptionInput = ({ type, label, disabled, getStatus, getValue,
115
114
  <InputHeader error={error} label={label} />
116
115
  <div role='none'>
117
116
  {options && (
118
- <List.Root items={options} isItem={(item) => true} onMove={handleMove}>
117
+ <List.Root
118
+ items={options}
119
+ isItem={(item) => true}
120
+ onMove={disabled ? undefined : handleMove}
121
+ readonly={disabled}
122
+ >
119
123
  {({ items }) => (
120
124
  <div role='list' className='w-full overflow-auto'>
121
125
  {items?.map((item) => (
@@ -123,26 +127,30 @@ export const SelectOptionInput = ({ type, label, disabled, getStatus, getValue,
123
127
  <List.Item
124
128
  role='button'
125
129
  item={item}
126
- classNames={mx('p-1 plb-2', 'flex flex-col', 'cursor-pointer', 'hover:bg-hoverOverlay')}
130
+ classNames={[subtleHover, 'flex flex-col cursor-pointer rounded-sm']}
127
131
  aria-expanded={selected === item.id}
128
132
  >
129
133
  <div className='flex items-center'>
130
- <List.ItemDragHandle />
134
+ <List.ItemDragHandle disabled={disabled} />
131
135
  <List.ItemTitle onClick={() => handleClick(item.id)} classNames='flex-1'>
132
136
  {/* TODO(ZaymonFC): Move spacer into Tag component. */}
133
137
  <Tag palette={item.color as ChromaticPalette}>{item.title || '\u200b'}</Tag>
134
138
  </List.ItemTitle>
135
- <Icon
139
+ <IconButton
140
+ iconOnly
141
+ variant='ghost'
142
+ label={t(selected === item.id ? 'collapse label' : 'expand label', { ns: 'os' })}
136
143
  icon={selected === item.id ? 'ph--caret-down--regular' : 'ph--caret-right--regular'}
137
144
  onClick={() => handleClick(item.id)}
138
145
  />
139
146
  </div>
140
147
  {selected === item.id && (
141
- <div className='mis-4 mbs-2 flex flex-col gap-1'>
148
+ <div className='pli-cardSpacingInline plb-cardSpacingBlock flex flex-col gap-1 density-fine'>
142
149
  {/* 16px to match drag handle width. */}
143
150
  <Input.Label classNames='text-xs'>{t('select option label')}</Input.Label>
144
- <div className='flex flex-row items-center gap-1'>
151
+ <div className='grid grid-cols-[1fr_min-content_min-content] gap-1'>
145
152
  <Input.TextInput
153
+ disabled={disabled}
146
154
  placeholder={t('select option label placeholder')}
147
155
  ref={selected === item.id ? inputRef : undefined}
148
156
  value={item.title}
@@ -151,14 +159,9 @@ export const SelectOptionInput = ({ type, label, disabled, getStatus, getValue,
151
159
  classNames='flex-1'
152
160
  data-no-submit
153
161
  />
154
- <Toolbar.Root classNames='p-0 m-0 !is-auto'>
155
- <HuePicker
156
- value={item.color}
157
- onChange={handleColorChange(item.id)}
158
- rootVariant='toolbar-button'
159
- />
160
- </Toolbar.Root>
162
+ <HuePicker disabled={disabled} value={item.color} onChange={handleColorChange(item.id)} />
161
163
  <IconButton
164
+ disabled={disabled}
162
165
  icon='ph--trash--fill'
163
166
  iconOnly
164
167
  label={t('select option delete')}
@@ -170,18 +173,14 @@ export const SelectOptionInput = ({ type, label, disabled, getStatus, getValue,
170
173
  </List.Item>
171
174
  </div>
172
175
  ))}
173
- <div role='listitem'>
174
- <div
175
- role='button'
176
- className={mx('p-1 plb-2', 'flex flex-col', 'cursor-pointer', 'hover:bg-hoverOverlay')}
177
- onClick={handleAdd}
178
- >
179
- <div className='flex items-center gap-1'>
180
- <Icon icon='ph--plus--regular' />
181
- <span className='text-sm'>{t('select option add')}</span>
182
- </div>
183
- </div>
184
- </div>
176
+ <IconButton
177
+ variant='ghost'
178
+ icon='ph--plus--regular'
179
+ label={t('select option add')}
180
+ onClick={handleAdd}
181
+ disabled={disabled}
182
+ classNames='is-full'
183
+ />
185
184
  </div>
186
185
  )}
187
186
  </List.Root>
@@ -5,9 +5,9 @@
5
5
  import { FormatEnum } from '@dxos/echo-schema';
6
6
  import { type SimpleType } from '@dxos/effect';
7
7
 
8
+ import { GeoPointInput } from './custom';
8
9
  import { BooleanInput, MarkdownInput, NumberInput, TextInput } from './Defaults';
9
10
  import { type InputComponent } from './Input';
10
- import { GeoPointInput } from './custom';
11
11
 
12
12
  /**
13
13
  * Get property input component.
@@ -6,24 +6,26 @@ import '@dxos-theme';
6
6
 
7
7
  import { type Meta, type StoryObj } from '@storybook/react-vite';
8
8
  import { Schema } from 'effect';
9
- import React, { useCallback, useMemo, useState, useEffect } from 'react';
9
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
10
10
 
11
- import { Format, type EchoSchema, toJsonSchema, TypedObject } from '@dxos/echo-schema';
11
+ import { Obj, Type } from '@dxos/echo';
12
+ import { type EchoSchema, Format, TypedObject, toJsonSchema } from '@dxos/echo-schema';
12
13
  import { Filter, useQuery, useSpace } from '@dxos/react-client/echo';
13
14
  import { withClientProvider } from '@dxos/react-client/testing';
14
15
  import { useAsyncEffect } from '@dxos/react-ui';
15
- import { ViewProjection, ViewType, createView } from '@dxos/schema';
16
- import { withTheme, withLayout } from '@dxos/storybook-utils';
16
+ import { DataType, ProjectionModel, createView } from '@dxos/schema';
17
+ import { withLayout, withTheme } from '@dxos/storybook-utils';
17
18
 
18
- import { ViewEditor, type ViewEditorProps } from './ViewEditor';
19
19
  import { translations } from '../../translations';
20
20
  import { TestLayout, TestPanel, VIEW_EDITOR_DEBUG_SYMBOL } from '../testing';
21
21
 
22
+ import { ViewEditor, type ViewEditorProps } from './ViewEditor';
23
+
22
24
  // Type definition for debug objects exposed to tests.
23
25
  export type ViewEditorDebugObjects = {
24
26
  schema: EchoSchema;
25
- view: ViewType;
26
- projection: ViewProjection;
27
+ view: DataType.View;
28
+ projection: ProjectionModel;
27
29
  };
28
30
 
29
31
  type StoryProps = Pick<ViewEditorProps, 'readonly'>;
@@ -31,8 +33,8 @@ type StoryProps = Pick<ViewEditorProps, 'readonly'>;
31
33
  const DefaultStory = (props: StoryProps) => {
32
34
  const space = useSpace();
33
35
  const [schema, setSchema] = useState<EchoSchema>();
34
- const [view, setView] = useState<ViewType>();
35
- const [projection, setProjection] = useState<ViewProjection>();
36
+ const [view, setView] = useState<DataType.View>();
37
+ const [projection, setProjection] = useState<ProjectionModel>();
36
38
  useAsyncEffect(async () => {
37
39
  if (space) {
38
40
  class TestSchema extends TypedObject({ typename: 'example.com/type/Test', version: '0.1.0' })({
@@ -42,8 +44,13 @@ const DefaultStory = (props: StoryProps) => {
42
44
  }) {}
43
45
 
44
46
  const [schema] = await space.db.schemaRegistry.register([TestSchema]);
45
- const view = createView({ name: 'Test', typename: schema.typename, jsonSchema: toJsonSchema(TestSchema) });
46
- const projection = new ViewProjection(schema.jsonSchema, view);
47
+ const view = createView({
48
+ name: 'Test',
49
+ typename: schema.typename,
50
+ jsonSchema: toJsonSchema(TestSchema),
51
+ presentation: Obj.make(Type.Expando, {}),
52
+ });
53
+ const projection = new ProjectionModel(schema.jsonSchema, view.projection);
47
54
 
48
55
  setSchema(schema);
49
56
  setView(view);
@@ -51,7 +58,7 @@ const DefaultStory = (props: StoryProps) => {
51
58
  }
52
59
  }, [space]);
53
60
 
54
- const views = useQuery(space, Filter.type(ViewType));
61
+ const views = useQuery(space, Filter.type(DataType.View));
55
62
  const currentTypename = useMemo(() => view?.query?.typename, [view]);
56
63
  const updateViewTypename = useCallback(
57
64
  (newTypename: string) => {
@@ -3,15 +3,16 @@
3
3
  //
4
4
 
5
5
  import { composeStories } from '@storybook/react';
6
- import { screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
6
+ import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react';
7
7
  import { afterEach, describe, expect, test } from 'vitest';
8
8
 
9
- import { EchoSchema } from '@dxos/echo-schema';
10
- import { ViewProjection, ViewType } from '@dxos/schema';
9
+ import { EchoSchema, isInstanceOf } from '@dxos/echo-schema';
10
+ import { DataType, ProjectionModel } from '@dxos/schema';
11
+
12
+ import { VIEW_EDITOR_DEBUG_SYMBOL } from '../testing';
11
13
 
12
14
  import * as stories from './ViewEditor.stories';
13
15
  import { type ViewEditorDebugObjects } from './ViewEditor.stories';
14
- import { VIEW_EDITOR_DEBUG_SYMBOL } from '../testing';
15
16
 
16
17
  const { Default } = composeStories(stories);
17
18
 
@@ -19,8 +20,8 @@ const getViewEditorDebugObjects = (): ViewEditorDebugObjects => {
19
20
  const debugObjects = (window as any)[VIEW_EDITOR_DEBUG_SYMBOL] as ViewEditorDebugObjects;
20
21
  expect(debugObjects).toBeDefined();
21
22
  expect(debugObjects.schema).toBeInstanceOf(EchoSchema);
22
- expect(debugObjects.view).toBeInstanceOf(ViewType);
23
- expect(debugObjects.projection).toBeInstanceOf(ViewProjection);
23
+ expect(isInstanceOf(DataType.View, debugObjects.view)).toBeTruthy();
24
+ expect(debugObjects.projection).toBeInstanceOf(ProjectionModel);
24
25
  return debugObjects;
25
26
  };
26
27
 
@@ -41,7 +42,7 @@ describe('ViewEditor', () => {
41
42
  test('renders view editor', async () => {
42
43
  await Default.run();
43
44
  await waitForViewEditor();
44
- expect(screen.getByText('View')).toBeInTheDocument();
45
+ expect(screen.getByText('Record type')).toBeInTheDocument();
45
46
  });
46
47
 
47
48
  test('change property name', async () => {
@@ -73,7 +74,7 @@ describe('ViewEditor', () => {
73
74
  expect(schemaProperties!.name).toBeUndefined();
74
75
 
75
76
  // Check view contains new_property field.
76
- const newPropertyField = debugObjects.view.fields.find((field: any) => field.path === 'new_property');
77
+ const newPropertyField = debugObjects.view.projection.fields.find((field: any) => field.path === 'new_property');
77
78
  expect(newPropertyField).toBeDefined();
78
79
  expect(newPropertyField!.path).toBe('new_property');
79
80
 
@@ -120,7 +121,9 @@ describe('ViewEditor', () => {
120
121
  expect(schemaProperties!.added_property).toBeDefined();
121
122
 
122
123
  // Check view contains added_property field.
123
- const addedPropertyField = debugObjects.view.fields.find((field: any) => field.path === 'added_property');
124
+ const addedPropertyField = debugObjects.view.projection.fields.find(
125
+ (field: any) => field.path === 'added_property',
126
+ );
124
127
  expect(addedPropertyField).toBeDefined();
125
128
  expect(addedPropertyField!.path).toBe('added_property');
126
129
 
@@ -138,7 +141,7 @@ describe('ViewEditor', () => {
138
141
  // Find the delete button for the 'name' property.
139
142
  const nameProperty = screen.getByText('name');
140
143
  const propertyRow = nameProperty.closest('[role="listitem"]');
141
- const deleteButton = propertyRow?.querySelector('button:last-child');
144
+ const deleteButton = propertyRow?.querySelector('[data-testid="field.delete"]');
142
145
 
143
146
  if (!deleteButton) {
144
147
  throw new Error('Delete button not found');
@@ -161,7 +164,7 @@ describe('ViewEditor', () => {
161
164
  expect(schemaProperties!.name).toBeUndefined();
162
165
 
163
166
  // Check view no longer contains the name field.
164
- const nameField = debugObjects.view.fields.find((field: any) => field.path === 'name');
167
+ const nameField = debugObjects.view.projection.fields.find((field: any) => field.path === 'name');
165
168
  expect(nameField).toBeUndefined();
166
169
 
167
170
  // Check projection no longer contains the name property.
@@ -187,18 +190,18 @@ describe('ViewEditor', () => {
187
190
  const debugObjects = getViewEditorDebugObjects();
188
191
 
189
192
  // Check that hiddenFields is non-empty.
190
- expect(debugObjects.view.hiddenFields).toBeDefined();
191
- expect(debugObjects.view.hiddenFields!.length).toBeGreaterThan(0);
193
+ expect(debugObjects.view.projection.hiddenFields).toBeDefined();
194
+ expect(debugObjects.view.projection.hiddenFields!.length).toBeGreaterThan(0);
192
195
 
193
196
  // Click the show button
194
197
  fireEvent.click(screen.getByTestId('show-field-button'));
195
198
 
196
199
  // Wait for the data to update and check that hiddenFields is empty.
197
200
  await waitFor(() => {
198
- expect(debugObjects.view.hiddenFields!.length).toBe(0);
201
+ expect(debugObjects.view.projection.hiddenFields!.length).toBe(0);
199
202
  });
200
203
 
201
204
  // Also verify that the field is back in the visible fields list.
202
- expect(debugObjects.view.fields.length).toBeGreaterThan(0);
205
+ expect(debugObjects.view.projection.fields.length).toBeGreaterThan(0);
203
206
  });
204
207
  });
@@ -11,21 +11,19 @@ import { invariant } from '@dxos/invariant';
11
11
  import { Callout, IconButton, type ThemedClassName, useTranslation } from '@dxos/react-ui';
12
12
  import { List } from '@dxos/react-ui-list';
13
13
  import { cardSpacing } from '@dxos/react-ui-stack';
14
- import { ghostHover, inputTextLabel, mx } from '@dxos/react-ui-theme';
15
- import { FieldSchema, type FieldType, type ViewType, ViewProjection, VIEW_FIELD_LIMIT } from '@dxos/schema';
14
+ import { inputTextLabel, mx, subtleHover } from '@dxos/react-ui-theme';
15
+ import { type DataType, FieldSchema, type FieldType, ProjectionModel, VIEW_FIELD_LIMIT } from '@dxos/schema';
16
16
 
17
17
  import { translationKey } from '../../translations';
18
18
  import { FieldEditor } from '../FieldEditor';
19
19
  import { Form, type FormProps } from '../Form';
20
20
 
21
- const grid = 'grid grid-cols-[32px_1fr_32px_32px] min-bs-[2.5rem]';
21
+ const listGrid = 'grid grid-cols-[min-content_1fr_min-content_min-content_min-content]';
22
+ const listItemGrid = 'grid grid-cols-subgrid col-span-5';
22
23
 
23
24
  const ViewMetaSchema = Schema.Struct({
24
- name: Schema.String.annotations({
25
- title: 'View',
26
- }),
27
25
  typename: Format.URL.annotations({
28
- title: 'Typename',
26
+ title: 'Record type',
29
27
  }),
30
28
  }).pipe(Schema.mutable);
31
29
 
@@ -34,7 +32,7 @@ type ViewMetaType = Schema.Schema.Type<typeof ViewMetaSchema>;
34
32
  export type ViewEditorProps = ThemedClassName<
35
33
  {
36
34
  schema: Schema.Schema.AnyNoContext;
37
- view: ViewType;
35
+ view: DataType.View;
38
36
  registry?: SchemaRegistry;
39
37
  readonly?: boolean;
40
38
  showHeading?: boolean;
@@ -61,27 +59,23 @@ export const ViewEditor = ({
61
59
  const projection = useMemo(() => {
62
60
  // Use reactive and mutable version of json schema when schema is mutable.
63
61
  const jsonSchema = schema instanceof EchoSchema ? schema.jsonSchema : toJsonSchema(schema);
64
- return new ViewProjection(jsonSchema, view);
65
- }, [schema, view]);
66
- const [field, setField] = useState<FieldType>();
62
+ return new ProjectionModel(jsonSchema, view.projection);
63
+ }, [schema, view.projection]);
64
+ const [expandedField, setExpandedField] = useState<FieldType['id']>();
67
65
  const readonly = _readonly || !isMutable(schema);
68
66
 
69
67
  // TODO(burdon): Should be reactive.
70
68
  const viewValues = useMemo(() => {
71
69
  return {
72
- name: view.name,
73
70
  // TODO(burdon): Need to warn user of possible consequences of editing.
74
71
  // TODO(burdon): Settings should have domain name owned by user.
75
72
  typename: view.query.typename,
76
73
  };
77
- }, [view]);
74
+ }, [view.query.typename]);
78
75
 
79
- const handleSelect = useCallback(
76
+ const handleToggleField = useCallback(
80
77
  (field: FieldType) => {
81
- if (readonly) {
82
- return;
83
- }
84
- setField((f) => (f === field ? undefined : field));
78
+ setExpandedField((prevExpandedFieldId) => (prevExpandedFieldId === field.id ? undefined : field.id));
85
79
  },
86
80
  [readonly],
87
81
  );
@@ -91,56 +85,52 @@ export const ViewEditor = ({
91
85
  const handleAdd = useCallback(() => {
92
86
  invariant(!readonly);
93
87
  const field = projection.createFieldProjection();
94
- setField(field);
95
- }, [schema, view, readonly]);
88
+ setExpandedField(field.id);
89
+ }, [schema, projection, readonly]);
96
90
 
97
91
  const handleUpdate = useCallback(
98
- ({ name, typename }: ViewMetaType) => {
92
+ ({ typename }: ViewMetaType) => {
99
93
  invariant(!readonly);
100
94
  requestAnimationFrame(() => {
101
- if (view.name !== name) {
102
- view.name = name;
103
- }
104
-
105
95
  if (view.query.typename !== typename && !readonly) {
106
96
  onTypenameChanged?.(typename);
107
97
  }
108
98
  });
109
99
  },
110
- [schema, view, onTypenameChanged, readonly],
100
+ [view.query.typename, onTypenameChanged, readonly],
111
101
  );
112
102
 
113
103
  const handleDelete = useCallback(
114
104
  (fieldId: string) => {
115
105
  invariant(!readonly);
116
- if (fieldId === field?.id) {
117
- setField(undefined);
106
+ if (fieldId === expandedField) {
107
+ setExpandedField(undefined);
118
108
  }
119
109
 
120
110
  onDelete?.(fieldId);
121
111
  },
122
- [schema, field, onDelete, readonly],
112
+ [expandedField, onDelete, readonly],
123
113
  );
124
114
 
125
115
  const handleMove = useCallback(
126
116
  (fromIndex: number, toIndex: number) => {
127
117
  invariant(!readonly);
128
118
  // NOTE(ZaymonFC): Using arrayMove here causes a race condition with the kanban model.
129
- const fields = [...view.fields];
119
+ const fields = [...view.projection.fields];
130
120
  const [moved] = fields.splice(fromIndex, 1);
131
121
  fields.splice(toIndex, 0, moved);
132
- view.fields = fields;
122
+ view.projection.fields = fields;
133
123
  },
134
- [schema, view.fields, readonly],
124
+ [view.projection.fields, readonly],
135
125
  );
136
126
 
137
- const handleClose = useCallback(() => setField(undefined), []);
127
+ const handleClose = useCallback(() => setExpandedField(undefined), []);
138
128
 
139
129
  const hiddenProperties = projection.getHiddenProperties();
140
130
 
141
131
  const handleHide = useCallback(
142
132
  (fieldId: string) => {
143
- setField(undefined);
133
+ setExpandedField(undefined);
144
134
  projection.hideFieldProjection(fieldId);
145
135
  },
146
136
  [projection],
@@ -148,7 +138,7 @@ export const ViewEditor = ({
148
138
 
149
139
  const handleShow = useCallback(
150
140
  (property: string) => {
151
- setField(undefined);
141
+ setExpandedField(undefined);
152
142
  projection.showFieldProjection(property as JsonProp);
153
143
  },
154
144
  [projection],
@@ -157,11 +147,12 @@ export const ViewEditor = ({
157
147
  return (
158
148
  <div role='none' className={mx(classNames)}>
159
149
  {readonly && (
160
- <div role='none' className={mx('is-full', outerSpacing && 'plb-cardSpacingBlock pli-cardSpacingInline')}>
161
- <Callout.Root valence='info'>
162
- <Callout.Title>{t('system schema description')}</Callout.Title>
163
- </Callout.Root>
164
- </div>
150
+ <Callout.Root
151
+ valence='info'
152
+ classNames={['is-full mlb-cardSpacingBlock', outerSpacing && 'mli-cardSpacingInline']}
153
+ >
154
+ <Callout.Title>{t('system schema description')}</Callout.Title>
155
+ </Callout.Root>
165
156
  )}
166
157
 
167
158
  {/* TODO(burdon): Is the form read-only or just the schema? */}
@@ -176,46 +167,65 @@ export const ViewEditor = ({
176
167
  />
177
168
 
178
169
  <div role='none' className={outerSpacing ? cardSpacing : 'mlb-cardSpacingBlock'}>
179
- <label className={mx(inputTextLabel)}>{t('fields label')}</label>
170
+ <h2 className={mx(inputTextLabel)}>{t('fields label')}</h2>
180
171
 
181
172
  <List.Root<FieldType>
182
- items={view.fields}
173
+ items={view.projection.fields}
183
174
  isItem={Schema.is(FieldSchema)}
184
175
  getId={(field) => field.id}
185
- readonly={readonly}
186
176
  onMove={readonly ? undefined : handleMove}
177
+ readonly={readonly}
187
178
  >
188
179
  {({ items: fields }) => (
189
180
  <>
190
- {showHeading && (
191
- <div role='heading' className={grid}>
192
- <div />
193
- <div className='flex items-center text-sm'>{t('field path label')}</div>
194
- </div>
195
- )}
181
+ {showHeading && <h3 className='text-sm'>{t('field path label')}</h3>}
196
182
 
197
- <div role='list' className='flex flex-col is-full'>
183
+ <div role='list' className={listGrid}>
198
184
  {fields?.map((field) => (
199
185
  <List.Item<FieldType>
200
186
  key={field.id}
201
187
  item={field}
202
- classNames={mx(grid, ghostHover, 'overflow-hidden', !readonly && 'cursor-pointer')}
188
+ classNames={listItemGrid}
189
+ aria-expanded={expandedField === field.id}
203
190
  >
204
- <List.ItemDragHandle />
205
- <List.ItemTitle onClick={() => handleSelect(field)}>{field.path}</List.ItemTitle>
206
- <List.ItemButton
207
- data-testid='hide-field-button'
208
- icon='ph--eye-slash--regular'
209
- // TDOO(burdon): Is this the correct test?
210
- disabled={view.fields.length <= 1}
211
- onClick={() => handleHide(field.id)}
212
- />
213
- {/* TODO(burdon): Remove unless implement undo. */}
214
- {!readonly && (
191
+ <div role='none' className={mx(subtleHover, listItemGrid, 'rounded-sm cursor-pointer min-bs-10')}>
192
+ <List.ItemDragHandle disabled={readonly} />
193
+ <List.ItemTitle onClick={() => handleToggleField(field)}>{field.path}</List.ItemTitle>
194
+ <List.ItemButton
195
+ label={t('hide field label')}
196
+ data-testid='hide-field-button'
197
+ icon='ph--eye-slash--regular'
198
+ autoHide={false}
199
+ // TDOO(burdon): Is this the correct test?
200
+ disabled={readonly || view.projection.fields.length <= 1}
201
+ onClick={() => handleHide(field.id)}
202
+ />
203
+ {/* TODO(burdon): Remove unless implement undo. */}
215
204
  <List.ItemDeleteButton
216
- disabled={view.fields.length <= 1}
205
+ label={t('delete field label')}
206
+ autoHide={false}
207
+ disabled={readonly || view.projection.fields.length <= 1}
217
208
  onClick={() => handleDelete(field.id)}
209
+ data-testid='field.delete'
218
210
  />
211
+ <IconButton
212
+ iconOnly
213
+ variant='ghost'
214
+ label={t('toggle expand label', { ns: 'os' })}
215
+ icon={expandedField === field.id ? 'ph--caret-down--regular' : 'ph--caret-right--regular'}
216
+ onClick={() => handleToggleField(field)}
217
+ />
218
+ </div>
219
+ {expandedField === field.id && (
220
+ <div role='none' className='col-span-5'>
221
+ <FieldEditor
222
+ readonly={readonly}
223
+ projection={projection}
224
+ field={field}
225
+ registry={registry}
226
+ onSave={handleClose}
227
+ />
228
+ </div>
219
229
  )}
220
230
  </List.Item>
221
231
  ))}
@@ -226,7 +236,7 @@ export const ViewEditor = ({
226
236
 
227
237
  {hiddenProperties.length > 0 && (
228
238
  <>
229
- <label className={mx(inputTextLabel)}>{t('hidden fields label')}</label>
239
+ <h2 className={mx(inputTextLabel)}>{t('hidden fields label')}</h2>
230
240
 
231
241
  <List.Root<string>
232
242
  items={hiddenProperties}
@@ -234,16 +244,17 @@ export const ViewEditor = ({
234
244
  getId={(property) => property}
235
245
  >
236
246
  {({ items: properties }) => (
237
- <div role='list' className='flex flex-col is-full'>
247
+ <div role='list' className='grid grid-cols-[1fr_min-content]'>
238
248
  {properties?.map((property) => (
239
249
  <List.Item<string>
240
250
  key={property}
241
251
  item={property}
242
- classNames={mx(grid, ghostHover, !readonly && 'cursor-pointer')}
252
+ classNames={mx('grid grid-cols-subgrid col-span-2 rounded-sm', subtleHover)}
243
253
  >
244
- <List.ItemDragHandle disabled />
245
254
  <List.ItemTitle>{property}</List.ItemTitle>
246
255
  <List.ItemButton
256
+ label={t('show field label')}
257
+ disabled={readonly}
247
258
  icon='ph--eye--regular'
248
259
  onClick={() => handleShow(property)}
249
260
  data-testid='show-field-button'
@@ -257,26 +268,15 @@ export const ViewEditor = ({
257
268
  )}
258
269
  </div>
259
270
 
260
- {field && (
261
- <FieldEditor
262
- key={field.id}
263
- view={view}
264
- projection={projection}
265
- field={field}
266
- registry={registry}
267
- onSave={handleClose}
268
- />
269
- )}
270
-
271
- {!readonly && !field && (
271
+ {!readonly && !expandedField && (
272
272
  <div role='none' className={outerSpacing ? cardSpacing : 'mlb-cardSpacingBlock'}>
273
273
  <IconButton
274
274
  icon='ph--plus--regular'
275
275
  label={t('button add property')}
276
276
  onClick={readonly ? undefined : handleAdd}
277
277
  // TODO(burdon): Show field limit in ux (not tooltip).
278
- disabled={view.fields.length >= VIEW_FIELD_LIMIT}
279
- classNames='flex is-full'
278
+ disabled={view.projection.fields.length >= VIEW_FIELD_LIMIT}
279
+ classNames='is-full'
280
280
  />
281
281
  </div>
282
282
  )}
@@ -8,13 +8,14 @@ import { type Meta, type StoryObj } from '@storybook/react-vite';
8
8
  import React from 'react';
9
9
 
10
10
  import { live } from '@dxos/live-object';
11
- import { testData, TestSchema, testView, type TestType } from '@dxos/schema/testing';
11
+ import { TestSchema, type TestType, testData, testView } from '@dxos/schema/testing';
12
12
  import { withLayout, withTheme } from '@dxos/storybook-utils';
13
13
 
14
- import { DeprecatedForm, type DeprecatedFormProps } from './Form';
15
14
  import { translations } from '../../translations';
16
15
  import { TestLayout, TestPanel } from '../testing';
17
16
 
17
+ import { DeprecatedForm, type DeprecatedFormProps } from './Form';
18
+
18
19
  const DefaultStory = (props: DeprecatedFormProps) => (
19
20
  <TestLayout json={{ props }}>
20
21
  <TestPanel>
@@ -40,14 +41,14 @@ type Story = StoryObj<DeprecatedFormProps<TestType>>;
40
41
  export const Default: Story = {
41
42
  args: {
42
43
  object: live(testData),
43
- view: live(testView),
44
+ projection: live(testView).projection,
44
45
  schema: TestSchema,
45
46
  },
46
47
  };
47
48
 
48
49
  export const Empty: Story = {
49
50
  args: {
50
- view: live(testView),
51
+ projection: live(testView).projection,
51
52
  schema: TestSchema,
52
53
  },
53
54
  };
@@ -55,7 +56,7 @@ export const Empty: Story = {
55
56
  export const Readonly: Story = {
56
57
  args: {
57
58
  object: live(testData),
58
- view: live(testView),
59
+ projection: live(testView).projection,
59
60
  schema: TestSchema,
60
61
  readonly: true,
61
62
  },