@dxos/react-ui-form 0.8.4-main.b97322e → 0.8.4-main.dedc0f3

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 (94) hide show
  1. package/dist/lib/browser/index.mjs +601 -528
  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 +601 -528
  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 +79 -4
  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 +74 -4
  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 +5 -4
  20. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  21. package/dist/types/src/components/Form/Form.stories.d.ts +126 -30
  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 +16 -10
  25. package/dist/types/src/components/Form/FormContent.d.ts.map +1 -1
  26. package/dist/types/src/components/Form/FormContext.d.ts +1 -1
  27. package/dist/types/src/components/Form/FormContext.d.ts.map +1 -1
  28. package/dist/types/src/components/Form/Input.d.ts +4 -1
  29. package/dist/types/src/components/Form/Input.d.ts.map +1 -1
  30. package/dist/types/src/components/Form/RefField.d.ts +10 -4
  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 +74 -3
  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.map +1 -1
  40. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +3 -2
  41. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  42. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +76 -5
  43. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  44. package/dist/types/src/components/index.d.ts +0 -1
  45. package/dist/types/src/components/index.d.ts.map +1 -1
  46. package/dist/types/src/components/testing.d.ts +6 -3
  47. package/dist/types/src/components/testing.d.ts.map +1 -1
  48. package/dist/types/src/hooks/useForm.d.ts +1 -1
  49. package/dist/types/src/hooks/useForm.d.ts.map +1 -1
  50. package/dist/types/src/translations.d.ts +9 -4
  51. package/dist/types/src/translations.d.ts.map +1 -1
  52. package/dist/types/src/util/field.d.ts.map +1 -1
  53. package/dist/types/tsconfig.tsbuildinfo +1 -1
  54. package/package.json +35 -30
  55. package/src/components/ControlSection/ControlSection.stories.tsx +8 -10
  56. package/src/components/ControlSection/ControlSection.tsx +26 -12
  57. package/src/components/FieldEditor/FieldEditor.stories.tsx +8 -9
  58. package/src/components/FieldEditor/FieldEditor.test.tsx +3 -2
  59. package/src/components/FieldEditor/FieldEditor.tsx +22 -17
  60. package/src/components/Form/ArrayField.tsx +8 -8
  61. package/src/components/Form/Defaults.tsx +22 -20
  62. package/src/components/Form/Form.stories.tsx +129 -93
  63. package/src/components/Form/Form.tsx +16 -12
  64. package/src/components/Form/FormActions.tsx +2 -1
  65. package/src/components/Form/FormContent.tsx +96 -34
  66. package/src/components/Form/FormContext.tsx +5 -5
  67. package/src/components/Form/Input.tsx +4 -1
  68. package/src/components/Form/RefField.tsx +126 -40
  69. package/src/components/Form/custom/GeoPointInput.tsx +29 -21
  70. package/src/components/Form/custom/SelectOptionsInput.stories.tsx +7 -6
  71. package/src/components/Form/custom/SelectOptionsInput.tsx +29 -30
  72. package/src/components/Form/custom/TupleInput.tsx +2 -2
  73. package/src/components/Form/factory.tsx +1 -1
  74. package/src/components/ViewEditor/ViewEditor.stories.tsx +50 -23
  75. package/src/components/ViewEditor/ViewEditor.test.tsx +7 -10
  76. package/src/components/ViewEditor/ViewEditor.tsx +151 -132
  77. package/src/components/index.ts +0 -2
  78. package/src/components/testing.tsx +15 -9
  79. package/src/hooks/useForm.ts +5 -3
  80. package/src/hooks/useRefQueryLookupHandler.ts +1 -1
  81. package/src/translations.ts +11 -5
  82. package/src/util/field.ts +8 -18
  83. package/dist/types/src/components/deprecated/Form.d.ts +0 -15
  84. package/dist/types/src/components/deprecated/Form.d.ts.map +0 -1
  85. package/dist/types/src/components/deprecated/Form.stories.d.ts +0 -13
  86. package/dist/types/src/components/deprecated/Form.stories.d.ts.map +0 -1
  87. package/dist/types/src/components/deprecated/FormInput.d.ts +0 -16
  88. package/dist/types/src/components/deprecated/FormInput.d.ts.map +0 -1
  89. package/dist/types/src/components/deprecated/index.d.ts +0 -2
  90. package/dist/types/src/components/deprecated/index.d.ts.map +0 -1
  91. package/src/components/deprecated/Form.stories.tsx +0 -62
  92. package/src/components/deprecated/Form.tsx +0 -119
  93. package/src/components/deprecated/FormInput.tsx +0 -71
  94. package/src/components/deprecated/index.ts +0 -5
