@dxos/react-ui-form 0.8.4-main.8360d9e660 → 0.8.4-main.8baae0fced
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.
- package/LICENSE +102 -5
- package/dist/lib/browser/index.mjs +1009 -612
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/translations.mjs +82 -0
- package/dist/lib/browser/translations.mjs.map +7 -0
- package/dist/lib/node-esm/index.mjs +1009 -612
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/lib/node-esm/translations.mjs +84 -0
- package/dist/lib/node-esm/translations.mjs.map +7 -0
- package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
- package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +63 -61
- package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.d.ts +40 -12
- package/dist/types/src/components/Form/Form.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.stories.d.ts +76 -74
- package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField.d.ts +11 -5
- package/dist/types/src/components/Form/FormField.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormFieldComponent.d.ts +8 -6
- package/dist/types/src/components/Form/FormFieldComponent.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormFieldSet.d.ts +5 -8
- package/dist/types/src/components/Form/FormFieldSet.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/ArrayField.d.ts +4 -4
- package/dist/types/src/components/Form/fields/ArrayField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/BooleanField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/GeoPointField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/MarkdownField.d.ts +8 -1
- package/dist/types/src/components/Form/fields/MarkdownField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts +84 -0
- package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts.map +1 -0
- package/dist/types/src/components/Form/fields/NumberField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/RefField.d.ts +12 -6
- package/dist/types/src/components/Form/fields/RefField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/SelectField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/SelectOptionField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts +63 -61
- package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/TextAreaField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/TextField.d.ts +1 -1
- package/dist/types/src/components/Form/fields/TextField.d.ts.map +1 -1
- package/dist/types/src/components/Form/fields/TupleField.d.ts.map +1 -1
- package/dist/types/src/components/ObjectForm/ObjectForm.d.ts +9 -0
- package/dist/types/src/components/ObjectForm/ObjectForm.d.ts.map +1 -0
- package/dist/types/src/components/ObjectForm/index.d.ts +2 -0
- package/dist/types/src/components/ObjectForm/index.d.ts.map +1 -0
- package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +26 -4
- package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
- package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +63 -61
- package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -1
- package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts +9 -0
- package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts.map +1 -0
- package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts +127 -0
- package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts.map +1 -0
- package/dist/types/src/components/ObjectProperties/ObjectProperties.test.d.ts +2 -0
- package/dist/types/src/components/ObjectProperties/ObjectProperties.test.d.ts.map +1 -0
- package/dist/types/src/components/ObjectProperties/index.d.ts +2 -0
- package/dist/types/src/components/ObjectProperties/index.d.ts.map +1 -0
- package/dist/types/src/components/Settings/Settings.d.ts +28 -33
- package/dist/types/src/components/Settings/Settings.d.ts.map +1 -1
- package/dist/types/src/components/Settings/Settings.stories.d.ts +64 -61
- package/dist/types/src/components/Settings/Settings.stories.d.ts.map +1 -1
- package/dist/types/src/components/Settings/SettingsFieldSet.d.ts +43 -0
- package/dist/types/src/components/Settings/SettingsFieldSet.d.ts.map +1 -0
- package/dist/types/src/components/Settings/SettingsItem.d.ts +16 -0
- package/dist/types/src/components/Settings/SettingsItem.d.ts.map +1 -0
- package/dist/types/src/components/Settings/SettingsSelectField.d.ts +18 -0
- package/dist/types/src/components/Settings/SettingsSelectField.d.ts.map +1 -0
- package/dist/types/src/components/Settings/index.d.ts +2 -0
- package/dist/types/src/components/Settings/index.d.ts.map +1 -1
- package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +3 -4
- package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +65 -63
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/index.d.ts +2 -0
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/components/testing.d.ts +4 -4
- package/dist/types/src/components/testing.d.ts.map +1 -1
- package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -1
- package/dist/types/src/hooks/useKeyHandler.d.ts +2 -1
- package/dist/types/src/hooks/useKeyHandler.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +0 -2
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +63 -61
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/src/util/field-type.d.ts +19 -0
- package/dist/types/src/util/field-type.d.ts.map +1 -0
- package/dist/types/src/util/field.d.ts.map +1 -1
- package/dist/types/src/util/formatting.d.ts.map +1 -1
- package/dist/types/src/util/index.d.ts +1 -0
- package/dist/types/src/util/index.d.ts.map +1 -1
- package/dist/types/src/util/properties.d.ts +7 -1
- package/dist/types/src/util/properties.d.ts.map +1 -1
- package/dist/types/src/util/properties.test.d.ts +2 -0
- package/dist/types/src/util/properties.test.d.ts.map +1 -0
- package/dist/types/src/util/refs.d.ts +1 -0
- package/dist/types/src/util/refs.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +43 -41
- package/src/components/FieldEditor/FieldEditor.stories.tsx +2 -2
- package/src/components/FieldEditor/FieldEditor.test.tsx +0 -1
- package/src/components/FieldEditor/FieldEditor.tsx +4 -3
- package/src/components/Form/AUDIT.md +17 -0
- package/src/components/Form/Form.stories.tsx +23 -36
- package/src/components/Form/Form.tsx +77 -48
- package/src/components/Form/FormField.tsx +27 -15
- package/src/components/Form/FormFieldComponent.tsx +6 -3
- package/src/components/Form/FormFieldSet.tsx +49 -32
- package/src/components/Form/fields/ArrayField.tsx +14 -8
- package/src/components/Form/fields/GeoPointField.tsx +8 -7
- package/src/components/Form/fields/MarkdownField.stories.tsx +206 -0
- package/src/components/Form/fields/MarkdownField.tsx +135 -45
- package/src/components/Form/fields/RefField.tsx +70 -35
- package/src/components/Form/fields/SelectOptionField.stories.tsx +8 -6
- package/src/components/Form/fields/SelectOptionField.tsx +12 -12
- package/src/components/Form/fields/TextField.tsx +2 -2
- package/src/components/Form/fields/TupleField.tsx +1 -1
- package/src/components/ObjectForm/ObjectForm.tsx +114 -0
- package/src/components/ObjectForm/index.ts +5 -0
- package/src/components/ObjectPicker/ObjectPicker.stories.tsx +12 -10
- package/src/components/ObjectPicker/ObjectPicker.tsx +92 -65
- package/src/components/ObjectProperties/ObjectProperties.stories.tsx +362 -0
- package/src/components/ObjectProperties/ObjectProperties.test.tsx +101 -0
- package/src/components/ObjectProperties/ObjectProperties.tsx +148 -0
- package/src/components/ObjectProperties/index.ts +5 -0
- package/src/components/Settings/AUDIT.md +51 -0
- package/src/components/Settings/Settings.stories.tsx +69 -21
- package/src/components/Settings/Settings.tsx +33 -183
- package/src/components/Settings/SettingsFieldSet.tsx +257 -0
- package/src/components/Settings/SettingsItem.tsx +58 -0
- package/src/components/Settings/SettingsSelectField.tsx +59 -0
- package/src/components/Settings/index.ts +2 -0
- package/src/components/ViewEditor/ViewEditor.stories.tsx +12 -13
- package/src/components/ViewEditor/ViewEditor.test.tsx +0 -1
- package/src/components/ViewEditor/ViewEditor.tsx +25 -32
- package/src/components/index.ts +2 -0
- package/src/components/testing.tsx +19 -11
- package/src/hooks/useKeyHandler.tsx +7 -7
- package/src/index.ts +0 -4
- package/src/translations.ts +66 -63
- package/src/util/field-type.ts +60 -0
- package/src/util/index.ts +1 -0
- package/src/util/properties.test.ts +63 -0
- package/src/util/properties.ts +14 -3
- package/src/util/refs.ts +7 -3
|
@@ -23,9 +23,9 @@ import {
|
|
|
23
23
|
import { useTranslation } from '@dxos/react-ui';
|
|
24
24
|
import { type ProjectionModel } from '@dxos/schema';
|
|
25
25
|
|
|
26
|
-
import { translationKey } from '
|
|
27
|
-
import { getRefProps } from '../../util';
|
|
26
|
+
import { translationKey } from '#translations';
|
|
28
27
|
|
|
28
|
+
import { getRefProps } from '../../util';
|
|
29
29
|
import {
|
|
30
30
|
ArrayField,
|
|
31
31
|
BooleanField,
|
|
@@ -78,14 +78,21 @@ export type FormFieldProps = {
|
|
|
78
78
|
* Function to lookup custom renderers for specific properties.
|
|
79
79
|
*/
|
|
80
80
|
fieldProvider?: FormFieldProvider;
|
|
81
|
+
/**
|
|
82
|
+
* Typename of the ref type that the create props apply to.
|
|
83
|
+
* When set, createOptionLabel/createOptionIcon/createInitialValuePath/createFieldMap/onCreate
|
|
84
|
+
* are only passed to ref fields whose typename matches.
|
|
85
|
+
*/
|
|
86
|
+
createTypename?: string;
|
|
81
87
|
} & Pick<FormFieldComponentProps, 'autoFocus' | 'readonly' | 'layout'> &
|
|
82
88
|
Pick<
|
|
83
89
|
RefFieldProps,
|
|
84
90
|
| 'createOptionLabel'
|
|
85
91
|
| 'createOptionIcon'
|
|
86
92
|
| 'createInitialValuePath'
|
|
93
|
+
| 'createFieldMap'
|
|
87
94
|
| 'db'
|
|
88
|
-
| '
|
|
95
|
+
| 'useSchema'
|
|
89
96
|
| 'getOptions'
|
|
90
97
|
| 'onCreate'
|
|
91
98
|
>;
|
|
@@ -102,12 +109,14 @@ export const FormField = (props: FormFieldProps) => {
|
|
|
102
109
|
layout,
|
|
103
110
|
|
|
104
111
|
// RefFieldProps
|
|
112
|
+
createTypename,
|
|
105
113
|
createOptionLabel,
|
|
106
114
|
createOptionIcon,
|
|
107
115
|
createInitialValuePath,
|
|
116
|
+
createFieldMap,
|
|
108
117
|
db,
|
|
109
|
-
schemaHook,
|
|
110
|
-
getOptions
|
|
118
|
+
useSchema: schemaHook,
|
|
119
|
+
getOptions,
|
|
111
120
|
onCreate,
|
|
112
121
|
} = props;
|
|
113
122
|
const { t } = useTranslation(translationKey);
|
|
@@ -117,7 +126,7 @@ export const FormField = (props: FormFieldProps) => {
|
|
|
117
126
|
|
|
118
127
|
const label = useMemo(() => title ?? String.capitalize(name), [title, name]);
|
|
119
128
|
const placeholder = useMemo(
|
|
120
|
-
() => (examples?.length ? `${t('example
|
|
129
|
+
() => (examples?.length ? `${t('example.placeholder')}: ${examples[0]}` : (description ?? label)),
|
|
121
130
|
[examples, description, label],
|
|
122
131
|
);
|
|
123
132
|
|
|
@@ -129,6 +138,7 @@ export const FormField = (props: FormFieldProps) => {
|
|
|
129
138
|
label,
|
|
130
139
|
placeholder,
|
|
131
140
|
layout,
|
|
141
|
+
db,
|
|
132
142
|
...fieldState,
|
|
133
143
|
};
|
|
134
144
|
|
|
@@ -169,7 +179,7 @@ export const FormField = (props: FormFieldProps) => {
|
|
|
169
179
|
// Select field.
|
|
170
180
|
//
|
|
171
181
|
|
|
172
|
-
const options =
|
|
182
|
+
const options = getSelectOptions(type);
|
|
173
183
|
if (options) {
|
|
174
184
|
// Resolve labels from projection metadata when available.
|
|
175
185
|
const fieldProjections = projection?.getFieldProjections();
|
|
@@ -196,16 +206,18 @@ export const FormField = (props: FormFieldProps) => {
|
|
|
196
206
|
|
|
197
207
|
const refProps = getRefProps(type);
|
|
198
208
|
if (refProps) {
|
|
209
|
+
const isCreateTarget = !createTypename || refProps.typename === createTypename;
|
|
199
210
|
return (
|
|
200
211
|
<RefField
|
|
201
212
|
{...fieldProps}
|
|
202
213
|
{...refProps}
|
|
203
|
-
createOptionLabel={createOptionLabel}
|
|
204
|
-
createOptionIcon={createOptionIcon}
|
|
205
|
-
createInitialValuePath={createInitialValuePath}
|
|
214
|
+
createOptionLabel={isCreateTarget ? createOptionLabel : undefined}
|
|
215
|
+
createOptionIcon={isCreateTarget ? createOptionIcon : undefined}
|
|
216
|
+
createInitialValuePath={isCreateTarget ? createInitialValuePath : undefined}
|
|
217
|
+
createFieldMap={isCreateTarget ? createFieldMap : undefined}
|
|
206
218
|
db={db}
|
|
207
|
-
|
|
208
|
-
getOptions={
|
|
219
|
+
useSchema={schemaHook}
|
|
220
|
+
getOptions={getOptions}
|
|
209
221
|
onCreate={onCreate}
|
|
210
222
|
/>
|
|
211
223
|
);
|
|
@@ -237,8 +249,8 @@ export const FormField = (props: FormFieldProps) => {
|
|
|
237
249
|
createOptionIcon={createOptionIcon}
|
|
238
250
|
createInitialValuePath={createInitialValuePath}
|
|
239
251
|
db={db}
|
|
240
|
-
|
|
241
|
-
getOptions={
|
|
252
|
+
useSchema={schemaHook}
|
|
253
|
+
getOptions={getOptions}
|
|
242
254
|
onCreate={onCreate}
|
|
243
255
|
/>
|
|
244
256
|
);
|
|
@@ -285,7 +297,7 @@ const getFormField = ({ type, format }: FormFieldComponentProps): FormFieldCompo
|
|
|
285
297
|
}
|
|
286
298
|
};
|
|
287
299
|
|
|
288
|
-
const
|
|
300
|
+
const getSelectOptions = (ast: SchemaAST.AST): Format.Options[] | undefined => {
|
|
289
301
|
if (isLiteralUnion(ast)) {
|
|
290
302
|
return ast.types.map((type) => type.literal).filter((v): v is string | number => v !== null);
|
|
291
303
|
}
|
|
@@ -13,6 +13,7 @@ import React, {
|
|
|
13
13
|
type ReactNode,
|
|
14
14
|
} from 'react';
|
|
15
15
|
|
|
16
|
+
import { type Database } from '@dxos/echo';
|
|
16
17
|
import { type Format } from '@dxos/echo/internal';
|
|
17
18
|
import { Icon, Input, Tooltip } from '@dxos/react-ui';
|
|
18
19
|
import { inputTextLabel } from '@dxos/ui-theme';
|
|
@@ -50,6 +51,8 @@ export type FormFieldComponentProps<T = any> = {
|
|
|
50
51
|
placeholder?: string;
|
|
51
52
|
autoFocus?: boolean;
|
|
52
53
|
layout?: Presentation;
|
|
54
|
+
/** Database the form is editing against; populated for fields whose value is an ECHO object/Ref. */
|
|
55
|
+
db?: Database.Database;
|
|
53
56
|
} & FormFieldStateProps<T>;
|
|
54
57
|
|
|
55
58
|
/**
|
|
@@ -77,7 +80,7 @@ export type FormFieldLabelProps = {
|
|
|
77
80
|
export const FormFieldLabel = ({ label, error, readonly, asChild }: FormFieldLabelProps) => {
|
|
78
81
|
const Label = readonly || asChild ? 'span' : Input.Label;
|
|
79
82
|
return (
|
|
80
|
-
<div
|
|
83
|
+
<div className='flex items-center justify-between'>
|
|
81
84
|
<Label className={inputTextLabel}>{label}</Label>
|
|
82
85
|
{error && (
|
|
83
86
|
<Tooltip.Trigger asChild content={error} side='bottom'>
|
|
@@ -113,7 +116,7 @@ export const FormFieldWrapper = <T,>(props: FormFieldWrapperProps<T>) => {
|
|
|
113
116
|
const str = String(value ?? '');
|
|
114
117
|
|
|
115
118
|
return (
|
|
116
|
-
<div
|
|
119
|
+
<div className='contents'>
|
|
117
120
|
<Input.Root validationValence={status}>
|
|
118
121
|
{layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} />}
|
|
119
122
|
{layout === 'static' ? <p>{str}</p> : children ? children({ value }) : null}
|
|
@@ -156,7 +159,7 @@ export class FormFieldErrorBoundary extends Component<FormFieldErrorBoundaryProp
|
|
|
156
159
|
if (this.state.error) {
|
|
157
160
|
return (
|
|
158
161
|
<div className='flex gap-2 border border-rose-fill font-mono text-sm'>
|
|
159
|
-
<span className='bg-rose-fill text-
|
|
162
|
+
<span className='bg-rose-fill text-base-foreground px-1 font-thin'>ERROR</span>
|
|
160
163
|
{String(this.props.path?.join('.'))}
|
|
161
164
|
</div>
|
|
162
165
|
);
|
|
@@ -9,7 +9,6 @@ import { type SchemaProperty } from '@dxos/effect';
|
|
|
9
9
|
|
|
10
10
|
import { type FormHandlerProps } from '../../hooks';
|
|
11
11
|
import { getFormProperties } from '../../util';
|
|
12
|
-
|
|
13
12
|
import { useFormValues } from './Form';
|
|
14
13
|
import { FormField, type FormFieldProps } from './FormField';
|
|
15
14
|
import { FormFieldErrorBoundary, FormFieldLabel } from './FormFieldComponent';
|
|
@@ -30,11 +29,13 @@ export type FormFieldSetProps<T extends AnyProperties> = {
|
|
|
30
29
|
| 'projection'
|
|
31
30
|
| 'fieldMap'
|
|
32
31
|
| 'fieldProvider'
|
|
32
|
+
| 'createTypename'
|
|
33
33
|
| 'createOptionLabel'
|
|
34
34
|
| 'createOptionIcon'
|
|
35
35
|
| 'createInitialValuePath'
|
|
36
|
+
| 'createFieldMap'
|
|
36
37
|
| 'db'
|
|
37
|
-
| '
|
|
38
|
+
| 'useSchema'
|
|
38
39
|
| 'getOptions'
|
|
39
40
|
| 'onCreate'
|
|
40
41
|
>;
|
|
@@ -54,10 +55,54 @@ export const FormFieldSet = ({
|
|
|
54
55
|
...props
|
|
55
56
|
}: FormFieldSetProps<any>) => {
|
|
56
57
|
const values = useFormValues(FORM_FIELDSET_NAME, path);
|
|
58
|
+
const properties = useFormFieldSetProperties({ schema, values, exclude, sort, projection });
|
|
59
|
+
if ((readonly || layout === 'static') && values == null) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
57
62
|
|
|
63
|
+
return (
|
|
64
|
+
<>
|
|
65
|
+
{layout !== 'inline' && label && <FormFieldLabel label={label} asChild />}
|
|
66
|
+
{properties.map((property) => {
|
|
67
|
+
const name = property.name.toString();
|
|
68
|
+
return (
|
|
69
|
+
<FormFieldErrorBoundary key={name} path={[...(path ?? []), name]}>
|
|
70
|
+
<FormField
|
|
71
|
+
type={property.type}
|
|
72
|
+
name={name}
|
|
73
|
+
path={[...(path ?? []), name]}
|
|
74
|
+
readonly={readonly}
|
|
75
|
+
layout={layout}
|
|
76
|
+
projection={projection}
|
|
77
|
+
{...props}
|
|
78
|
+
/>
|
|
79
|
+
</FormFieldErrorBoundary>
|
|
80
|
+
);
|
|
81
|
+
})}
|
|
82
|
+
</>
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
FormFieldSet.displayName = FORM_FIELDSET_NAME;
|
|
87
|
+
|
|
88
|
+
type UseFormFieldSetPropertiesParams = Pick<FormFieldSetProps<any>, 'schema' | 'exclude' | 'projection' | 'sort'> & {
|
|
89
|
+
values: AnyProperties | undefined;
|
|
90
|
+
sort?: string[];
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolves ordered schema properties for a field set (projection order, exclude, or sort).
|
|
95
|
+
*/
|
|
96
|
+
const useFormFieldSetProperties = ({
|
|
97
|
+
schema,
|
|
98
|
+
exclude,
|
|
99
|
+
projection,
|
|
100
|
+
values,
|
|
101
|
+
sort,
|
|
102
|
+
}: UseFormFieldSetPropertiesParams): SchemaProperty[] => {
|
|
58
103
|
// TODO(burdon): Updates on every value change.
|
|
59
104
|
// Remove values dep if can remove from getSchemaProperties.
|
|
60
|
-
|
|
105
|
+
return useMemo(() => {
|
|
61
106
|
if (!schema) {
|
|
62
107
|
return [];
|
|
63
108
|
}
|
|
@@ -78,7 +123,7 @@ export const FormFieldSet = ({
|
|
|
78
123
|
// Add properties in projection field order.
|
|
79
124
|
for (const fieldProjection of fieldProjections) {
|
|
80
125
|
const fieldPath = String(fieldProjection.field.path);
|
|
81
|
-
const prop = visibleProps.find((
|
|
126
|
+
const prop = visibleProps.find((prop) => prop.name === fieldPath);
|
|
82
127
|
if (prop) {
|
|
83
128
|
orderedProps.push(prop);
|
|
84
129
|
}
|
|
@@ -96,32 +141,4 @@ export const FormFieldSet = ({
|
|
|
96
141
|
? [...filteredProps].sort(({ name: a }, { name: b }) => sort.indexOf(a.toString()) - sort.indexOf(b.toString()))
|
|
97
142
|
: filteredProps;
|
|
98
143
|
}, [schema, values, exclude, sort, projection]);
|
|
99
|
-
|
|
100
|
-
if ((readonly || layout === 'static') && values == null) {
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return (
|
|
105
|
-
<>
|
|
106
|
-
{layout !== 'inline' && label && <FormFieldLabel label={label} asChild />}
|
|
107
|
-
{properties.map((property) => {
|
|
108
|
-
const name = property.name.toString();
|
|
109
|
-
return (
|
|
110
|
-
<FormFieldErrorBoundary key={name} path={[...(path ?? []), name]}>
|
|
111
|
-
<FormField
|
|
112
|
-
type={property.type}
|
|
113
|
-
name={name}
|
|
114
|
-
path={[...(path ?? []), name]}
|
|
115
|
-
readonly={readonly}
|
|
116
|
-
layout={layout}
|
|
117
|
-
projection={projection}
|
|
118
|
-
{...props}
|
|
119
|
-
/>
|
|
120
|
-
</FormFieldErrorBoundary>
|
|
121
|
-
);
|
|
122
|
-
})}
|
|
123
|
-
</>
|
|
124
|
-
);
|
|
125
144
|
};
|
|
126
|
-
|
|
127
|
-
FormFieldSet.displayName = FORM_FIELDSET_NAME;
|
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
import * as Option from 'effect/Option';
|
|
6
6
|
import * as SchemaAST from 'effect/SchemaAST';
|
|
7
|
-
import React, {
|
|
7
|
+
import React, { useCallback } from 'react';
|
|
8
8
|
|
|
9
9
|
import { findNode, getArrayElementType, getDiscriminatedType, isDiscriminatedUnion, isNestedType } from '@dxos/effect';
|
|
10
10
|
import { IconButton, useTranslation } from '@dxos/react-ui';
|
|
11
11
|
|
|
12
|
-
import { translationKey } from '
|
|
12
|
+
import { translationKey } from '#translations';
|
|
13
|
+
|
|
13
14
|
import { getFormProperties } from '../../../util';
|
|
14
15
|
import { useFormValues } from '../Form';
|
|
15
16
|
import { FormField, type FormFieldProps } from '../FormField';
|
|
@@ -77,10 +78,10 @@ export const ArrayField = ({
|
|
|
77
78
|
<FormFieldLabel readonly={readonly} label={label} asChild />
|
|
78
79
|
)}
|
|
79
80
|
|
|
80
|
-
<div
|
|
81
|
+
<div className='flex flex-col gap-form-gap'>
|
|
81
82
|
{values?.map((_, index) => {
|
|
82
83
|
return (
|
|
83
|
-
<div
|
|
84
|
+
<div key={index} className='grid grid-cols-[1fr_min-content] gap-form-gap last:mb-form-gap items-center'>
|
|
84
85
|
<FormField
|
|
85
86
|
autoFocus={index === values.length - 1}
|
|
86
87
|
type={elementType}
|
|
@@ -91,13 +92,13 @@ export const ArrayField = ({
|
|
|
91
92
|
/>
|
|
92
93
|
|
|
93
94
|
{!readonly && layout !== 'static' && (
|
|
94
|
-
<div
|
|
95
|
+
<div className='flex flex-col h-full justify-end'>
|
|
95
96
|
{/* NOTE: Aligns with center of last field if multi-field object. */}
|
|
96
|
-
<div
|
|
97
|
+
<div className='flex items-center h-[2rem]'>
|
|
97
98
|
<IconButton
|
|
98
99
|
icon='ph--x--regular'
|
|
99
100
|
iconOnly
|
|
100
|
-
label={t('button
|
|
101
|
+
label={t('remove.button')}
|
|
101
102
|
onClick={() => handleDelete(index)}
|
|
102
103
|
classNames='self-center'
|
|
103
104
|
/>
|
|
@@ -111,7 +112,12 @@ export const ArrayField = ({
|
|
|
111
112
|
|
|
112
113
|
{/* TODO(burdon): Get label from schema. */}
|
|
113
114
|
{!readonly && layout !== 'static' && (
|
|
114
|
-
<IconButton
|
|
115
|
+
<IconButton
|
|
116
|
+
classNames='flex w-full'
|
|
117
|
+
icon='ph--plus--regular'
|
|
118
|
+
label={t('add-item.button')}
|
|
119
|
+
onClick={handleAdd}
|
|
120
|
+
/>
|
|
115
121
|
)}
|
|
116
122
|
</>
|
|
117
123
|
);
|
|
@@ -8,7 +8,8 @@ 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
|
-
import { translationKey } from '
|
|
11
|
+
import { translationKey } from '#translations';
|
|
12
|
+
|
|
12
13
|
import { type FormFieldComponentProps, FormFieldLabel } from '../FormFieldComponent';
|
|
13
14
|
|
|
14
15
|
export const GeoPointField = ({
|
|
@@ -62,17 +63,17 @@ export const GeoPointField = ({
|
|
|
62
63
|
{layout === 'static' ? (
|
|
63
64
|
<LatLng {...value} />
|
|
64
65
|
) : (
|
|
65
|
-
<div
|
|
66
|
+
<div className='grid grid-cols-2 gap-form-gap'>
|
|
66
67
|
<div>
|
|
67
68
|
<Input.Root>
|
|
68
|
-
{layout !== 'inline' && <Input.Label>{t('latitude
|
|
69
|
+
{layout !== 'inline' && <Input.Label>{t('latitude.label')}</Input.Label>}
|
|
69
70
|
<Input.TextInput
|
|
70
71
|
type='number'
|
|
71
72
|
step='0.00001'
|
|
72
73
|
min='-90'
|
|
73
74
|
max='90'
|
|
74
75
|
disabled={!!readonly}
|
|
75
|
-
placeholder={t('latitude
|
|
76
|
+
placeholder={t('latitude.placeholder')}
|
|
76
77
|
value={latitudeText ?? ''}
|
|
77
78
|
onChange={handleChange('latitude', setLatitudeText)}
|
|
78
79
|
onBlur={onBlur}
|
|
@@ -81,14 +82,14 @@ export const GeoPointField = ({
|
|
|
81
82
|
</div>
|
|
82
83
|
<div>
|
|
83
84
|
<Input.Root>
|
|
84
|
-
{layout !== 'inline' && <Input.Label>{t('longitude
|
|
85
|
+
{layout !== 'inline' && <Input.Label>{t('longitude.label')}</Input.Label>}
|
|
85
86
|
<Input.TextInput
|
|
86
87
|
type='number'
|
|
87
88
|
step='0.00001'
|
|
88
89
|
min='-180'
|
|
89
90
|
max='180'
|
|
90
91
|
disabled={!!readonly}
|
|
91
|
-
placeholder={t('longitude
|
|
92
|
+
placeholder={t('longitude.placeholder')}
|
|
92
93
|
value={longitudeText ?? ''}
|
|
93
94
|
onChange={handleChange('longitude', setLongitudeText)}
|
|
94
95
|
onBlur={onBlur}
|
|
@@ -106,7 +107,7 @@ const LatLng = ({ latitude = 0, longitude = 0 }: GeoLocation) => {
|
|
|
106
107
|
const lngHem = longitude >= 0 ? 'E' : 'W';
|
|
107
108
|
|
|
108
109
|
return (
|
|
109
|
-
<span className='inline-flex items-center gap-
|
|
110
|
+
<span className='inline-flex items-center gap-form-gap'>
|
|
110
111
|
<span>
|
|
111
112
|
<span>{Math.abs(latitude).toFixed(5)}</span>
|
|
112
113
|
<span className='text-subdued'>°{latHem}</span>
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
|
+
import * as Schema from 'effect/Schema';
|
|
7
|
+
import React, { useMemo } from 'react';
|
|
8
|
+
import { expect, userEvent } from 'storybook/test';
|
|
9
|
+
|
|
10
|
+
import { Filter, Format, Obj, Ref } from '@dxos/echo';
|
|
11
|
+
import { useQuery } from '@dxos/react-client/echo';
|
|
12
|
+
import { useClientStory, withClientProvider } from '@dxos/react-client/testing';
|
|
13
|
+
import { Panel } from '@dxos/react-ui';
|
|
14
|
+
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
15
|
+
import { Text } from '@dxos/schema';
|
|
16
|
+
|
|
17
|
+
import { translations } from '#translations';
|
|
18
|
+
|
|
19
|
+
import { TestLayout } from '../../testing';
|
|
20
|
+
import { Form } from '../Form';
|
|
21
|
+
|
|
22
|
+
//
|
|
23
|
+
// String-backed markdown.
|
|
24
|
+
//
|
|
25
|
+
|
|
26
|
+
const StringSchema = Schema.Struct({
|
|
27
|
+
notes: Schema.String.pipe(Format.FormatAnnotation.set(Format.TypeFormat.Markdown)).annotations({ title: 'Notes' }),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const StringStory = () => (
|
|
31
|
+
<TestLayout>
|
|
32
|
+
<Form.Root schema={StringSchema} defaultValues={{ notes: '# Hello\n\nMarkdown text *here*.' }}>
|
|
33
|
+
<Form.Viewport>
|
|
34
|
+
<Form.Content>
|
|
35
|
+
<Form.FieldSet />
|
|
36
|
+
</Form.Content>
|
|
37
|
+
</Form.Viewport>
|
|
38
|
+
</Form.Root>
|
|
39
|
+
</TestLayout>
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
//
|
|
43
|
+
// Ref<Text>-backed markdown.
|
|
44
|
+
//
|
|
45
|
+
|
|
46
|
+
const TextNoteHolder = Schema.Struct({
|
|
47
|
+
instructions: Ref.Ref(Text.Text)
|
|
48
|
+
.pipe(Format.FormatAnnotation.set(Format.TypeFormat.Markdown))
|
|
49
|
+
.annotations({ title: 'Instructions' }),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const RefStory = () => {
|
|
53
|
+
const { space } = useClientStory();
|
|
54
|
+
const [text] = useQuery(space?.db, Filter.type(Text.Text));
|
|
55
|
+
const values = useMemo(() => (text ? { instructions: Ref.make(text) } : undefined), [text]);
|
|
56
|
+
|
|
57
|
+
if (!space || !values) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<TestLayout>
|
|
63
|
+
<Panel.Root>
|
|
64
|
+
<Panel.Content asChild>
|
|
65
|
+
<Form.Root schema={TextNoteHolder} values={values as any} db={space.db}>
|
|
66
|
+
<Form.Viewport>
|
|
67
|
+
<Form.Content>
|
|
68
|
+
<Form.FieldSet />
|
|
69
|
+
</Form.Content>
|
|
70
|
+
</Form.Viewport>
|
|
71
|
+
</Form.Root>
|
|
72
|
+
</Panel.Content>
|
|
73
|
+
</Panel.Root>
|
|
74
|
+
</TestLayout>
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
//
|
|
79
|
+
// Ref<Text> empty — shows the create button.
|
|
80
|
+
//
|
|
81
|
+
|
|
82
|
+
const EmptyRefSchema = Schema.Struct({
|
|
83
|
+
instructions: Schema.optional(
|
|
84
|
+
Ref.Ref(Text.Text)
|
|
85
|
+
.pipe(Format.FormatAnnotation.set(Format.TypeFormat.Markdown))
|
|
86
|
+
.annotations({ title: 'Instructions' }),
|
|
87
|
+
),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const EmptyRefStory = () => {
|
|
91
|
+
const { space } = useClientStory();
|
|
92
|
+
if (!space) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<TestLayout>
|
|
98
|
+
<Panel.Root>
|
|
99
|
+
<Panel.Content asChild>
|
|
100
|
+
<Form.Root schema={EmptyRefSchema} defaultValues={{}} db={space.db}>
|
|
101
|
+
<Form.Viewport>
|
|
102
|
+
<Form.Content>
|
|
103
|
+
<Form.FieldSet />
|
|
104
|
+
</Form.Content>
|
|
105
|
+
</Form.Viewport>
|
|
106
|
+
</Form.Root>
|
|
107
|
+
</Panel.Content>
|
|
108
|
+
</Panel.Root>
|
|
109
|
+
</TestLayout>
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const meta = {
|
|
114
|
+
title: 'ui/react-ui-form/MarkdownField',
|
|
115
|
+
decorators: [withTheme(), withLayout({ layout: 'column' })],
|
|
116
|
+
parameters: {
|
|
117
|
+
layout: 'fullscreen',
|
|
118
|
+
translations,
|
|
119
|
+
},
|
|
120
|
+
} satisfies Meta;
|
|
121
|
+
|
|
122
|
+
export default meta;
|
|
123
|
+
|
|
124
|
+
type Story = StoryObj<typeof meta>;
|
|
125
|
+
|
|
126
|
+
export const StringBacked: Story = {
|
|
127
|
+
render: () => <StringStory />,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Regression test: keystrokes into a string-backed markdown field must not
|
|
131
|
+
// blur the editor. Previously the EditorView was destroyed and recreated on
|
|
132
|
+
// every change because `onChange` was a useTextEditor dep and form callbacks
|
|
133
|
+
// are unstable across renders.
|
|
134
|
+
export const StringBackedKeepsFocus: Story = {
|
|
135
|
+
render: () => (
|
|
136
|
+
<TestLayout>
|
|
137
|
+
<Form.Root schema={StringSchema} defaultValues={{ notes: '' }}>
|
|
138
|
+
<Form.Viewport>
|
|
139
|
+
<Form.Content>
|
|
140
|
+
<Form.FieldSet />
|
|
141
|
+
</Form.Content>
|
|
142
|
+
</Form.Viewport>
|
|
143
|
+
</Form.Root>
|
|
144
|
+
</TestLayout>
|
|
145
|
+
),
|
|
146
|
+
play: async ({ canvasElement }) => {
|
|
147
|
+
const waitFor = async <T,>(predicate: () => T | null | undefined, message: string): Promise<T> => {
|
|
148
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
149
|
+
const value = predicate();
|
|
150
|
+
if (value) {
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
154
|
+
}
|
|
155
|
+
throw new Error(message);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const editorRoot = await waitFor(
|
|
159
|
+
() => canvasElement.querySelector<HTMLElement>('.cm-editor'),
|
|
160
|
+
'CodeMirror editor root (.cm-editor) not found',
|
|
161
|
+
);
|
|
162
|
+
const editorContent = editorRoot.querySelector<HTMLElement>('.cm-content')!;
|
|
163
|
+
await expect(editorContent).toBeVisible();
|
|
164
|
+
|
|
165
|
+
// Focus editor and wait for focus to settle inside the editor root.
|
|
166
|
+
editorContent.focus();
|
|
167
|
+
await waitFor(() => (editorRoot.contains(document.activeElement) ? true : null), 'Editor did not receive focus');
|
|
168
|
+
|
|
169
|
+
// Type several characters; focus must be preserved inside the editor root
|
|
170
|
+
// after every keystroke. Regression: previously the EditorView was
|
|
171
|
+
// destroyed and recreated on every change, which detached the focused
|
|
172
|
+
// contentDOM and blurred the input.
|
|
173
|
+
const phrase = 'hello world';
|
|
174
|
+
for (const ch of phrase) {
|
|
175
|
+
await userEvent.keyboard(ch);
|
|
176
|
+
await expect(editorRoot.contains(document.activeElement)).toBe(true);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await expect(editorContent).toHaveTextContent(phrase);
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export const RefPopulated: Story = {
|
|
184
|
+
render: () => <RefStory />,
|
|
185
|
+
decorators: [
|
|
186
|
+
withClientProvider({
|
|
187
|
+
createIdentity: true,
|
|
188
|
+
createSpace: true,
|
|
189
|
+
types: [Text.Text],
|
|
190
|
+
onCreateSpace: async ({ space }) => {
|
|
191
|
+
space.db.add(Obj.make(Text.Text, { content: '# Magazine brief\n\nLong-form text describing what to gather.' }));
|
|
192
|
+
},
|
|
193
|
+
}),
|
|
194
|
+
],
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export const RefEmpty: Story = {
|
|
198
|
+
render: () => <EmptyRefStory />,
|
|
199
|
+
decorators: [
|
|
200
|
+
withClientProvider({
|
|
201
|
+
createIdentity: true,
|
|
202
|
+
createSpace: true,
|
|
203
|
+
types: [Text.Text],
|
|
204
|
+
}),
|
|
205
|
+
],
|
|
206
|
+
};
|