@dxos/react-ui-form 0.7.1-staging.599df14

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 (93) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +4 -0
  3. package/dist/lib/browser/index.mjs +1008 -0
  4. package/dist/lib/browser/index.mjs.map +7 -0
  5. package/dist/lib/browser/meta.json +1 -0
  6. package/dist/lib/node/index.cjs +1028 -0
  7. package/dist/lib/node/index.cjs.map +7 -0
  8. package/dist/lib/node/meta.json +1 -0
  9. package/dist/lib/node-esm/index.mjs +1010 -0
  10. package/dist/lib/node-esm/index.mjs.map +7 -0
  11. package/dist/lib/node-esm/meta.json +1 -0
  12. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +16 -0
  13. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -0
  14. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +9 -0
  15. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -0
  16. package/dist/types/src/components/FieldEditor/index.d.ts +2 -0
  17. package/dist/types/src/components/FieldEditor/index.d.ts.map +1 -0
  18. package/dist/types/src/components/Form/Custom.d.ts +4 -0
  19. package/dist/types/src/components/Form/Custom.d.ts.map +1 -0
  20. package/dist/types/src/components/Form/Defaults.d.ts +7 -0
  21. package/dist/types/src/components/Form/Defaults.d.ts.map +1 -0
  22. package/dist/types/src/components/Form/Form.d.ts +26 -0
  23. package/dist/types/src/components/Form/Form.d.ts.map +1 -0
  24. package/dist/types/src/components/Form/Form.stories.d.ts +38 -0
  25. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -0
  26. package/dist/types/src/components/Form/Input.d.ts +29 -0
  27. package/dist/types/src/components/Form/Input.d.ts.map +1 -0
  28. package/dist/types/src/components/Form/factory.d.ts +8 -0
  29. package/dist/types/src/components/Form/factory.d.ts.map +1 -0
  30. package/dist/types/src/components/Form/index.d.ts +5 -0
  31. package/dist/types/src/components/Form/index.d.ts.map +1 -0
  32. package/dist/types/src/components/ViewEditor/Test.stories.d.ts +10 -0
  33. package/dist/types/src/components/ViewEditor/Test.stories.d.ts.map +1 -0
  34. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +18 -0
  35. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -0
  36. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +8 -0
  37. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -0
  38. package/dist/types/src/components/ViewEditor/index.d.ts +2 -0
  39. package/dist/types/src/components/ViewEditor/index.d.ts.map +1 -0
  40. package/dist/types/src/components/deprecated/Form.d.ts +15 -0
  41. package/dist/types/src/components/deprecated/Form.d.ts.map +1 -0
  42. package/dist/types/src/components/deprecated/Form.stories.d.ts +13 -0
  43. package/dist/types/src/components/deprecated/Form.stories.d.ts.map +1 -0
  44. package/dist/types/src/components/deprecated/FormInput.d.ts +11 -0
  45. package/dist/types/src/components/deprecated/FormInput.d.ts.map +1 -0
  46. package/dist/types/src/components/deprecated/index.d.ts +2 -0
  47. package/dist/types/src/components/deprecated/index.d.ts.map +1 -0
  48. package/dist/types/src/components/index.d.ts +5 -0
  49. package/dist/types/src/components/index.d.ts.map +1 -0
  50. package/dist/types/src/components/testing.d.ts +7 -0
  51. package/dist/types/src/components/testing.d.ts.map +1 -0
  52. package/dist/types/src/hooks/index.d.ts +2 -0
  53. package/dist/types/src/hooks/index.d.ts.map +1 -0
  54. package/dist/types/src/hooks/useForm.d.ts +58 -0
  55. package/dist/types/src/hooks/useForm.d.ts.map +1 -0
  56. package/dist/types/src/index.d.ts +6 -0
  57. package/dist/types/src/index.d.ts.map +1 -0
  58. package/dist/types/src/translations.d.ts +46 -0
  59. package/dist/types/src/translations.d.ts.map +1 -0
  60. package/dist/types/src/util/field.d.ts +22 -0
  61. package/dist/types/src/util/field.d.ts.map +1 -0
  62. package/dist/types/src/util/formatting.d.ts +15 -0
  63. package/dist/types/src/util/formatting.d.ts.map +1 -0
  64. package/dist/types/src/util/index.d.ts +3 -0
  65. package/dist/types/src/util/index.d.ts.map +1 -0
  66. package/package.json +76 -0
  67. package/src/components/FieldEditor/FieldEditor.stories.tsx +56 -0
  68. package/src/components/FieldEditor/FieldEditor.tsx +167 -0
  69. package/src/components/FieldEditor/index.ts +5 -0
  70. package/src/components/Form/Custom.tsx +65 -0
  71. package/src/components/Form/Defaults.tsx +130 -0
  72. package/src/components/Form/Form.stories.tsx +137 -0
  73. package/src/components/Form/Form.tsx +176 -0
  74. package/src/components/Form/Input.tsx +56 -0
  75. package/src/components/Form/factory.tsx +32 -0
  76. package/src/components/Form/index.ts +8 -0
  77. package/src/components/ViewEditor/Test.stories.tsx +45 -0
  78. package/src/components/ViewEditor/ViewEditor.stories.tsx +73 -0
  79. package/src/components/ViewEditor/ViewEditor.tsx +158 -0
  80. package/src/components/ViewEditor/index.ts +5 -0
  81. package/src/components/deprecated/Form.stories.tsx +62 -0
  82. package/src/components/deprecated/Form.tsx +119 -0
  83. package/src/components/deprecated/FormInput.tsx +58 -0
  84. package/src/components/deprecated/index.ts +5 -0
  85. package/src/components/index.ts +9 -0
  86. package/src/components/testing.tsx +24 -0
  87. package/src/hooks/index.ts +5 -0
  88. package/src/hooks/useForm.ts +229 -0
  89. package/src/index.ts +11 -0
  90. package/src/translations.ts +59 -0
  91. package/src/util/field.ts +137 -0
  92. package/src/util/formatting.ts +132 -0
  93. package/src/util/index.ts +6 -0
