@dxos/react-ui-form 0.7.5-labs.e27f9b9 → 0.7.5-main.2567c87

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 (66) hide show
  1. package/dist/lib/browser/index.mjs +467 -726
  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 +506 -750
  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 +467 -726
  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.map +1 -1
  11. package/dist/types/src/components/Form/Defaults.d.ts +6 -5
  12. package/dist/types/src/components/Form/Defaults.d.ts.map +1 -1
  13. package/dist/types/src/components/Form/Form.d.ts +21 -12
  14. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  15. package/dist/types/src/components/Form/Form.stories.d.ts +1 -2
  16. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  17. package/dist/types/src/components/Form/Input.d.ts +6 -5
  18. package/dist/types/src/components/Form/Input.d.ts.map +1 -1
  19. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts +2 -1
  20. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts.map +1 -1
  21. package/dist/types/src/components/Form/custom/TupleInput.d.ts +2 -1
  22. package/dist/types/src/components/Form/custom/TupleInput.d.ts.map +1 -1
  23. package/dist/types/src/components/Form/custom/index.d.ts +0 -1
  24. package/dist/types/src/components/Form/custom/index.d.ts.map +1 -1
  25. package/dist/types/src/components/Form/factory.d.ts +2 -2
  26. package/dist/types/src/components/Form/factory.d.ts.map +1 -1
  27. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  28. package/dist/types/src/components/deprecated/FormInput.d.ts +0 -5
  29. package/dist/types/src/components/deprecated/FormInput.d.ts.map +1 -1
  30. package/dist/types/src/hooks/useForm.d.ts +11 -10
  31. package/dist/types/src/hooks/useForm.d.ts.map +1 -1
  32. package/dist/types/src/translations.d.ts +0 -6
  33. package/dist/types/src/translations.d.ts.map +1 -1
  34. package/package.json +27 -28
  35. package/src/components/FieldEditor/FieldEditor.stories.tsx +1 -1
  36. package/src/components/FieldEditor/FieldEditor.tsx +6 -7
  37. package/src/components/Form/Defaults.tsx +33 -20
  38. package/src/components/Form/Form.stories.tsx +5 -15
  39. package/src/components/Form/Form.tsx +355 -46
  40. package/src/components/Form/Input.tsx +6 -5
  41. package/src/components/Form/custom/GeoPointInput.tsx +15 -6
  42. package/src/components/Form/custom/TupleInput.tsx +9 -5
  43. package/src/components/Form/custom/index.ts +0 -1
  44. package/src/components/Form/factory.tsx +5 -2
  45. package/src/components/ViewEditor/ViewEditor.tsx +2 -5
  46. package/src/components/deprecated/FormInput.tsx +7 -20
  47. package/src/hooks/useForm.ts +49 -57
  48. package/src/translations.ts +0 -8
  49. package/dist/types/src/components/Form/ArrayField.d.ts +0 -16
  50. package/dist/types/src/components/Form/ArrayField.d.ts.map +0 -1
  51. package/dist/types/src/components/Form/FormActions.d.ts +0 -7
  52. package/dist/types/src/components/Form/FormActions.d.ts.map +0 -1
  53. package/dist/types/src/components/Form/FormContent.d.ts +0 -26
  54. package/dist/types/src/components/Form/FormContent.d.ts.map +0 -1
  55. package/dist/types/src/components/Form/FormContext.d.ts +0 -20
  56. package/dist/types/src/components/Form/FormContext.d.ts.map +0 -1
  57. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts +0 -4
  58. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts.map +0 -1
  59. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts +0 -7
  60. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +0 -1
  61. package/src/components/Form/ArrayField.tsx +0 -111
  62. package/src/components/Form/FormActions.tsx +0 -41
  63. package/src/components/Form/FormContent.tsx +0 -173
  64. package/src/components/Form/FormContext.tsx +0 -48
  65. package/src/components/Form/custom/SelectOptionsInput.stories.tsx +0 -58
  66. package/src/components/Form/custom/SelectOptionsInput.tsx +0 -192
