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

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 (115) hide show
  1. package/dist/lib/browser/index.mjs +806 -572
  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 +806 -572
  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 +80 -5
  10. package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts.map +1 -1
  11. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +1 -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 +75 -5
  14. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
  15. package/dist/types/src/components/Form/ArrayField.d.ts +2 -2
  16. package/dist/types/src/components/Form/ArrayField.d.ts.map +1 -1
  17. package/dist/types/src/components/Form/Defaults.d.ts +5 -5
  18. package/dist/types/src/components/Form/Defaults.d.ts.map +1 -1
  19. package/dist/types/src/components/Form/Form.d.ts +6 -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 +128 -32
  22. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  23. package/dist/types/src/components/Form/FormContent.d.ts +16 -10
  24. package/dist/types/src/components/Form/FormContent.d.ts.map +1 -1
  25. package/dist/types/src/components/Form/FormContext.d.ts +2 -2
  26. package/dist/types/src/components/Form/FormContext.d.ts.map +1 -1
  27. package/dist/types/src/components/Form/Input.d.ts +5 -2
  28. package/dist/types/src/components/Form/Input.d.ts.map +1 -1
  29. package/dist/types/src/components/Form/RefField.d.ts +14 -4
  30. package/dist/types/src/components/Form/RefField.d.ts.map +1 -1
  31. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts +1 -1
  32. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts.map +1 -1
  33. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts +1 -1
  34. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts +75 -4
  35. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +1 -1
  36. package/dist/types/src/components/Form/custom/TupleInput.d.ts +1 -1
  37. package/dist/types/src/components/Form/factory.d.ts +1 -1
  38. package/dist/types/src/components/Form/factory.d.ts.map +1 -1
  39. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +41 -0
  40. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -0
  41. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +81 -0
  42. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -0
  43. package/dist/types/src/components/ObjectPicker/index.d.ts +2 -0
  44. package/dist/types/src/components/ObjectPicker/index.d.ts.map +1 -0
  45. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +20 -6
  46. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  47. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +79 -8
  48. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  49. package/dist/types/src/components/index.d.ts +1 -1
  50. package/dist/types/src/components/index.d.ts.map +1 -1
  51. package/dist/types/src/components/testing.d.ts +6 -3
  52. package/dist/types/src/components/testing.d.ts.map +1 -1
  53. package/dist/types/src/hooks/useForm.d.ts +2 -2
  54. package/dist/types/src/hooks/useForm.d.ts.map +1 -1
  55. package/dist/types/src/hooks/useQueryRefOptions.d.ts +9 -3
  56. package/dist/types/src/hooks/useQueryRefOptions.d.ts.map +1 -1
  57. package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts.map +1 -1
  58. package/dist/types/src/translations.d.ts +11 -7
  59. package/dist/types/src/translations.d.ts.map +1 -1
  60. package/dist/types/src/util/field.d.ts +1 -1
  61. package/dist/types/src/util/field.d.ts.map +1 -1
  62. package/dist/types/src/util/formatting.d.ts +1 -1
  63. package/dist/types/src/util/formatting.d.ts.map +1 -1
  64. package/dist/types/src/util/refs.d.ts +1 -1
  65. package/dist/types/src/util/refs.d.ts.map +1 -1
  66. package/dist/types/src/util/schema.d.ts +1 -1
  67. package/dist/types/src/util/schema.d.ts.map +1 -1
  68. package/dist/types/tsconfig.tsbuildinfo +1 -1
  69. package/package.json +41 -36
  70. package/src/components/ControlSection/ControlSection.stories.tsx +10 -14
  71. package/src/components/ControlSection/ControlSection.tsx +26 -12
  72. package/src/components/FieldEditor/FieldEditor.stories.tsx +10 -11
  73. package/src/components/FieldEditor/FieldEditor.tsx +12 -14
  74. package/src/components/Form/ArrayField.tsx +6 -6
  75. package/src/components/Form/Defaults.tsx +21 -20
  76. package/src/components/Form/Form.stories.tsx +132 -97
  77. package/src/components/Form/Form.tsx +27 -14
  78. package/src/components/Form/FormActions.tsx +2 -2
  79. package/src/components/Form/FormContent.tsx +166 -45
  80. package/src/components/Form/FormContext.tsx +3 -3
  81. package/src/components/Form/Input.tsx +5 -2
  82. package/src/components/Form/RefField.tsx +107 -38
  83. package/src/components/Form/custom/GeoPointInput.tsx +28 -20
  84. package/src/components/Form/custom/SelectOptionsInput.stories.tsx +9 -10
  85. package/src/components/Form/custom/SelectOptionsInput.tsx +10 -10
  86. package/src/components/Form/custom/TupleInput.tsx +2 -2
  87. package/src/components/Form/factory.tsx +1 -1
  88. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +207 -0
  89. package/src/components/ObjectPicker/ObjectPicker.tsx +161 -0
  90. package/src/components/ObjectPicker/index.ts +5 -0
  91. package/src/components/ViewEditor/ViewEditor.stories.tsx +123 -49
  92. package/src/components/ViewEditor/ViewEditor.test.tsx +8 -11
  93. package/src/components/ViewEditor/ViewEditor.tsx +281 -233
  94. package/src/components/index.ts +1 -2
  95. package/src/components/testing.tsx +15 -9
  96. package/src/hooks/useForm.ts +7 -4
  97. package/src/hooks/useQueryRefOptions.ts +15 -6
  98. package/src/hooks/useRefQueryLookupHandler.ts +3 -3
  99. package/src/translations.ts +13 -8
  100. package/src/util/field.ts +9 -19
  101. package/src/util/formatting.ts +1 -1
  102. package/src/util/refs.ts +7 -7
  103. package/src/util/schema.ts +2 -4
  104. package/dist/types/src/components/deprecated/Form.d.ts +0 -15
  105. package/dist/types/src/components/deprecated/Form.d.ts.map +0 -1
  106. package/dist/types/src/components/deprecated/Form.stories.d.ts +0 -13
  107. package/dist/types/src/components/deprecated/Form.stories.d.ts.map +0 -1
  108. package/dist/types/src/components/deprecated/FormInput.d.ts +0 -16
  109. package/dist/types/src/components/deprecated/FormInput.d.ts.map +0 -1
  110. package/dist/types/src/components/deprecated/index.d.ts +0 -2
  111. package/dist/types/src/components/deprecated/index.d.ts.map +0 -1
  112. package/src/components/deprecated/Form.stories.tsx +0 -63
  113. package/src/components/deprecated/Form.tsx +0 -119
  114. package/src/components/deprecated/FormInput.tsx +0 -71
  115. package/src/components/deprecated/index.ts +0 -5
