@dxos/react-ui-form 0.7.4 → 0.7.5-labs.071a3e2

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 (77) hide show
  1. package/dist/lib/browser/index.mjs +841 -521
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node/index.cjs +877 -571
  5. package/dist/lib/node/index.cjs.map +4 -4
  6. package/dist/lib/node/meta.json +1 -1
  7. package/dist/lib/node-esm/index.mjs +841 -521
  8. package/dist/lib/node-esm/index.mjs.map +4 -4
  9. package/dist/lib/node-esm/meta.json +1 -1
  10. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +2 -2
  11. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  12. package/dist/types/src/components/Form/ArrayField.d.ts +16 -0
  13. package/dist/types/src/components/Form/ArrayField.d.ts.map +1 -0
  14. package/dist/types/src/components/Form/Defaults.d.ts +5 -6
  15. package/dist/types/src/components/Form/Defaults.d.ts.map +1 -1
  16. package/dist/types/src/components/Form/Form.d.ts +14 -17
  17. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  18. package/dist/types/src/components/Form/Form.stories.d.ts +7 -1
  19. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  20. package/dist/types/src/components/Form/FormActions.d.ts +7 -0
  21. package/dist/types/src/components/Form/FormActions.d.ts.map +1 -0
  22. package/dist/types/src/components/Form/FormContent.d.ts +26 -0
  23. package/dist/types/src/components/Form/FormContent.d.ts.map +1 -0
  24. package/dist/types/src/components/Form/FormContext.d.ts +20 -0
  25. package/dist/types/src/components/Form/FormContext.d.ts.map +1 -0
  26. package/dist/types/src/components/Form/Input.d.ts +5 -6
  27. package/dist/types/src/components/Form/Input.d.ts.map +1 -1
  28. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts +4 -0
  29. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts.map +1 -0
  30. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts +4 -0
  31. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts.map +1 -0
  32. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts +7 -0
  33. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +1 -0
  34. package/dist/types/src/components/Form/custom/TupleInput.d.ts +6 -0
  35. package/dist/types/src/components/Form/custom/TupleInput.d.ts.map +1 -0
  36. package/dist/types/src/components/Form/custom/index.d.ts +4 -0
  37. package/dist/types/src/components/Form/custom/index.d.ts.map +1 -0
  38. package/dist/types/src/components/Form/factory.d.ts +2 -2
  39. package/dist/types/src/components/Form/factory.d.ts.map +1 -1
  40. package/dist/types/src/components/Form/index.d.ts +2 -2
  41. package/dist/types/src/components/Form/index.d.ts.map +1 -1
  42. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +6 -5
  43. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  44. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  45. package/dist/types/src/components/deprecated/FormInput.d.ts +5 -0
  46. package/dist/types/src/components/deprecated/FormInput.d.ts.map +1 -1
  47. package/dist/types/src/hooks/useForm.d.ts +13 -15
  48. package/dist/types/src/hooks/useForm.d.ts.map +1 -1
  49. package/dist/types/src/translations.d.ts +6 -0
  50. package/dist/types/src/translations.d.ts.map +1 -1
  51. package/dist/types/tsconfig.tsbuildinfo +1 -0
  52. package/package.json +28 -27
  53. package/src/components/FieldEditor/FieldEditor.stories.tsx +3 -3
  54. package/src/components/FieldEditor/FieldEditor.tsx +77 -51
  55. package/src/components/Form/ArrayField.tsx +111 -0
  56. package/src/components/Form/Defaults.tsx +20 -33
  57. package/src/components/Form/Form.stories.tsx +48 -6
  58. package/src/components/Form/Form.tsx +49 -329
  59. package/src/components/Form/FormActions.tsx +41 -0
  60. package/src/components/Form/FormContent.tsx +173 -0
  61. package/src/components/Form/FormContext.tsx +48 -0
  62. package/src/components/Form/Input.tsx +5 -6
  63. package/src/components/Form/custom/GeoPointInput.tsx +44 -0
  64. package/src/components/Form/custom/SelectOptionsInput.stories.tsx +58 -0
  65. package/src/components/Form/custom/SelectOptionsInput.tsx +192 -0
  66. package/src/components/Form/custom/TupleInput.tsx +49 -0
  67. package/src/components/Form/custom/index.ts +7 -0
  68. package/src/components/Form/factory.tsx +4 -7
  69. package/src/components/Form/index.ts +3 -2
  70. package/src/components/ViewEditor/ViewEditor.stories.tsx +30 -8
  71. package/src/components/ViewEditor/ViewEditor.tsx +18 -10
  72. package/src/components/deprecated/FormInput.tsx +20 -7
  73. package/src/hooks/useForm.ts +67 -79
  74. package/src/translations.ts +8 -0
  75. package/dist/types/src/components/Form/Custom.d.ts +0 -5
  76. package/dist/types/src/components/Form/Custom.d.ts.map +0 -1
  77. package/src/components/Form/Custom.tsx +0 -65
