@dxos/react-ui-form 0.8.4-main.422d1c7879 → 0.8.4-main.4f23b4e393

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 (110) hide show
  1. package/dist/lib/browser/index.mjs +148 -273
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/browser/translations.mjs +82 -0
  5. package/dist/lib/browser/translations.mjs.map +7 -0
  6. package/dist/lib/node-esm/index.mjs +148 -273
  7. package/dist/lib/node-esm/index.mjs.map +4 -4
  8. package/dist/lib/node-esm/meta.json +1 -1
  9. package/dist/lib/node-esm/translations.mjs +84 -0
  10. package/dist/lib/node-esm/translations.mjs.map +7 -0
  11. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  12. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +63 -63
  13. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
  14. package/dist/types/src/components/Form/Form.d.ts +19 -3
  15. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  16. package/dist/types/src/components/Form/Form.stories.d.ts +72 -72
  17. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  18. package/dist/types/src/components/Form/FormField.d.ts +4 -4
  19. package/dist/types/src/components/Form/FormField.d.ts.map +1 -1
  20. package/dist/types/src/components/Form/FormFieldComponent.d.ts +5 -6
  21. package/dist/types/src/components/Form/FormFieldComponent.d.ts.map +1 -1
  22. package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts.map +1 -1
  23. package/dist/types/src/components/Form/FormFieldSet.d.ts +4 -7
  24. package/dist/types/src/components/Form/FormFieldSet.d.ts.map +1 -1
  25. package/dist/types/src/components/Form/fields/ArrayField.d.ts +4 -4
  26. package/dist/types/src/components/Form/fields/ArrayField.d.ts.map +1 -1
  27. package/dist/types/src/components/Form/fields/BooleanField.d.ts.map +1 -1
  28. package/dist/types/src/components/Form/fields/GeoPointField.d.ts.map +1 -1
  29. package/dist/types/src/components/Form/fields/MarkdownField.d.ts.map +1 -1
  30. package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts +64 -63
  31. package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts.map +1 -1
  32. package/dist/types/src/components/Form/fields/NumberField.d.ts.map +1 -1
  33. package/dist/types/src/components/Form/fields/RefField.d.ts.map +1 -1
  34. package/dist/types/src/components/Form/fields/SelectField.d.ts.map +1 -1
  35. package/dist/types/src/components/Form/fields/SelectOptionField.d.ts.map +1 -1
  36. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts +63 -63
  37. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts.map +1 -1
  38. package/dist/types/src/components/Form/fields/TextAreaField.d.ts.map +1 -1
  39. package/dist/types/src/components/Form/fields/TextField.d.ts.map +1 -1
  40. package/dist/types/src/components/Form/fields/TupleField.d.ts.map +1 -1
  41. package/dist/types/src/components/ObjectForm/ObjectForm.d.ts.map +1 -1
  42. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +1 -1
  43. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
  44. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +63 -63
  45. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -1
  46. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts +1 -1
  47. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts.map +1 -1
  48. package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts +63 -63
  49. package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts.map +1 -1
  50. package/dist/types/src/components/Settings/Settings.d.ts +10 -2
  51. package/dist/types/src/components/Settings/Settings.d.ts.map +1 -1
  52. package/dist/types/src/components/Settings/Settings.stories.d.ts +63 -63
  53. package/dist/types/src/components/Settings/Settings.stories.d.ts.map +1 -1
  54. package/dist/types/src/components/Settings/SettingsFieldSet.d.ts +15 -10
  55. package/dist/types/src/components/Settings/SettingsFieldSet.d.ts.map +1 -1
  56. package/dist/types/src/components/Settings/SettingsItem.d.ts +8 -9
  57. package/dist/types/src/components/Settings/SettingsItem.d.ts.map +1 -1
  58. package/dist/types/src/components/Settings/SettingsSelectField.d.ts +18 -0
  59. package/dist/types/src/components/Settings/SettingsSelectField.d.ts.map +1 -0
  60. package/dist/types/src/components/Settings/index.d.ts +1 -0
  61. package/dist/types/src/components/Settings/index.d.ts.map +1 -1
  62. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +3 -4
  63. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  64. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +63 -63
  65. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  66. package/dist/types/src/components/testing.d.ts.map +1 -1
  67. package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -1
  68. package/dist/types/src/hooks/useKeyHandler.d.ts.map +1 -1
  69. package/dist/types/src/index.d.ts +0 -2
  70. package/dist/types/src/index.d.ts.map +1 -1
  71. package/dist/types/src/translations.d.ts +63 -63
  72. package/dist/types/src/translations.d.ts.map +1 -1
  73. package/dist/types/src/util/field-type.d.ts +1 -1
  74. package/dist/types/src/util/field-type.d.ts.map +1 -1
  75. package/dist/types/src/util/field.d.ts.map +1 -1
  76. package/dist/types/src/util/formatting.d.ts.map +1 -1
  77. package/dist/types/src/util/properties.d.ts.map +1 -1
  78. package/dist/types/src/util/refs.d.ts.map +1 -1
  79. package/dist/types/tsconfig.tsbuildinfo +1 -1
  80. package/package.json +39 -30
  81. package/src/components/FieldEditor/FieldEditor.stories.tsx +2 -1
  82. package/src/components/FieldEditor/FieldEditor.tsx +3 -2
  83. package/src/components/Form/Form.stories.tsx +2 -1
  84. package/src/components/Form/Form.tsx +3 -3
  85. package/src/components/Form/FormField.tsx +2 -1
  86. package/src/components/Form/FormFieldComponent.tsx +3 -3
  87. package/src/components/Form/fields/ArrayField.tsx +6 -9
  88. package/src/components/Form/fields/GeoPointField.tsx +3 -2
  89. package/src/components/Form/fields/MarkdownField.stories.tsx +56 -1
  90. package/src/components/Form/fields/MarkdownField.tsx +2 -1
  91. package/src/components/Form/fields/RefField.tsx +4 -3
  92. package/src/components/Form/fields/SelectOptionField.stories.tsx +2 -1
  93. package/src/components/Form/fields/SelectOptionField.tsx +6 -5
  94. package/src/components/ObjectForm/ObjectForm.tsx +6 -5
  95. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +8 -5
  96. package/src/components/ObjectPicker/ObjectPicker.tsx +18 -9
  97. package/src/components/ObjectProperties/ObjectProperties.stories.tsx +2 -1
  98. package/src/components/ObjectProperties/ObjectProperties.tsx +5 -4
  99. package/src/components/Settings/AUDIT.md +52 -0
  100. package/src/components/Settings/Settings.stories.tsx +2 -1
  101. package/src/components/Settings/Settings.tsx +3 -4
  102. package/src/components/Settings/SettingsFieldSet.tsx +55 -9
  103. package/src/components/Settings/SettingsItem.tsx +4 -8
  104. package/src/components/Settings/SettingsSelectField.tsx +59 -0
  105. package/src/components/Settings/index.ts +1 -0
  106. package/src/components/ViewEditor/ViewEditor.stories.tsx +7 -7
  107. package/src/components/ViewEditor/ViewEditor.tsx +17 -24
  108. package/src/components/testing.tsx +1 -1
  109. package/src/index.ts +0 -4
  110. package/src/util/field-type.ts +4 -1