@@ -2,15 +2,17 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { Schema, SchemaAST, pipe } from 'effect';
6
- import { capitalize } from 'effect/String';
7
- import React, { forwardRef, useMemo } from 'react';
5
+ import * as Function from 'effect/Function';
6
+ import * as Schema from 'effect/Schema';
7
+ import * as SchemaAST from 'effect/SchemaAST';
8
+ import * as StringEffect from 'effect/String';
9
+ import React, { Component, type PropsWithChildren, type ReactNode, forwardRef, useMemo } from 'react';
8
10
 
9
11
  import { createJsonPath, findNode, getDiscriminatedType, isDiscriminatedUnion } from '@dxos/effect';
10
12
  import { type ThemedClassName } from '@dxos/react-ui';
11
13
  import { mx } from '@dxos/react-ui-theme';
12
- import { type SchemaProperty, getSchemaProperties } from '@dxos/schema';
13
- import { isNotFalsy } from '@dxos/util';
14
+ import { type ProjectionModel, type SchemaProperty, getSchemaProperties } from '@dxos/schema';
15
+ import { isTruthy } from '@dxos/util';
14
16
 
15
17
  import { type QueryRefOptions } from '../../hooks';
16
18
  import { getRefProps } from '../../util';
@@ -21,32 +23,46 @@ import { getInputComponent } from './factory';
21
23
  import { type ComponentLookup } from './Form';