@@ -0,0 +1,158 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import React, { useCallback, useMemo, useState } from 'react';
6
+
7
+ import { type SchemaResolver } from '@dxos/echo-db';
8
+ import { AST, Format, type MutableSchema, S } from '@dxos/echo-schema';
9
+ import { IconButton, type ThemedClassName, useTranslation } from '@dxos/react-ui';
10
+ import { List } from '@dxos/react-ui-list';
11
+ import { ghostHover, inputTextLabel, mx } from '@dxos/react-ui-theme';
12
+ import { FieldSchema, type FieldType, type ViewType, ViewProjection, VIEW_FIELD_LIMIT } from '@dxos/schema';
13
+ import { arrayMove } from '@dxos/util';
14
+
15
+ import { translationKey } from '../../translations';
16
+ import { FieldEditor } from '../FieldEditor';
17
+ import { Form } from '../Form';
18
+
19
+ const grid = 'grid grid-cols-[32px_1fr_32px] min-bs-[2.5rem]';
20
+
21
+ const ViewMetaSchema = S.Struct({
22
+ name: S.String.annotations({
23
+ [AST.TitleAnnotationId]: 'View',
24
+ }),
25
+ type: Format.DXN.annotations({
26
+ [AST.TitleAnnotationId]: 'Typename',
27
+ }),
28
+ }).pipe(S.mutable);
29
+
30
+ type ViewMetaType = S.Schema.Type<typeof ViewMetaSchema>;
31
+
32
+ export type ViewEditorProps = ThemedClassName<{
33
+ schema: MutableSchema;
34
+ view: ViewType;
35
+ registry?: SchemaResolver;
36
+ readonly?: boolean;
37
+ showHeading?: boolean;
38
+ onDelete: (fieldId: string) => void;
39
+ }>;
40
+
41
+ /**
42
+ * Schema-based object form.
43
+ */
44
+ export const ViewEditor = ({
45
+ classNames,
46
+ schema,
47
+ view,
48
+ registry,
49
+ readonly,
50
+ showHeading = false,
51
+ onDelete,
52
+ }: ViewEditorProps) => {
53
+ const { t } = useTranslation(translationKey);
54
+ const projection = useMemo(() => new ViewProjection(schema, view), [schema, view]);
55
+ const [field, setField] = useState<FieldType>();
56
+
57
+ // TODO(burdon): Should be reactive.
58
+ const viewValues = useMemo(() => {
59
+ return {
60
+ name: view.name,
61
+ // TODO(burdon): Need to warn user of possible consequences of editing.
62
+ // TODO(burdon): Settings should have domain name owned by user.
63
+ type: view.query.type,
64
+ };
65
+ }, [view]);
66
+
67
+ const handleViewUpdate = useCallback(
68
+ ({ name, type }: ViewMetaType) => {
69
+ requestAnimationFrame(() => {
70
+ view.name = name;
71
+ view.query.type = type;
72
+ schema.updateTypename(type);
73
+ });
74
+ },
75
+ [view, schema],
76
+ );
77
+
78
+ const handleSelect = useCallback((field: FieldType) => {
79
+ setField((f) => (f === field ? undefined : field));
80
+ }, []);
81
+
82
+ const handleAdd = useCallback(() => {
83
+ const field = projection.createFieldProjection();
84
+ setField(field);
85
+ }, [view]);
86
+
87
+ const handleMove = useCallback(
88
+ (fromIndex: number, toIndex: number) => {
89
+ arrayMove(view.fields, fromIndex, toIndex);
90
+ },
91
+ [view.fields],
92
+ );
93
+
94
+ const handleClose = useCallback(() => setField(undefined), []);
95
+
96
+ return (
97
+ <div role='none' className={mx('flex flex-col w-full divide-y divide-separator', classNames)}>
98
+ <Form<ViewMetaType> autoFocus autoSave schema={ViewMetaSchema} values={viewValues} onSubmit={handleViewUpdate} />
99
+
100
+ <div>
101
+ {/* TODO(burdon): Clean up common form ux. */}
102
+ <div role='none' className='p-2'>
103
+ <label className={mx(inputTextLabel)}>{t('fields label')}</label>
104
+ </div>
105
+
106
+ <List.Root<FieldType>
107
+ items={view.fields}
108
+ isItem={S.is(FieldSchema)}
109
+ getId={(field) => field.id}
110
+ onMove={handleMove}
111
+ >
112
+ {({ items: fields }) => (
113
+ <>
114
+ {showHeading && (
115
+ <div role='heading' className={grid}>
116
+ <div />
117
+ <div className='flex items-center text-sm'>{t('field path label')}</div>
118
+ </div>
119
+ )}
120
+
121
+ <div role='list' className='flex flex-col w-full'>
122
+ {fields?.map((field) => (
123
+ <List.Item<FieldType> key={field.id} item={field} classNames={mx(grid, ghostHover, 'cursor-pointer')}>
124
+ <List.ItemDragHandle />
125
+ <List.ItemTitle onClick={() => handleSelect(field)}>{field.path}</List.ItemTitle>
126
+ <List.ItemDeleteButton disabled={view.fields.length <= 1} onClick={() => onDelete(field.id)} />
127
+ </List.Item>
128
+ ))}
129
+ </div>
130
+ </>
131
+ )}
132
+ </List.Root>
133
+ </div>
134
+
135
+ {field && (
136
+ <FieldEditor
137
+ key={field.id}
138
+ view={view}
139
+ projection={projection}
140
+ field={field}
141
+ registry={registry}
142
+ onClose={handleClose}
143
+ />
144
+ )}
145
+ {!readonly && !field && (
146
+ <div className='flex p-2 justify-center'>
147
+ <IconButton
148
+ icon='ph--plus--regular'
149
+ label={t('button add property')}
150
+ onClick={handleAdd}
151
+ // TODO(burdon): Show field limit in ux (not tooltip).
152
+ disabled={view.fields.length >= VIEW_FIELD_LIMIT}
153
+ />
154
+ </div>
155
+ )}
156
+ </div>
157
+ );
158
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ export * from './ViewEditor';
@@ -0,0 +1,62 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import '@dxos-theme';
6
+
7
+ import { type Meta, type StoryObj } from '@storybook/react';
8
+ import React from 'react';
9
+
10
+ import { create } from '@dxos/echo-schema';
11
+ import { testData, testView, TestSchema, type TestType } from '@dxos/schema/testing';
12
+ import { withTheme, withLayout } from '@dxos/storybook-utils';
13
+
14
+ import { type DeprecatedFormProps, DeprecatedForm } from './Form';
15
+ import translations from '../../translations';
16
+ import { TestLayout, TestPanel } from '../testing';
17
+
18
+ const DefaultStory = (props: DeprecatedFormProps) => (
19
+ <TestLayout json={{ props }}>
20
+ <TestPanel>
21
+ <DeprecatedForm {...props} />
22
+ </TestPanel>
23
+ </TestLayout>
24
+ );
25
+
26
+ const meta: Meta<typeof DefaultStory> = {
27
+ title: 'ui/react-ui-form/DeprecatedForm',
28
+ component: DeprecatedForm,
29
+ render: DefaultStory,
30
+ decorators: [withLayout({ fullscreen: true }), withTheme],
31
+ parameters: {
32
+ translations,
33
+ },
34
+ };
35
+
36
+ export default meta;
37
+
38
+ type Story = StoryObj<DeprecatedFormProps<TestType>>;
39
+
40
+ export const Default: Story = {
41
+ args: {
42
+ object: create(testData),
43
+ view: create(testView),
44
+ schema: TestSchema,
45
+ },
46
+ };
47
+
48
+ export const Empty: Story = {
49
+ args: {
50
+ view: create(testView),
51
+ schema: TestSchema,
52
+ },
53
+ };
54
+
55
+ export const Readonly: Story = {
56
+ args: {
57
+ object: create(testData),
58
+ view: create(testView),
59
+ schema: TestSchema,
60
+ readonly: true,
61
+ },
62
+ };
@@ -0,0 +1,119 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { Option, pipe } from 'effect';
6
+ import React, { useState } from 'react';
7
+
8
+ import { AST, type S, findProperty } from '@dxos/effect';
9
+ import { Input, type ThemedClassName, type TextInputProps as NativeTextInputProps } from '@dxos/react-ui';
10
+ import { mx } from '@dxos/react-ui-theme';
11
+ import { type ViewType, getFieldValue, setFieldValue } from '@dxos/schema';
12
+
13
+ export type DeprecatedFormProps<T extends {} = {}> = ThemedClassName<{
14
+ view: ViewType;
15
+ object: T;
16
+ schema?: S.Schema<T>;
17
+ readonly?: boolean;
18
+ }>;
19
+
20
+ /**
21
+ * Schema-based object form.
22
+ */
23
+ export const DeprecatedForm = <T extends {} = {}>({
24
+ classNames,
25
+ view,
26
+ object,
27
+ schema,
28
+ readonly,
29
+ }: DeprecatedFormProps<T>) => {
30
+ return (
31
+ <div role='none' className={mx('flex flex-col w-full gap-2 p-2', classNames)}>
32
+ {view.fields.map((field) => {
33
+ const prop = schema && findProperty(schema, field.path);
34
+ const title = (prop && pipe(AST.getTitleAnnotation(prop), Option.getOrUndefined)) ?? '';
35
+ const description = (prop && pipe(AST.getDescriptionAnnotation(prop), Option.getOrUndefined)) ?? title;
36
+ // const format =
37
+ // (prop && (getPatternAnnotation(prop) ?? (AST.isNumberKeyword(prop) && RealNumberFormat))) || undefined;
38
+
39
+ //
40
+ // Boolean
41
+ //
42
+
43
+ if (prop && AST.isBooleanKeyword(prop)) {
44
+ const value = object && getFieldValue(object, field);
45
+ const onChange = (checked: boolean) => {
46
+ setFieldValue(object, field, checked);
47
+ };
48
+
49
+ return (
50
+ <div key={field.path} className='flex flex-col w-full gap-1'>
51
+ <Input.Root>
52
+ <Input.Label classNames='px-1'>{title}</Input.Label>
53
+ <Input.Switch disabled={readonly} checked={!!value} onCheckedChange={(checked) => onChange(checked)} />
54
+ </Input.Root>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ //
60
+ // Strings
61
+ // TODO(burdon): Other types:
62
+ // - RichText editor
63
+ // - Numbers (with formatting: commas, currency, etc.)
64
+ // - Date (https://ark-ui.com/react/docs/components/date-picker)
65
+ // - Enum (single/multi-select)
66
+ //
67
+
68
+ const value = object ? getFieldValue(object, field) : undefined;
69
+
70
+ return (
71
+ <div key={field.path} className='flex flex-col w-full gap-1'>
72
+ <Input.Root>
73
+ <Input.Label classNames='px-1'>{title}</Input.Label>
74
+ <TextInput
75
+ disabled={readonly}
76
+ placeholder={description}
77
+ // format={format}
78
+ value={value ? String(value) : ''}
79
+ onChange={(event) => setFieldValue(object, field, event.target.value)}
80
+ />
81
+ </Input.Root>
82
+ </div>
83
+ );
84
+ })}
85
+ </div>
86
+ );
87
+ };
88
+
89
+ type TextInputProps = {
90
+ // format?: PatternAnnotation;
91
+ } & Omit<NativeTextInputProps, 'style'>;
92
+
93
+ /**
94
+ * @deprecated
95
+ */
96
+ // TODO(burdon): Remove.
97
+ // TODO(burdon): Use schema validation directly.
98
+ const TextInput = ({ onChange, ...props }: TextInputProps) => {
99
+ const [valid, _setValid] = useState(true);
100
+
101
+ const handleChange: NativeTextInputProps['onChange'] = (ev) => {
102
+ // const text = ev.target.value;
103
+ // if (!format?.filter || text.match(format.filter)) {
104
+ onChange?.(ev);
105
+ // if (format?.valid) {
106
+ // setValid(format.valid.test(text));
107
+ // }
108
+ // }
109
+ };
110
+
111
+ return (
112
+ <Input.TextInput
113
+ {...props}
114
+ // TODO(burdon): validationValence on Input.
115
+ style={valid ? {} : ({ '--tw-ring-color': 'red' } as any)}
116
+ onChange={handleChange}
117
+ />
118
+ );
119
+ };
@@ -0,0 +1,58 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import React, { type JSX, type PropsWithChildren } from 'react';
6
+
7
+ import { Input } from '@dxos/react-ui';
8
+
9
+ export type DeprecatedFormInputProps = {
10
+ label: string;
11
+ description?: JSX.Element;
12
+ secondary?: JSX.Element;
13
+ };
14
+
15
+ /**
16
+ * @deprecated
17
+ */
18
+ // TODO(burdon): Still used in ObservabilitySettings and all settings.
19
+ export const DeprecatedFormInput = ({
20
+ label,
21
+ description,
22
+ secondary,
23
+ children,
24
+ }: PropsWithChildren<DeprecatedFormInputProps>) => {
25
+ const primary = (
26
+ <div role='none' className='flex w-full gap-4 py-1'>
27
+ <Input.Root>
28
+ <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>
31
+ {description && (
32
+ <Input.DescriptionAndValidation classNames='mbs-0.5'>
33
+ <Input.Description>{description}</Input.Description>
34
+ </Input.DescriptionAndValidation>
35
+ )}
36
+ </div>
37
+
38
+ <div role='none'>
39
+ <div role='none' className='flex min-h-[40px] items-center'>
40
+ {children}
41
+ </div>
42
+ </div>
43
+ </Input.Root>
44
+ </div>
45
+ );
46
+
47
+ if (secondary) {
48
+ // console.log(secondary);
49
+ return (
50
+ <div role='none' className='flex flex-col w-full'>
51
+ {primary}
52
+ {secondary}
53
+ </div>
54
+ );
55
+ }
56
+
57
+ return primary;
58
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ export * from './FormInput';
@@ -0,0 +1,9 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ export * from './deprecated';
6
+
7
+ export * from './FieldEditor';
8
+ export * from './Form';
9
+ export * from './ViewEditor';
@@ -0,0 +1,24 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import React, { type PropsWithChildren } from 'react';
6
+
7
+ import { type ThemedClassName } from '@dxos/react-ui';
8
+ import { SyntaxHighlighter } from '@dxos/react-ui-syntax-highlighter';
9
+ import { mx } from '@dxos/react-ui-theme';
10
+
11
+ export const TestPanel = ({ classNames, children }: ThemedClassName<PropsWithChildren>) => (
12
+ <div className='flex flex-col p-4'>
13
+ <div className={mx('flex flex-col border border-separator rounded w-[300px]', classNames)}>{children}</div>
14
+ </div>
15
+ );
16
+
17
+ export const TestLayout = ({ classNames, json, children }: ThemedClassName<PropsWithChildren<{ json?: any }>>) => (
18
+ <div className='w-full h-full grid grid-cols-3'>
19
+ <div className={mx('flex col-span-2 w-full justify-center overflow-y-auto', classNames)}>{children}</div>
20
+ <SyntaxHighlighter language='json' className='w-full text-xs font-thin'>
21
+ {JSON.stringify(json, null, 2)}
22
+ </SyntaxHighlighter>
23
+ </div>
24
+ );
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ export * from './useForm';
@@ -0,0 +1,229 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { type FocusEvent, useCallback, useEffect, useMemo, useState } from 'react';
6
+
7
+ import { type SimpleType, type S } from '@dxos/effect';
8
+ import { invariant } from '@dxos/invariant';
9
+ import { log } from '@dxos/log';
10
+ import { validateSchema, type PropertyKey, type ValidationError } from '@dxos/schema';
11
+
12
+ /**
13
+ * Return type from `useForm` hook.
14
+ */
15
+ export type FormHandler<T extends object = {}> = {
16
+ //
17
+ // Form state management.
18
+ //
19
+
20
+ values: T;
21
+ errors: Record<PropertyKey<T>, string>;
22
+ touched: Record<PropertyKey<T>, boolean>;
23
+ changed: Record<PropertyKey<T>, boolean>;
24
+ canSubmit: boolean;
25
+ handleSubmit: () => void;
26
+
27
+ //
28
+ // Form input component helpers.
29
+ //
30
+
31
+ getStatus: (property: PropertyKey<T>) => { status?: 'error'; error?: string };
32
+ getValue: <V>(property: PropertyKey<T>) => V;
33
+ onValueChange: <V>(property: PropertyKey<T>, type: SimpleType, value: V) => void;
34
+ onBlur: (event: FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
35
+ };
36
+
37
+ /**
38
+ * Hook options.
39
+ */
40
+ export interface FormOptions<T extends object> {
41
+ schema: S.Schema<T>;
42
+
43
+ // TODO(burdon): Are these reactive?
44
+ initialValues: T;
45
+
46
+ /**
47
+ * Callback for value changes. Note: This is called even when values are invalid.
48
+ * Sometimes the parent component may want to know about changes even if the form is
49
+ * in an invalid state.
50
+ */
51
+ onValuesChanged?: (values: T) => void;
52
+
53
+ /**
54
+ * Custom validation function that runs only after schema validation passes.
55
+ * Use this for complex validation logic that can't be expressed in the schema.
56
+ * @returns Array of validation errors, or undefined if validation passes
57
+ */
58
+ // TODO(burdon): Change to key x value.
59
+ onValidate?: (values: T) => ValidationError[] | undefined;
60
+
61
+ /**
62
+ * Called when the form is valid.
63
+ */
64
+ onValid?: (values: T, meta: { changed: FormHandler<T>['changed'] }) => void;
65
+
66
+ /**
67
+ * Called when the form is submitted and passes validation.
68
+ */
69
+ onSubmit?: (values: T, meta: { changed: FormHandler<T>['changed'] }) => void;
70
+ }
71
+
72
+ /**
73
+ * Creates a hook for managing form state, including values, validation, and submission.
74
+ * Deeply integrated with `@dxos/schema` for schema-based validation.
75
+ */
76
+ export const useForm = <T extends object>({
77
+ schema,
78
+ initialValues,
79
+ onValuesChanged,
80
+ onValidate,
81
+ onValid,
82
+ onSubmit,
83
+ }: FormOptions<T>): FormHandler<T> => {
84
+ const [values, setValues] = useState<T>(initialValues);
85
+ useEffect(() => {
86
+ setValues(initialValues);
87
+ }, [initialValues]);
88
+
89
+ const [touched, setTouched] = useState<Record<PropertyKey<T>, boolean>>(createKeySet(initialValues, false));
90
+ const [changed, setChanged] = useState<Record<PropertyKey<T>, boolean>>(createKeySet(initialValues, false));
91
+ const [errors, setErrors] = useState<Record<PropertyKey<T>, string>>({} as Record<PropertyKey<T>, string>);
92
+
93
+ //
94
+ // Validation.
95
+ //
96
+
97
+ // TODO(burdon): Validate each property separately.
98
+ const validate = useCallback(
99
+ (values: T) => {
100
+ let errors: ValidationError[] = validateSchema(schema, values) ?? [];
101
+ if (errors.length === 0 && onValidate) {
102
+ errors = onValidate(values) ?? [];
103
+ }
104
+
105
+ setErrors(flatMap(errors));
106
+ return errors.length === 0;
107
+ },
108
+ [schema, onValidate],
109
+ );
110
+
111
+ // Validate on schema change.
112
+ const [schemaDirty, setSchemaDirty] = useState(false);
113
+ useEffect(() => setSchemaDirty(true), [schema]);
114
+ useEffect(() => {
115
+ if (schemaDirty) {
116
+ validate(values);
117
+ setSchemaDirty(false);
118
+ }
119
+ }, [values, schemaDirty]);
120
+
121
+ /**
122
+ * NOTE: We can submit if there is no touched field that has an error.
123
+ * Basically, if there's a validation message visible in the form, submit should be disabled.
124
+ */
125
+ const canSubmit = useMemo(
126
+ () =>
127
+ Object.keys(values).every(
128
+ (property) => touched[property as PropertyKey<T>] === false || !errors[property as PropertyKey<T>],
129
+ ),
130
+ [values, touched, errors],
131
+ );
132
+
133
+ const handleSubmit = useCallback(() => {
134
+ if (validate(values)) {
135
+ onSubmit?.(values, { changed });
136
+ }
137
+ }, [values, validate, onSubmit]);
138
+
139
+ //
140
+ // Fields.
141
+ //
142
+
143
+ const getStatus = useCallback<FormHandler<T>['getStatus']>(
144
+ (property: PropertyKey<T>) => ({
145
+ status: errors[property] ? 'error' : undefined,
146
+ error: errors[property] ? errors[property] : undefined,
147
+ }),
148
+ [touched, errors],
149
+ );
150
+
151
+ // TODO(burdon): Use path to extract hierarchical value.
152
+ const getValue = <V>(property: PropertyKey<T>): V => {
153
+ return values[property] as V;
154
+ };
155
+
156
+ // TODO(burdon): Use path to set hierarchical value.
157
+ const onValueChange = (property: PropertyKey<T>, type: SimpleType, value: any) => {
158
+ let parsedValue = value;
159
+ try {
160
+ if (type === 'number') {
161
+ parsedValue = parseFloat(value as string) || 0;
162
+ }
163
+ } catch (err) {
164
+ log.catch(err);
165
+ parsedValue = undefined;
166
+ }
167
+
168
+ const newValues = { ...values, [property]: parsedValue };
169
+ setValues(newValues);
170
+ setChanged((prev) => ({ ...prev, [property]: true }));
171
+ onValuesChanged?.(newValues);
172
+
173
+ const isValid = validate(newValues);
174
+ if (isValid && onValid) {
175
+ onValid(newValues, { changed });
176
+ }
177
+ };
178
+
179
+ // TODO(burdon): This is a leaky abstraction: the hook ideally shouldn't get involved in UX.
180
+ const onBlur = useCallback(
181
+ (event: FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
182
+ const { name } = event.target;
183
+ setTouched((touched) => ({ ...touched, [name]: true }));
184
+
185
+ // TODO(Zan): This should be configurable behavior.
186
+ if (event.relatedTarget?.getAttribute('type') === 'submit') {
187
+ // NOTE: We do this here instead of onSubmit because the blur event is triggered before the submit event
188
+ // and results in the submit button being disabled when the form is invalid.
189
+ setTouched(createKeySet(values, true));
190
+ }
191
+
192
+ validate(values);
193
+ },
194
+ [validate, values],
195
+ );
196
+
197
+ return {
198
+ // State.
199
+ values,
200
+ errors,
201
+ touched,
202
+ changed,
203
+ canSubmit,
204
+ handleSubmit,
205
+
206
+ // Field utils.
207
+ getStatus,
208
+ getValue,
209
+ onValueChange,
210
+ onBlur,
211
+ } satisfies FormHandler<T>;
212
+ };
213
+
214
+ const createKeySet = <T extends object, V>(obj: T, value: V): Record<PropertyKey<T>, V> => {
215
+ invariant(obj);
216
+ return Object.keys(obj).reduce((acc, key) => ({ ...acc, [key]: value }), {} as Record<PropertyKey<T>, V>);
217
+ };
218
+
219
+ const flatMap = <T extends object>(errors: ValidationError[]) => {
220
+ return errors.reduce(
221
+ (result, { path, message }) => {
222
+ if (!(path in result)) {
223
+ result[path as PropertyKey<T>] = message;
224
+ }
225
+ return result;
226
+ },
227
+ {} as Record<PropertyKey<T>, string>,
228
+ );
229
+ };
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import translations from './translations';
6
+
7
+ export * from './components';
8
+ export * from './hooks';
9
+ export * from './util';
10
+
11
+ export { translations };