@@ -15,7 +15,8 @@ import { FactoryAnnotation } from '@dxos/schema';
15
15
  import { composable, composableProps } from '@dxos/ui-theme';
16
16
  import { isNonNullable } from '@dxos/util';
17
17
 
18
- import { translationKey } from '../../translations';
18
+ import { translationKey } from '#translations';
19
+
19
20
  import { Form, type FormFieldMap, omitId } from '../Form';
20
21
 
21
22
  export type ObjectPropertiesProps = PropsWithChildren<{ object: Obj.Unknown }>;
@@ -70,10 +71,10 @@ export const ObjectProperties = composable<HTMLDivElement, ObjectPropertiesProps
70
71
 
71
72
  const changedPaths = Object.keys(changed).filter((path) => changed[path as JsonPath]) as JsonPath[];
72
73
 
73
- // Handle tags separately using Obj.change.
74
+ // Handle tags separately using Obj.update.
74
75
  const hasTagsChange = changedPaths.some((path) => splitJsonPath(path)[0] === 'tags');
75
76
  if (hasTagsChange) {
76
- Obj.change(object, (object) => {
77
+ Obj.update(object, (object) => {
77
78
  Obj.getMeta(object).tags = tags?.map((tag: Ref.Ref<Tag.Tag>) => tag.dxn.toString()) ?? [];
78
79
  });
79
80
  }
@@ -81,7 +82,7 @@ export const ObjectProperties = composable<HTMLDivElement, ObjectPropertiesProps
81
82
  // Handle other property changes.
82
83
  const nonTagPaths = changedPaths.filter((path) => splitJsonPath(path)[0] !== 'tags');
83
84
  if (nonTagPaths.length > 0) {
84
- Obj.change(object, () => {
85
+ Obj.update(object, () => {
85
86
  for (const path of nonTagPaths) {
86
87
  const parts = splitJsonPath(path);
87
88
  const value = Obj.getValue(values as any, parts);
@@ -0,0 +1,52 @@
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-files` | `IconButton` actions (select root, export, import) and gating on non-schema state (`state.rootHandle`). |
44
+ | `plugin-space` | Renders a list of spaces with per-space "open settings" buttons — not bound to any schema field. |
45
+ | `plugin-spacetime` | Section is currently empty; nothing to render. |
46
+ | `plugin-thread` | Settings schema is `Schema.Struct({})` — nothing to render. |
47
+
48
+ ## When to extend `SettingsFieldSet` vs. add a custom field
49
+
50
+ - **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`.
51
+ - **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.
52
+ - **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`.
@@ -10,7 +10,8 @@ import { random } from '@dxos/random';
10
10
  import { Button, Input } from '@dxos/react-ui';
11
11
  import { withLayout, withTheme } from '@dxos/react-ui/testing';
12
12
 
13
- import { translations } from '../../translations';
13
+ import { translations } from '#translations';
14
+
14
15
  import { Settings } from './Settings';
15
16
 
16
17
  random.seed(132);
@@ -7,7 +7,8 @@ import React, { type PropsWithChildren } from 'react';
7
7
  import { type Label, ScrollArea, type ThemedClassName, toLocalizedString, useTranslation } from '@dxos/react-ui';
8
8
  import { composable, composableProps, mx } from '@dxos/ui-theme';
9
9
 
10
- import { translationKey } from '../../translations';
10
+ import { translationKey } from '#translations';
11
+
11
12
  import { SettingsFieldSet } from './SettingsFieldSet';
12
13
  import { SettingsItem, SettingsPanel } from './SettingsItem';
13
14
 
@@ -35,9 +36,7 @@ const SettingsViewport = composable<HTMLDivElement>(({ children, ...props }, for
35
36
  return (
36
37
  <ScrollArea.Root {...composableProps(props)} orientation='vertical' centered thin ref={forwardedRef}>
37
38
  <ScrollArea.Viewport classNames='px-4 pointer-coarse:px-2'>
38
- <div role='none' className='dx-document flex flex-col gap-2 py-4'>
39
- {children}
40
- </div>
39
+ <div className='dx-document flex flex-col gap-2 py-4'>{children}</div>
41
40
  </ScrollArea.Viewport>
42
41
  </ScrollArea.Root>
43
42
  );
@@ -27,9 +27,14 @@ export type SettingsFieldProps<T = any> = {
27
27
  };
28
28
 
29
29
  /**
30
- * Map of property names to custom field renderers.
30
+ * Map of property names to either a custom field renderer or a nested field map for struct fields.
31
+ * When a value type `T` is provided, the map shape is checked against the schema's value type.
31
32
  */
32
- export type SettingsFieldMap = Record<string, React.FC<SettingsFieldProps>>;
33
+ export type SettingsFieldMap<T extends Record<string, any> = Record<string, any>> = {
34
+ [K in keyof T]?: NonNullable<T[K]> extends Record<string, any>
35
+ ? SettingsFieldMap<NonNullable<T[K]>>
36
+ : React.FC<SettingsFieldProps<T[K]>>;
37
+ };
33
38
 
34
39
  export type SettingsFieldSetProps<T extends Record<string, any> = Record<string, any>> = {
35
40
  /** Effect Schema for the settings object. */
@@ -44,14 +49,17 @@ export type SettingsFieldSetProps<T extends Record<string, any> = Record<string,
44
49
  /** When true, all controls are disabled. */
45
50
  readonly?: boolean;
46
51
 
47
- /** Map of property names to custom field renderers. */
48
- fieldMap?: SettingsFieldMap;
52
+ /** Map of property names to custom field renderers. Nested objects map to struct sub-fields. */
53
+ fieldMap?: SettingsFieldMap<T>;
49
54
 
50
- /** Control field visibility. Return false to hide a field. */
55
+ /** Control field visibility. Receives the dotted path (e.g. `'modelDefaults.edge'`) at every level. */
51
56
  visible?: (path: string, values: T) => boolean;
52
57
 
53
- /** Override the order of fields. Fields not listed are appended in schema order. */
58
+ /** Override the order of fields. Fields not listed are appended in schema order. Applied per scope. */
54
59
  sort?: string[];
60
+
61
+ /** Internal: dot-path prefix used by recursive struct rendering. */
62
+ pathPrefix?: string;
55
63
  };
56
64
 
57
65
  //
@@ -66,6 +74,7 @@ export const SettingsFieldSet = <T extends Record<string, any>>({
66
74
  fieldMap,
67
75
  visible,
68
76
  sort,
77
+ pathPrefix = '',
69
78
  }: SettingsFieldSetProps<T>) => {
70
79
  const properties = useMemo(() => {
71
80
  const props = getFormProperties(schema.ast);
@@ -90,18 +99,27 @@ export const SettingsFieldSet = <T extends Record<string, any>>({
90
99
  <>
91
100
  {properties.map((property) => {
92
101
  const name = property.name.toString();
93
- if (visible && !visible(name, values)) {
102
+ const path = pathPrefix ? `${pathPrefix}.${name}` : name;
103
+ if (visible && !visible(path, values)) {
94
104
  return null;
95
105
  }
96
106
 
107
+ const entry = fieldMap?.[name as keyof typeof fieldMap] as
108
+ | React.FC<SettingsFieldProps>
109
+ | SettingsFieldMap
110
+ | undefined;
97
111
  return (
98
112
  <SettingsFieldItem
99
113
  key={name}
114
+ path={path}
100
115
  property={property}
101
116
  value={values[name]}
102
117
  onChange={(value) => handleChange(name, value)}
103
118
  readonly={readonly}
104
- customField={fieldMap?.[name]}
119
+ customField={typeof entry === 'function' ? entry : undefined}
120
+ nestedFieldMap={typeof entry === 'object' ? entry : undefined}
121
+ visible={visible as (path: string, values: any) => boolean}
122
+ sort={sort}
105
123
  />
106
124
  );
107
125
  })}
@@ -117,13 +135,27 @@ SettingsFieldSet.displayName = 'Settings.FieldSet';
117
135
 
118
136
  type SettingsFieldItemProps = {
119
137
  property: SchemaProperty;
138
+ path: string;
120
139
  value: any;
121
140
  onChange: (value: any) => void;
122
141
  readonly?: boolean;
123
142
  customField?: React.FC<SettingsFieldProps>;
143
+ nestedFieldMap?: SettingsFieldMap;
144
+ visible?: (path: string, values: any) => boolean;
145
+ sort?: string[];
124
146
  };
125
147
 
126
- const SettingsFieldItem = ({ property, value, onChange, readonly, customField }: SettingsFieldItemProps) => {
148
+ const SettingsFieldItem = ({
149
+ property,
150
+ path,
151
+ value,
152
+ onChange,
153
+ readonly,
154
+ customField,
155
+ nestedFieldMap,
156
+ visible,
157
+ sort,
158
+ }: SettingsFieldItemProps) => {
127
159
  const { type } = property;
128
160
  const name = property.name.toString();
129
161
  const title = getAnnotation<string>(SchemaAST.TitleAnnotationId)(type) ?? String.capitalize(name);
@@ -142,6 +174,20 @@ const SettingsFieldItem = ({ property, value, onChange, readonly, customField }:
142
174
  const fieldType = detectFieldType(type);
143
175
 
144
176
  switch (fieldType) {
177
+ case 'struct':
178
+ return (
179
+ <SettingsFieldSet
180
+ schema={{ ast: type }}
181
+ values={value ?? {}}
182
+ onValuesChanged={onChange}
183
+ readonly={readonly}
184
+ fieldMap={nestedFieldMap}
185
+ visible={visible}
186
+ sort={sort}
187
+ pathPrefix={path}
188
+ />
189
+ );
190
+
145
191
  case 'boolean':
146
192
  return (
147
193
  <SettingsItem title={title} description={description}>
@@ -7,7 +7,7 @@ import React, { type PropsWithChildren } from 'react';
7
7
  import { Input, type Label, type ThemedClassName, toLocalizedString, useTranslation } from '@dxos/react-ui';
8
8
  import { mx } from '@dxos/ui-theme';
9
9
 
10
- import { translationKey } from '../../translations';
10
+ import { translationKey } from '#translations';
11
11
 
12
12
  const SETTINGS_PANEL_NAME = 'Settings.Panel';
13
13
  const SETTINGS_ITEM_NAME = 'Settings.Item';
@@ -20,9 +20,7 @@ type SettingsPanelProps = ThemedClassName<PropsWithChildren>;
20
20
 
21
21
  export const SettingsPanel = ({ classNames, children }: SettingsPanelProps) => {
22
22
  return (
23
- <div role='none' className={mx('flex flex-col gap-3 p-trim-md border border-separator rounded-sm', classNames)}>
24
- {children}
25
- </div>
23
+ <div className={mx('flex flex-col gap-3 p-trim-md border border-separator rounded-sm', classNames)}>{children}</div>
26
24
  );
27
25
  };
28
26
 
@@ -43,7 +41,7 @@ export const SettingsItem = ({ title, description = '', children }: SettingsItem
43
41
  return (
44
42
  <Input.Root>
45
43
  <SettingsPanel classNames='grid grid-cols-1 md:grid-cols-[1fr_1fr] gap-x-trim-lg gap-y-0'>
46
- <Input.Label classNames='pb-trim-md text-base-surface-text text-lg md:col-span-2'>
44
+ <Input.Label classNames='pb-trim-md text-base-foreground text-lg md:col-span-2'>
47
45
  {toLocalizedString(title, t)}
48
46
  </Input.Label>
49
47
  <Input.DescriptionAndValidation>
@@ -51,9 +49,7 @@ export const SettingsItem = ({ title, description = '', children }: SettingsItem
51
49
  {toLocalizedString(description, t)}
52
50
  </Input.Description>
53
51
  </Input.DescriptionAndValidation>
54
- <div role='none' className='text-end py-1'>
55
- {children}
56
- </div>
52
+ <div className='text-end py-1'>{children}</div>
57
53
  </SettingsPanel>
58
54
  </Input.Root>
59
55
  );
@@ -0,0 +1,59 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React from 'react';
6
+
7
+ import { Select } from '@dxos/react-ui';
8
+
9
+ import { type SettingsFieldProps } from './SettingsFieldSet';
10
+
11
+ export type SelectFieldOption = {
12
+ value: string;
13
+ label?: string;
14
+ };
15
+
16
+ export type CreateSelectFieldOptions = {
17
+ /** Options to display. Strings are used as both value and label. */
18
+ options: ReadonlyArray<string | SelectFieldOption>;
19
+ /** Label for the sentinel option that maps to `undefined`. Defaults to `'Default'`. Pass `null` to omit. */
20
+ defaultLabel?: string | null;
21
+ };
22
+
23
+ /**
24
+ * Factory for a `SettingsFieldSet` `fieldMap` entry that renders a Select control
25
+ * over a fixed list of options, with an optional sentinel for `undefined`.
26
+ */
27
+ export const createSelectField = ({
28
+ options,
29
+ defaultLabel = 'Default',
30
+ }: CreateSelectFieldOptions): React.FC<SettingsFieldProps<string | undefined>> => {
31
+ const normalized = options.map((option) => (typeof option === 'string' ? { value: option, label: option } : option));
32
+ const hasDefault = defaultLabel !== null;
33
+ // Per-instance sentinel avoids collisions with real option values; empty string when the
34
+ // sentinel is omitted so `undefined` maps to a value Select.Root treats as "no selection".
35
+ const sentinel = hasDefault ? `__default__${Math.random().toString(36).slice(2)}` : '';
36
+
37
+ return ({ value, onChange, readonly }) => (
38
+ <Select.Root
39
+ disabled={readonly}
40
+ value={value ?? sentinel}
41
+ onValueChange={(next) => onChange(hasDefault && next === sentinel ? undefined : next)}
42
+ >
43
+ <Select.TriggerButton disabled={readonly} />
44
+ <Select.Portal>
45
+ <Select.Content>
46
+ <Select.Viewport>
47
+ {hasDefault && <Select.Option value={sentinel}>{defaultLabel}</Select.Option>}
48
+ {normalized.map((option) => (
49
+ <Select.Option key={option.value} value={option.value}>
50
+ {option.label ?? option.value}
51
+ </Select.Option>
52
+ ))}
53
+ </Select.Viewport>
54
+ <Select.Arrow />
55
+ </Select.Content>
56
+ </Select.Portal>
57
+ </Select.Root>
58
+ );
59
+ };
@@ -4,3 +4,4 @@
4
4
 
5
5
  export * from './Settings';
6
6
  export { type SettingsFieldMap, type SettingsFieldProps, type SettingsFieldSetProps } from './SettingsFieldSet';
7
+ export { createSelectField, type CreateSelectFieldOptions, type SelectFieldOption } from './SettingsSelectField';
@@ -6,8 +6,7 @@ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
6
  import * as Schema from 'effect/Schema';
7
7
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
8
8
 
9
- import { DXN, Filter, JsonSchema, Obj, Query, type QueryAST, Tag, Type } from '@dxos/echo';
10
- import { type View } from '@dxos/echo';
9
+ import { DXN, Filter, JsonSchema, Obj, Query, type QueryAST, Tag, Type, type View } from '@dxos/echo';
11
10
  import { type EchoSchema, Format, type Mutable } from '@dxos/echo/internal';
12
11
  import { useQuery } from '@dxos/react-client/echo';
13
12
  import { useClientStory, withClientProvider } from '@dxos/react-client/testing';
@@ -16,7 +15,8 @@ import { withLayout, withTheme } from '@dxos/react-ui/testing';
16
15
  import { type ProjectionModel, ViewModel, getTypenameFromQuery } from '@dxos/schema';
17
16
  import { Employer, Organization, Person, Pipeline } from '@dxos/types';
18
17
 
19
- import { translations } from '../../translations';
18
+ import { translations } from '#translations';
19
+
20
20
  import { TestLayout, VIEW_EDITOR_DEBUG_SYMBOL } from '../testing';
21
21
  import { ViewEditor, type ViewEditorProps } from './ViewEditor';
22
22
 
@@ -89,8 +89,8 @@ const DefaultStory = (props: DefaultStoryProps) => {
89
89
 
90
90
  if (props.mode === 'tag') {
91
91
  const queue = target && DXN.tryParse(target) ? target : undefined;
92
- const query = queue ? Query.fromAst(newQuery).from({ queues: [queue] }) : Query.fromAst(newQuery);
93
- Obj.change(view, (view) => {
92
+ const query = queue ? Query.fromAst(newQuery).from({ feeds: [queue] }) : Query.fromAst(newQuery);
93
+ Obj.update(view, (view) => {
94
94
  view.query.ast = query.ast as Mutable<typeof query.ast>;
95
95
  });
96
96
 
@@ -104,12 +104,12 @@ const DefaultStory = (props: DefaultStoryProps) => {
104
104
  query,
105
105
  jsonSchema: newSchema.jsonSchema,
106
106
  });
107
- Obj.change(view, (view) => {
107
+ Obj.update(view, (view) => {
108
108
  view.projection = Obj.getSnapshot(newView).projection as Mutable<typeof view.projection>;
109
109
  });
110
110
  setSchema(() => newSchema);
111
111
  } else {
112
- Obj.change(view, (view) => {
112
+ Obj.update(view, (view) => {
113
113
  view.query.ast = newQuery as Mutable<typeof newQuery>;
114
114
  });
115
115
  schema.updateTypename(getTypenameFromQuery(newQuery));
@@ -9,8 +9,7 @@ import * as Option from 'effect/Option';
9
9
  import * as Schema from 'effect/Schema';
10
10
  import React, { forwardRef, useCallback, useContext, useImperativeHandle, useMemo, useState } from 'react';
11
11
 
12
- import { Entity, Feed, Filter, Format, Obj, Query, QueryAST, Ref, type SchemaRegistry } from '@dxos/echo';
13
- import { View } from '@dxos/echo';
12
+ import { Entity, Feed, Filter, Format, Obj, Query, QueryAST, Ref, type SchemaRegistry, View } from '@dxos/echo';
14
13
  import { EchoSchema, type JsonProp, isMutable, toJsonSchema } from '@dxos/echo/internal';
15
14
  import { invariant } from '@dxos/invariant';
16
15
  import { useObject, useQuery } from '@dxos/react-client/echo';
@@ -24,9 +23,10 @@ import {
24
23
  createEchoChangeCallback,
25
24
  getTypenameFromQuery,
26
25
  } from '@dxos/schema';
27
- import { mx, osTranslations, subtleHover } from '@dxos/ui-theme';
26
+ import { mx, osTranslations } from '@dxos/ui-theme';
27
+
28
+ import { translationKey } from '#translations';
28
29
 
29
- import { translationKey } from '../../translations';
30
30
  import { FieldEditor } from '../FieldEditor';
31
31
  import {
32
32
  Form,
@@ -100,8 +100,8 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
100
100
  if (from._tag !== 'scope') {
101
101
  return undefined;
102
102
  }
103
- return Option.fromNullable(from.scope.queues).pipe(
104
- Option.flatMap((queues) => Array.head(queues)),
103
+ return Option.fromNullable(from.scope.feeds).pipe(
104
+ Option.flatMap((feeds) => Array.head(feeds)),
105
105
  Option.map(String),
106
106
  Option.getOrUndefined,
107
107
  );
@@ -160,13 +160,13 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
160
160
  const handleUpdate = useCallback(
161
161
  (values: any) => {
162
162
  const targetValue = values.target;
163
- let queueDxn: string | undefined;
163
+ let feedDXN: string | undefined;
164
164
 
165
165
  if (Ref.isRef(targetValue)) {
166
- const feedDxn = targetValue.dxn.toString();
167
- const feed = feeds.find((feed) => Obj.getDXN(feed).toString() === feedDxn);
166
+ const targetDXN = targetValue.dxn.toString();
167
+ const feed = feeds.find((feed) => Obj.getDXN(feed).toString() === targetDXN);
168
168
  if (feed) {
169
- queueDxn = Feed.getQueueDxn(feed)?.toString();
169
+ feedDXN = Feed.getQueueDxn(feed)?.toString();
170
170
  }
171
171
  }
172
172
 
@@ -175,7 +175,7 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
175
175
  mode === 'schema'
176
176
  ? Query.select(Filter.typename(values.query)).ast
177
177
  : JSON.parse(JSON.stringify(values.query));
178
- onQueryChanged?.(query, queueDxn);
178
+ onQueryChanged?.(query, feedDXN);
179
179
  },
180
180
  [onQueryChanged, mode, feeds],
181
181
  );
@@ -189,7 +189,7 @@ export const ViewEditor = forwardRef<ProjectionModel, ViewEditorProps>(
189
189
  );
190
190
 
191
191
  return (
192
- <div role='none' className={mx(classNames)}>
192
+ <div className={mx(classNames)}>
193
193
  {/* If readonly is set, then the callout is not needed. */}
194
194
  {schemaReadonly && !readonly && (
195
195
  <Message.Root valence='info' classNames='my-form-padding'>
@@ -274,7 +274,7 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
274
274
  const handleMove = useCallback(
275
275
  (fromIndex: number, toIndex: number) => {
276
276
  invariant(!readonly);
277
- Obj.change(view, (view) => {
277
+ Obj.update(view, (view) => {
278
278
  // NOTE(ZaymonFC): Using arrayMove here causes a race condition with the kanban model.
279
279
  const fields = [...view.projection.fields];
280
280
  const [moved] = fields.splice(fromIndex, 1);
@@ -328,20 +328,13 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
328
328
  classNames={'grid grid-cols-subgrid col-span-5'}
329
329
  aria-expanded={expandedField === field.id}
330
330
  >
331
- <div
332
- role='none'
333
- className={mx(
334
- subtleHover,
335
- 'grid grid-cols-subgrid col-span-5',
336
- 'rounded-xs cursor-pointer min-h-10',
337
- )}
338
- >
331
+ <div className='grid grid-cols-subgrid col-span-5 rounded-xs cursor-pointer min-h-10 dx-hover'>
339
332
  <List.ItemDragHandle disabled={readonly || schemaReadonly} />
340
333
  <List.ItemTitle classNames={hidden && 'text-subdued'} onClick={() => handleToggleField(field)}>
341
334
  {field.path}
342
335
  </List.ItemTitle>
343
336
  <List.ItemIconButton
344
- label={t(hidden ? 'show field label' : 'hide field label')}
337
+ label={t(hidden ? 'show-field.label' : 'hide-field.label')}
345
338
  data-testid={hidden ? 'show-field-button' : 'hide-field-button'}
346
339
  icon={hidden ? 'ph--eye-closed--regular' : 'ph--eye--regular'}
347
340
  autoHide={false}
@@ -369,7 +362,7 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
369
362
  )}
370
363
  </div>
371
364
  {expandedField === field.id && !readonly && (
372
- <div role='none' className='col-span-full mt-1 mb-1 border border-separator rounded-md'>
365
+ <div className='col-span-full mt-1 mb-1 border border-separator rounded-md'>
373
366
  <FieldEditor
374
367
  readonly={readonly || schemaReadonly}
375
368
  registry={registry}
@@ -384,7 +377,7 @@ const FieldList = ({ schema, view, registry, readonly, showHeading = false, onDe
384
377
  })}
385
378
  </div>
386
379
  {!readonly && !expandedField && (
387
- <div role='none' className='my-form-padding'>
380
+ <div className='my-form-padding'>
388
381
  <IconButton
389
382
  icon='ph--plus--regular'
390
383
  label={t('add-property-button.label')}
@@ -12,7 +12,7 @@ type TestLayoutProps = PropsWithChildren<{ json?: any }>;
12
12
 
13
13
  export const TestLayout = ({ children, json }: TestLayoutProps) => {
14
14
  return (
15
- <div role='none' className='dx-container grid grid-cols-[1fr_1fr] p-4 gap-4'>
15
+ <div className='dx-container grid grid-cols-[1fr_1fr] p-4 gap-4'>
16
16
  <TestPanel>{children}</TestPanel>
17
17
  <TestPanel>
18
18
  <Syntax.Root data={json}>
package/src/index.ts CHANGED
@@ -2,10 +2,6 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { translations } from './translations';
6
-
7
5
  export * from './components';
8
6
  export * from './hooks';
9
7
  export * from './util';
10
-
11
- export { translations };
@@ -9,7 +9,7 @@ import { getAnnotation, isLiteralUnion } from '@dxos/effect';
9
9
  /**
10
10
  * Detected field type for a schema property.
11
11
  */
12
- export type SettingsFieldType = 'boolean' | 'string' | 'number' | 'select';
12
+ export type SettingsFieldType = 'boolean' | 'string' | 'number' | 'select' | 'struct';
13
13
 
14
14
  export type SelectOption = {
15
15
  value: string | number;
@@ -23,6 +23,9 @@ export const detectFieldType = (ast: SchemaAST.AST): SettingsFieldType | undefin
23
23
  if (isLiteralUnion(ast)) {
24
24
  return 'select';
25
25
  }
26
+ if (SchemaAST.isTypeLiteral(ast)) {
27
+ return 'struct';
28
+ }
26
29
 
27
30
  switch (ast._tag) {
28
31
  case 'BooleanKeyword':