@dxos/react-ui-form 0.7.5-labs.a279d8c → 0.7.5-labs.c0e040f
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 +601 -283
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node/index.cjs +595 -273
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +601 -283
- 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/Defaults.d.ts +1 -0
- package/dist/types/src/components/Form/Defaults.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.d.ts +3 -2
- package/dist/types/src/components/Form/Form.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormContent.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormContext.d.ts +4 -1
- package/dist/types/src/components/Form/FormContext.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/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.map +1 -1
- package/dist/types/src/components/Form/index.d.ts +2 -0
- package/dist/types/src/components/Form/index.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 +1 -0
- 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/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +25 -24
- package/src/components/FieldEditor/FieldEditor.stories.tsx +1 -1
- package/src/components/FieldEditor/FieldEditor.tsx +3 -2
- package/src/components/Form/ArrayField.tsx +1 -1
- package/src/components/Form/Defaults.tsx +61 -4
- package/src/components/Form/Form.tsx +20 -3
- package/src/components/Form/FormActions.tsx +1 -1
- package/src/components/Form/FormContent.tsx +24 -9
- package/src/components/Form/FormContext.tsx +48 -4
- package/src/components/Form/custom/SelectOptionsInput.stories.tsx +58 -0
- package/src/components/Form/custom/SelectOptionsInput.tsx +190 -0
- package/src/components/Form/custom/index.ts +1 -0
- package/src/components/Form/factory.tsx +3 -1
- package/src/components/Form/index.ts +3 -0
- package/src/components/ViewEditor/ViewEditor.tsx +7 -4
- package/src/components/deprecated/FormInput.tsx +20 -7
- package/src/hooks/useForm.ts +6 -1
- package/src/translations.ts +8 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import React, { type ReactElement, useMemo } from 'react';
|
|
5
|
+
import React, { type ReactElement, useEffect, useMemo, useRef } from 'react';
|
|
6
6
|
|
|
7
7
|
import { type BaseObject, type S, type PropertyKey } from '@dxos/echo-schema';
|
|
8
8
|
import { type ThemedClassName } from '@dxos/react-ui';
|
|
@@ -23,6 +23,8 @@ export type ComponentLookup = (args: {
|
|
|
23
23
|
inputProps: InputProps;
|
|
24
24
|
}) => ReactElement | undefined;
|
|
25
25
|
|
|
26
|
+
export type CustomInputMap = Partial<Record<string, InputComponent>>;
|
|
27
|
+
|
|
26
28
|
export type FormProps<T extends BaseObject> = ThemedClassName<
|
|
27
29
|
{
|
|
28
30
|
values: Partial<T>;
|
|
@@ -44,7 +46,7 @@ export type FormProps<T extends BaseObject> = ThemedClassName<
|
|
|
44
46
|
* Map of custom renderers for specific properties.
|
|
45
47
|
* Prefer lookupComponent for plugin specific input surfaces.
|
|
46
48
|
*/
|
|
47
|
-
Custom?:
|
|
49
|
+
Custom?: CustomInputMap;
|
|
48
50
|
} & Pick<FormOptions<T>, 'schema' | 'onValuesChanged' | 'onValidate' | 'onSave'>
|
|
49
51
|
>;
|
|
50
52
|
|
|
@@ -53,6 +55,7 @@ export const Form = <T extends BaseObject>({
|
|
|
53
55
|
schema,
|
|
54
56
|
values: initialValues,
|
|
55
57
|
path = [],
|
|
58
|
+
autoFocus,
|
|
56
59
|
readonly,
|
|
57
60
|
filter,
|
|
58
61
|
sort,
|
|
@@ -65,18 +68,32 @@ export const Form = <T extends BaseObject>({
|
|
|
65
68
|
lookupComponent,
|
|
66
69
|
Custom,
|
|
67
70
|
}: FormProps<T>) => {
|
|
71
|
+
const formRef = useRef<HTMLDivElement>(null);
|
|
68
72
|
const onValid = useMemo(() => (autoSave ? onSave : undefined), [autoSave, onSave]);
|
|
69
73
|
|
|
74
|
+
// Focus the first input element within this form.
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (autoFocus && formRef.current) {
|
|
77
|
+
const input = formRef.current.querySelector('input');
|
|
78
|
+
if (input) {
|
|
79
|
+
input.focus();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}, [autoFocus]);
|
|
83
|
+
|
|
70
84
|
return (
|
|
71
85
|
<FormProvider
|
|
86
|
+
formRef={formRef}
|
|
72
87
|
schema={schema}
|
|
88
|
+
autoSave={autoSave}
|
|
73
89
|
initialValues={initialValues}
|
|
74
90
|
onValuesChanged={onValuesChanged}
|
|
75
91
|
onValidate={onValidate}
|
|
76
92
|
onValid={onValid}
|
|
77
93
|
onSave={onSave}
|
|
78
94
|
>
|
|
79
|
-
|
|
95
|
+
{/* TODO(burdon): Remove padding. */}
|
|
96
|
+
<div ref={formRef} role='none' className={mx('p-2', classNames)} data-testid={testId}>
|
|
80
97
|
<FormFields
|
|
81
98
|
schema={schema}
|
|
82
99
|
path={path}
|
|
@@ -20,7 +20,7 @@ export const FormActions = ({ onCancel, readonly }: FormActionsProps) => {
|
|
|
20
20
|
const { canSave, handleSave } = useFormContext();
|
|
21
21
|
|
|
22
22
|
return (
|
|
23
|
-
<div role='none' className='flex justify-center'>
|
|
23
|
+
<div role='none' className='mt-2 flex justify-center'>
|
|
24
24
|
<div role='none' className={mx(onCancel && !readonly && 'grid grid-cols-2 gap-2')}>
|
|
25
25
|
{onCancel && !readonly && (
|
|
26
26
|
<IconButton data-testid='cancel-button' icon='ph--x--regular' label={t('button cancel')} onClick={onCancel} />
|
|
@@ -49,10 +49,6 @@ export const FormField = ({ property, path, readonly, inline, lookupComponent, C
|
|
|
49
49
|
[examples, description],
|
|
50
50
|
);
|
|
51
51
|
|
|
52
|
-
if (array) {
|
|
53
|
-
return <ArrayField property={property} path={path} inputProps={inputProps} readonly={readonly} Custom={Custom} />;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
52
|
const FoundComponent = lookupComponent?.({
|
|
57
53
|
prop: name,
|
|
58
54
|
schema: S.make(ast),
|
|
@@ -67,14 +63,33 @@ export const FormField = ({ property, path, readonly, inline, lookupComponent, C
|
|
|
67
63
|
});
|
|
68
64
|
|
|
69
65
|
if (FoundComponent) {
|
|
70
|
-
return <div>{FoundComponent}</div>;
|
|
66
|
+
return <div role='none'>{FoundComponent}</div>;
|
|
71
67
|
}
|
|
72
68
|
|
|
73
69
|
const jsonPath = createJsonPath(path ?? []);
|
|
74
|
-
const
|
|
70
|
+
const CustomComponent = Custom?.[jsonPath];
|
|
71
|
+
if (CustomComponent) {
|
|
72
|
+
return (
|
|
73
|
+
<CustomComponent
|
|
74
|
+
type={type}
|
|
75
|
+
format={format}
|
|
76
|
+
label={label}
|
|
77
|
+
inputOnly={inline}
|
|
78
|
+
placeholder={placeholder}
|
|
79
|
+
disabled={readonly}
|
|
80
|
+
{...inputProps}
|
|
81
|
+
/>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (array) {
|
|
86
|
+
return <ArrayField property={property} path={path} inputProps={inputProps} readonly={readonly} Custom={Custom} />;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const InputComponent = getInputComponent(type, format);
|
|
75
90
|
if (InputComponent) {
|
|
76
91
|
return (
|
|
77
|
-
<div>
|
|
92
|
+
<div role='none'>
|
|
78
93
|
<InputComponent
|
|
79
94
|
type={type}
|
|
80
95
|
format={format}
|
|
@@ -113,7 +128,7 @@ export const FormField = ({ property, path, readonly, inline, lookupComponent, C
|
|
|
113
128
|
|
|
114
129
|
if (typeLiteral) {
|
|
115
130
|
return (
|
|
116
|
-
<div>
|
|
131
|
+
<div role='none'>
|
|
117
132
|
{!inline && <div>{label}</div>}
|
|
118
133
|
<FormFields schema={S.make(typeLiteral)} path={path} readonly={readonly} Custom={Custom} />
|
|
119
134
|
</div>
|
|
@@ -135,7 +150,7 @@ export const FormFields = ({ schema, path, filter, sort, readonly, lookupCompone
|
|
|
135
150
|
}, [schema, values, filter, sort]);
|
|
136
151
|
|
|
137
152
|
return (
|
|
138
|
-
<div role='form' className={mx('flex flex-col w-full gap-2
|
|
153
|
+
<div role='form' className={mx('flex flex-col w-full gap-2')}>
|
|
139
154
|
{properties
|
|
140
155
|
.map((property) => {
|
|
141
156
|
return (
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright 2025 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import React, { createContext, useContext, type FocusEvent, type PropsWithChildren } from 'react';
|
|
5
|
+
import React, { createContext, useContext, useEffect, type FocusEvent, type PropsWithChildren } from 'react';
|
|
6
6
|
|
|
7
7
|
import { raise } from '@dxos/debug';
|
|
8
8
|
import { type BaseObject, getValue } from '@dxos/echo-schema';
|
|
@@ -42,7 +42,51 @@ export const useInputProps = (path: (string | number)[] = []): FormInputStatePro
|
|
|
42
42
|
};
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
-
export const FormProvider = ({
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
export const FormProvider = ({
|
|
46
|
+
children,
|
|
47
|
+
formRef,
|
|
48
|
+
autoSave,
|
|
49
|
+
...formOptions
|
|
50
|
+
}: PropsWithChildren<
|
|
51
|
+
FormOptions<any> & {
|
|
52
|
+
formRef?: React.RefObject<HTMLDivElement>;
|
|
53
|
+
autoSave?: boolean;
|
|
54
|
+
}
|
|
55
|
+
>) => {
|
|
56
|
+
const form = useForm(formOptions);
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!formRef?.current) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
64
|
+
const keyIsEnter = event.key === 'Enter';
|
|
65
|
+
const modifierUsed = event.ctrlKey || event.altKey || event.metaKey || event.shiftKey;
|
|
66
|
+
const inputIsTextarea = (event.target as HTMLElement).tagName.toLowerCase() === 'textarea';
|
|
67
|
+
const inputOptOut = (event.target as HTMLElement).hasAttribute('data-no-submit');
|
|
68
|
+
|
|
69
|
+
// Regular inputs: Submit on Enter (no modifiers).
|
|
70
|
+
const shouldSubmitRegularInput = !inputIsTextarea && keyIsEnter && !modifierUsed;
|
|
71
|
+
|
|
72
|
+
// Textareas: Submit only on Meta+Enter.
|
|
73
|
+
const shouldSubmitTextarea = inputIsTextarea && keyIsEnter && event.metaKey;
|
|
74
|
+
|
|
75
|
+
if ((shouldSubmitRegularInput || shouldSubmitTextarea) && !inputOptOut) {
|
|
76
|
+
if (!autoSave && form.canSave) {
|
|
77
|
+
form.handleSave();
|
|
78
|
+
}
|
|
79
|
+
if (autoSave && form.formIsValid) {
|
|
80
|
+
(event.target as HTMLElement).blur();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const formElement = formRef.current;
|
|
86
|
+
|
|
87
|
+
formElement.addEventListener('keydown', handleKeyDown);
|
|
88
|
+
return () => formElement.removeEventListener('keydown', handleKeyDown);
|
|
89
|
+
}, [formRef, form.canSave, form.formIsValid, form.handleSave, autoSave]);
|
|
90
|
+
|
|
91
|
+
return <FormContext.Provider value={form}>{children}</FormContext.Provider>;
|
|
48
92
|
};
|
|
@@ -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,190 @@
|
|
|
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 { hues, 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
|
+
// Pick a random unused option from hueTokenThemes.
|
|
36
|
+
const unusedHues = hues.filter((hue) => !usedHues.has(hue));
|
|
37
|
+
if (unusedHues.length > 0) {
|
|
38
|
+
return unusedHues[Math.floor(Math.random() * unusedHues.length)];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Pick a random one that hasn't been used in the last three options
|
|
42
|
+
const lastThree = new Set(options?.slice(-3).map((option) => option.color) ?? []);
|
|
43
|
+
const availableHues = hues.filter((hue) => !lastThree.has(hue));
|
|
44
|
+
return (
|
|
45
|
+
availableHues[Math.floor(Math.random() * availableHues.length)] ?? hues[Math.floor(Math.random() * hues.length)]
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const handleAdd = useCallback(() => {
|
|
50
|
+
const newId = PublicKey.random().truncate();
|
|
51
|
+
const newOption = { id: newId, title: '', color: randomHue() };
|
|
52
|
+
onValueChange(type, [...(options ?? []), newOption]);
|
|
53
|
+
setSelectedId(newId);
|
|
54
|
+
setIsNewOption(true);
|
|
55
|
+
}, [options, type, onValueChange]);
|
|
56
|
+
|
|
57
|
+
const handleDelete = useCallback(
|
|
58
|
+
(id: string) => {
|
|
59
|
+
const newOptions = options?.filter((option) => option.id !== id) ?? [];
|
|
60
|
+
onValueChange(type, newOptions);
|
|
61
|
+
setSelectedId(null);
|
|
62
|
+
},
|
|
63
|
+
[options, type, onValueChange],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const handleMove = useCallback(
|
|
67
|
+
(from: number, to: number) => {
|
|
68
|
+
if (!options) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const newOptions = [...options];
|
|
73
|
+
const [removed] = newOptions.splice(from, 1);
|
|
74
|
+
newOptions.splice(to, 0, removed);
|
|
75
|
+
onValueChange(type, newOptions);
|
|
76
|
+
},
|
|
77
|
+
[options, type, onValueChange],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const handleClick = useCallback((id: string) => {
|
|
81
|
+
setSelectedId((current) => (current === id ? null : id));
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
const handleTitleChange = useCallback(
|
|
85
|
+
(id: string) => (e: ChangeEvent<HTMLInputElement>) => {
|
|
86
|
+
const newOptions = options?.map((o) => (o.id === id ? { ...o, title: e.target.value } : o));
|
|
87
|
+
onValueChange(type, newOptions ?? []);
|
|
88
|
+
},
|
|
89
|
+
[options, type, onValueChange],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
93
|
+
if (e.key === 'Enter') {
|
|
94
|
+
setSelectedId(null);
|
|
95
|
+
}
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
const handleColorChange = useCallback(
|
|
99
|
+
(id: string) => (hue: string) => {
|
|
100
|
+
const newOptions = options?.map((o) => (o.id === id ? { ...o, color: hue } : o));
|
|
101
|
+
onValueChange(type, newOptions ?? []);
|
|
102
|
+
},
|
|
103
|
+
[options, type, onValueChange],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (selected && isNewOption && inputRef.current) {
|
|
108
|
+
inputRef.current.focus();
|
|
109
|
+
setIsNewOption(false);
|
|
110
|
+
}
|
|
111
|
+
}, [selected, isNewOption]);
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<Input.Root validationValence={status}>
|
|
115
|
+
<InputHeader error={error}>
|
|
116
|
+
<Input.Label>{label}</Input.Label>
|
|
117
|
+
</InputHeader>
|
|
118
|
+
<div role='none'>
|
|
119
|
+
{options && (
|
|
120
|
+
<List.Root items={options} isItem={(item) => true} onMove={handleMove}>
|
|
121
|
+
{({ items }) => (
|
|
122
|
+
<div role='list' className='w-full overflow-auto'>
|
|
123
|
+
{items?.map((item) => (
|
|
124
|
+
<div key={item.id}>
|
|
125
|
+
<List.Item
|
|
126
|
+
role='button'
|
|
127
|
+
item={item}
|
|
128
|
+
classNames={mx('p-1 plb-2', 'flex flex-col', 'cursor-pointer', 'hover:bg-hoverOverlay')}
|
|
129
|
+
aria-expanded={selected === item.id}
|
|
130
|
+
>
|
|
131
|
+
<div className='flex items-center'>
|
|
132
|
+
<List.ItemDragHandle />
|
|
133
|
+
<List.ItemTitle onClick={() => handleClick(item.id)} classNames='flex-1'>
|
|
134
|
+
{/* TODO(ZaymonFC): Move spacer into Tag component. */}
|
|
135
|
+
<Tag palette={item.color as ChromaticPalette}>{item.title || '\u200b'}</Tag>
|
|
136
|
+
</List.ItemTitle>
|
|
137
|
+
<Icon
|
|
138
|
+
icon={selected === item.id ? 'ph--caret-down--regular' : 'ph--caret-right--regular'}
|
|
139
|
+
onClick={() => handleClick(item.id)}
|
|
140
|
+
/>
|
|
141
|
+
</div>
|
|
142
|
+
{selected === item.id && (
|
|
143
|
+
<div className='mis-4 mbs-2 flex flex-col gap-1'>
|
|
144
|
+
{/* 16px to match drag handle width. */}
|
|
145
|
+
<Input.Label classNames='text-xs'>{t('select option label')}</Input.Label>
|
|
146
|
+
<div className='flex flex-row items-center gap-1'>
|
|
147
|
+
<Input.TextInput
|
|
148
|
+
placeholder={t('select option label placeholder')}
|
|
149
|
+
ref={selected === item.id ? inputRef : undefined}
|
|
150
|
+
value={item.title}
|
|
151
|
+
onChange={handleTitleChange(item.id)}
|
|
152
|
+
onKeyDown={handleKeyDown}
|
|
153
|
+
classNames='flex-1'
|
|
154
|
+
data-no-submit
|
|
155
|
+
/>
|
|
156
|
+
<Toolbar.Root classNames='p-0 m-0 !is-auto'>
|
|
157
|
+
<HuePickerToolbarButton hue={item.color} onChangeHue={handleColorChange(item.id)} />
|
|
158
|
+
</Toolbar.Root>
|
|
159
|
+
<IconButton
|
|
160
|
+
icon='ph--trash--fill'
|
|
161
|
+
iconOnly
|
|
162
|
+
label={t('select option delete')}
|
|
163
|
+
onClick={() => handleDelete(item.id)}
|
|
164
|
+
/>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
</List.Item>
|
|
169
|
+
</div>
|
|
170
|
+
))}
|
|
171
|
+
<div role='listitem'>
|
|
172
|
+
<div
|
|
173
|
+
role='button'
|
|
174
|
+
className={mx('p-1 plb-2', 'flex flex-col', 'cursor-pointer', 'hover:bg-hoverOverlay')}
|
|
175
|
+
onClick={handleAdd}
|
|
176
|
+
>
|
|
177
|
+
<div className='flex items-center gap-1'>
|
|
178
|
+
<Icon icon='ph--plus--regular' />
|
|
179
|
+
<span className='text-sm'>{t('select option add')}</span>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
</List.Root>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
</Input.Root>
|
|
189
|
+
);
|
|
190
|
+
};
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { FormatEnum } from '@dxos/echo-schema';
|
|
6
6
|
import { type SimpleType } from '@dxos/effect';
|
|
7
7
|
|
|
8
|
-
import { BooleanInput, NumberInput, TextInput } from './Defaults';
|
|
8
|
+
import { BooleanInput, MarkdownInput, NumberInput, TextInput } from './Defaults';
|
|
9
9
|
import { type InputComponent } from './Input';
|
|
10
10
|
import { GeoPointInput } from './custom';
|
|
11
11
|
|
|
@@ -16,6 +16,8 @@ export const getInputComponent = (type: SimpleType, format?: FormatEnum): InputC
|
|
|
16
16
|
switch (format) {
|
|
17
17
|
case FormatEnum.LatLng:
|
|
18
18
|
return GeoPointInput;
|
|
19
|
+
case FormatEnum.Markdown:
|
|
20
|
+
return MarkdownInput;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
switch (type) {
|
|
@@ -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';
|
|
@@ -22,7 +21,7 @@ const ViewMetaSchema = S.Struct({
|
|
|
22
21
|
name: S.String.annotations({
|
|
23
22
|
[AST.TitleAnnotationId]: 'View',
|
|
24
23
|
}),
|
|
25
|
-
typename: Format.
|
|
24
|
+
typename: Format.URL.annotations({
|
|
26
25
|
[AST.TitleAnnotationId]: 'Typename',
|
|
27
26
|
}),
|
|
28
27
|
}).pipe(S.mutable);
|
|
@@ -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
|
);
|
|
@@ -100,7 +103,7 @@ export const ViewEditor = ({
|
|
|
100
103
|
|
|
101
104
|
return (
|
|
102
105
|
<div role='none' className={mx('flex flex-col w-full divide-y divide-separator', classNames)}>
|
|
103
|
-
<Form<ViewMetaType>
|
|
106
|
+
<Form<ViewMetaType> autoSave schema={ViewMetaSchema} values={viewValues} onSave={handleViewUpdate} />
|
|
104
107
|
|
|
105
108
|
<div>
|
|
106
109
|
{/* TODO(burdon): Clean up common form ux. */}
|
|
@@ -4,7 +4,21 @@
|
|
|
4
4
|
|
|
5
5
|
import React, { type JSX, type PropsWithChildren } from 'react';
|
|
6
6
|
|
|
7
|
-
import { Input } from '@dxos/react-ui';
|
|
7
|
+
import { Input, type ThemedClassName } from '@dxos/react-ui';
|
|
8
|
+
import { mx, textBlockWidth } from '@dxos/react-ui-theme';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @deprecated
|
|
12
|
+
*/
|
|
13
|
+
export const DeprecatedFormContainer = ({ children, classNames }: ThemedClassName<PropsWithChildren>) => {
|
|
14
|
+
return (
|
|
15
|
+
<div role='none' className='w-full p-4 justify-center overflow-x-hidden overflow-y-auto'>
|
|
16
|
+
<div role='form' className={mx('flex flex-col', textBlockWidth, classNames)}>
|
|
17
|
+
{children}
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
};
|
|
8
22
|
|
|
9
23
|
export type DeprecatedFormInputProps = {
|
|
10
24
|
label: string;
|
|
@@ -15,7 +29,6 @@ export type DeprecatedFormInputProps = {
|
|
|
15
29
|
/**
|
|
16
30
|
* @deprecated
|
|
17
31
|
*/
|
|
18
|
-
// TODO(burdon): Still used in ObservabilitySettings and all settings.
|
|
19
32
|
export const DeprecatedFormInput = ({
|
|
20
33
|
label,
|
|
21
34
|
description,
|
|
@@ -26,8 +39,9 @@ export const DeprecatedFormInput = ({
|
|
|
26
39
|
<div role='none' className='flex w-full gap-4 py-1'>
|
|
27
40
|
<Input.Root>
|
|
28
41
|
<div role='none' className='flex flex-col w-full'>
|
|
29
|
-
|
|
30
|
-
|
|
42
|
+
<Input.Label classNames='flex min-bs-[--rail-action] items-center' style={{ fontSize: 'unset' }}>
|
|
43
|
+
{label}
|
|
44
|
+
</Input.Label>
|
|
31
45
|
{description && (
|
|
32
46
|
<Input.DescriptionAndValidation classNames='mbs-0.5'>
|
|
33
47
|
<Input.Description>{description}</Input.Description>
|
|
@@ -36,7 +50,7 @@ export const DeprecatedFormInput = ({
|
|
|
36
50
|
</div>
|
|
37
51
|
|
|
38
52
|
<div role='none'>
|
|
39
|
-
<div role='none' className='flex min-
|
|
53
|
+
<div role='none' className='flex min-bs-[--rail-action] items-center'>
|
|
40
54
|
{children}
|
|
41
55
|
</div>
|
|
42
56
|
</div>
|
|
@@ -45,9 +59,8 @@ export const DeprecatedFormInput = ({
|
|
|
45
59
|
);
|
|
46
60
|
|
|
47
61
|
if (secondary) {
|
|
48
|
-
// console.log(secondary);
|
|
49
62
|
return (
|
|
50
|
-
<div role='none'
|
|
63
|
+
<div role='none'>
|
|
51
64
|
{primary}
|
|
52
65
|
{secondary}
|
|
53
66
|
</div>
|
package/src/hooks/useForm.ts
CHANGED
|
@@ -24,6 +24,7 @@ export type FormHandler<T extends BaseObject> = {
|
|
|
24
24
|
touched: Record<JsonPath, boolean>;
|
|
25
25
|
changed: Record<JsonPath, boolean>;
|
|
26
26
|
canSave: boolean;
|
|
27
|
+
formIsValid: boolean;
|
|
27
28
|
handleSave: () => void;
|
|
28
29
|
|
|
29
30
|
//
|
|
@@ -104,7 +105,6 @@ export const useForm = <T extends BaseObject>({
|
|
|
104
105
|
// Validation.
|
|
105
106
|
//
|
|
106
107
|
|
|
107
|
-
// TODO(burdon): Validate each property separately.
|
|
108
108
|
const validate = useCallback(
|
|
109
109
|
(values: Partial<T>): values is T => {
|
|
110
110
|
let errors: ValidationError[] = validateSchema(schema, values) ?? [];
|
|
@@ -130,6 +130,10 @@ export const useForm = <T extends BaseObject>({
|
|
|
130
130
|
}
|
|
131
131
|
}, [values, schemaDirty]);
|
|
132
132
|
|
|
133
|
+
const formIsValid = useMemo(() => {
|
|
134
|
+
return Object.keys(errors).length === 0;
|
|
135
|
+
}, [errors]);
|
|
136
|
+
|
|
133
137
|
/**
|
|
134
138
|
* NOTE: We can submit if there is no touched field that has an error.
|
|
135
139
|
* Basically, if there's a validation message visible in the form, submit should be disabled.
|
|
@@ -223,6 +227,7 @@ export const useForm = <T extends BaseObject>({
|
|
|
223
227
|
changed,
|
|
224
228
|
canSave,
|
|
225
229
|
handleSave,
|
|
230
|
+
formIsValid,
|
|
226
231
|
|
|
227
232
|
// Field utils.
|
|
228
233
|
getStatus,
|
package/src/translations.ts
CHANGED
|
@@ -42,6 +42,7 @@ export default [
|
|
|
42
42
|
'format ref': 'Reference',
|
|
43
43
|
'format regex': 'RegExp',
|
|
44
44
|
'format string': 'String',
|
|
45
|
+
'format single-select': 'Select',
|
|
45
46
|
'format text': 'Long text',
|
|
46
47
|
'format time': 'Time',
|
|
47
48
|
'format timestamp': 'Timestamp',
|
|
@@ -53,6 +54,13 @@ export default [
|
|
|
53
54
|
// Placeholders
|
|
54
55
|
'placeholder latitude': 'Latitude',
|
|
55
56
|
'placeholder longitude': 'Longitude',
|
|
57
|
+
|
|
58
|
+
// SelectOptionsInput
|
|
59
|
+
'select option label': 'Label',
|
|
60
|
+
'select option label placeholder': 'Option label',
|
|
61
|
+
'select option color': 'Color',
|
|
62
|
+
'select option delete': 'Delete',
|
|
63
|
+
'select option add': 'Add option',
|
|
56
64
|
},
|
|
57
65
|
},
|
|
58
66
|
},
|