@dxos/react-ui-form 0.7.5-main.9d2a38b → 0.7.5-main.e9bb01b
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/dist/lib/browser/index.mjs +703 -462
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node/index.cjs +730 -504
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +703 -462
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
- package/dist/types/src/components/Form/ArrayField.d.ts +16 -0
- package/dist/types/src/components/Form/ArrayField.d.ts.map +1 -0
- package/dist/types/src/components/Form/Defaults.d.ts +5 -6
- package/dist/types/src/components/Form/Defaults.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.d.ts +11 -20
- package/dist/types/src/components/Form/Form.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.stories.d.ts +2 -1
- package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormActions.d.ts +7 -0
- package/dist/types/src/components/Form/FormActions.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormContent.d.ts +26 -0
- package/dist/types/src/components/Form/FormContent.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormContext.d.ts +20 -0
- package/dist/types/src/components/Form/FormContext.d.ts.map +1 -0
- package/dist/types/src/components/Form/Input.d.ts +5 -6
- package/dist/types/src/components/Form/Input.d.ts.map +1 -1
- package/dist/types/src/components/Form/custom/GeoPointInput.d.ts +1 -2
- package/dist/types/src/components/Form/custom/GeoPointInput.d.ts.map +1 -1
- package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts +4 -0
- package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts.map +1 -0
- package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts +7 -0
- package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +1 -0
- package/dist/types/src/components/Form/custom/TupleInput.d.ts +1 -2
- package/dist/types/src/components/Form/custom/TupleInput.d.ts.map +1 -1
- package/dist/types/src/components/Form/custom/index.d.ts +1 -0
- package/dist/types/src/components/Form/custom/index.d.ts.map +1 -1
- package/dist/types/src/components/Form/factory.d.ts +2 -2
- package/dist/types/src/components/Form/factory.d.ts.map +1 -1
- package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
- package/dist/types/src/components/deprecated/FormInput.d.ts +5 -0
- package/dist/types/src/components/deprecated/FormInput.d.ts.map +1 -1
- package/dist/types/src/hooks/useForm.d.ts +9 -10
- package/dist/types/src/hooks/useForm.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +6 -0
- package/dist/types/src/translations.d.ts.map +1 -1
- package/package.json +25 -24
- package/src/components/FieldEditor/FieldEditor.stories.tsx +1 -1
- package/src/components/FieldEditor/FieldEditor.tsx +7 -6
- package/src/components/Form/ArrayField.tsx +111 -0
- package/src/components/Form/Defaults.tsx +18 -33
- package/src/components/Form/Form.stories.tsx +15 -5
- package/src/components/Form/Form.tsx +38 -358
- package/src/components/Form/FormActions.tsx +41 -0
- package/src/components/Form/FormContent.tsx +173 -0
- package/src/components/Form/FormContext.tsx +48 -0
- package/src/components/Form/Input.tsx +5 -6
- package/src/components/Form/custom/GeoPointInput.tsx +6 -15
- package/src/components/Form/custom/SelectOptionsInput.stories.tsx +58 -0
- package/src/components/Form/custom/SelectOptionsInput.tsx +192 -0
- package/src/components/Form/custom/TupleInput.tsx +5 -9
- package/src/components/Form/custom/index.ts +1 -0
- package/src/components/Form/factory.tsx +2 -5
- package/src/components/ViewEditor/ViewEditor.tsx +5 -2
- package/src/components/deprecated/FormInput.tsx +19 -3
- package/src/hooks/useForm.ts +54 -47
- package/src/translations.ts +8 -0
|
@@ -4,30 +4,29 @@
|
|
|
4
4
|
|
|
5
5
|
import React, { type FC, type PropsWithChildren } from 'react';
|
|
6
6
|
|
|
7
|
-
import { type
|
|
7
|
+
import { type FormatEnum } from '@dxos/echo-schema';
|
|
8
8
|
import { type SimpleType } from '@dxos/effect';
|
|
9
9
|
import { Icon, Tooltip } from '@dxos/react-ui';
|
|
10
10
|
import { errorText } from '@dxos/react-ui-theme';
|
|
11
11
|
|
|
12
|
-
import { type
|
|
12
|
+
import { type FormInputStateProps } from './FormContext';
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Props passed to input components.
|
|
16
16
|
*/
|
|
17
|
-
export type InputProps
|
|
18
|
-
property: PropertyKey<T>;
|
|
17
|
+
export type InputProps = {
|
|
19
18
|
type: SimpleType;
|
|
20
19
|
format?: FormatEnum;
|
|
21
20
|
label?: string;
|
|
22
21
|
disabled?: boolean;
|
|
23
22
|
placeholder?: string;
|
|
24
23
|
inputOnly?: boolean;
|
|
25
|
-
} &
|
|
24
|
+
} & FormInputStateProps;
|
|
26
25
|
|
|
27
26
|
/**
|
|
28
27
|
* Form input component.
|
|
29
28
|
*/
|
|
30
|
-
export type InputComponent
|
|
29
|
+
export type InputComponent = FC<InputProps>;
|
|
31
30
|
|
|
32
31
|
export type InputHeaderProps = PropsWithChildren<{
|
|
33
32
|
error?: string;
|
|
@@ -4,26 +4,17 @@
|
|
|
4
4
|
|
|
5
5
|
import React from 'react';
|
|
6
6
|
|
|
7
|
-
import { type
|
|
7
|
+
import { type GeoPoint } from '@dxos/echo-schema';
|
|
8
8
|
import { Input, useTranslation } from '@dxos/react-ui';
|
|
9
9
|
import { safeParseFloat } from '@dxos/util';
|
|
10
10
|
|
|
11
11
|
import { translationKey } from '../../../translations';
|
|
12
12
|
import { InputHeader, type InputProps } from '../Input';
|
|
13
13
|
|
|
14
|
-
export const GeoPointInput =
|
|
15
|
-
property,
|
|
16
|
-
type,
|
|
17
|
-
label,
|
|
18
|
-
disabled,
|
|
19
|
-
getStatus,
|
|
20
|
-
getValue,
|
|
21
|
-
onValueChange,
|
|
22
|
-
onBlur,
|
|
23
|
-
}: InputProps<T>) => {
|
|
14
|
+
export const GeoPointInput = ({ type, label, disabled, getStatus, getValue, onValueChange, onBlur }: InputProps) => {
|
|
24
15
|
const { t } = useTranslation(translationKey);
|
|
25
|
-
const { status, error } = getStatus
|
|
26
|
-
const [lng = 0, lat = 0] = getValue<GeoPoint>(
|
|
16
|
+
const { status, error } = getStatus();
|
|
17
|
+
const [lng = 0, lat = 0] = getValue<GeoPoint>() ?? [];
|
|
27
18
|
|
|
28
19
|
return (
|
|
29
20
|
<Input.Root validationValence={status}>
|
|
@@ -36,7 +27,7 @@ export const GeoPointInput = <T extends BaseObject>({
|
|
|
36
27
|
disabled={disabled}
|
|
37
28
|
placeholder={t('placeholder longitude')}
|
|
38
29
|
value={lng}
|
|
39
|
-
onChange={(event) => onValueChange(
|
|
30
|
+
onChange={(event) => onValueChange(type, [lat, safeParseFloat(event.target.value, 0)])}
|
|
40
31
|
onBlur={onBlur}
|
|
41
32
|
/>
|
|
42
33
|
<Input.TextInput
|
|
@@ -44,7 +35,7 @@ export const GeoPointInput = <T extends BaseObject>({
|
|
|
44
35
|
disabled={disabled}
|
|
45
36
|
placeholder={t('placeholder latitude')}
|
|
46
37
|
value={lat}
|
|
47
|
-
onChange={(event) => onValueChange(
|
|
38
|
+
onChange={(event) => onValueChange(type, [safeParseFloat(event.target.value, 0), lng])}
|
|
48
39
|
onBlur={onBlur}
|
|
49
40
|
/>
|
|
50
41
|
</div>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import '@dxos-theme';
|
|
6
|
+
|
|
7
|
+
import { type Meta, type StoryObj } from '@storybook/react';
|
|
8
|
+
import React, { useState } from 'react';
|
|
9
|
+
|
|
10
|
+
import { S, SelectOptionSchema, type SelectOption } from '@dxos/echo-schema';
|
|
11
|
+
import { withLayout, withTheme } from '@dxos/storybook-utils';
|
|
12
|
+
|
|
13
|
+
import { SelectOptionInput } from './SelectOptionsInput';
|
|
14
|
+
import translations from '../../../translations';
|
|
15
|
+
import { TestLayout, TestPanel } from '../../testing';
|
|
16
|
+
import { Form } from '../Form';
|
|
17
|
+
|
|
18
|
+
const schema = S.Struct({
|
|
19
|
+
options: S.Array(SelectOptionSchema).pipe(S.mutable),
|
|
20
|
+
}).pipe(S.mutable);
|
|
21
|
+
|
|
22
|
+
const DefaultStory = () => {
|
|
23
|
+
const [values, setValues] = useState<{ options: SelectOption[] }>({
|
|
24
|
+
// TODO(Zaymon): Pick colors from our limited set.
|
|
25
|
+
options: [
|
|
26
|
+
{ id: 'opt_1', title: 'First option', color: 'amber' },
|
|
27
|
+
{ id: 'opt_2', title: 'Second option', color: 'emerald' },
|
|
28
|
+
{ id: 'opt_3', title: 'Third option', color: 'rose' },
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<TestLayout>
|
|
34
|
+
<TestPanel>
|
|
35
|
+
<Form
|
|
36
|
+
schema={schema}
|
|
37
|
+
values={values}
|
|
38
|
+
onSave={(vals) => setValues(vals)}
|
|
39
|
+
Custom={{ options: SelectOptionInput }}
|
|
40
|
+
/>
|
|
41
|
+
</TestPanel>
|
|
42
|
+
</TestLayout>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const meta: Meta = {
|
|
47
|
+
title: 'ui/react-ui-form/SelectOptionsInput',
|
|
48
|
+
component: SelectOptionInput,
|
|
49
|
+
decorators: [withTheme, withLayout({ tooltips: true })],
|
|
50
|
+
parameters: { translations },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export default meta;
|
|
54
|
+
type Story = StoryObj;
|
|
55
|
+
|
|
56
|
+
export const Default: Story = {
|
|
57
|
+
render: DefaultStory,
|
|
58
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React, { useCallback, useEffect, useState, type ChangeEvent, useRef } from 'react';
|
|
6
|
+
|
|
7
|
+
import { type SelectOption } from '@dxos/echo-schema';
|
|
8
|
+
import { PublicKey } from '@dxos/keys';
|
|
9
|
+
import { type ChromaticPalette, Icon, IconButton, Input, Tag, Toolbar, useTranslation } from '@dxos/react-ui';
|
|
10
|
+
import { List } from '@dxos/react-ui-list';
|
|
11
|
+
import { HuePickerToolbarButton } from '@dxos/react-ui-pickers';
|
|
12
|
+
import { hueTokenThemes, mx } from '@dxos/react-ui-theme';
|
|
13
|
+
|
|
14
|
+
import { translationKey } from '../../../translations';
|
|
15
|
+
import { InputHeader, type InputProps } from '../Input';
|
|
16
|
+
|
|
17
|
+
// TODO(ZaymonFC): Handle disabled.
|
|
18
|
+
export const SelectOptionInput = ({ type, label, disabled, getStatus, getValue, onValueChange }: InputProps) => {
|
|
19
|
+
const [selected, setSelectedId] = useState<string | null>(null);
|
|
20
|
+
const [isNewOption, setIsNewOption] = useState(false);
|
|
21
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
22
|
+
const { t } = useTranslation(translationKey);
|
|
23
|
+
const { status, error } = getStatus();
|
|
24
|
+
const options = getValue<SelectOption[] | undefined>();
|
|
25
|
+
|
|
26
|
+
// Initialization.
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (options === undefined) {
|
|
29
|
+
onValueChange(type, []);
|
|
30
|
+
}
|
|
31
|
+
}, [options, onValueChange, type]);
|
|
32
|
+
|
|
33
|
+
const randomHue = () => {
|
|
34
|
+
const usedHues = new Set(options?.map((option) => option.color) ?? []);
|
|
35
|
+
const hueKeys = Object.keys(hueTokenThemes) as Array<keyof typeof hueTokenThemes>;
|
|
36
|
+
|
|
37
|
+
// Pick a random unused option from hueTokenThemes.
|
|
38
|
+
const unusedHues = hueKeys.filter((hue) => !usedHues.has(hue));
|
|
39
|
+
if (unusedHues.length > 0) {
|
|
40
|
+
return unusedHues[Math.floor(Math.random() * unusedHues.length)];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Pick a random one that hasn't been used in the last three options
|
|
44
|
+
const lastThree = new Set(options?.slice(-3).map((option) => option.color) ?? []);
|
|
45
|
+
const availableHues = hueKeys.filter((hue) => !lastThree.has(hue));
|
|
46
|
+
return (
|
|
47
|
+
availableHues[Math.floor(Math.random() * availableHues.length)] ??
|
|
48
|
+
hueKeys[Math.floor(Math.random() * hueKeys.length)]
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const handleAdd = useCallback(() => {
|
|
53
|
+
const newId = PublicKey.random().truncate();
|
|
54
|
+
const newOption = { id: newId, title: '', color: randomHue() };
|
|
55
|
+
onValueChange(type, [...(options ?? []), newOption]);
|
|
56
|
+
setSelectedId(newId);
|
|
57
|
+
setIsNewOption(true);
|
|
58
|
+
}, [options, type, onValueChange]);
|
|
59
|
+
|
|
60
|
+
const handleDelete = useCallback(
|
|
61
|
+
(id: string) => {
|
|
62
|
+
const newOptions = options?.filter((option) => option.id !== id) ?? [];
|
|
63
|
+
onValueChange(type, newOptions);
|
|
64
|
+
setSelectedId(null);
|
|
65
|
+
},
|
|
66
|
+
[options, type, onValueChange],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const handleMove = useCallback(
|
|
70
|
+
(from: number, to: number) => {
|
|
71
|
+
if (!options) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const newOptions = [...options];
|
|
76
|
+
const [removed] = newOptions.splice(from, 1);
|
|
77
|
+
newOptions.splice(to, 0, removed);
|
|
78
|
+
onValueChange(type, newOptions);
|
|
79
|
+
},
|
|
80
|
+
[options, type, onValueChange],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const handleClick = useCallback((id: string) => {
|
|
84
|
+
setSelectedId((current) => (current === id ? null : id));
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
const handleTitleChange = useCallback(
|
|
88
|
+
(id: string) => (e: ChangeEvent<HTMLInputElement>) => {
|
|
89
|
+
const newOptions = options?.map((o) => (o.id === id ? { ...o, title: e.target.value } : o));
|
|
90
|
+
onValueChange(type, newOptions ?? []);
|
|
91
|
+
},
|
|
92
|
+
[options, type, onValueChange],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
96
|
+
if (e.key === 'Enter') {
|
|
97
|
+
setSelectedId(null);
|
|
98
|
+
}
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
const handleColorChange = useCallback(
|
|
102
|
+
(id: string) => (hue: string) => {
|
|
103
|
+
const newOptions = options?.map((o) => (o.id === id ? { ...o, color: hue } : o));
|
|
104
|
+
onValueChange(type, newOptions ?? []);
|
|
105
|
+
},
|
|
106
|
+
[options, type, onValueChange],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (selected && isNewOption && inputRef.current) {
|
|
111
|
+
inputRef.current.focus();
|
|
112
|
+
setIsNewOption(false);
|
|
113
|
+
}
|
|
114
|
+
}, [selected, isNewOption]);
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<Input.Root validationValence={status}>
|
|
118
|
+
<InputHeader error={error}>
|
|
119
|
+
<Input.Label>{label}</Input.Label>
|
|
120
|
+
</InputHeader>
|
|
121
|
+
<div role='none'>
|
|
122
|
+
{options && (
|
|
123
|
+
<List.Root items={options} isItem={(item) => true} onMove={handleMove}>
|
|
124
|
+
{({ items }) => (
|
|
125
|
+
<div role='list' className='w-full overflow-auto'>
|
|
126
|
+
{items?.map((item) => (
|
|
127
|
+
<div key={item.id}>
|
|
128
|
+
<List.Item
|
|
129
|
+
role='button'
|
|
130
|
+
item={item}
|
|
131
|
+
classNames={mx('p-1 plb-2', 'flex flex-col', 'cursor-pointer', 'hover:bg-hoverOverlay')}
|
|
132
|
+
aria-expanded={selected === item.id}
|
|
133
|
+
>
|
|
134
|
+
<div className='flex items-center'>
|
|
135
|
+
<List.ItemDragHandle />
|
|
136
|
+
<List.ItemTitle onClick={() => handleClick(item.id)} classNames='flex-1'>
|
|
137
|
+
{/* TODO(ZaymonFC): Move spacer into Tag component. */}
|
|
138
|
+
<Tag palette={item.color as ChromaticPalette}>{item.title || '\u200b'}</Tag>
|
|
139
|
+
</List.ItemTitle>
|
|
140
|
+
<Icon
|
|
141
|
+
icon={selected === item.id ? 'ph--caret-down--regular' : 'ph--caret-right--regular'}
|
|
142
|
+
onClick={() => handleClick(item.id)}
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
{selected === item.id && (
|
|
146
|
+
<div className='mis-4 mbs-2 flex flex-col gap-1'>
|
|
147
|
+
{/* 16px to match drag handle width. */}
|
|
148
|
+
<Input.Label classNames='text-xs'>{t('select option label')}</Input.Label>
|
|
149
|
+
<div className='flex flex-row items-center gap-1'>
|
|
150
|
+
<Input.TextInput
|
|
151
|
+
placeholder={t('select option label placeholder')}
|
|
152
|
+
ref={selected === item.id ? inputRef : undefined}
|
|
153
|
+
value={item.title}
|
|
154
|
+
onChange={handleTitleChange(item.id)}
|
|
155
|
+
onKeyDown={handleKeyDown}
|
|
156
|
+
classNames='flex-1'
|
|
157
|
+
/>
|
|
158
|
+
<Toolbar.Root classNames='p-0 m-0 !is-auto'>
|
|
159
|
+
<HuePickerToolbarButton hue={item.color} onChangeHue={handleColorChange(item.id)} />
|
|
160
|
+
</Toolbar.Root>
|
|
161
|
+
<IconButton
|
|
162
|
+
icon='ph--trash--fill'
|
|
163
|
+
iconOnly
|
|
164
|
+
label={t('select option delete')}
|
|
165
|
+
onClick={() => handleDelete(item.id)}
|
|
166
|
+
/>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
</List.Item>
|
|
171
|
+
</div>
|
|
172
|
+
))}
|
|
173
|
+
<div role='listitem'>
|
|
174
|
+
<div
|
|
175
|
+
role='button'
|
|
176
|
+
className={mx('p-1 plb-2', 'flex flex-col', 'cursor-pointer', 'hover:bg-hoverOverlay')}
|
|
177
|
+
onClick={handleAdd}
|
|
178
|
+
>
|
|
179
|
+
<div className='flex items-center gap-1'>
|
|
180
|
+
<Icon icon='ph--plus--regular' />
|
|
181
|
+
<span className='text-sm'>{t('select option add')}</span>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
</List.Root>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
</Input.Root>
|
|
191
|
+
);
|
|
192
|
+
};
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
import React from 'react';
|
|
6
6
|
|
|
7
|
-
import { type BaseObject } from '@dxos/echo-schema';
|
|
8
7
|
import { Input } from '@dxos/react-ui';
|
|
9
8
|
import { mx } from '@dxos/react-ui-theme';
|
|
10
9
|
import { safeParseFloat } from '@dxos/util';
|
|
@@ -13,9 +12,8 @@ import { InputHeader, type InputProps } from '../Input';
|
|
|
13
12
|
|
|
14
13
|
const gridCols = ['grid-cols-1', 'grid-cols-2', 'grid-cols-3', 'grid-cols-4'];
|
|
15
14
|
|
|
16
|
-
export const TupleInput =
|
|
15
|
+
export const TupleInput = ({
|
|
17
16
|
binding,
|
|
18
|
-
property,
|
|
19
17
|
type,
|
|
20
18
|
label,
|
|
21
19
|
disabled,
|
|
@@ -23,11 +21,11 @@ export const TupleInput = <T extends BaseObject>({
|
|
|
23
21
|
getValue,
|
|
24
22
|
onValueChange,
|
|
25
23
|
onBlur,
|
|
26
|
-
}: InputProps
|
|
27
|
-
const { status, error } = getStatus
|
|
24
|
+
}: InputProps & { binding: string[] }) => {
|
|
25
|
+
const { status, error } = getStatus();
|
|
28
26
|
|
|
29
27
|
// TODO(burdon): Generalize number/float/string, etc.
|
|
30
|
-
const values: Record<string, number> = getValue<Record<string, number>>(
|
|
28
|
+
const values: Record<string, number> = getValue<Record<string, number>>() ?? {};
|
|
31
29
|
|
|
32
30
|
return (
|
|
33
31
|
<Input.Root validationValence={status}>
|
|
@@ -41,9 +39,7 @@ export const TupleInput = <T extends BaseObject>({
|
|
|
41
39
|
type='number'
|
|
42
40
|
disabled={disabled}
|
|
43
41
|
value={values[prop]}
|
|
44
|
-
onChange={(event) =>
|
|
45
|
-
onValueChange(property, type, { ...values, [prop]: safeParseFloat(event.target.value, 0) })
|
|
46
|
-
}
|
|
42
|
+
onChange={(event) => onValueChange(type, { ...values, [prop]: safeParseFloat(event.target.value, 0) })}
|
|
47
43
|
onBlur={onBlur}
|
|
48
44
|
/>
|
|
49
45
|
))}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { FormatEnum } from '@dxos/echo-schema';
|
|
6
6
|
import { type SimpleType } from '@dxos/effect';
|
|
7
7
|
|
|
8
8
|
import { BooleanInput, NumberInput, TextInput } from './Defaults';
|
|
@@ -12,10 +12,7 @@ import { GeoPointInput } from './custom';
|
|
|
12
12
|
/**
|
|
13
13
|
* Get property input component.
|
|
14
14
|
*/
|
|
15
|
-
export const getInputComponent =
|
|
16
|
-
type: SimpleType,
|
|
17
|
-
format?: FormatEnum,
|
|
18
|
-
): InputComponent<T> | undefined => {
|
|
15
|
+
export const getInputComponent = (type: SimpleType, format?: FormatEnum): InputComponent | undefined => {
|
|
19
16
|
switch (format) {
|
|
20
17
|
case FormatEnum.LatLng:
|
|
21
18
|
return GeoPointInput;
|
|
@@ -10,7 +10,6 @@ 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';
|
|
@@ -91,7 +90,11 @@ export const ViewEditor = ({
|
|
|
91
90
|
|
|
92
91
|
const handleMove = useCallback(
|
|
93
92
|
(fromIndex: number, toIndex: number) => {
|
|
94
|
-
|
|
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;
|
|
95
98
|
},
|
|
96
99
|
[view.fields],
|
|
97
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;
|
|
@@ -27,7 +41,9 @@ export const DeprecatedFormInput = ({
|
|
|
27
41
|
<Input.Root>
|
|
28
42
|
<div role='none' className='flex flex-col w-full'>
|
|
29
43
|
{/* TODO(burdon): Consistent height for controls (e.g., Select, Textbox, and Checkbox are all different). */}
|
|
30
|
-
<Input.Label classNames='flex min-
|
|
44
|
+
<Input.Label classNames='flex min-bs-[--rail-action] items-center' style={{ fontSize: 'unset' }}>
|
|
45
|
+
{label}
|
|
46
|
+
</Input.Label>
|
|
31
47
|
{description && (
|
|
32
48
|
<Input.DescriptionAndValidation classNames='mbs-0.5'>
|
|
33
49
|
<Input.Description>{description}</Input.Description>
|
|
@@ -36,7 +52,7 @@ export const DeprecatedFormInput = ({
|
|
|
36
52
|
</div>
|
|
37
53
|
|
|
38
54
|
<div role='none'>
|
|
39
|
-
<div role='none' className='flex min-
|
|
55
|
+
<div role='none' className='flex min-bs-[--rail-action] items-center'>
|
|
40
56
|
{children}
|
|
41
57
|
</div>
|
|
42
58
|
</div>
|
package/src/hooks/useForm.ts
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
6
6
|
|
|
7
|
-
import { type BaseObject,
|
|
8
|
-
import { type SimpleType, type S, type JsonPath } from '@dxos/effect';
|
|
7
|
+
import { type BaseObject, getValue, setValue } from '@dxos/echo-schema';
|
|
8
|
+
import { type SimpleType, type S, 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';
|
|
@@ -20,9 +20,9 @@ export type FormHandler<T extends BaseObject> = {
|
|
|
20
20
|
//
|
|
21
21
|
|
|
22
22
|
values: Partial<T>;
|
|
23
|
-
errors: Record<
|
|
24
|
-
touched: Record<
|
|
25
|
-
changed: Record<
|
|
23
|
+
errors: Record<JsonPath, string>;
|
|
24
|
+
touched: Record<JsonPath, boolean>;
|
|
25
|
+
changed: Record<JsonPath, boolean>;
|
|
26
26
|
canSave: boolean;
|
|
27
27
|
handleSave: () => void;
|
|
28
28
|
|
|
@@ -30,10 +30,10 @@ export type FormHandler<T extends BaseObject> = {
|
|
|
30
30
|
// Form input component helpers.
|
|
31
31
|
//
|
|
32
32
|
|
|
33
|
-
getStatus: (
|
|
34
|
-
getValue: <V>(
|
|
35
|
-
onValueChange: <V>(
|
|
36
|
-
|
|
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;
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
/**
|
|
@@ -94,16 +94,15 @@ export const useForm = <T extends BaseObject>({
|
|
|
94
94
|
setValues(initialValues);
|
|
95
95
|
}, [initialValues]);
|
|
96
96
|
|
|
97
|
-
const [touched, setTouched] = useState<Record<
|
|
98
|
-
const [changed, setChanged] = useState<Record<
|
|
99
|
-
const [errors, setErrors] = useState<Record<
|
|
97
|
+
const [touched, setTouched] = useState<Record<JsonPath, boolean>>(createKeySet(initialValues, false));
|
|
98
|
+
const [changed, setChanged] = useState<Record<JsonPath, boolean>>(createKeySet(initialValues, false));
|
|
99
|
+
const [errors, setErrors] = useState<Record<JsonPath, string>>({});
|
|
100
100
|
const [saving, setSaving] = useState(false);
|
|
101
101
|
|
|
102
102
|
//
|
|
103
103
|
// Validation.
|
|
104
104
|
//
|
|
105
105
|
|
|
106
|
-
// TODO(burdon): Validate each property separately.
|
|
107
106
|
const validate = useCallback(
|
|
108
107
|
(values: Partial<T>): values is T => {
|
|
109
108
|
let errors: ValidationError[] = validateSchema(schema, values) ?? [];
|
|
@@ -136,8 +135,15 @@ export const useForm = <T extends BaseObject>({
|
|
|
136
135
|
const canSave = useMemo(
|
|
137
136
|
() =>
|
|
138
137
|
!saving &&
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
// Check if any error paths that are touched have errors
|
|
139
|
+
!Object.entries(touched).some(
|
|
140
|
+
([path, isTouched]) =>
|
|
141
|
+
isTouched &&
|
|
142
|
+
Object.keys(errors).some(
|
|
143
|
+
(errorPath) => errorPath === path || errorPath.startsWith(`${path}.`) || errorPath.startsWith(`${path}[`),
|
|
144
|
+
),
|
|
145
|
+
),
|
|
146
|
+
[touched, errors, saving],
|
|
141
147
|
);
|
|
142
148
|
|
|
143
149
|
const handleSave = useCallback(async () => {
|
|
@@ -156,19 +162,27 @@ export const useForm = <T extends BaseObject>({
|
|
|
156
162
|
//
|
|
157
163
|
|
|
158
164
|
const getStatus = useCallback<FormHandler<T>['getStatus']>(
|
|
159
|
-
(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
165
|
+
(path) => {
|
|
166
|
+
const jsonPath = Array.isArray(path) ? createJsonPath(path) : path;
|
|
167
|
+
const matchingError = Object.entries(errors).find(
|
|
168
|
+
([errorPath]) =>
|
|
169
|
+
errorPath === jsonPath || errorPath.startsWith(`${jsonPath}.`) || errorPath.startsWith(`${jsonPath}[`),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
status: matchingError ? 'error' : undefined,
|
|
174
|
+
error: matchingError ? matchingError[1] : undefined,
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
[errors],
|
|
164
178
|
);
|
|
165
179
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
return getValue(values, property as any as JsonPath);
|
|
180
|
+
const getFormValue = <V>(path: (string | number)[]): V | undefined => {
|
|
181
|
+
return getValue(values, createJsonPath(path));
|
|
169
182
|
};
|
|
170
183
|
|
|
171
|
-
const onValueChange = (
|
|
184
|
+
const onValueChange = (path: (string | number)[], type: SimpleType, value: any) => {
|
|
185
|
+
const jsonPath = createJsonPath(path);
|
|
172
186
|
let parsedValue = value;
|
|
173
187
|
try {
|
|
174
188
|
if (type === 'number') {
|
|
@@ -179,9 +193,9 @@ export const useForm = <T extends BaseObject>({
|
|
|
179
193
|
parsedValue = undefined;
|
|
180
194
|
}
|
|
181
195
|
|
|
182
|
-
const newValues = { ...setValue(values,
|
|
196
|
+
const newValues = { ...setValue(values, jsonPath, parsedValue) };
|
|
183
197
|
setValues(newValues);
|
|
184
|
-
setChanged((prev) => ({ ...prev, [
|
|
198
|
+
setChanged((prev) => ({ ...prev, [jsonPath]: true }));
|
|
185
199
|
onValuesChanged?.(newValues);
|
|
186
200
|
|
|
187
201
|
const isValid = validate(newValues);
|
|
@@ -190,19 +204,10 @@ export const useForm = <T extends BaseObject>({
|
|
|
190
204
|
}
|
|
191
205
|
};
|
|
192
206
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
setTouched((touched) => ({ ...touched, [name]: true }));
|
|
198
|
-
|
|
199
|
-
// TODO(Zan): This should be configurable behavior.
|
|
200
|
-
if (event.relatedTarget?.getAttribute('type') === 'submit') {
|
|
201
|
-
// NOTE: We do this here instead of onSave because the blur event is triggered before the submit event
|
|
202
|
-
// and results in the submit button being disabled when the form is invalid.
|
|
203
|
-
setTouched(createKeySet(values, true));
|
|
204
|
-
}
|
|
205
|
-
|
|
207
|
+
const onTouched = useCallback(
|
|
208
|
+
(path: (string | number)[]) => {
|
|
209
|
+
const jsonPath = createJsonPath(path);
|
|
210
|
+
setTouched((touched) => ({ ...touched, [jsonPath]: true }));
|
|
206
211
|
validate(values);
|
|
207
212
|
},
|
|
208
213
|
[validate, values],
|
|
@@ -221,23 +226,25 @@ export const useForm = <T extends BaseObject>({
|
|
|
221
226
|
getStatus,
|
|
222
227
|
getValue: getFormValue,
|
|
223
228
|
onValueChange,
|
|
224
|
-
|
|
229
|
+
onTouched,
|
|
225
230
|
} satisfies FormHandler<T>;
|
|
226
231
|
};
|
|
227
232
|
|
|
228
|
-
const createKeySet = <T extends BaseObject, V>(obj: T, value: V): Record<
|
|
233
|
+
const createKeySet = <T extends BaseObject, V>(obj: T, value: V): Record<JsonPath, V> => {
|
|
229
234
|
invariant(obj);
|
|
230
|
-
return Object.keys(obj).reduce((acc, key) => ({ ...acc, [key]: value }), {} as Record<
|
|
235
|
+
return Object.keys(obj).reduce((acc, key) => ({ ...acc, [key]: value }), {} as Record<JsonPath, V>);
|
|
231
236
|
};
|
|
232
237
|
|
|
233
|
-
const flatMap =
|
|
238
|
+
const flatMap = (errors: ValidationError[]) => {
|
|
234
239
|
return errors.reduce(
|
|
235
240
|
(result, { path, message }) => {
|
|
236
|
-
|
|
237
|
-
|
|
241
|
+
// Convert the validation error path format to our JsonPath format
|
|
242
|
+
const jsonPath = fromEffectValidationPath(path);
|
|
243
|
+
if (!(jsonPath in result)) {
|
|
244
|
+
result[jsonPath] = message;
|
|
238
245
|
}
|
|
239
246
|
return result;
|
|
240
247
|
},
|
|
241
|
-
{} as Record<
|
|
248
|
+
{} as Record<JsonPath, string>,
|
|
242
249
|
);
|
|
243
250
|
};
|