@@ -5,12 +5,13 @@
5
5
  import '@dxos-theme';
6
6
 
7
7
  import { type Meta, type StoryObj } from '@storybook/react';
8
- import React, { useCallback, useEffect, useState } from 'react';
8
+ import React, { useCallback, useMemo, useState } from 'react';
9
9
 
10
- import { Format, type MutableSchema, S, toJsonSchema, TypedObject } from '@dxos/echo-schema';
11
- import { useSpace } from '@dxos/react-client/echo';
10
+ import { Format, type EchoSchema, S, toJsonSchema, TypedObject } from '@dxos/echo-schema';
11
+ import { Filter, useQuery, useSpace } from '@dxos/react-client/echo';
12
12
  import { withClientProvider } from '@dxos/react-client/testing';
13
- import { type ViewType, ViewProjection, createView } from '@dxos/schema';
13
+ import { useAsyncEffect } from '@dxos/react-ui';
14
+ import { ViewProjection, ViewType, createView } from '@dxos/schema';
14
15
  import { withTheme, withLayout } from '@dxos/storybook-utils';
15
16
 
16
17
  import { ViewEditor } from './ViewEditor';
@@ -19,10 +20,10 @@ import { TestLayout, TestPanel } from '../testing';
19
20
 
20
21
  const DefaultStory = () => {
21
22
  const space = useSpace();
22
- const [schema, setSchema] = useState<MutableSchema>();
23
+ const [schema, setSchema] = useState<EchoSchema>();
23
24
  const [view, setView] = useState<ViewType>();
24
25
  const [projection, setProjection] = useState<ViewProjection>();
25
- useEffect(() => {
26
+ useAsyncEffect(async () => {
26
27
  if (space) {
27
28
  class TestSchema extends TypedObject({ typename: 'example.com/type/Test', version: '0.1.0' })({
28
29
  name: S.String,
@@ -30,7 +31,7 @@ const DefaultStory = () => {
30
31
  salary: Format.Currency(),
31
32
  }) {}
32
33
 
33
- const schema = space.db.schemaRegistry.addSchema(TestSchema);
34
+ const [schema] = await space.db.schemaRegistry.register([TestSchema]);
34
35
  const view = createView({ name: 'Test', typename: schema.typename, jsonSchema: toJsonSchema(TestSchema) });
35
36
  const projection = new ViewProjection(schema, view);
36
37
 
@@ -40,6 +41,21 @@ const DefaultStory = () => {
40
41
  }
41
42
  }, [space]);
42
43
 
44
+ const views = useQuery(space, Filter.schema(ViewType));
45
+ const currentTypename = useMemo(() => view?.query?.type, [view]);
46
+ const updateViewTypename = useCallback(
47
+ (newTypename: string) => {
48
+ if (!schema) {
49
+ return;
50
+ }
51
+ const matchingViews = views.filter((view) => view.query.type === currentTypename);
52
+ for (const view of matchingViews) {
53
+ view.query.type = newTypename;
54
+ }
55
+ schema.updateTypename(newTypename);
56
+ },
57
+ [views, schema],
58
+ );
43
59
  const handleDelete = useCallback((property: string) => projection?.deleteFieldProjection(property), [projection]);
44
60
 
45
61
  if (!schema || !view || !projection) {
@@ -49,7 +65,13 @@ const DefaultStory = () => {
49
65
  return (
50
66
  <TestLayout json={{ schema, view, projection }}>
51
67
  <TestPanel>
52
- <ViewEditor schema={schema} view={view} registry={space?.db.schemaRegistry} onDelete={handleDelete} />
68
+ <ViewEditor
69
+ schema={schema}
70
+ view={view}
71
+ registry={space?.db.schemaRegistry}
72
+ onTypenameChanged={updateViewTypename}
73
+ onDelete={handleDelete}
74
+ />
53
75
  </TestPanel>
54
76
  </TestLayout>
55
77
  );
@@ -4,13 +4,12 @@
4
4
 
5
5
  import React, { useCallback, useMemo, useState } from 'react';
6
6
 
7
- import { type SchemaResolver } from '@dxos/echo-db';
8
- import { AST, Format, type MutableSchema, S } from '@dxos/echo-schema';
7
+ import { type SchemaRegistry } from '@dxos/echo-db';
8
+ import { AST, Format, type EchoSchema, S } from '@dxos/echo-schema';
9
9
  import { IconButton, type ThemedClassName, useTranslation } from '@dxos/react-ui';
10
10
  import { List } from '@dxos/react-ui-list';
11
11
  import { ghostHover, inputTextLabel, mx } from '@dxos/react-ui-theme';
12
12
  import { FieldSchema, type FieldType, type ViewType, ViewProjection, VIEW_FIELD_LIMIT } from '@dxos/schema';
13
- import { arrayMove } from '@dxos/util';
14
13
 
15
14
  import { translationKey } from '../../translations';
16
15
  import { FieldEditor } from '../FieldEditor';
@@ -30,11 +29,12 @@ const ViewMetaSchema = S.Struct({
30
29
  type ViewMetaType = S.Schema.Type<typeof ViewMetaSchema>;
31
30
 
32
31
  export type ViewEditorProps = ThemedClassName<{
33
- schema: MutableSchema;
32
+ schema: EchoSchema;
34
33
  view: ViewType;
35
- registry?: SchemaResolver;
34
+ registry?: SchemaRegistry;
36
35
  readonly?: boolean;
37
36
  showHeading?: boolean;
37
+ onTypenameChanged: (typename: string) => void;
38
38
  onDelete: (fieldId: string) => void;
39
39
  }>;
40
40
 
@@ -48,6 +48,7 @@ export const ViewEditor = ({
48
48
  registry,
49
49
  readonly,
50
50
  showHeading = false,
51
+ onTypenameChanged,
51
52
  onDelete,
52
53
  }: ViewEditorProps) => {
53
54
  const { t } = useTranslation(translationKey);
@@ -67,12 +68,15 @@ export const ViewEditor = ({
67
68
  const handleViewUpdate = useCallback(
68
69
  ({ name, typename }: ViewMetaType) => {
69
70
  requestAnimationFrame(() => {
70
- view.name = name;
71
- view.query.type = typename;
72
- schema.updateTypename(typename);
71
+ if (view.name !== name) {
72
+ view.name = name;
73
+ }
74
+ if (view.query.type !== typename) {
75
+ onTypenameChanged(typename);
76
+ }
73
77
  });
74
78
  },
75
- [view, schema],
79
+ [view, schema, onTypenameChanged],
76
80
  );
77
81
 
78
82
  const handleSelect = useCallback((field: FieldType) => {
@@ -86,7 +90,11 @@ export const ViewEditor = ({
86
90
 
87
91
  const handleMove = useCallback(
88
92
  (fromIndex: number, toIndex: number) => {
89
- arrayMove(view.fields, fromIndex, toIndex);
93
+ // NOTE(ZaymonFC): Using arrayMove here causes a race condition with the kanban model.
94
+ const fields = [...view.fields];
95
+ const [moved] = fields.splice(fromIndex, 1);
96
+ fields.splice(toIndex, 0, moved);
97
+ view.fields = fields;
90
98
  },
91
99
  [view.fields],
92
100
  );
@@ -4,7 +4,21 @@
4
4
 
5
5
  import React, { type JSX, type PropsWithChildren } from 'react';
6
6
 
7
- import { Input } from '@dxos/react-ui';
7
+ import { Input, type ThemedClassName } from '@dxos/react-ui';
8
+ import { mx, textBlockWidth } from '@dxos/react-ui-theme';
9
+
10
+ /**
11
+ * @deprecated
12
+ */
13
+ export const DeprecatedFormContainer = ({ children, classNames }: ThemedClassName<PropsWithChildren>) => {
14
+ return (
15
+ <div role='none' className='w-full p-4 justify-center overflow-x-hidden overflow-y-auto'>
16
+ <div role='form' className={mx('flex flex-col', textBlockWidth, classNames)}>
17
+ {children}
18
+ </div>
19
+ </div>
20
+ );
21
+ };
8
22
 
9
23
  export type DeprecatedFormInputProps = {
10
24
  label: string;
@@ -15,7 +29,6 @@ export type DeprecatedFormInputProps = {
15
29
  /**
16
30
  * @deprecated
17
31
  */
18
- // TODO(burdon): Still used in ObservabilitySettings and all settings.
19
32
  export const DeprecatedFormInput = ({
20
33
  label,
21
34
  description,
@@ -26,8 +39,9 @@ export const DeprecatedFormInput = ({
26
39
  <div role='none' className='flex w-full gap-4 py-1'>
27
40
  <Input.Root>
28
41
  <div role='none' className='flex flex-col w-full'>
29
- {/* TODO(burdon): Consistent height for controls (e.g., Select, Textbox, and Checkbox are all different). */}
30
- <Input.Label classNames='flex min-h-[40px] items-center'>{label}</Input.Label>
42
+ <Input.Label classNames='flex min-bs-[--rail-action] items-center' style={{ fontSize: 'unset' }}>
43
+ {label}
44
+ </Input.Label>
31
45
  {description && (
32
46
  <Input.DescriptionAndValidation classNames='mbs-0.5'>
33
47
  <Input.Description>{description}</Input.Description>
@@ -36,7 +50,7 @@ export const DeprecatedFormInput = ({
36
50
  </div>
37
51
 
38
52
  <div role='none'>
39
- <div role='none' className='flex min-h-[40px] items-center'>
53
+ <div role='none' className='flex min-bs-[--rail-action] items-center'>
40
54
  {children}
41
55
  </div>
42
56
  </div>
@@ -45,9 +59,8 @@ export const DeprecatedFormInput = ({
45
59
  );
46
60
 
47
61
  if (secondary) {
48
- // console.log(secondary);
49
62
  return (
50
- <div role='none' className='flex flex-col w-full'>
63
+ <div role='none'>
51
64
  {primary}
52
65
  {secondary}
53
66
  </div>
@@ -2,18 +2,15 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { type FocusEvent, useCallback, useEffect, useMemo, useState } from 'react';
5
+ import { useCallback, useEffect, useMemo, useState } from 'react';
6
6
 
7
- import { type BaseObject, type PropertyKey } from '@dxos/echo-schema';
8
- import { type SimpleType, type S } from '@dxos/effect';
7
+ import { type BaseObject, getValue, setValue } from '@dxos/echo-schema';
8
+ import { AST, type S, type SimpleType, type JsonPath, createJsonPath, fromEffectValidationPath } from '@dxos/effect';
9
9
  import { invariant } from '@dxos/invariant';
10
10
  import { log } from '@dxos/log';
11
11
  import { validateSchema, type ValidationError } from '@dxos/schema';
12
12
  import { type MaybePromise } from '@dxos/util';
13
13
 
14
- // TODO(ZaymonFC): Where should this live?
15
- export type FormPath<T extends object = {}> = PropertyKey<T> | `${PropertyKey<T>}.${number}`;
16
-
17
14
  /**
18
15
  * Return type from `useForm` hook.
19
16
  */
@@ -22,10 +19,10 @@ export type FormHandler<T extends BaseObject> = {
22
19
  // Form state management.
23
20
  //
24
21
 
25
- values: T;
26
- errors: Record<FormPath<T>, string>;
27
- touched: Record<FormPath<T>, boolean>;
28
- changed: Record<FormPath<T>, boolean>;
22
+ values: Partial<T>;
23
+ errors: Record<JsonPath, string>;
24
+ touched: Record<JsonPath, boolean>;
25
+ changed: Record<JsonPath, boolean>;
29
26
  canSave: boolean;
30
27
  handleSave: () => void;
31
28
 
@@ -33,10 +30,10 @@ export type FormHandler<T extends BaseObject> = {
33
30
  // Form input component helpers.
34
31
  //
35
32
 
36
- getStatus: (property: FormPath<T>) => { status?: 'error'; error?: string };
37
- getValue: <V>(property: FormPath<T>) => V;
38
- onValueChange: <V>(property: FormPath<T>, type: SimpleType, value: V) => void;
39
- onBlur: (event: FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
33
+ getStatus: (path: string | (string | number)[]) => { status?: 'error'; error?: string };
34
+ getValue: <V>(path: (string | number)[]) => V | undefined;
35
+ onValueChange: <V>(path: (string | number)[], type: SimpleType, value: V) => void;
36
+ onTouched: (path: (string | number)[]) => void;
40
37
  };
41
38
 
42
39
  /**
@@ -44,23 +41,21 @@ export type FormHandler<T extends BaseObject> = {
44
41
  */
45
42
  export interface FormOptions<T extends BaseObject> {
46
43
  /**
47
- * Effect schema.
44
+ * Effect schema (Type literal).
48
45
  */
49
- // TODO(burdon): Change to S.Struct<T>?
50
46
  schema: S.Schema<T>;
51
47
 
52
48
  /**
53
49
  * Initial values (which may not pass validation).
54
50
  */
55
- // TODO(burdon): Should be partial?
56
- initialValues: T;
51
+ initialValues: Partial<T>;
57
52
 
58
53
  /**
59
54
  * Callback for value changes. Note: This is called even when values are invalid.
60
55
  * Sometimes the parent component may want to know about changes even if the form is
61
56
  * in an invalid state.
62
57
  */
63
- onValuesChanged?: (values: T) => void;
58
+ onValuesChanged?: (values: Partial<T>) => void;
64
59
 
65
60
  /**
66
61
  * Custom validation function that runs only after schema validation passes.
@@ -93,30 +88,33 @@ export const useForm = <T extends BaseObject>({
93
88
  onValid,
94
89
  onSave,
95
90
  }: FormOptions<T>): FormHandler<T> => {
96
- const [values, setValues] = useState<T>(initialValues);
91
+ invariant(AST.isTypeLiteral(schema.ast));
92
+
93
+ const [values, setValues] = useState<Partial<T>>(initialValues);
97
94
  useEffect(() => {
98
95
  setValues(initialValues);
99
96
  }, [initialValues]);
100
97
 
101
- const [touched, setTouched] = useState<Record<FormPath<T>, boolean>>(createKeySet(initialValues, false));
102
- const [changed, setChanged] = useState<Record<FormPath<T>, boolean>>(createKeySet(initialValues, false));
103
- const [errors, setErrors] = useState<Record<FormPath<T>, string>>({} as Record<FormPath<T>, string>);
98
+ const [touched, setTouched] = useState<Record<JsonPath, boolean>>(createKeySet(initialValues, false));
99
+ const [changed, setChanged] = useState<Record<JsonPath, boolean>>(createKeySet(initialValues, false));
100
+ const [errors, setErrors] = useState<Record<JsonPath, string>>({});
104
101
  const [saving, setSaving] = useState(false);
105
102
 
106
103
  //
107
104
  // Validation.
108
105
  //
109
106
 
110
- // TODO(burdon): Validate each property separately.
111
107
  const validate = useCallback(
112
- (values: T) => {
108
+ (values: Partial<T>): values is T => {
113
109
  let errors: ValidationError[] = validateSchema(schema, values) ?? [];
114
110
  if (errors.length === 0 && onValidate) {
115
- errors = onValidate(values) ?? [];
111
+ const validatedValues = values as T;
112
+ errors = onValidate(validatedValues) ?? [];
116
113
  }
117
114
 
118
115
  setErrors(flatMap(errors));
119
- return errors.length === 0;
116
+ const valid = errors.length === 0;
117
+ return valid;
120
118
  },
121
119
  [schema, onValidate],
122
120
  );
@@ -138,10 +136,15 @@ export const useForm = <T extends BaseObject>({
138
136
  const canSave = useMemo(
139
137
  () =>
140
138
  !saving &&
141
- Object.keys(values).every(
142
- (property) => touched[property as PropertyKey<T>] === false || !errors[property as PropertyKey<T>],
139
+ // Check if any error paths that are touched have errors
140
+ !Object.entries(touched).some(
141
+ ([path, isTouched]) =>
142
+ isTouched &&
143
+ Object.keys(errors).some(
144
+ (errorPath) => errorPath === path || errorPath.startsWith(`${path}.`) || errorPath.startsWith(`${path}[`),
145
+ ),
143
146
  ),
144
- [values, touched, errors, saving],
147
+ [touched, errors, saving],
145
148
  );
146
149
 
147
150
  const handleSave = useCallback(async () => {
@@ -153,27 +156,34 @@ export const useForm = <T extends BaseObject>({
153
156
  setSaving(false);
154
157
  }
155
158
  }
156
- }, [values, validate, onSave]);
159
+ }, [values, validate, onSave, changed]);
157
160
 
158
161
  //
159
162
  // Fields.
160
163
  //
161
164
 
162
165
  const getStatus = useCallback<FormHandler<T>['getStatus']>(
163
- (property: FormPath<T>) => ({
164
- status: errors[property] ? 'error' : undefined,
165
- error: errors[property] ? errors[property] : undefined,
166
- }),
167
- [touched, errors],
166
+ (path) => {
167
+ const jsonPath = Array.isArray(path) ? createJsonPath(path) : path;
168
+ const matchingError = Object.entries(errors).find(
169
+ ([errorPath]) =>
170
+ errorPath === jsonPath || errorPath.startsWith(`${jsonPath}.`) || errorPath.startsWith(`${jsonPath}[`),
171
+ );
172
+
173
+ return {
174
+ status: matchingError ? 'error' : undefined,
175
+ error: matchingError ? matchingError[1] : undefined,
176
+ };
177
+ },
178
+ [errors],
168
179
  );
169
180
 
170
- // TODO(burdon): Use path to extract hierarchical value.
171
- const getValue = <V>(property: FormPath<T>): V => {
172
- return getByPath(values, property);
181
+ const getFormValue = <V>(path: (string | number)[]): V | undefined => {
182
+ return getValue(values, createJsonPath(path));
173
183
  };
174
184
 
175
- // TODO(burdon): Use path to set hierarchical value.
176
- const onValueChange = (property: FormPath<T>, type: SimpleType, value: any) => {
185
+ const onValueChange = (path: (string | number)[], type: SimpleType, value: any) => {
186
+ const jsonPath = createJsonPath(path);
177
187
  let parsedValue = value;
178
188
  try {
179
189
  if (type === 'number') {
@@ -184,9 +194,9 @@ export const useForm = <T extends BaseObject>({
184
194
  parsedValue = undefined;
185
195
  }
186
196
 
187
- const newValues = setByPath(values, property, parsedValue);
197
+ const newValues = { ...setValue(values, jsonPath, parsedValue) };
188
198
  setValues(newValues);
189
- setChanged((prev) => ({ ...prev, [property]: true }));
199
+ setChanged((prev) => ({ ...prev, [jsonPath]: true }));
190
200
  onValuesChanged?.(newValues);
191
201
 
192
202
  const isValid = validate(newValues);
@@ -195,19 +205,10 @@ export const useForm = <T extends BaseObject>({
195
205
  }
196
206
  };
197
207
 
198
- // TODO(burdon): This is a leaky abstraction: the hook ideally shouldn't get involved in UX.
199
- const onBlur = useCallback(
200
- (event: FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
201
- const { name } = event.target;
202
- setTouched((touched) => ({ ...touched, [name]: true }));
203
-
204
- // TODO(Zan): This should be configurable behavior.
205
- if (event.relatedTarget?.getAttribute('type') === 'submit') {
206
- // NOTE: We do this here instead of onSave because the blur event is triggered before the submit event
207
- // and results in the submit button being disabled when the form is invalid.
208
- setTouched(createKeySet(values, true));
209
- }
210
-
208
+ const onTouched = useCallback(
209
+ (path: (string | number)[]) => {
210
+ const jsonPath = createJsonPath(path);
211
+ setTouched((touched) => ({ ...touched, [jsonPath]: true }));
211
212
  validate(values);
212
213
  },
213
214
  [validate, values],
@@ -224,40 +225,27 @@ export const useForm = <T extends BaseObject>({
224
225
 
225
226
  // Field utils.
226
227
  getStatus,
227
- getValue,
228
+ getValue: getFormValue,
228
229
  onValueChange,
229
- onBlur,
230
+ onTouched,
230
231
  } satisfies FormHandler<T>;
231
232
  };
232
233
 
233
- const createKeySet = <T extends BaseObject, V>(obj: T, value: V): Record<FormPath<T>, V> => {
234
+ const createKeySet = <T extends BaseObject, V>(obj: T, value: V): Record<JsonPath, V> => {
234
235
  invariant(obj);
235
- return Object.keys(obj).reduce((acc, key) => ({ ...acc, [key]: value }), {} as Record<FormPath<T>, V>);
236
+ return Object.keys(obj).reduce((acc, key) => ({ ...acc, [key]: value }), {} as Record<JsonPath, V>);
236
237
  };
237
238
 
238
- const flatMap = <T extends BaseObject>(errors: ValidationError[]) => {
239
+ const flatMap = (errors: ValidationError[]) => {
239
240
  return errors.reduce(
240
241
  (result, { path, message }) => {
241
- if (!(path in result)) {
242
- result[path as FormPath<T>] = message;
242
+ // Convert the validation error path format to our JsonPath format
243
+ const jsonPath = fromEffectValidationPath(path);
244
+ if (!(jsonPath in result)) {
245
+ result[jsonPath] = message;
243
246
  }
244
247
  return result;
245
248
  },
246
- {} as Record<FormPath<T>, string>,
249
+ {} as Record<JsonPath, string>,
247
250
  );
248
251
  };
249
-
250
- // TODO(ZaymonFC): Move to util?
251
- const getByPath = <T extends object, V>(obj: T, path: FormPath<T>): V => {
252
- const [key, index] = path.split('.');
253
- return index === undefined ? (obj[key as keyof T] as V) : ((obj[key as keyof T] as any[])[parseInt(index)] as V);
254
- };
255
-
256
- const setByPath = <T extends object>(obj: T, path: FormPath<T>, value: any): T => {
257
- const [key, index] = path.split('.');
258
- const newValue =
259
- index === undefined
260
- ? value
261
- : [...(obj[key as keyof T] as any[])].map((v, i) => (i === parseInt(index) ? value : v));
262
- return { ...obj, [key]: newValue };
263
- };
@@ -42,6 +42,7 @@ export default [
42
42
  'format ref': 'Reference',
43
43
  'format regex': 'RegExp',
44
44
  'format string': 'String',
45
+ 'format single-select': 'Select',
45
46
  'format text': 'Long text',
46
47
  'format time': 'Time',
47
48
  'format timestamp': 'Timestamp',
@@ -53,6 +54,13 @@ export default [
53
54
  // Placeholders
54
55
  'placeholder latitude': 'Latitude',
55
56
  'placeholder longitude': 'Longitude',
57
+
58
+ // SelectOptionsInput
59
+ 'select option label': 'Label',
60
+ 'select option label placeholder': 'Option label',
61
+ 'select option color': 'Color',
62
+ 'select option delete': 'Delete',
63
+ 'select option add': 'Add option',
56
64
  },
57
65
  },
58
66
  },
@@ -1,5 +0,0 @@
1
- import React from 'react';
2
- import { type BaseObject } from '@dxos/echo-schema';
3
- import { type InputProps } from './Input';
4
- export declare const GeiPointInput: <T extends BaseObject>({ property, type, label, disabled, getStatus, getValue, onValueChange, onBlur, }: InputProps<T>) => React.JSX.Element;
5
- //# sourceMappingURL=Custom.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"Custom.d.ts","sourceRoot":"","sources":["../../../../../src/components/Form/Custom.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,OAAO,EAAE,KAAK,UAAU,EAAiB,MAAM,mBAAmB,CAAC;AAGnE,OAAO,EAAe,KAAK,UAAU,EAAE,MAAM,SAAS,CAAC;AAOvD,eAAO,MAAM,aAAa,GAAI,CAAC,SAAS,UAAU,oFAS/C,UAAU,CAAC,CAAC,CAAC,sBA8Bf,CAAC"}
@@ -1,65 +0,0 @@
1
- //
2
- // Copyright 2024 DXOS.org
3
- //
4
-
5
- import React from 'react';
6
-
7
- import { type BaseObject, type GeoPoint } from '@dxos/echo-schema';
8
- import { Input, useTranslation } from '@dxos/react-ui';
9
-
10
- import { InputHeader, type InputProps } from './Input';
11
- import { translationKey } from '../../translations';
12
-
13
- //
14
- // Custom format components.
15
- //
16
-
17
- export const GeiPointInput = <T extends BaseObject>({
18
- property,
19
- type,
20
- label,
21
- disabled,
22
- getStatus,
23
- getValue,
24
- onValueChange,
25
- onBlur,
26
- }: InputProps<T>) => {
27
- const { t } = useTranslation(translationKey);
28
- const { status, error } = getStatus?.(property);
29
- const [lng = 0, lat = 0] = getValue<GeoPoint>(property) ?? [];
30
-
31
- return (
32
- <Input.Root validationValence={status}>
33
- <InputHeader error={error}>
34
- <Input.Label>{label}</Input.Label>
35
- </InputHeader>
36
- <div className='grid grid-cols-2 gap-2'>
37
- <Input.TextInput
38
- type='number'
39
- disabled={disabled}
40
- placeholder={t('placeholder longitude')}
41
- value={lng}
42
- onChange={(event) => onValueChange(property, type, [lat, safeParseFloat(event.target.value, 0)])}
43
- onBlur={onBlur}
44
- />
45
- <Input.TextInput
46
- type='number'
47
- disabled={disabled}
48
- placeholder={t('placeholder latitude')}
49
- value={lat}
50
- onChange={(event) => onValueChange(property, type, [safeParseFloat(event.target.value, 0), lng])}
51
- onBlur={onBlur}
52
- />
53
- </div>
54
- </Input.Root>
55
- );
56
- };
57
-
58
- // TODO(burdon): Factor out.
59
- const safeParseFloat = (str: string, defaultValue?: number): number | undefined => {
60
- try {
61
- return parseFloat(str);
62
- } catch {
63
- return defaultValue ?? 0;
64
- }
65
- };