@@ -20,7 +20,7 @@ import {
20
20
  } from '@dxos/schema';
21
21
 
22
22
  import { translationKey } from '../../translations';
23
- import { Form, type FormProps, type InputComponent, SelectInput, SelectOptionInput } from '../Form';
23
+ import { Form, type FormProps, type InputComponent, SelectInput } from '../Form';
24
24
 
25
25
  export type FieldEditorProps = {
26
26
  view: ViewType;
@@ -95,7 +95,7 @@ export const FieldEditor = ({ view, projection, field, registry, onSave, onCance
95
95
  return { ...props, ..._props, type };
96
96
  }
97
97
 
98
- return _props as PropertyType;
98
+ return props;
99
99
  });
100
100
  },
101
101
  [schemas, props.format, props.referenceSchema],
@@ -129,10 +129,10 @@ export const FieldEditor = ({ view, projection, field, registry, onSave, onCance
129
129
  onSave();
130
130
  }, [onSave]);
131
131
 
132
- const Custom: Partial<Record<string, InputComponent>> = useMemo(
132
+ const Custom: Partial<Record<string, InputComponent<PropertyType>>> = useMemo(
133
133
  () => ({
134
134
  ['format' satisfies keyof PropertyType]: (props) => (
135
- <SelectInput
135
+ <SelectInput<PropertyType>
136
136
  {...props}
137
137
  options={FormatEnums.filter((value) => value !== FormatEnum.None).map((value) => ({
138
138
  value,
@@ -141,7 +141,7 @@ export const FieldEditor = ({ view, projection, field, registry, onSave, onCance
141
141
  />
142
142
  ),
143
143
  ['referenceSchema' satisfies keyof PropertyType]: (props) => (
144
- <SelectInput
144
+ <SelectInput<PropertyType>
145
145
  {...props}
146
146
  options={schemas.map((schema) => ({
147
147
  value: schema.typename,
@@ -149,7 +149,7 @@ export const FieldEditor = ({ view, projection, field, registry, onSave, onCance
149
149
  />
150
150
  ),
151
151
  ['referencePath' satisfies keyof PropertyType]: (props) => (
152
- <SelectInput
152
+ <SelectInput<PropertyType>
153
153
  {...props}
154
154
  options={
155
155
  referenceSchema
@@ -160,7 +160,6 @@ export const FieldEditor = ({ view, projection, field, registry, onSave, onCance
160
160
  }
161
161
  />
162
162
  ),
163
- ['options' satisfies keyof PropertyType]: (props) => <SelectOptionInput {...props} />,
164
163
  }),
165
164
  [t, schemas, referenceSchema],
166
165
  );
@@ -4,11 +4,13 @@
4
4
 
5
5
  import React from 'react';
6
6
 
7
+ import { type BaseObject } from '@dxos/echo-schema';
7
8
  import { Input, Select } from '@dxos/react-ui';
8
9
 
9
10
  import { type InputProps, InputHeader } from './Input';
10
11
 
11
- export const TextInput = ({
12
+ export const TextInput = <T extends BaseObject>({
13
+ property,
12
14
  type,
13
15
  label,
14
16
  inputOnly,
@@ -18,8 +20,8 @@ export const TextInput = ({
18
20
  getValue,
19
21
  onValueChange,
20
22
  onBlur,
21
- }: InputProps) => {
22
- const { status, error } = getStatus();
23
+ }: InputProps<T>) => {
24
+ const { status, error } = getStatus?.(property);
23
25
 
24
26
  return (
25
27
  <Input.Root validationValence={status}>
@@ -31,8 +33,8 @@ export const TextInput = ({
31
33
  <Input.TextInput
32
34
  disabled={disabled}
33
35
  placeholder={placeholder}
34
- value={getValue() ?? ''}
35
- onChange={(event) => onValueChange(type, event.target.value)}
36
+ value={getValue(property) ?? ''}
37
+ onChange={(event) => onValueChange(property, type, event.target.value)}
36
38
  onBlur={onBlur}
37
39
  />
38
40
  {inputOnly && <Input.Validation>{error}</Input.Validation>}
@@ -40,7 +42,8 @@ export const TextInput = ({
40
42
  );
41
43
  };
42
44
 
43
- export const NumberInput = ({
45
+ export const NumberInput = <T extends BaseObject>({
46
+ property,
44
47
  type,
45
48
  label,
46
49
  inputOnly,
@@ -50,8 +53,8 @@ export const NumberInput = ({
50
53
  getValue,
51
54
  onValueChange,
52
55
  onBlur,
53
- }: InputProps) => {
54
- const { status, error } = getStatus();
56
+ }: InputProps<T>) => {
57
+ const { status, error } = getStatus?.(property);
55
58
 
56
59
  return (
57
60
  <Input.Root validationValence={status}>
@@ -64,8 +67,8 @@ export const NumberInput = ({
64
67
  type='number'
65
68
  disabled={disabled}
66
69
  placeholder={placeholder}
67
- value={getValue() ?? 0}
68
- onChange={(event) => onValueChange(type, event.target.value)}
70
+ value={getValue(property) ?? 0}
71
+ onChange={(event) => onValueChange(property, type, event.target.value)}
69
72
  onBlur={onBlur}
70
73
  />
71
74
  {inputOnly && <Input.DescriptionAndValidation>{error}</Input.DescriptionAndValidation>}
@@ -73,11 +76,17 @@ export const NumberInput = ({
73
76
  );
74
77
  };
75
78
 
76
- export const BooleanInput = ({ type, label, inputOnly, getStatus, getValue, onValueChange }: InputProps) => {
77
- const { status, error } = getStatus();
79
+ export const BooleanInput = <T extends BaseObject>({
80
+ property,
81
+ type,
82
+ label,
83
+ inputOnly,
84
+ getStatus,
85
+ getValue,
86
+ onValueChange,
87
+ }: InputProps<T>) => {
88
+ const { status, error } = getStatus?.(property);
78
89
 
79
- // TODO(burdon): Runtime error:
80
- // Warning: Unknown event handler property `onCheckedChange`. It will be ignored.
81
90
  return (
82
91
  <Input.Root validationValence={status}>
83
92
  {!inputOnly && (
@@ -85,17 +94,21 @@ export const BooleanInput = ({ type, label, inputOnly, getStatus, getValue, onVa
85
94
  <Input.Label>{label}</Input.Label>
86
95
  </InputHeader>
87
96
  )}
88
- <Input.Switch checked={getValue()} onCheckedChange={(value) => onValueChange(type, value)} />
97
+ <Input.Switch
98
+ checked={getValue<boolean>(property)}
99
+ onCheckedChange={(value) => onValueChange(property, type, value)}
100
+ />
89
101
  {inputOnly && <Input.DescriptionAndValidation>{error}</Input.DescriptionAndValidation>}
90
102
  </Input.Root>
91
103
  );
92
104
  };
93
105
 
94
- export type SelectInputOptions = InputProps & {
106
+ export type SelectInputOptions<T extends BaseObject> = InputProps<T> & {
95
107
  options?: Array<{ value: string | number; label?: string }>;
96
108
  };
97
109
 
98
- export const SelectInput = ({
110
+ export const SelectInput = <T extends BaseObject>({
111
+ property,
99
112
  type,
100
113
  label,
101
114
  inputOnly,
@@ -105,8 +118,8 @@ export const SelectInput = ({
105
118
  getStatus,
106
119
  getValue,
107
120
  onValueChange,
108
- }: SelectInputOptions) => {
109
- const { status, error } = getStatus();
121
+ }: SelectInputOptions<T>) => {
122
+ const { status, error } = getStatus?.(property);
110
123
 
111
124
  return (
112
125
  <Input.Root validationValence={status}>
@@ -115,7 +128,7 @@ export const SelectInput = ({
115
128
  <Input.Label>{label}</Input.Label>
116
129
  </InputHeader>
117
130
  )}
118
- <Select.Root value={getValue()} onValueChange={(value) => onValueChange(type, value)}>
131
+ <Select.Root value={getValue(property)} onValueChange={(value) => onValueChange(property, type, value)}>
119
132
  {/* TODO(burdon): Placeholder not working? */}
120
133
  <Select.TriggerButton classNames='is-full' disabled={disabled} placeholder={placeholder} />
121
134
  <Select.Portal>
@@ -36,7 +36,7 @@ type TestType = S.Schema.Type<typeof TestSchema>;
36
36
 
37
37
  type StoryProps<T extends BaseObject> = { schema: S.Schema<T> } & FormProps<T>;
38
38
 
39
- const DefaultStory = <T extends BaseObject>({ schema, values: initialValues, ...props }: StoryProps<T>) => {
39
+ const DefaultStory = <T extends BaseObject>({ schema, values: initialValues }: StoryProps<T>) => {
40
40
  const [values, setValues] = useState(initialValues);
41
41
  const handleSave = useCallback<NonNullable<FormProps<T>['onSave']>>((values) => {
42
42
  setValues(values);
@@ -45,7 +45,7 @@ const DefaultStory = <T extends BaseObject>({ schema, values: initialValues, ...
45
45
  return (
46
46
  <TestLayout json={{ values, schema: schema.ast.toJSON() }}>
47
47
  <TestPanel>
48
- <Form<T> schema={schema} values={values} onSave={handleSave} {...props} />
48
+ <Form<T> schema={schema} values={values} onSave={handleSave} />
49
49
  </TestPanel>
50
50
  </TestLayout>
51
51
  );
@@ -78,6 +78,7 @@ export const Default: Story<TestType> = {
78
78
  },
79
79
  };
80
80
 
81
+ // TODO(burdon): Should accept partial values.
81
82
  export const Org: Story<Testing.OrgSchemaType> = {
82
83
  args: {
83
84
  schema: Testing.OrgSchema,
@@ -88,17 +89,6 @@ export const Org: Story<Testing.OrgSchemaType> = {
88
89
  },
89
90
  };
90
91
 
91
- export const OrgAutoSave: Story<Testing.OrgSchemaType> = {
92
- args: {
93
- schema: Testing.OrgSchema,
94
- values: {
95
- name: 'DXOS',
96
- // website: 'https://dxos.org',
97
- },
98
- autoSave: true,
99
- },
100
- };
101
-
102
92
  // TODO(burdon): Type issue with employer reference.
103
93
  // TODO(burdon): Test table/form with compound values (e.g., address).
104
94
  // export const Contact: Story<Testing.ContactSchemaType> = {
@@ -124,7 +114,7 @@ const ShapeSchema = S.Struct({
124
114
  }),
125
115
  S.Struct({
126
116
  type: S.Literal('square').annotations({ [AST.TitleAnnotationId]: 'Type' }),
127
- size: S.optional(S.Number.pipe(S.nonNegative()).annotations({ [AST.TitleAnnotationId]: 'Size' })),
117
+ size: S.optional(S.Number.annotations({ [AST.TitleAnnotationId]: 'Size' })),
128
118
  }),
129
119
  ).annotations({ [AST.TitleAnnotationId]: 'Shape' }),
130
120
  ),
@@ -149,7 +139,7 @@ const DiscriminatedUnionStory = ({ values: initialValues }: DiscriminatedUnionSt
149
139
  onSave={handleSave}
150
140
  Custom={{
151
141
  ['shape.type' as const]: (props) => (
152
- <SelectInput
142
+ <SelectInput<ShapeType>
153
143
  {...props}
154
144
  options={['circle', 'square'].map((value) => ({
155
145
  value,
@@ -2,28 +2,29 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import React, { type ReactElement, useEffect, useMemo } from 'react';
5
+ import { pipe } from 'effect';
6
+ import { capitalize } from 'effect/String';
7
+ import React, { useEffect, useMemo } from 'react';
6
8
 
7
- import { type BaseObject, type S, type PropertyKey } from '@dxos/echo-schema';
8
- import { type ThemedClassName } from '@dxos/react-ui';
9
+ import { AST, type BaseObject, S, type PropertyKey, type FormatEnum } from '@dxos/echo-schema';
10
+ import { findNode, getDiscriminatedType, isDiscriminatedUnion, SimpleType } from '@dxos/effect';
11
+ import { log } from '@dxos/log';
12
+ import { IconButton, type ThemedClassName, useTranslation } from '@dxos/react-ui';
9
13
  import { mx } from '@dxos/react-ui-theme';
10
- import { type SchemaProperty } from '@dxos/schema';
14
+ import { getSchemaProperties, type SchemaProperty } from '@dxos/schema';
15
+ import { isNotFalsy } from '@dxos/util';
11
16
 
12
- import { FormActions } from './FormActions';
13
- import { FormFields } from './FormContent';
14
- import { FormProvider } from './FormContext';
15
- import { type InputProps, type InputComponent } from './Input';
16
- import { type FormOptions } from '../../hooks';
17
+ import { SelectInput } from './Defaults';
18
+ import { InputHeader, type InputProps, type InputComponent } from './Input';
19
+ import { getInputComponent } from './factory';
20
+ import { type FormHandler, type FormOptions, useForm } from '../../hooks';
21
+ import { translationKey } from '../../translations';
17
22
 
18
- export type PropsFilter<T extends BaseObject> = (props: SchemaProperty<T>[]) => SchemaProperty<T>[];
23
+ // TODO(burdon): Rename package react-ui-form; delete react-ui-card.
19
24
 
20
- export type ComponentLookup = (args: {
21
- prop: string;
22
- schema: S.Schema<any>;
23
- inputProps: InputProps;
24
- }) => ReactElement | undefined;
25
+ const padding = 'px-2';
25
26
 
26
- export type CustomInputMap = Partial<Record<string, InputComponent>>;
27
+ export type PropsFilter<T extends BaseObject> = (props: SchemaProperty<T>[]) => SchemaProperty<T>[];
27
28
 
28
29
  export type FormProps<T extends BaseObject> = ThemedClassName<
29
30
  {
@@ -41,21 +42,29 @@ export type FormProps<T extends BaseObject> = ThemedClassName<
41
42
  autoSave?: boolean;
42
43
  testId?: string;
43
44
  onCancel?: () => void;
44
- lookupComponent?: ComponentLookup;
45
+
46
+ lookupComponent?: (args: {
47
+ prop: string;
48
+ schema: S.Schema<any>;
49
+ inputProps: InputProps<T>;
50
+ }) => React.ReactElement | undefined;
45
51
  /**
46
52
  * Map of custom renderers for specific properties.
47
- * Prefer lookupComponent for plugin specific input surfaces.
53
+ * @deprecated Use lookupComponent instead.
48
54
  */
49
- Custom?: CustomInputMap;
55
+ Custom?: Partial<Record<string, InputComponent<T>>>;
50
56
  } & Pick<FormOptions<T>, 'schema' | 'onValuesChanged' | 'onValidate' | 'onSave'>
51
57
  >;
52
58
 
59
+ /**
60
+ * General purpose form component that displays field controls based on the given schema.
61
+ */
62
+ // TODO(burdon): Area to show general validation errors.
53
63
  export const Form = <T extends BaseObject>({
54
64
  classNames,
55
65
  schema,
56
66
  values: initialValues,
57
67
  path = [],
58
- autoFocus,
59
68
  readonly,
60
69
  filter,
61
70
  sort,
@@ -69,38 +78,338 @@ export const Form = <T extends BaseObject>({
69
78
  Custom,
70
79
  }: FormProps<T>) => {
71
80
  const onValid = useMemo(() => (autoSave ? onSave : undefined), [autoSave, onSave]);
81
+ const { canSave, values, errors, handleSave, ...inputProps } = useForm<T>({
82
+ schema,
83
+ initialValues,
84
+ onValuesChanged,
85
+ onValidate,
86
+ onValid,
87
+ onSave,
88
+ });
89
+
90
+ // Filter and sort props.
91
+ // TODO(burdon): Move into useForm?
92
+ const properties = useMemo(() => {
93
+ const props = getSchemaProperties<T>(schema.ast, values);
94
+ const filtered = filter ? filter(props) : props;
95
+ const findIndex = (props: PropertyKey<T>[], prop: PropertyKey<T>) => {
96
+ const idx = props.findIndex((p) => p === prop);
97
+ return idx === -1 ? Infinity : idx;
98
+ };
99
+
100
+ return sort ? filtered.sort(({ name: a }, { name: b }) => findIndex(sort, a) - findIndex(sort, b)) : filtered;
101
+ }, [schema, filter, values]);
72
102
 
73
- // TODO(burdon): Hack to select first input.
103
+ // TODO(burdon): Highlight in UX.
74
104
  useEffect(() => {
75
- if (autoFocus) {
76
- const input = document.querySelector('input');
77
- if (input) {
78
- input.focus();
79
- }
105
+ if (errors && Object.keys(errors).length) {
106
+ log.warn('validation', { errors });
80
107
  }
81
- }, [autoFocus]);
108
+ }, [errors]);
82
109
 
83
110
  return (
84
- <FormProvider
85
- schema={schema}
86
- initialValues={initialValues}
87
- onValuesChanged={onValuesChanged}
88
- onValidate={onValidate}
89
- onValid={onValid}
90
- onSave={onSave}
91
- >
92
- <div role='none' className={mx('p-2', classNames)} data-testid={testId}>
93
- <FormFields
94
- schema={schema}
95
- path={path}
96
- readonly={readonly}
97
- filter={filter}
98
- sort={sort}
99
- lookupComponent={lookupComponent}
100
- Custom={Custom}
111
+ <div role='form' className={mx('flex flex-col w-full gap-2 py-2', classNames)} data-testid={testId}>
112
+ {properties
113
+ .map((property) => {
114
+ const { ast, name, type, format, title, description, examples, options, array } = property;
115
+ const key = [...path, name];
116
+ const label = title ?? pipe(name, capitalize);
117
+ const placeholder = examples?.length ? `Example: "${examples[0]}"` : description;
118
+
119
+ const FoundComponent = lookupComponent?.({
120
+ prop: name,
121
+ schema: S.make(ast),
122
+ inputProps: {
123
+ property: name,
124
+ type,
125
+ format,
126
+ label,
127
+ disabled: readonly,
128
+ placeholder,
129
+ ...inputProps,
130
+ },
131
+ });
132
+
133
+ if (FoundComponent) {
134
+ return (
135
+ <div key={name} role='none' className={padding}>
136
+ {FoundComponent}
137
+ </div>
138
+ );
139
+ }
140
+
141
+ // Get generic input.
142
+ // TODO(ZaymonFC): We might need to switch this to using globs since we're now indexing into arrays.
143
+ let InputComponent = Custom?.[key.join('.')];
144
+ if (!InputComponent) {
145
+ // Select.
146
+ if (options) {
147
+ return (
148
+ <div key={name} role='none' className={padding}>
149
+ <SelectInput
150
+ type={type}
151
+ format={format}
152
+ property={name}
153
+ disabled={readonly}
154
+ label={label}
155
+ options={options.map((option) => ({ value: option, label: String(option) }))}
156
+ placeholder={placeholder}
157
+ {...inputProps}
158
+ />
159
+ </div>
160
+ );
161
+ }
162
+
163
+ InputComponent = getInputComponent<T>(type, format);
164
+
165
+ // TODO(ZaymonFC): Array handling is inelegant. We need to push this complexity down into SDK/Schema.
166
+ if (array) {
167
+ return (
168
+ <ArrayField
169
+ key={name}
170
+ name={name}
171
+ type={type}
172
+ array={true}
173
+ label={label}
174
+ ast={ast}
175
+ values={values}
176
+ format={format}
177
+ readonly={readonly}
178
+ placeholder={placeholder}
179
+ inputProps={inputProps}
180
+ Custom={Custom}
181
+ />
182
+ );
183
+ }
184
+ }
185
+
186
+ if (!InputComponent) {
187
+ // Recursively render form.
188
+ if (type === 'object') {
189
+ const baseNode = findNode(ast, isDiscriminatedUnion);
190
+ const typeLiteral = baseNode
191
+ ? getDiscriminatedType(baseNode, values[name] as any)
192
+ : findNode(ast, AST.isTypeLiteral);
193
+
194
+ if (typeLiteral) {
195
+ const schema = S.make(typeLiteral);
196
+ return (
197
+ <div key={name} role='none'>
198
+ <div className={padding}>{label}</div>
199
+ <Form<any>
200
+ schema={schema}
201
+ path={key}
202
+ values={values[name] ?? {}}
203
+ onValuesChanged={(childValues) => {
204
+ inputProps.onValueChange(name, 'object', childValues);
205
+ }}
206
+ Custom={Custom as any}
207
+ />
208
+ </div>
209
+ );
210
+ }
211
+ }
212
+
213
+ log('no renderer for property', { name, type });
214
+ return null;
215
+ }
216
+
217
+ return (
218
+ <div key={name} role='none' className={padding}>
219
+ <InputComponent
220
+ type={type}
221
+ format={format}
222
+ property={name}
223
+ disabled={readonly}
224
+ label={label}
225
+ placeholder={placeholder}
226
+ {...inputProps}
227
+ />
228
+ </div>
229
+ );
230
+ })
231
+ .filter(isNotFalsy)}
232
+ {(onCancel || onSave) && !autoSave && (
233
+ <FormActions onCancel={onCancel} onSubmit={handleSave} canSubmit={canSave} readonly={readonly} />
234
+ )}
235
+ </div>
236
+ );
237
+ };
238
+
239
+ type ArrayFieldProps<T extends BaseObject> = {
240
+ name: PropertyKey<T>;
241
+ type: SimpleType;
242
+ array: true;
243
+ label: string;
244
+ ast: AST.AST;
245
+ values: Record<string, any>;
246
+ format?: FormatEnum;
247
+ readonly?: boolean;
248
+ placeholder?: string;
249
+ inputProps: Pick<FormHandler<T>, 'getStatus' | 'getValue' | 'onValueChange' | 'onBlur'>;
250
+ Custom?: Partial<Record<string, InputComponent<T>>>;
251
+ };
252
+
253
+ const ArrayField = <T extends BaseObject>({
254
+ name,
255
+ type,
256
+ label,
257
+ ast,
258
+ values,
259
+ format,
260
+ readonly,
261
+ placeholder,
262
+ inputProps,
263
+ Custom,
264
+ }: ArrayFieldProps<T>) => {
265
+ const { t } = useTranslation(translationKey);
266
+ const arrayValues = (values[name] ?? []) as any[];
267
+ const key = [name];
268
+
269
+ // TODO(ZaymonFC): Should this unwrapping happen at a lower level?
270
+ const tupleType = findNode(ast, AST.isTupleType);
271
+ const elementType = (tupleType as AST.TupleType | undefined)?.rest[0]?.type;
272
+
273
+ if (type === 'object' && elementType) {
274
+ const baseNode = findNode(elementType, isDiscriminatedUnion);
275
+ const typeLiteral = baseNode
276
+ ? getDiscriminatedType(baseNode, values[name] as any)
277
+ : findNode(elementType, AST.isTypeLiteral);
278
+
279
+ if (typeLiteral) {
280
+ const schema = S.make(typeLiteral);
281
+
282
+ return (
283
+ <div key={name} role='none' className={mx(padding)}>
284
+ <InputHeader>{label}</InputHeader>
285
+ <div role='none' className='flex flex-col gap-1'>
286
+ {arrayValues.map((value, index) => {
287
+ return (
288
+ <div key={index} role='none' className='flex items-center gap-1'>
289
+ <div role='none' className='flex-1'>
290
+ <Form<any>
291
+ schema={schema}
292
+ path={[...key, `[${index}]`]}
293
+ values={value}
294
+ onValuesChanged={(childValues) => {
295
+ const newValues = [...arrayValues];
296
+ newValues[index] = childValues;
297
+ inputProps.onValueChange(name, 'object', newValues);
298
+ }}
299
+ Custom={Custom as any}
300
+ />
301
+ </div>
302
+ <IconButton
303
+ icon='ph--trash--regular'
304
+ iconOnly
305
+ label={t('button remove')}
306
+ onClick={() => {
307
+ const newValues = arrayValues.filter((_, i) => i !== index);
308
+ inputProps.onValueChange(name, type, newValues);
309
+ }}
310
+ />
311
+ </div>
312
+ );
313
+ })}
314
+ </div>
315
+ <div role='none' className='flex justify-between items-center plb-1'>
316
+ <IconButton
317
+ icon='ph--plus--regular'
318
+ iconOnly
319
+ label={t('button add')}
320
+ onClick={() => {
321
+ const newValues = [...arrayValues, {}];
322
+ inputProps.onValueChange(name, type, newValues);
323
+ }}
324
+ />
325
+ </div>
326
+ </div>
327
+ );
328
+ }
329
+ return (
330
+ <div key={name} role='none'>
331
+ Nested form not supported in arrays, yet.
332
+ </div>
333
+ );
334
+ }
335
+
336
+ const InputComponent = getInputComponent<T>(type, format);
337
+ if (!InputComponent) {
338
+ return null;
339
+ }
340
+
341
+ return (
342
+ <div role='none' key='meta-input' className={mx(padding)}>
343
+ <InputHeader>{label}</InputHeader>
344
+ <div role='none' className='flex flex-col gap-1'>
345
+ {arrayValues.map((_, index) => (
346
+ <div key={index} role='none' className='flex items-start gap-1'>
347
+ <div role='none' className='flex-1'>
348
+ <InputComponent
349
+ type={type}
350
+ format={format}
351
+ label={label}
352
+ inputOnly
353
+ property={`${name}[${index}]` as PropertyKey<T>}
354
+ disabled={readonly}
355
+ placeholder={placeholder}
356
+ {...inputProps}
357
+ />
358
+ </div>
359
+ <IconButton
360
+ icon='ph--trash--regular'
361
+ iconOnly
362
+ label={t('button remove')}
363
+ onClick={() => {
364
+ const newValues = arrayValues.filter((_, i) => i !== index);
365
+ inputProps.onValueChange(name, type, newValues);
366
+ }}
367
+ />
368
+ </div>
369
+ ))}
370
+ </div>
371
+ <div role='none' className='flex justify-between items-center plb-1'>
372
+ <IconButton
373
+ icon='ph--plus--regular'
374
+ iconOnly
375
+ label={t('button add')}
376
+ onClick={() => {
377
+ const newValues = [...arrayValues, SimpleType.getDefaultValue(type)];
378
+ inputProps.onValueChange(name, type, newValues);
379
+ }}
101
380
  />
102
- {(onCancel || onSave) && !autoSave && <FormActions onCancel={onCancel} readonly={readonly} />}
103
381
  </div>
104
- </FormProvider>
382
+ </div>
383
+ );
384
+ };
385
+
386
+ type FormActionsProps = {
387
+ readonly?: boolean;
388
+ canSubmit: boolean;
389
+ onCancel?: () => void;
390
+ onSubmit: () => void;
391
+ };
392
+
393
+ export const FormActions = ({ onCancel, onSubmit, canSubmit, readonly }: FormActionsProps) => {
394
+ const { t } = useTranslation(translationKey);
395
+
396
+ return (
397
+ <div role='none' className='flex justify-center'>
398
+ <div role='none' className={mx(onCancel && !readonly && 'grid grid-cols-2 gap-2')}>
399
+ {onCancel && !readonly && (
400
+ <IconButton data-testid='cancel-button' icon='ph--x--regular' label={t('button cancel')} onClick={onCancel} />
401
+ )}
402
+ {onSubmit && (
403
+ <IconButton
404
+ type='submit'
405
+ data-testid='save-button'
406
+ disabled={!canSubmit}
407
+ icon='ph--check--regular'
408
+ label={t('button save')}
409
+ onClick={onSubmit}
410
+ />
411
+ )}
412
+ </div>
413
+ </div>
105
414
  );
106
415
  };