22
24
  import { useFormValues, useInputProps } from './FormContext';
23
25
  import { type InputComponent } from './Input';
24
- import { RefField } from './RefField';
26
+ import { RefField, type RefFieldProps } from './RefField';
25
27
 
26
28
  export type FormFieldProps = {
27
29
  property: SchemaProperty<any>;
28
30
  path?: (string | number)[];
29
- readonly?: boolean;
30
31
  /** Used to indicate if input should be presented inline (e.g. for array items). */
31
32
  inline?: boolean;
32
- onQueryRefOptions?: QueryRefOptions;
33
+ projection?: ProjectionModel;
33
34
  lookupComponent?: ComponentLookup;
34
35
  Custom?: Partial<Record<string, InputComponent>>;
35
- };
36
+ } & Pick<
37
+ RefFieldProps,
38
+ | 'readonly'
39
+ | 'onQueryRefOptions'
40
+ | 'createOptionLabel'
41
+ | 'createOptionIcon'
42
+ | 'onCreate'
43
+ | 'createSchema'
44
+ | 'createInitialValuePath'
45
+ >;
36
46
 
37
47
  export const FormField = ({
38
48
  property,
39
49
  path,
40
50
  readonly,
41
51
  inline,
52
+ projection,
42
53
  onQueryRefOptions,
54
+ createOptionLabel,
55
+ createOptionIcon,
56
+ onCreate,
57
+ createSchema,
58
+ createInitialValuePath,
43
59
  lookupComponent,
44
60
  Custom,
45
61
  }: FormFieldProps) => {
46
62
  const { ast, name, type, format, title, description, options, examples, array } = property;
47
63
  const inputProps = useInputProps(path);
48
64
 
49
- const label = useMemo(() => title ?? pipe(name, capitalize), [title, name]);
65
+ const label = useMemo(() => title ?? Function.pipe(name, StringEffect.capitalize), [title, name]);
50
66
  const placeholder = useMemo(
51
67
  () => (examples?.length ? `Example: "${examples[0]}"` : description),
52
68
  [examples, description],
@@ -63,7 +79,7 @@ export const FormField = ({
63
79
  type,
64
80
  format,
65
81
  label,
66
- disabled: readonly,
82
+ readonly,
67
83
  placeholder,
68
84
  ...inputProps,
69
85
  },
@@ -83,7 +99,7 @@ export const FormField = ({
83
99
  label={label}
84
100
  inputOnly={inline}
85
101
  placeholder={placeholder}
86
- disabled={readonly}
102
+ readonly={readonly}
87
103
  {...inputProps}
88
104
  />
89
105
  );
@@ -103,9 +119,14 @@ export const FormField = ({
103
119
  format={format}
104
120
  label={label}
105
121
  placeholder={placeholder}
106
- disabled={readonly}
122
+ readonly={readonly}
107
123
  inputOnly={inline}
108
124
  onQueryRefOptions={onQueryRefOptions}
125
+ createOptionLabel={createOptionLabel}
126
+ createOptionIcon={createOptionIcon}
127
+ onCreate={onCreate}
128
+ createSchema={createSchema}
129
+ createInitialValuePath={createInitialValuePath}
109
130
  {...inputProps}
110
131
  />
111
132
  );
@@ -132,7 +153,7 @@ export const FormField = ({
132
153
  label={label}
133
154
  inputOnly={inline}
134
155
  placeholder={placeholder}
135
- disabled={readonly}
156
+ readonly={readonly}
136
157
  {...inputProps}
137
158
  />
138
159
  );
@@ -147,7 +168,7 @@ export const FormField = ({
147
168
  <SelectInput
148
169
  type={type}
149
170
  format={format}
150
- disabled={readonly}
171
+ readonly={readonly}
151
172
  inputOnly={inline}
152
173
  label={label}
153
174
  options={options.map((option) => ({ value: option, label: String(option) }))}
@@ -175,7 +196,11 @@ export const FormField = ({
175
196
  schema={Schema.make(typeLiteral)}
176
197
  path={path}
177
198
  readonly={readonly}
199
+ projection={projection}
178
200
  onQueryRefOptions={onQueryRefOptions}
201
+ createOptionLabel={createOptionLabel}
202
+ createOptionIcon={createOptionIcon}
203
+ onCreate={onCreate}
179
204
  Custom={Custom}
180
205
  lookupComponent={lookupComponent}
181
206
  />
@@ -187,55 +212,151 @@ export const FormField = ({
187
212
  return null;
188
213
  };
189
214
 
190
- export type FormFieldsProps = ThemedClassName<{
191
- testId?: string;
192
- readonly?: boolean;
193
- schema: Schema.Schema.All;
194
- /**
195
- * Path to the current object from the root. Used with nested forms.
196
- */
197
- path?: (string | number)[];
198
- filter?: (props: SchemaProperty<any>[]) => SchemaProperty<any>[];
199
- sort?: string[];
200
- lookupComponent?: ComponentLookup;
201
- /**
202
- * Map of custom renderers for specific properties.
203
- * Prefer lookupComponent for plugin specific input surfaces.
204
- */
205
- Custom?: Partial<Record<string, InputComponent>>;
206
- onQueryRefOptions?: QueryRefOptions;
207
- }>;
215
+ export type FormFieldsProps = ThemedClassName<
216
+ {
217
+ testId?: string;
218
+ schema: Schema.Schema.All;
219
+ /**
220
+ * Path to the current object from the root. Used with nested forms.
221
+ */
222
+ path?: (string | number)[];
223
+ filter?: (props: SchemaProperty<any>[]) => SchemaProperty<any>[];
224
+ sort?: string[];
225
+ /**
226
+ * Optional projection for projection-based field management
227
+ */
228
+ projection?: ProjectionModel;
229
+ lookupComponent?: ComponentLookup;
230
+ /**
231
+ * Map of custom renderers for specific properties.
232
+ * Prefer lookupComponent for plugin specific input surfaces.
233
+ */
234
+ Custom?: Partial<Record<string, InputComponent>>;
235
+ onQueryRefOptions?: QueryRefOptions;
236
+ } & Pick<FormFieldProps, 'readonly'> &
237
+ Pick<
238
+ RefFieldProps,
239
+ | 'onQueryRefOptions'
240
+ | 'createOptionLabel'
241
+ | 'createOptionIcon'
242
+ | 'onCreate'
243
+ | 'createSchema'
244
+ | 'createInitialValuePath'
245
+ >
246
+ >;
208
247
 
209
248
  export const FormFields = forwardRef<HTMLDivElement, FormFieldsProps>(
210
249
  (
211
- { classNames, testId, schema, path, filter, sort, readonly, lookupComponent, Custom, onQueryRefOptions },
250
+ {
251
+ classNames,
252
+ schema,
253
+ path,
254
+ filter,
255
+ sort,
256
+ projection,
257
+ readonly,
258
+ lookupComponent,
259
+ Custom,
260
+ onQueryRefOptions,
261
+ ...props
262
+ },
212
263
  forwardRef,
213
264
  ) => {
214
265
  const values = useFormValues(path);
215
266
  const properties = useMemo(() => {
216
267
  const props = getSchemaProperties(schema.ast, values);
268
+
269
+ // Use projection-based field management when view and projection are available
270
+ if (projection) {
271
+ const fieldProjections = projection.getFieldProjections();
272
+ const hiddenProperties = new Set(projection.getHiddenProperties());
273
+
274
+ // Filter properties to only include visible ones and order by projection
275
+ const visibleProps = props.filter((prop) => !hiddenProperties.has(prop.name));
276
+ const orderedProps: SchemaProperty<any>[] = [];
277
+
278
+ // Add properties in projection field order
279
+ for (const fieldProjection of fieldProjections) {
280
+ const fieldPath = String(fieldProjection.field.path);
281
+ const prop = visibleProps.find((p) => p.name === fieldPath);
282
+ if (prop) {
283
+ orderedProps.push(prop);
284
+ }
285
+ }
286
+
287
+ // Add any remaining properties not in projection
288
+ const projectionPaths = new Set(fieldProjections.map((fp) => String(fp.field.path)));
289
+ const remainingProps = visibleProps.filter((prop) => !projectionPaths.has(prop.name));
290
+ orderedProps.push(...remainingProps);
291
+
292
+ return orderedProps;
293
+ }
294
+
295
+ // Fallback to legacy filter/sort behavior
217
296
  const filtered = filter ? filter(props) : props;
218
297
  return sort ? filtered.sort((a, b) => sort.indexOf(a.name) - sort.indexOf(b.name)) : filtered;
219
- }, [schema, values, filter, sort]);
298
+ }, [schema, values, filter, sort, projection?.fields]);
220
299
 
221
300
  return (
222
301
  <div role='form' className={mx('is-full', classNames)} ref={forwardRef}>
223
302
  {properties
224
303
  .map((property) => {
225
304
  return (
226
- <FormField
227
- key={property.name}
228
- property={property}
229
- path={[...(path ?? []), property.name]}
230
- readonly={readonly}
231
- onQueryRefOptions={onQueryRefOptions}
232
- lookupComponent={lookupComponent}
233
- Custom={Custom}
234
- />
305
+ <ErrorBoundary key={property.name} path={[...(path ?? []), property.name]}>
306
+ <FormField
307
+ property={property}
308
+ path={[...(path ?? []), property.name]}
309
+ readonly={readonly}
310
+ projection={projection}
311
+ onQueryRefOptions={onQueryRefOptions}
312
+ lookupComponent={lookupComponent}
313
+ Custom={Custom}
314
+ {...props}
315
+ />
316
+ </ErrorBoundary>
235
317
  );
236
318
  })
237
- .filter(isNotFalsy)}
319
+ .filter(isTruthy)}
238
320
  </div>
239
321
  );
240
322
  },
241
323
  );
324
+
325
+ type ErrorState = {
326
+ error: Error | undefined;
327
+ };
328
+
329
+ type ErrorBoundaryProps = PropsWithChildren<{
330
+ path?: (string | number)[];
331
+ }>;
332
+
333
+ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorState> {
334
+ static getDerivedStateFromError(error: Error): { error: Error } {
335
+ return { error };
336
+ }
337
+
338
+ override state = { error: undefined };
339
+
340
+ override componentDidUpdate(prevProps: ErrorBoundaryProps): void {
341
+ if (prevProps.path !== this.props.path) {
342
+ this.resetError();
343
+ }
344
+ }
345
+
346
+ override render(): ReactNode {
347
+ if (this.state.error) {
348
+ return (
349
+ <div className='flex gap-2 border border-roseFill font-mono text-sm'>
350
+ <span className='bg-roseFill text-surfaceText pli-1 font-thin'>ERROR</span>
351
+ {String(this.props.path?.join('.'))}
352
+ </div>
353
+ );
354
+ }
355
+
356
+ return this.props.children;
357
+ }
358
+
359
+ private resetError(): void {
360
+ this.setState({ error: undefined });
361
+ }
362
+ }
@@ -14,7 +14,7 @@ import React, {
14
14
  } from 'react';
15
15
 
16
16
  import { raise } from '@dxos/debug';
17
- import { type BaseObject, getValue } from '@dxos/echo-schema';
17
+ import { type BaseObject, getValue } from '@dxos/echo/internal';
18
18
  import { type SimpleType, createJsonPath } from '@dxos/effect';
19
19
 
20
20
  import { type FormHandler, type FormOptions, useForm } from '../../hooks';
@@ -62,7 +62,7 @@ export const FormProvider = ({
62
62
  ...formOptions
63
63
  }: PropsWithChildren<
64
64
  FormOptions<any> & {
65
- formRef?: RefObject<HTMLDivElement>;
65
+ formRef?: RefObject<HTMLDivElement | null>;
66
66
  autoSave?: boolean;
67
67
  }
68
68
  >) => {
@@ -92,7 +92,7 @@ export const FormProvider = ({
92
92
  }
93
93
  }
94
94
  },
95
- [form, autoSave],
95
+ [form.canSave, form.formIsValid, form.handleSave, autoSave],
96
96
  );
97
97
 
98
98
  useEffect(() => {
@@ -4,7 +4,7 @@
4
4
 
5
5
  import React, { type FC } from 'react';
6
6
 
7
- import { type FormatEnum } from '@dxos/echo-schema';
7
+ import { type FormatEnum } from '@dxos/echo/internal';
8
8
  import { type SimpleType } from '@dxos/effect';
9
9
  import { Icon, Input, Tooltip } from '@dxos/react-ui';
10
10
  import { labelSpacing } from '@dxos/react-ui-stack';
@@ -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
  /**
@@ -2,46 +2,67 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { type SchemaAST } from 'effect';
6
- import React, { type FocusEvent, useCallback, useMemo } from 'react';
5
+ import '@dxos/lit-ui/dx-tag-picker.pcss';
6
+
7
+ import type * as Schema from 'effect/Schema';
8
+ import type * as SchemaAST from 'effect/SchemaAST';
9
+ import React, { useCallback, useMemo } from 'react';
7
10
 
8
11
  import {
12
+ type EchoSchema,
9
13
  Expando,
10
14
  Ref,
11
15
  ReferenceAnnotationId,
12
16
  type ReferenceAnnotationValue,
13
17
  getTypeAnnotation,
14
- } from '@dxos/echo-schema';
18
+ } from '@dxos/echo/internal';
15
19
  import { findAnnotation } from '@dxos/effect';
16
20
  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 TagPickerItemData, type TagPickerMode } from '@dxos/react-ui-tag-picker';
21
+ import { type DxTagPickerItemClick } from '@dxos/lit-ui';
22
+ import { DxAnchor, DxTagPickerItem } from '@dxos/lit-ui/react';
23
+ import { Button, Icon, Input, useTranslation } from '@dxos/react-ui';
20
24
  import { descriptionText, mx } from '@dxos/react-ui-theme';
21
25
  import { isNonNullable } from '@dxos/util';
22
26
 
23
27
  import { type QueryRefOptions, useQueryRefOptions } from '../../hooks';
24
28
  import { translationKey } from '../../translations';
29
+ import { ObjectPicker } from '../ObjectPicker';
25
30
 
26
31
  import { TextInput } from './Defaults';
27
32
  import { InputHeader, type InputProps } from './Input';
28
33
 
29
- type RefFieldProps = InputProps & {
34
+ export type RefFieldProps = InputProps & {
30
35
  ast?: SchemaAST.AST;
31
36
  array?: boolean;
37
+ createOptionLabel?: [string, { ns: string }];
38
+ createOptionIcon?: string;
39
+ onCreate?: (values: any) => void;
40
+ createSchema?: Schema.Schema.AnyNoContext;
41
+ createInitialValuePath?: string;
32
42
  onQueryRefOptions?: QueryRefOptions;
43
+ schema?: EchoSchema;
44
+ };
45
+
46
+ // TODO(thure): Is this a standard that should be better canonized?
47
+ const isRefSnapShot = (val: any): val is { '/': string } => {
48
+ return typeof val === 'object' && typeof (val as any)?.['/'] === 'string';
33
49
  };
34
50
 
35
51
  export const RefField = ({
36
52
  type,
37
53
  label,
38
- disabled,
54
+ readonly,
39
55
  placeholder,
40
56
  inputOnly,
41
57
  array,
42
58
  ast,
43
59
  getValue,
60
+ createOptionLabel,
61
+ createOptionIcon,
44
62
  onBlur,
63
+ onCreate,
64
+ createSchema,
65
+ createInitialValuePath,
45
66
  onQueryRefOptions,
46
67
  onValueChange,
47
68
  ...restInputProps
@@ -51,23 +72,28 @@ export const RefField = ({
51
72
  () => (ast ? findAnnotation<ReferenceAnnotationValue>(ast, ReferenceAnnotationId) : undefined),
52
73
  [ast],
53
74
  );
54
- const { options: availableOptions, loading: _loading } = useQueryRefOptions({ refTypeInfo, onQueryRefOptions });
75
+ const {
76
+ options: availableOptions,
77
+ update: updateOptions,
78
+ loading: _loading,
79
+ } = useQueryRefOptions({ refTypeInfo, onQueryRefOptions });
55
80
 
56
81
  if ((refTypeInfo && refTypeInfo?.typename === getTypeAnnotation(Expando)?.typename) || !onQueryRefOptions) {
57
82
  // If ref type is expando, fall back to taking a DXN in string format.
58
83
  return (
59
84
  <RefFieldFallback
60
- {...{ type, label, placeholder, disabled, inputOnly, getValue, onBlur, onValueChange, ...restInputProps }}
85
+ {...{ type, label, placeholder, readonly, inputOnly, getValue, onBlur, onValueChange, ...restInputProps }}
61
86
  />
62
87
  );
63
88
  }
64
89
 
65
- const handleGetValue = useCallback((): TagPickerItemData[] => {
90
+ const handleGetValue = useCallback(() => {
66
91
  const formValue = getValue();
67
92
 
68
93
  const unknownToRefOption = (val: unknown) => {
69
- if (Ref.isRef(val)) {
70
- const dxnString = val.dxn.toString();
94
+ const isRef = Ref.isRef(val);
95
+ if (isRef || isRefSnapShot(val)) {
96
+ const dxnString = isRef ? val.dxn.toString() : val['/'];
71
97
  const matchingOption = availableOptions.find((option) => option.id === dxnString);
72
98
  if (matchingOption) {
73
99
  return matchingOption;
@@ -88,15 +114,6 @@ export const RefField = ({
88
114
  return [];
89
115
  }, [getValue, availableOptions, array]);
90
116
 
91
- const handleSearch = useCallback(
92
- (text: string, ids: string[]): TagPickerItemData[] =>
93
- availableOptions
94
- .filter((option) => !ids.includes(option.id))
95
- .filter((option) => option.label.toLowerCase().includes(text.toLowerCase()))
96
- .map((option) => ({ id: option.id, label: option.label, hue: option.hue as any })),
97
- [availableOptions],
98
- );
99
-
100
117
  const handleUpdate = useCallback(
101
118
  (ids: string[]) => {
102
119
  if (ids.length === 0) {
@@ -124,7 +141,13 @@ export const RefField = ({
124
141
  [availableOptions, array, onValueChange],
125
142
  );
126
143
 
127
- const tagPickerMode: TagPickerMode = useMemo(() => (array ? 'multi-select' : 'single-select'), [array]);
144
+ const handleCreate = useCallback(
145
+ (values: any) => {
146
+ onCreate?.(values);
147
+ updateOptions();
148
+ },
149
+ [onCreate, updateOptions],
150
+ );
128
151
 
129
152
  if (!refTypeInfo) {
130
153
  return null;
@@ -133,32 +156,78 @@ export const RefField = ({
133
156
  const { status, error } = restInputProps.getStatus();
134
157
 
135
158
  const items = handleGetValue();
159
+ const selectedIds = useMemo(() => items.map((i: any) => i.id), [items]);
160
+ const toggleSelect = useCallback(
161
+ (id: string) => {
162
+ if (array) {
163
+ const nextIds = selectedIds.includes(id) ? selectedIds.filter((x) => x !== id) : [...selectedIds, id];
164
+ handleUpdate(nextIds);
165
+ } else {
166
+ if (selectedIds[0] === id) {
167
+ handleUpdate([]);
168
+ } else {
169
+ handleUpdate([id]);
170
+ }
171
+ }
172
+ },
173
+ [array, selectedIds, handleUpdate],
174
+ );
136
175
 
137
176
  // NOTE(thure): I left both predicates in-place in case we decide to add variants which do render readonly but empty values.
138
- return disabled && items.length < 1 ? null : (
177
+ return readonly && items.length < 1 ? null : (
139
178
  <Input.Root validationValence={status}>
140
179
  {!inputOnly && <InputHeader error={error} label={label} />}
141
180
  <div data-no-submit>
142
- {disabled ? (
181
+ {readonly ? (
143
182
  items.length < 1 ? (
144
183
  <p className={mx(descriptionText, 'mbe-2')}>{t('empty readonly ref field label')}</p>
145
184
  ) : (
146
185
  items.map((item) => (
147
- <DxRefTag key={item.id} refid={item.id} rootclassname='mie-1' style={{ marginInlineEnd: '.25rem' }}>
186
+ <DxAnchor key={item.id} refid={item.id} rootclassname='mie-1'>
148
187
  {item.label}
149
- </DxRefTag>
188
+ </DxAnchor>
150
189
  ))
151
190
  )
152
191
  ) : (
153
- <TagPicker
154
- readonly={disabled}
155
- items={items}
156
- mode={tagPickerMode}
157
- onBlur={(event) => onBlur(event as unknown as FocusEvent<HTMLElement>)}
158
- onUpdate={handleUpdate}
159
- onSearch={handleSearch}
160
- classNames='bg-inputSurface p-1.5 rounded-sm'
161
- />
192
+ <ObjectPicker.Root>
193
+ <ObjectPicker.Trigger asChild>
194
+ <Button variant='ghost' classNames='is-full text-start gap-2'>
195
+ <div role='none' className='grow'>
196
+ {items?.length ? (
197
+ items?.map((item) => (
198
+ <DxTagPickerItem
199
+ key={item.id}
200
+ itemId={item.id}
201
+ label={item.label}
202
+ {...(item.hue ? { hue: item.hue } : {})}
203
+ removeLabel={t('remove item label')}
204
+ onItemClick={(event: DxTagPickerItemClick) => {
205
+ if (event.action === 'remove') {
206
+ toggleSelect(item.id);
207
+ }
208
+ }}
209
+ />
210
+ ))
211
+ ) : (
212
+ <span className='text-description'>
213
+ {placeholder ?? t('ref field placeholder', { count: array ? 99 : 1 })}
214
+ </span>
215
+ )}
216
+ </div>
217
+ <Icon size={3} icon='ph--caret-down--bold' />
218
+ </Button>
219
+ </ObjectPicker.Trigger>
220
+ <ObjectPicker.Content
221
+ options={availableOptions}
222
+ selectedIds={selectedIds}
223
+ onSelect={toggleSelect}
224
+ createSchema={createSchema}
225
+ createOptionLabel={createOptionLabel}
226
+ createOptionIcon={createOptionIcon}
227
+ createInitialValuePath={createInitialValuePath}
228
+ onCreate={handleCreate}
229
+ />
230
+ </ObjectPicker.Root>
162
231
  )}
163
232
  </div>
164
233
  {inputOnly && <Input.DescriptionAndValidation>{error}</Input.DescriptionAndValidation>}
@@ -169,7 +238,7 @@ export const RefField = ({
169
238
  const RefFieldFallback = ({
170
239
  type,
171
240
  label,
172
- disabled,
241
+ readonly,
173
242
  placeholder,
174
243
  inputOnly,
175
244
  getValue,
@@ -203,7 +272,7 @@ const RefFieldFallback = ({
203
272
  <TextInput
204
273
  type={type}
205
274
  label={label}
206
- disabled={disabled}
275
+ readonly={readonly}
207
276
  placeholder={placeholder}
208
277
  inputOnly={inputOnly}
209
278
  getValue={handleGetValue as <V>() => V | undefined}
@@ -4,14 +4,14 @@
4
4
 
5
5
  import React, { type ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react';
6
6
 
7
- import { GeoLocation, type GeoPoint } from '@dxos/echo-schema';
7
+ import { GeoLocation, type GeoPoint } from '@dxos/echo/internal';
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
  );