@@ -19,9 +19,12 @@ export type InputProps = {
19
19
  type: SimpleType;
20
20
  format?: FormatEnum;
21
21
  label: string;
22
- disabled?: boolean;
23
22
  placeholder?: string;
24
23
  inputOnly?: boolean;
24
+ /**
25
+ * Specifies the readonly variant: either disabled inputs, elements indicating they are usually editable but currently are not; or `static`, a field’s representation as regular content without signifiers that it is ever editable.
26
+ */
27
+ readonly?: 'disabled-input' | 'static' | false;
25
28
  } & FormInputStateProps;
26
29
 
27
30
  /**
@@ -3,38 +3,50 @@
3
3
  //
4
4
 
5
5
  import { type SchemaAST } from 'effect';
6
- import React, { useCallback, useMemo, type FocusEvent } from 'react';
6
+ import React, { useCallback, useMemo, useState } from 'react';
7
7
 
8
8
  import {
9
9
  Expando,
10
- getTypeAnnotation,
11
10
  Ref,
12
11
  ReferenceAnnotationId,
13
12
  type ReferenceAnnotationValue,
13
+ type TypeAnnotation,
14
+ getTypeAnnotation,
14
15
  } from '@dxos/echo-schema';
15
16
  import { findAnnotation } from '@dxos/effect';
16
17
  import { DXN } from '@dxos/keys';
17
- import { DxRefTag } from '@dxos/lit-ui/react';
18
- import { Input, useTranslation } from '@dxos/react-ui';
19
- import { TagPicker, type TagPickerMode, type TagPickerItemData } from '@dxos/react-ui-tag-picker';
18
+ import { type DxTagPickerItemClick } from '@dxos/lit-ui';
19
+ import { DxAnchor } from '@dxos/lit-ui/react';
20
+ import { Button, Icon, Input, useTranslation } from '@dxos/react-ui';
21
+ import { PopoverCombobox } from '@dxos/react-ui-searchlist';
22
+ import { TagPickerItem } from '@dxos/react-ui-tag-picker';
20
23
  import { descriptionText, mx } from '@dxos/react-ui-theme';
21
- import { isNonNullable } from '@dxos/util';
24
+ import { type MaybePromise, isNonNullable } from '@dxos/util';
22
25
 
23
- import { TextInput } from './Defaults';
24
- import { InputHeader, type InputProps } from './Input';
25
26
  import { type QueryRefOptions, useQueryRefOptions } from '../../hooks';
26
27
  import { translationKey } from '../../translations';
27
28
 
28
- type RefFieldProps = InputProps & {
29
+ import { TextInput } from './Defaults';
30
+ import { InputHeader, type InputProps } from './Input';
31
+
32
+ export type RefFieldProps = InputProps & {
29
33
  ast?: SchemaAST.AST;
30
34
  array?: boolean;
31
35
  onQueryRefOptions?: QueryRefOptions;
36
+ createOptionLabel?: [string, { ns: string }];
37
+ createOptionIcon?: string;
38
+ onCreateFromQuery?: (type: TypeAnnotation, query: string) => MaybePromise<void>;
39
+ };
40
+
41
+ // TODO(thure): Is this a standard that should be better canonized?
42
+ const isRefSnapShot = (val: any): val is { '/': string } => {
43
+ return typeof val === 'object' && typeof (val as any)?.['/'] === 'string';
32
44
  };
33
45
 
34
46
  export const RefField = ({
35
47
  type,
36
48
  label,
37
- disabled,
49
+ readonly,
38
50
  placeholder,
39
51
  inputOnly,
40
52
  array,
@@ -42,6 +54,9 @@ export const RefField = ({
42
54
  getValue,
43
55
  onBlur,
44
56
  onQueryRefOptions,
57
+ createOptionLabel,
58
+ createOptionIcon,
59
+ onCreateFromQuery,
45
60
  onValueChange,
46
61
  ...restInputProps
47
62
  }: RefFieldProps) => {
@@ -56,17 +71,18 @@ export const RefField = ({
56
71
  // If ref type is expando, fall back to taking a DXN in string format.
57
72
  return (
58
73
  <RefFieldFallback
59
- {...{ type, label, placeholder, disabled, inputOnly, getValue, onBlur, onValueChange, ...restInputProps }}
74
+ {...{ type, label, placeholder, readonly, inputOnly, getValue, onBlur, onValueChange, ...restInputProps }}
60
75
  />
61
76
  );
62
77
  }
63
78
 
64
- const handleGetValue = useCallback((): TagPickerItemData[] => {
79
+ const handleGetValue = useCallback(() => {
65
80
  const formValue = getValue();
66
81
 
67
82
  const unknownToRefOption = (val: unknown) => {
68
- if (Ref.isRef(val)) {
69
- const dxnString = val.dxn.toString();
83
+ const isRef = Ref.isRef(val);
84
+ if (isRef || isRefSnapShot(val)) {
85
+ const dxnString = isRef ? val.dxn.toString() : val['/'];
70
86
  const matchingOption = availableOptions.find((option) => option.id === dxnString);
71
87
  if (matchingOption) {
72
88
  return matchingOption;
@@ -87,15 +103,6 @@ export const RefField = ({
87
103
  return [];
88
104
  }, [getValue, availableOptions, array]);
89
105
 
90
- const handleSearch = useCallback(
91
- (text: string, ids: string[]): TagPickerItemData[] =>
92
- availableOptions
93
- .filter((option) => !ids.includes(option.id))
94
- .filter((option) => option.label.toLowerCase().includes(text.toLowerCase()))
95
- .map((option) => ({ id: option.id, label: option.label, hue: option.hue as any })),
96
- [availableOptions],
97
- );
98
-
99
106
  const handleUpdate = useCallback(
100
107
  (ids: string[]) => {
101
108
  if (ids.length === 0) {
@@ -123,8 +130,6 @@ export const RefField = ({
123
130
  [availableOptions, array, onValueChange],
124
131
  );
125
132
 
126
- const tagPickerMode: TagPickerMode = useMemo(() => (array ? 'multi-select' : 'single-select'), [array]);
127
-
128
133
  if (!refTypeInfo) {
129
134
  return null;
130
135
  }
@@ -132,32 +137,113 @@ export const RefField = ({
132
137
  const { status, error } = restInputProps.getStatus();
133
138
 
134
139
  const items = handleGetValue();
140
+ const selectedIds = useMemo(() => items.map((i: any) => i.id), [items]);
141
+ const labelById = useMemo(
142
+ () =>
143
+ availableOptions.reduce((acc: Record<string, string>, option) => {
144
+ acc[option.id.toLowerCase()] = option.label.toLowerCase();
145
+ return acc;
146
+ }, {}),
147
+ [availableOptions],
148
+ );
149
+ const [query, setQuery] = useState('');
150
+ const toggleSelect = useCallback(
151
+ (id: string) => {
152
+ if (array) {
153
+ const nextIds = selectedIds.includes(id) ? selectedIds.filter((x) => x !== id) : [...selectedIds, id];
154
+ handleUpdate(nextIds);
155
+ } else {
156
+ if (selectedIds[0] === id) {
157
+ handleUpdate([]);
158
+ } else {
159
+ handleUpdate([id]);
160
+ }
161
+ }
162
+ },
163
+ [array, selectedIds, handleUpdate],
164
+ );
135
165
 
136
166
  // NOTE(thure): I left both predicates in-place in case we decide to add variants which do render readonly but empty values.
137
- return disabled && items.length < 1 ? null : (
167
+ return readonly && items.length < 1 ? null : (
138
168
  <Input.Root validationValence={status}>
139
169
  {!inputOnly && <InputHeader error={error} label={label} />}
140
170
  <div data-no-submit>
141
- {disabled ? (
171
+ {readonly ? (
142
172
  items.length < 1 ? (
143
173
  <p className={mx(descriptionText, 'mbe-2')}>{t('empty readonly ref field label')}</p>
144
174
  ) : (
145
175
  items.map((item) => (
146
- <DxRefTag key={item.id} refid={item.id} rootclassname='mie-1' style={{ marginInlineEnd: '.25rem' }}>
176
+ <DxAnchor key={item.id} refid={item.id} rootclassname='mie-1'>
147
177
  {item.label}
148
- </DxRefTag>
178
+ </DxAnchor>
149
179
  ))
150
180
  )
151
181
  ) : (
152
- <TagPicker
153
- readonly={disabled}
154
- items={items}
155
- mode={tagPickerMode}
156
- onBlur={(event) => onBlur(event as unknown as FocusEvent<HTMLElement>)}
157
- onUpdate={handleUpdate}
158
- onSearch={handleSearch}
159
- classNames='bg-inputSurface p-1.5 rounded-sm'
160
- />
182
+ <PopoverCombobox.Root>
183
+ <PopoverCombobox.Trigger asChild>
184
+ <Button variant='ghost' classNames='is-full text-start gap-2'>
185
+ <div role='none' className='grow'>
186
+ {items?.length ? (
187
+ items?.map((item) => (
188
+ <TagPickerItem
189
+ key={item.id}
190
+ itemId={item.id}
191
+ label={item.label}
192
+ {...(item.hue ? { hue: item.hue } : {})}
193
+ removeLabel={t('remove item label')}
194
+ onItemClick={(event: DxTagPickerItemClick) => {
195
+ if (event.action === 'remove') {
196
+ toggleSelect(item.id);
197
+ }
198
+ }}
199
+ />
200
+ ))
201
+ ) : (
202
+ <span className='text-description'>
203
+ {placeholder ?? t('ref field placeholder', { count: array ? 99 : 1 })}
204
+ </span>
205
+ )}
206
+ </div>
207
+ <Icon size={3} icon='ph--caret-down--bold' />
208
+ </Button>
209
+ </PopoverCombobox.Trigger>
210
+ <PopoverCombobox.Content
211
+ filter={(value, search) =>
212
+ value === '__create__' || labelById[value]?.includes(search.toLowerCase()) ? 1 : 0
213
+ }
214
+ >
215
+ <PopoverCombobox.Input
216
+ placeholder={'Search...'}
217
+ value={query}
218
+ onValueChange={(v) => setQuery(v)}
219
+ autoFocus
220
+ />
221
+ <PopoverCombobox.List>
222
+ {availableOptions.map((option) => (
223
+ <PopoverCombobox.Item
224
+ key={option.id}
225
+ value={option.id}
226
+ onSelect={() => toggleSelect(option.id)}
227
+ classNames='flex items-center gap-2'
228
+ >
229
+ <span className='grow'>{option.label}</span>
230
+ {selectedIds.includes(option.id) && <Icon icon='ph--check--regular' />}
231
+ </PopoverCombobox.Item>
232
+ ))}
233
+ {query.length > 0 && createOptionLabel && createOptionIcon && onCreateFromQuery && (
234
+ <PopoverCombobox.Item
235
+ value='__create__'
236
+ onSelect={() => onCreateFromQuery?.(refTypeInfo, query)}
237
+ classNames='flex items-center gap-2'
238
+ >
239
+ <Icon icon={createOptionIcon} />
240
+ {t(createOptionLabel[0], { ns: createOptionLabel[1].ns, text: query })}
241
+ </PopoverCombobox.Item>
242
+ )}
243
+ </PopoverCombobox.List>
244
+ <PopoverCombobox.Arrow />
245
+ </PopoverCombobox.Content>
246
+ </PopoverCombobox.Root>
161
247
  )}
162
248
  </div>
163
249
  {inputOnly && <Input.DescriptionAndValidation>{error}</Input.DescriptionAndValidation>}
@@ -168,7 +254,7 @@ export const RefField = ({
168
254
  const RefFieldFallback = ({
169
255
  type,
170
256
  label,
171
- disabled,
257
+ readonly,
172
258
  placeholder,
173
259
  inputOnly,
174
260
  getValue,
@@ -202,7 +288,7 @@ const RefFieldFallback = ({
202
288
  <TextInput
203
289
  type={type}
204
290
  label={label}
205
- disabled={disabled}
291
+ readonly={readonly}
206
292
  placeholder={placeholder}
207
293
  inputOnly={inputOnly}
208
294
  getValue={handleGetValue as <V>() => V | undefined}
@@ -2,16 +2,16 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import React, { useCallback, useState, useEffect, useMemo, type ChangeEvent } from 'react';
5
+ import React, { type ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react';
6
6
 
7
- import { type GeoPoint, GeoLocation } from '@dxos/echo-schema';
7
+ import { GeoLocation, type GeoPoint } from '@dxos/echo-schema';
8
8
  import { Input, useTranslation } from '@dxos/react-ui';
9
9
  import { safeParseFloat } from '@dxos/util';
10
10
 
11
11
  import { translationKey } from '../../../translations';
12
12
  import { InputHeader, type InputProps } from '../Input';
13
13
 
14
- export const GeoPointInput = ({ type, label, disabled, getStatus, getValue, onValueChange, onBlur }: InputProps) => {
14
+ export const GeoPointInput = ({ type, label, readonly, getStatus, getValue, onValueChange, onBlur }: InputProps) => {
15
15
  const { t } = useTranslation(translationKey);
16
16
  const { status, error } = getStatus();
17
17
  const geoPoint = useMemo<GeoPoint>(() => getValue<GeoPoint>() ?? [0, 0], [getValue]);
@@ -48,24 +48,32 @@ export const GeoPointInput = ({ type, label, disabled, getStatus, getValue, onVa
48
48
  <Input.Root validationValence={status}>
49
49
  <InputHeader error={error} label={label} />
50
50
  <div role='none' className='grid grid-cols-2 gap-2'>
51
- <Input.TextInput
52
- type='text'
53
- pattern='^-?[0-9]*\.?[0-9]*$'
54
- disabled={disabled}
55
- placeholder={t('placeholder latitude')}
56
- value={latitudeText}
57
- onChange={handleCoordinateChange('latitude', setLatitudeText)}
58
- onBlur={onBlur}
59
- />
60
- <Input.TextInput
61
- type='text'
62
- pattern='^-?[0-9]*\.?[0-9]*$'
63
- disabled={disabled}
64
- placeholder={t('placeholder longitude')}
65
- value={longitudeText}
66
- onChange={handleCoordinateChange('longitude', setLongitudeText)}
67
- onBlur={onBlur}
68
- />
51
+ <div role='none'>
52
+ <Input.Label>{t('latitude label')}</Input.Label>
53
+ <Input.TextInput
54
+ type='number'
55
+ step='0.00001'
56
+ min='-90'
57
+ max='90'
58
+ disabled={!!readonly}
59
+ value={latitudeText}
60
+ onChange={handleCoordinateChange('latitude', setLatitudeText)}
61
+ onBlur={onBlur}
62
+ />
63
+ </div>
64
+ <div role='none'>
65
+ <Input.Label>{t('longitude label')}</Input.Label>
66
+ <Input.TextInput
67
+ type='number'
68
+ step='0.00001'
69
+ min='-180'
70
+ max='180'
71
+ disabled={!!readonly}
72
+ value={longitudeText}
73
+ onChange={handleCoordinateChange('longitude', setLongitudeText)}
74
+ onBlur={onBlur}
75
+ />
76
+ </div>
69
77
  </div>
70
78
  </Input.Root>
71
79
  );
@@ -8,14 +8,15 @@ import { type Meta, type StoryObj } from '@storybook/react-vite';
8
8
  import { Schema } from 'effect';
9
9
  import React, { useState } from 'react';
10
10
 
11
- import { SelectOptionSchema, type SelectOption } from '@dxos/echo-schema';
11
+ import { type SelectOption, SelectOptionSchema } from '@dxos/echo-schema';
12
12
  import { withLayout, withTheme } from '@dxos/storybook-utils';
13
13
 
14
- import { SelectOptionInput } from './SelectOptionsInput';
15
14
  import { translations } from '../../../translations';
16
15
  import { TestLayout, TestPanel } from '../../testing';
17
16
  import { Form } from '../Form';
18
17
 
18
+ import { SelectOptionInput } from './SelectOptionsInput';
19
+
19
20
  const schema = Schema.Struct({
20
21
  options: Schema.Array(SelectOptionSchema).pipe(Schema.mutable),
21
22
  }).pipe(Schema.mutable);
@@ -44,18 +45,18 @@ const DefaultStory = () => {
44
45
  );
45
46
  };
46
47
 
47
- const meta: Meta = {
48
+ const meta = {
48
49
  title: 'ui/react-ui-form/SelectOptionsInput',
49
- component: SelectOptionInput,
50
+ component: SelectOptionInput as any,
50
51
  render: DefaultStory,
51
52
  decorators: [withTheme, withLayout({ fullscreen: true })],
52
53
  parameters: {
53
54
  translations,
54
55
  },
55
- };
56
+ } satisfies Meta<typeof DefaultStory>;
56
57
 
57
58
  export default meta;
58
59
 
59
- type Story = StoryObj;
60
+ type Story = StoryObj<typeof meta>;
60
61
 
61
62
  export const Default: Story = {};
@@ -2,20 +2,19 @@
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
- export const SelectOptionInput = ({ type, label, disabled, getStatus, getValue, onValueChange }: InputProps) => {
17
+ export const SelectOptionInput = ({ type, label, readonly, getStatus, getValue, onValueChange }: InputProps) => {
19
18
  const [selected, setSelectedId] = useState<string | null>(null);
20
19
  const [isNewOption, setIsNewOption] = useState(false);
21
20
  const inputRef = useRef<HTMLInputElement>(null);
@@ -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={readonly ? undefined : handleMove}
121
+ readonly={!!readonly}
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={['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={!!readonly} />
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={!!readonly}
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={!!readonly} value={item.color} onChange={handleColorChange(item.id)} />
161
163
  <IconButton
164
+ disabled={!!readonly}
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={!!readonly}
182
+ classNames='is-full'
183
+ />
185
184
  </div>
186
185
  )}
187
186
  </List.Root>
@@ -16,7 +16,7 @@ export const TupleInput = ({
16
16
  binding,
17
17
  type,
18
18
  label,
19
- disabled,
19
+ readonly,
20
20
  getStatus,
21
21
  getValue,
22
22
  onValueChange,
@@ -35,7 +35,7 @@ export const TupleInput = ({
35
35
  <Input.TextInput
36
36
  key={prop}
37
37
  type='number'
38
- disabled={disabled}
38
+ disabled={!!readonly}
39
39
  value={values[prop]}
40
40
  onChange={(event) => onValueChange(type, { ...values, [prop]: safeParseFloat(event.target.value, 0) })}
41
41
  onBlur={onBlur}
@@ -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,20 +6,22 @@ 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 { Obj, Type } from '@dxos/echo';
12
- import { Format, type EchoSchema, toJsonSchema, TypedObject } from '@dxos/echo-schema';
13
- import { Filter, useQuery, useSpace } from '@dxos/react-client/echo';
11
+ import { Filter, Obj, Query, Type } from '@dxos/echo';
12
+ import { type EchoSchema, Format, TypedObject, toJsonSchema } from '@dxos/echo-schema';
13
+ import { useSpace } from '@dxos/react-client/echo';
14
14
  import { withClientProvider } from '@dxos/react-client/testing';
15
15
  import { useAsyncEffect } from '@dxos/react-ui';
16
- import { DataType, ProjectionModel, createView } from '@dxos/schema';
17
- import { withTheme, withLayout } from '@dxos/storybook-utils';
16
+ import { QueryParser, createFilter } from '@dxos/react-ui-components';
17
+ import { type DataType, ProjectionModel, createView, typenameFromQuery } from '@dxos/schema';
18
+ import { withLayout, withTheme } from '@dxos/storybook-utils';
18
19
 
19
- import { ViewEditor, type ViewEditorProps } from './ViewEditor';
20
20
  import { translations } from '../../translations';
21
21
  import { TestLayout, TestPanel, VIEW_EDITOR_DEBUG_SYMBOL } from '../testing';
22
22
 
23
+ import { ViewEditor, type ViewEditorProps } from './ViewEditor';
24
+
23
25
  // Type definition for debug objects exposed to tests.
24
26
  export type ViewEditorDebugObjects = {
25
27
  schema: EchoSchema;
@@ -27,7 +29,7 @@ export type ViewEditorDebugObjects = {
27
29
  projection: ProjectionModel;
28
30
  };
29
31
 
30
- type StoryProps = Pick<ViewEditorProps, 'readonly'>;
32
+ type StoryProps = Pick<ViewEditorProps, 'readonly' | 'kind'>;
31
33
 
32
34
  const DefaultStory = (props: StoryProps) => {
33
35
  const space = useSpace();
@@ -57,20 +59,32 @@ const DefaultStory = (props: StoryProps) => {
57
59
  }
58
60
  }, [space]);
59
61
 
60
- const views = useQuery(space, Filter.type(DataType.View));
61
- const currentTypename = useMemo(() => view?.query?.typename, [view]);
62
- const updateViewTypename = useCallback(
63
- (newTypename: string) => {
64
- if (!schema) {
62
+ const updateViewQuery = useCallback(
63
+ (newQueryString: string) => {
64
+ if (!schema || !view) {
65
65
  return;
66
66
  }
67
- const matchingViews = views.filter((view) => view.query.typename === currentTypename);
68
- for (const view of matchingViews) {
69
- view.query.typename = newTypename;
67
+
68
+ if (props.kind === 'advanced') {
69
+ try {
70
+ const parser = new QueryParser(newQueryString);
71
+ // TODO(wittjosiah): When this fails it should show validation errors in the UI.
72
+ const newQuery = Query.select(createFilter(parser.parse()));
73
+ view.query = newQuery.ast;
74
+
75
+ const typename = typenameFromQuery(newQuery.ast);
76
+ if (typename) {
77
+ schema.updateTypename(typename);
78
+ }
79
+ } catch {}
80
+ } else {
81
+ const typename = newQueryString;
82
+ const newQuery = Query.select(Filter.typename(typename));
83
+ view.query = newQuery.ast;
84
+ schema.updateTypename(typename);
70
85
  }
71
- schema.updateTypename(newTypename);
72
86
  },
73
- [views, schema],
87
+ [view, schema],
74
88
  );
75
89
 
76
90
  const handleDelete = useCallback((property: string) => projection?.deleteFieldProjection(property), [projection]);
@@ -99,8 +113,9 @@ const DefaultStory = (props: StoryProps) => {
99
113
  schema={schema}
100
114
  view={view}
101
115
  registry={space?.db.schemaRegistry}
116
+ kind={props.kind}
102
117
  readonly={props.readonly}
103
- onTypenameChanged={updateViewTypename}
118
+ onQueryChanged={updateViewQuery}
104
119
  onDelete={handleDelete}
105
120
  />
106
121
  </TestPanel>
@@ -108,19 +123,31 @@ const DefaultStory = (props: StoryProps) => {
108
123
  );
109
124
  };
110
125
 
111
- const meta: Meta<StoryProps> = {
126
+ const meta = {
112
127
  title: 'ui/react-ui-form/ViewEditor',
113
128
  render: DefaultStory,
114
- decorators: [withClientProvider({ createSpace: true }), withLayout({ fullscreen: true }), withTheme],
129
+ decorators: [
130
+ withClientProvider({
131
+ createSpace: true,
132
+ }),
133
+ withLayout({
134
+ fullscreen: true,
135
+ }),
136
+ withTheme,
137
+ ],
115
138
  parameters: { translations },
116
- };
139
+ } satisfies Meta<typeof DefaultStory>;
117
140
 
118
141
  export default meta;
119
142
 
120
- type Story = StoryObj<StoryProps>;
143
+ type Story = StoryObj<typeof meta>;
121
144
 
122
145
  export const Default: Story = {};
123
146
 
124
147
  export const Readonly: Story = {
125
148
  args: { readonly: true },
126
149
  };
150
+
151
+ export const Advanced: Story = {
152
+ args: { kind: 'advanced' },
153
+ };