@overmap-ai/forms 0.0.1-master.3
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/.husky/pre-commit +6 -0
- package/.prettierrc.json +10 -0
- package/.storybook/StoryDecorator.tsx +22 -0
- package/.storybook/main.ts +20 -0
- package/.storybook/palettes/green.css +66 -0
- package/.storybook/palettes/red.css +66 -0
- package/.storybook/preview.css +39 -0
- package/.storybook/preview.tsx +31 -0
- package/.storybook/tailwind-theme/accentPalette.css +181 -0
- package/.storybook/tailwind-theme/backgrounds.css +11 -0
- package/.storybook/tailwind-theme/basePalette.css +178 -0
- package/dev/publish-alpha.sh +13 -0
- package/dev/publish-patch.sh +3 -0
- package/eslint.config.js +56 -0
- package/package.json +93 -0
- package/src/ColorPicker/ColorPicker.tsx +47 -0
- package/src/ColorPicker/index.ts +1 -0
- package/src/FileBadge/FileBadge.tsx +27 -0
- package/src/FileBadge/index.ts +1 -0
- package/src/FileCard/FileCard.stories.tsx +69 -0
- package/src/FileCard/FileCard.tsx +53 -0
- package/src/FileCard/index.ts +1 -0
- package/src/FileIcon/FileIcon.tsx +31 -0
- package/src/FileIcon/index.ts +1 -0
- package/src/FileViewer/FileViewerProvider.stories.tsx +50 -0
- package/src/FileViewer/FileViewerProvider.tsx +72 -0
- package/src/FileViewer/context.ts +11 -0
- package/src/FileViewer/index.ts +3 -0
- package/src/FileViewer/typings.ts +5 -0
- package/src/ImageCard/ImageCard.stories.tsx +94 -0
- package/src/ImageCard/ImageCard.tsx +82 -0
- package/src/ImageCard/index.ts +1 -0
- package/src/ImageMarkup/ImageMarkup.stories.tsx +65 -0
- package/src/ImageMarkup/ImageMarkup.tsx +268 -0
- package/src/ImageMarkup/index.ts +1 -0
- package/src/ImageViewer/ImageViewer.stories.tsx +57 -0
- package/src/ImageViewer/ImageViewer.tsx +124 -0
- package/src/ImageViewer/constants.ts +1 -0
- package/src/ImageViewer/index.ts +2 -0
- package/src/PDFViewer/PDFViewer.stories.tsx +55 -0
- package/src/PDFViewer/PDFViewer.tsx +170 -0
- package/src/PDFViewer/constants.ts +1 -0
- package/src/PDFViewer/index.ts +2 -0
- package/src/SpreadsheetViewer/SpreadsheetViewer.stories.tsx +55 -0
- package/src/SpreadsheetViewer/SpreadsheetViewer.tsx +162 -0
- package/src/SpreadsheetViewer/constants.ts +8 -0
- package/src/SpreadsheetViewer/index.ts +2 -0
- package/src/forms/builder/DropDispatch.ts +84 -0
- package/src/forms/builder/FieldActions.tsx +155 -0
- package/src/forms/builder/FieldBuilder.tsx +386 -0
- package/src/forms/builder/FieldSectionWithActions.tsx +260 -0
- package/src/forms/builder/FieldWithActions.tsx +129 -0
- package/src/forms/builder/FieldsEditor.tsx +180 -0
- package/src/forms/builder/FormBuilder.stories.tsx +105 -0
- package/src/forms/builder/FormBuilder.tsx +237 -0
- package/src/forms/builder/constants.ts +18 -0
- package/src/forms/builder/hooks.tsx +24 -0
- package/src/forms/builder/index.ts +2 -0
- package/src/forms/builder/typings.ts +18 -0
- package/src/forms/builder/utils.ts +229 -0
- package/src/forms/constants.ts +9 -0
- package/src/forms/constantsJsx.tsx +67 -0
- package/src/forms/fields/BaseField/BaseField.ts +152 -0
- package/src/forms/fields/BaseField/hooks.tsx +60 -0
- package/src/forms/fields/BaseField/index.ts +4 -0
- package/src/forms/fields/BaseField/layouts.tsx +100 -0
- package/src/forms/fields/BaseField/typings.ts +9 -0
- package/src/forms/fields/BooleanField/BooleanField.tsx +48 -0
- package/src/forms/fields/BooleanField/BooleanInput.tsx +54 -0
- package/src/forms/fields/BooleanField/index.ts +2 -0
- package/src/forms/fields/CustomField/CustomField.tsx +45 -0
- package/src/forms/fields/CustomField/FieldInputClonerField/FieldInputCloner.tsx +25 -0
- package/src/forms/fields/CustomField/FieldInputClonerField/FieldInputClonerField.tsx +26 -0
- package/src/forms/fields/CustomField/FieldInputClonerField/index.ts +3 -0
- package/src/forms/fields/CustomField/FieldInputClonerField/typings.ts +8 -0
- package/src/forms/fields/CustomField/index.ts +1 -0
- package/src/forms/fields/DateField/DateField.tsx +42 -0
- package/src/forms/fields/DateField/DateInput.tsx +39 -0
- package/src/forms/fields/DateField/index.ts +2 -0
- package/src/forms/fields/FieldSection/FieldSection.tsx +173 -0
- package/src/forms/fields/FieldSection/FieldSectionLayout.tsx +56 -0
- package/src/forms/fields/FieldSection/index.ts +1 -0
- package/src/forms/fields/MultiStringField/MultiStringField.tsx +90 -0
- package/src/forms/fields/MultiStringField/MultiStringInput.tsx +207 -0
- package/src/forms/fields/MultiStringField/index.ts +2 -0
- package/src/forms/fields/NumberField/NumberField.tsx +173 -0
- package/src/forms/fields/NumberField/NumberInput.tsx +44 -0
- package/src/forms/fields/NumberField/index.ts +2 -0
- package/src/forms/fields/QrField/QrField.tsx +38 -0
- package/src/forms/fields/QrField/QrInput.module.sass +5 -0
- package/src/forms/fields/QrField/QrInput.tsx +144 -0
- package/src/forms/fields/QrField/index.ts +2 -0
- package/src/forms/fields/SelectField/BaseSelectField.ts +73 -0
- package/src/forms/fields/SelectField/MultiSelectField.tsx +53 -0
- package/src/forms/fields/SelectField/MultiSelectInput.tsx +80 -0
- package/src/forms/fields/SelectField/SelectField.tsx +49 -0
- package/src/forms/fields/SelectField/SelectInput.tsx +69 -0
- package/src/forms/fields/SelectField/index.ts +4 -0
- package/src/forms/fields/StringOrTextFields/StringField/StringField.tsx +61 -0
- package/src/forms/fields/StringOrTextFields/StringField/StringInput.tsx +41 -0
- package/src/forms/fields/StringOrTextFields/StringField/index.ts +2 -0
- package/src/forms/fields/StringOrTextFields/StringOrTextField.ts +143 -0
- package/src/forms/fields/StringOrTextFields/TextField/TextField.tsx +52 -0
- package/src/forms/fields/StringOrTextFields/TextField/TextInput.tsx +42 -0
- package/src/forms/fields/StringOrTextFields/TextField/index.ts +2 -0
- package/src/forms/fields/StringOrTextFields/index.ts +2 -0
- package/src/forms/fields/UploadField/UploadField.tsx +156 -0
- package/src/forms/fields/UploadField/UploadInput.tsx +220 -0
- package/src/forms/fields/UploadField/index.ts +2 -0
- package/src/forms/fields/UploadField/utils.ts +17 -0
- package/src/forms/fields/constants.ts +43 -0
- package/src/forms/fields/hooks.tsx +26 -0
- package/src/forms/fields/index.ts +12 -0
- package/src/forms/fields/typings.ts +45 -0
- package/src/forms/fields/utils.ts +125 -0
- package/src/forms/index.ts +5 -0
- package/src/forms/renderer/FormRenderer/FormRenderer.stories.tsx +142 -0
- package/src/forms/renderer/FormRenderer/FormRenderer.tsx +135 -0
- package/src/forms/renderer/PatchForm/Field.tsx +41 -0
- package/src/forms/renderer/PatchForm/PatchForm.stories.tsx +91 -0
- package/src/forms/renderer/PatchForm/Provider.tsx +119 -0
- package/src/forms/renderer/PatchForm/index.ts +2 -0
- package/src/forms/renderer/index.ts +2 -0
- package/src/forms/typings.ts +162 -0
- package/src/forms/utils.ts +69 -0
- package/src/index.ts +11 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.ts +8 -0
- package/tsconfig.json +26 -0
- package/vite.config.ts +23 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import { formRevisionToSchema, PartialFormRevision } from "../../fields"
|
|
4
|
+
import { FormRenderer } from "./FormRenderer"
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Renderer/FromRenderer",
|
|
8
|
+
component: FormRenderer,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
} satisfies Meta<typeof FormRenderer>
|
|
11
|
+
|
|
12
|
+
export default meta
|
|
13
|
+
type Story = StoryObj<typeof meta>
|
|
14
|
+
|
|
15
|
+
const partialFormRevision: PartialFormRevision = {
|
|
16
|
+
title: "Test form",
|
|
17
|
+
description: "This is a test form",
|
|
18
|
+
fields: [
|
|
19
|
+
{
|
|
20
|
+
type: "section",
|
|
21
|
+
label: "Section",
|
|
22
|
+
condition: null,
|
|
23
|
+
conditional: false,
|
|
24
|
+
identifier: "section",
|
|
25
|
+
fields: [
|
|
26
|
+
{
|
|
27
|
+
type: "string",
|
|
28
|
+
label: "String field",
|
|
29
|
+
description: "Hint: enter more than 1 characters",
|
|
30
|
+
required: true,
|
|
31
|
+
identifier: "string",
|
|
32
|
+
minimum_length: 2,
|
|
33
|
+
maximum_length: 10,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
type: "text",
|
|
37
|
+
label: "Text field",
|
|
38
|
+
required: true,
|
|
39
|
+
identifier: "text",
|
|
40
|
+
maximum_length: 400,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: "boolean",
|
|
44
|
+
label: "Boolean field",
|
|
45
|
+
required: true,
|
|
46
|
+
identifier: "boolean",
|
|
47
|
+
description: "Hint: enter true to pass validation and open and new section",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
type: "select",
|
|
51
|
+
label: "Select field",
|
|
52
|
+
required: true,
|
|
53
|
+
identifier: "select",
|
|
54
|
+
options: [
|
|
55
|
+
{ label: "option 1", value: "option 1" },
|
|
56
|
+
{ label: "option 2", value: "option 2" },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
type: "multi-select",
|
|
61
|
+
label: "Multi-select field",
|
|
62
|
+
required: true,
|
|
63
|
+
identifier: "multi_select",
|
|
64
|
+
options: [
|
|
65
|
+
{ label: "option 1", value: "option 1" },
|
|
66
|
+
{ label: "option 2", value: "option 2" },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
type: "multi-string",
|
|
71
|
+
label: "Multi-string field",
|
|
72
|
+
description: "Used to generate options",
|
|
73
|
+
required: true,
|
|
74
|
+
identifier: "multi_string",
|
|
75
|
+
minimum_length: 2,
|
|
76
|
+
maximum_length: 10,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
type: "upload",
|
|
80
|
+
label: "Upload Field",
|
|
81
|
+
description: "Used to upload files",
|
|
82
|
+
required: true,
|
|
83
|
+
identifier: "upload",
|
|
84
|
+
maximum_files: 10,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
type: "qr",
|
|
88
|
+
label: "QR Field",
|
|
89
|
+
description: "Used to scan QR codes",
|
|
90
|
+
required: true,
|
|
91
|
+
identifier: "qr",
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
type: "section",
|
|
97
|
+
label: "Another section",
|
|
98
|
+
identifier: "another_section",
|
|
99
|
+
condition: {
|
|
100
|
+
identifier: "boolean",
|
|
101
|
+
value: true,
|
|
102
|
+
},
|
|
103
|
+
conditional: true,
|
|
104
|
+
fields: [
|
|
105
|
+
{
|
|
106
|
+
type: "select",
|
|
107
|
+
label: "Select field",
|
|
108
|
+
required: true,
|
|
109
|
+
identifier: "select2",
|
|
110
|
+
options: [
|
|
111
|
+
{ label: "option 1", value: "option 1" },
|
|
112
|
+
{ label: "option 2", value: "option 2" },
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const RenderForSubmission: Story = {
|
|
121
|
+
args: {
|
|
122
|
+
schema: formRevisionToSchema(partialFormRevision),
|
|
123
|
+
cancelText: "Cancel",
|
|
124
|
+
onSubmit: () => {
|
|
125
|
+
alert("validation passed")
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const RenderForViewing: Story = {
|
|
131
|
+
args: {
|
|
132
|
+
schema: formRevisionToSchema(partialFormRevision, { readonly: true }),
|
|
133
|
+
values: {
|
|
134
|
+
string: "hello",
|
|
135
|
+
text: "world",
|
|
136
|
+
boolean: "true",
|
|
137
|
+
select: "option 1",
|
|
138
|
+
multi_string: ["hello", "world"],
|
|
139
|
+
multi_select: ["option 1", "option 2"],
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Button, ButtonProps, Card, Heading, RiIcon, Text } from "@overmap-ai/blocks"
|
|
2
|
+
import { cx } from "class-variance-authority"
|
|
3
|
+
import { FormikProvider, useFormik } from "formik"
|
|
4
|
+
import { forwardRef, memo, useEffect, useMemo } from "react"
|
|
5
|
+
|
|
6
|
+
import { SEVERITY_COLOR_MAPPING } from "../../constants"
|
|
7
|
+
import { ISchema, useFieldInputs } from "../../fields"
|
|
8
|
+
import { Form } from "../../typings"
|
|
9
|
+
import { initialFormValues, validateForm } from "../../utils"
|
|
10
|
+
|
|
11
|
+
interface FormRendererProps {
|
|
12
|
+
/** The schema of the form the render */
|
|
13
|
+
schema: ISchema
|
|
14
|
+
/** Initial values of the form */
|
|
15
|
+
values?: Form
|
|
16
|
+
onSubmit?: (values: Form) => Promise<void> | void
|
|
17
|
+
/** @default "Submit" */
|
|
18
|
+
submitText?: string
|
|
19
|
+
/** The text of the cancel button (hidden by default)
|
|
20
|
+
* @default null
|
|
21
|
+
*/
|
|
22
|
+
cancelText?: string
|
|
23
|
+
onCancel?: () => void
|
|
24
|
+
onDirty?: () => void
|
|
25
|
+
onDirtyChange?: (dirty: boolean) => void
|
|
26
|
+
/** Hide the form description
|
|
27
|
+
* @default false */
|
|
28
|
+
hideDescription?: boolean
|
|
29
|
+
/** Hide the title (and description)
|
|
30
|
+
* @default false
|
|
31
|
+
*/
|
|
32
|
+
hideTitle?: boolean
|
|
33
|
+
className?: string
|
|
34
|
+
buttonProps?: Omit<ButtonProps, "children">
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const defaultHandleSubmit = () => {
|
|
38
|
+
throw new Error("onSubmit must be provided if form is not readonly.")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const FormRenderer = memo(
|
|
42
|
+
forwardRef<HTMLFormElement, FormRendererProps>((props, ref) => {
|
|
43
|
+
const {
|
|
44
|
+
schema,
|
|
45
|
+
values = {},
|
|
46
|
+
onSubmit = defaultHandleSubmit,
|
|
47
|
+
submitText = "Submit",
|
|
48
|
+
cancelText,
|
|
49
|
+
onCancel,
|
|
50
|
+
onDirty,
|
|
51
|
+
onDirtyChange,
|
|
52
|
+
// if the title isn't provided, hide it by default
|
|
53
|
+
hideTitle = !schema.title,
|
|
54
|
+
hideDescription,
|
|
55
|
+
className,
|
|
56
|
+
buttonProps,
|
|
57
|
+
} = props
|
|
58
|
+
const { readonly } = schema.meta
|
|
59
|
+
|
|
60
|
+
// randomly generate a form id to ensure field ids are unique
|
|
61
|
+
const formId = useMemo(() => crypto.randomUUID(), [])
|
|
62
|
+
|
|
63
|
+
const formik = useFormik<Form>({
|
|
64
|
+
initialValues: initialFormValues(schema.fields, values),
|
|
65
|
+
onSubmit,
|
|
66
|
+
validate: (form) => validateForm(schema, form),
|
|
67
|
+
// only validate the entire form on submit
|
|
68
|
+
validateOnBlur: false,
|
|
69
|
+
validateOnChange: false,
|
|
70
|
+
})
|
|
71
|
+
const { dirty } = formik
|
|
72
|
+
|
|
73
|
+
const Title = useMemo(
|
|
74
|
+
() => (typeof schema.title === "string" ? <Heading size="lg">{schema.title}</Heading> : schema.title),
|
|
75
|
+
[schema.title],
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
const Description = useMemo(
|
|
79
|
+
() =>
|
|
80
|
+
typeof schema.description === "string" ? (
|
|
81
|
+
<Text accentColor="base">{schema.description}</Text>
|
|
82
|
+
) : (
|
|
83
|
+
schema.description
|
|
84
|
+
),
|
|
85
|
+
[schema.description],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const inputs = useFieldInputs(schema.fields, { formId, disabled: readonly })
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (dirty && onDirty) onDirty()
|
|
92
|
+
if (onDirtyChange) onDirtyChange(dirty)
|
|
93
|
+
}, [dirty, onDirty, onDirtyChange])
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<FormikProvider value={formik}>
|
|
97
|
+
<form
|
|
98
|
+
id={formId}
|
|
99
|
+
ref={ref}
|
|
100
|
+
className={cx(className, "flex flex-col gap-2")}
|
|
101
|
+
onSubmit={formik.handleSubmit}
|
|
102
|
+
>
|
|
103
|
+
{!hideTitle && (
|
|
104
|
+
<Card>
|
|
105
|
+
<div className="flex flex-col gap-1">
|
|
106
|
+
{Title}
|
|
107
|
+
{!hideDescription && Description}
|
|
108
|
+
</div>
|
|
109
|
+
</Card>
|
|
110
|
+
)}
|
|
111
|
+
{inputs}
|
|
112
|
+
{!readonly && (
|
|
113
|
+
<div className="flex items-center justify-end gap-2">
|
|
114
|
+
{cancelText && (
|
|
115
|
+
<Button
|
|
116
|
+
accentColor={SEVERITY_COLOR_MAPPING.danger}
|
|
117
|
+
{...buttonProps}
|
|
118
|
+
type="button"
|
|
119
|
+
onClick={onCancel}
|
|
120
|
+
>
|
|
121
|
+
<RiIcon icon="RiCloseLine" />
|
|
122
|
+
{cancelText}
|
|
123
|
+
</Button>
|
|
124
|
+
)}
|
|
125
|
+
<Button {...buttonProps} type="submit" disabled={!formik.isValid} accentColor="success">
|
|
126
|
+
<RiIcon icon="RiCheckLine" />
|
|
127
|
+
{submitText}
|
|
128
|
+
</Button>
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
</form>
|
|
132
|
+
</FormikProvider>
|
|
133
|
+
)
|
|
134
|
+
}),
|
|
135
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { FieldMetaProps, useField, useFormikContext } from "formik"
|
|
2
|
+
import { memo, ReactNode, useMemo } from "react"
|
|
3
|
+
|
|
4
|
+
import { FieldValue } from "../../typings"
|
|
5
|
+
|
|
6
|
+
interface RenderArgs {
|
|
7
|
+
value: FieldValue
|
|
8
|
+
meta: FieldMetaProps<unknown>
|
|
9
|
+
/** Intermediate changes to the field value */
|
|
10
|
+
setValue: (value: FieldValue) => void
|
|
11
|
+
/** EX: when the onBlur event is fired */
|
|
12
|
+
patchValue: () => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface PatchFieldProps {
|
|
16
|
+
name: string
|
|
17
|
+
render: (args: RenderArgs) => ReactNode
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const PatchField = memo((props: PatchFieldProps) => {
|
|
21
|
+
const { name, render } = props
|
|
22
|
+
const { submitForm } = useFormikContext()
|
|
23
|
+
const [fieldProps, _meta, helpers] = useField<FieldValue>(name)
|
|
24
|
+
|
|
25
|
+
const ret = useMemo(() => {
|
|
26
|
+
const setValue = (value: FieldValue) => {
|
|
27
|
+
void helpers.setValue(value, false)
|
|
28
|
+
}
|
|
29
|
+
return render({
|
|
30
|
+
value: fieldProps.value,
|
|
31
|
+
meta: _meta,
|
|
32
|
+
setValue,
|
|
33
|
+
patchValue: () => {
|
|
34
|
+
void submitForm()
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
}, [render, fieldProps.value, _meta, submitForm, helpers])
|
|
38
|
+
return <>{ret}</>
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
PatchField.displayName = "PatchField"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import { formRevisionToSchema, PartialFormRevision } from "../../fields"
|
|
4
|
+
import { PatchField } from "./Field"
|
|
5
|
+
import { PatchFormProvider } from "./Provider"
|
|
6
|
+
|
|
7
|
+
const partialFormRevision: PartialFormRevision = {
|
|
8
|
+
title: "Test form",
|
|
9
|
+
description: "This is a test form",
|
|
10
|
+
fields: [
|
|
11
|
+
{
|
|
12
|
+
type: "section",
|
|
13
|
+
label: "Section",
|
|
14
|
+
condition: null,
|
|
15
|
+
conditional: false,
|
|
16
|
+
identifier: "section",
|
|
17
|
+
fields: [
|
|
18
|
+
{
|
|
19
|
+
type: "string",
|
|
20
|
+
label: "String field",
|
|
21
|
+
required: true,
|
|
22
|
+
identifier: "string",
|
|
23
|
+
maximum_length: 10,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
type: "string",
|
|
27
|
+
label: "String field",
|
|
28
|
+
required: true,
|
|
29
|
+
identifier: "another_string",
|
|
30
|
+
minimum_length: 2,
|
|
31
|
+
maximum_length: 10,
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
}
|
|
37
|
+
const schema = formRevisionToSchema(partialFormRevision)
|
|
38
|
+
const values = { string: "string", another_string: "another" }
|
|
39
|
+
|
|
40
|
+
const meta = {
|
|
41
|
+
title: "Renderer/PatchForm",
|
|
42
|
+
component: PatchFormProvider,
|
|
43
|
+
tags: ["autodocs"],
|
|
44
|
+
args: {
|
|
45
|
+
values,
|
|
46
|
+
schema,
|
|
47
|
+
},
|
|
48
|
+
} satisfies Meta<typeof PatchFormProvider>
|
|
49
|
+
|
|
50
|
+
export default meta
|
|
51
|
+
type Story = StoryObj<typeof meta>
|
|
52
|
+
|
|
53
|
+
/** Use the `Actions` tab to see when the `onError` or `onPatch` events fire. */
|
|
54
|
+
export const Basic: Story["render"] = (props) => {
|
|
55
|
+
return (
|
|
56
|
+
<PatchFormProvider {...props}>
|
|
57
|
+
<div className="flex flex-col gap-2">
|
|
58
|
+
<label className="flex gap-1">
|
|
59
|
+
string
|
|
60
|
+
<PatchField
|
|
61
|
+
name="string"
|
|
62
|
+
render={(props) => (
|
|
63
|
+
<input
|
|
64
|
+
value={props.value as string}
|
|
65
|
+
onChange={(e) => {
|
|
66
|
+
props.setValue(e.target.value)
|
|
67
|
+
}}
|
|
68
|
+
onBlur={props.patchValue}
|
|
69
|
+
/>
|
|
70
|
+
)}
|
|
71
|
+
/>
|
|
72
|
+
</label>
|
|
73
|
+
<label className="flex gap-1">
|
|
74
|
+
another_string
|
|
75
|
+
<PatchField
|
|
76
|
+
name="another_string"
|
|
77
|
+
render={(props) => (
|
|
78
|
+
<input
|
|
79
|
+
value={props.value as string}
|
|
80
|
+
onChange={(e) => {
|
|
81
|
+
props.setValue(e.target.value)
|
|
82
|
+
}}
|
|
83
|
+
onBlur={props.patchValue}
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
86
|
+
/>
|
|
87
|
+
</label>
|
|
88
|
+
</div>
|
|
89
|
+
</PatchFormProvider>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { FormikErrors, FormikProvider, useFormik } from "formik"
|
|
2
|
+
import { FormEventHandler, forwardRef, memo, useCallback, useEffect, useMemo } from "react"
|
|
3
|
+
|
|
4
|
+
import { NewForm } from "../../builder"
|
|
5
|
+
import { ISchema } from "../../fields"
|
|
6
|
+
import { Form } from "../../typings"
|
|
7
|
+
import { hasKeys, initialFormValues, validateForm } from "../../utils"
|
|
8
|
+
|
|
9
|
+
interface PatchFormProviderProps {
|
|
10
|
+
children: React.ReactNode
|
|
11
|
+
schema: ISchema
|
|
12
|
+
values: Form
|
|
13
|
+
/** Called when `patchValue` is called on a child `PatchField` and the form is valid.
|
|
14
|
+
* @example ```js
|
|
15
|
+
* {"field name": "field value"}
|
|
16
|
+
* ``` */
|
|
17
|
+
onPatch: (values: Form) => void
|
|
18
|
+
/** Called when `patchValue` is called on a child `PatchField` and the form is not valid.
|
|
19
|
+
* After this event is fired, the form is reset to the initial values.
|
|
20
|
+
* @example ```js
|
|
21
|
+
* {"field name": "error message"}
|
|
22
|
+
* ``` */
|
|
23
|
+
onError: (error: FormikErrors<Form | NewForm>) => void
|
|
24
|
+
className?: string
|
|
25
|
+
/** If true (default), the form will only submit if there are changes. */
|
|
26
|
+
requiresDiff?: boolean
|
|
27
|
+
/** Called when the form's dirty state changes. */
|
|
28
|
+
onDirtyChange?: (dirty: boolean) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Use PatchForms to create patch edits to existing forms rather than editing the entire form. */
|
|
32
|
+
export const PatchFormProvider = memo(
|
|
33
|
+
forwardRef<HTMLFormElement, PatchFormProviderProps>((props, ref) => {
|
|
34
|
+
const { children, schema, values, onPatch, onError, requiresDiff = true, onDirtyChange, ...rest } = props
|
|
35
|
+
|
|
36
|
+
const initialValues = useMemo(() => initialFormValues(schema.fields, values), [schema.fields, values])
|
|
37
|
+
|
|
38
|
+
const getDiff = useCallback(
|
|
39
|
+
(values: Form): Form => {
|
|
40
|
+
const diff: Form = {}
|
|
41
|
+
|
|
42
|
+
for (const key in values) {
|
|
43
|
+
const value = values[key]
|
|
44
|
+
if (value !== initialValues[key] && value !== undefined) {
|
|
45
|
+
diff[key] = value
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return diff
|
|
50
|
+
},
|
|
51
|
+
[initialValues],
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const handlePatch = useCallback(
|
|
55
|
+
(values: Form) => {
|
|
56
|
+
const diff: Form = getDiff(values)
|
|
57
|
+
// skip if no changes
|
|
58
|
+
if (requiresDiff && !hasKeys(diff)) return
|
|
59
|
+
onPatch(diff)
|
|
60
|
+
},
|
|
61
|
+
[getDiff, onPatch, requiresDiff],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
const validate = useCallback(
|
|
65
|
+
(form: Form) => {
|
|
66
|
+
const error = validateForm(schema, form)
|
|
67
|
+
|
|
68
|
+
if (error) {
|
|
69
|
+
// report the error
|
|
70
|
+
onError(error)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (onDirtyChange) {
|
|
74
|
+
const diff = getDiff(form)
|
|
75
|
+
onDirtyChange(hasKeys(diff))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return error
|
|
79
|
+
},
|
|
80
|
+
[schema, onDirtyChange, onError, getDiff],
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
const formik = useFormik<Form>({
|
|
84
|
+
initialValues,
|
|
85
|
+
onSubmit: handlePatch,
|
|
86
|
+
validate,
|
|
87
|
+
// only validate the entire form on submit
|
|
88
|
+
validateOnBlur: false,
|
|
89
|
+
validateOnChange: false,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const handleChange: FormEventHandler<HTMLFormElement> = useCallback(() => {
|
|
93
|
+
if (onDirtyChange) {
|
|
94
|
+
const diff = getDiff(formik.values)
|
|
95
|
+
if (hasKeys(diff)) {
|
|
96
|
+
onDirtyChange(true)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}, [formik.values, getDiff, onDirtyChange])
|
|
100
|
+
|
|
101
|
+
const { errors, resetForm } = formik
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
// on errors, reset the form the initial values
|
|
105
|
+
if (hasKeys(errors)) {
|
|
106
|
+
resetForm({ values: initialValues, errors: {} })
|
|
107
|
+
}
|
|
108
|
+
}, [errors, initialValues, resetForm])
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<FormikProvider value={formik}>
|
|
112
|
+
{/* the `form` captures any submit events that are generated */}
|
|
113
|
+
<form {...rest} ref={ref} onSubmit={formik.handleSubmit} onChange={handleChange}>
|
|
114
|
+
{children}
|
|
115
|
+
</form>
|
|
116
|
+
</FormikProvider>
|
|
117
|
+
)
|
|
118
|
+
}),
|
|
119
|
+
)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { Marker } from "@overmap-ai/core"
|
|
2
|
+
import { HTMLInputTypeAttribute } from "react"
|
|
3
|
+
|
|
4
|
+
import { AnyField, BaseField } from "./fields"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* When upload fields are given initial values, that value will be
|
|
8
|
+
* a `Promise<File>[]` instead of a `File[]` as the files must be downloaded.
|
|
9
|
+
* `Promise<File>[]` is not add to the `FieldValue` type as it is not a valid
|
|
10
|
+
* value for submitted forms as the files must be present to be submitted
|
|
11
|
+
* TODO: This causes type errors in hemora-web. Maybe we should change this to:
|
|
12
|
+
* export type Form = {
|
|
13
|
+
* data: Record<string, FieldValue>,
|
|
14
|
+
* attachments: Record<string, Promise<File>[]>
|
|
15
|
+
* }
|
|
16
|
+
* Then, we could just use `data` for creating attachment instances.
|
|
17
|
+
*/
|
|
18
|
+
export type Form = Record<string, FieldValue | Promise<File>[]>
|
|
19
|
+
|
|
20
|
+
/** Helper type that extracts the TValue type from a BaseField. */
|
|
21
|
+
export type ValueOfField<Type extends AnyField> = Type extends BaseField<infer TValue> ? TValue : never
|
|
22
|
+
|
|
23
|
+
// Register all allowed field types here.
|
|
24
|
+
// Note: a serialized type does not exist for custom, as it is only used for internal forms
|
|
25
|
+
export type FieldTypeIdentifier =
|
|
26
|
+
| "string"
|
|
27
|
+
| "text"
|
|
28
|
+
| "boolean"
|
|
29
|
+
| "number"
|
|
30
|
+
| "date"
|
|
31
|
+
| "select"
|
|
32
|
+
| "custom"
|
|
33
|
+
| "section"
|
|
34
|
+
| "multi-string"
|
|
35
|
+
| "multi-select"
|
|
36
|
+
| "upload"
|
|
37
|
+
| "qr"
|
|
38
|
+
|
|
39
|
+
export interface BaseSerializedObject<TIdentifier extends FieldTypeIdentifier = FieldTypeIdentifier> {
|
|
40
|
+
description?: string | null
|
|
41
|
+
identifier: string
|
|
42
|
+
type: TIdentifier
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface BaseSerializedField<TIdentifier extends FieldTypeIdentifier = FieldTypeIdentifier>
|
|
46
|
+
extends BaseSerializedObject<TIdentifier> {
|
|
47
|
+
label: string
|
|
48
|
+
required: boolean
|
|
49
|
+
image?: File | Promise<File>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** All the possible field values */
|
|
53
|
+
export type FieldValue = string | number | boolean | string[] | File[] | Date | Marker | null
|
|
54
|
+
|
|
55
|
+
export interface SerializedCondition<TValue extends FieldValue = FieldValue> {
|
|
56
|
+
identifier: string
|
|
57
|
+
// multi-string fields would produce an array of `SelectFieldOption`
|
|
58
|
+
value: TValue
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface SerializedFieldSection extends BaseSerializedObject {
|
|
62
|
+
label: string | null
|
|
63
|
+
type: "section"
|
|
64
|
+
conditional: boolean
|
|
65
|
+
condition: SerializedCondition | null
|
|
66
|
+
fields: Exclude<ISerializedField, SerializedFieldSection>[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface BaseSerializedStringField extends BaseSerializedField {
|
|
70
|
+
minimum_length?: number
|
|
71
|
+
maximum_length: number
|
|
72
|
+
placeholder?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type StringInputType = Exclude<HTMLInputTypeAttribute, "checkbox" | "number" | "button">
|
|
76
|
+
|
|
77
|
+
export interface SerializedStringField extends BaseSerializedStringField {
|
|
78
|
+
type: "string"
|
|
79
|
+
input_type?: StringInputType
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface SerializedQrField extends BaseSerializedField {
|
|
83
|
+
type: "qr"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface SerializedTextField extends BaseSerializedStringField {
|
|
87
|
+
type: "text"
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface SerializedMultiStringField extends BaseSerializedStringField {
|
|
91
|
+
type: "multi-string"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface SerializedBooleanField extends BaseSerializedField {
|
|
95
|
+
type: "boolean"
|
|
96
|
+
// TODO:
|
|
97
|
+
// default?: boolean
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface SerializedNumberField extends BaseSerializedField {
|
|
101
|
+
type: "number"
|
|
102
|
+
minimum: number | undefined
|
|
103
|
+
maximum: number | undefined
|
|
104
|
+
integers: boolean
|
|
105
|
+
placeholder?: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface SerializedDateField extends BaseSerializedField {
|
|
109
|
+
type: "date"
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The types of values that can be selected in the select input.
|
|
113
|
+
export type SelectFieldOptionValue = string
|
|
114
|
+
|
|
115
|
+
/** Represents an option in the select input. Not to be confused with the 'field options' of SelectField. */
|
|
116
|
+
export interface SelectFieldOption {
|
|
117
|
+
value: SelectFieldOptionValue
|
|
118
|
+
label: string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface SerializedSelectField extends BaseSerializedField {
|
|
122
|
+
type: "select"
|
|
123
|
+
options: SelectFieldOption[]
|
|
124
|
+
placeholder?: string
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface SerializedMultiSelectField extends BaseSerializedField {
|
|
128
|
+
type: "multi-select"
|
|
129
|
+
options: SelectFieldOption[]
|
|
130
|
+
placeholder?: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface SerializedUploadField extends BaseSerializedField {
|
|
134
|
+
type: "upload"
|
|
135
|
+
/** @see https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept */
|
|
136
|
+
extensions?: string[]
|
|
137
|
+
/** in bytes */
|
|
138
|
+
maximum_size?: number
|
|
139
|
+
/** how many files the user can upload to this field
|
|
140
|
+
* @default 1
|
|
141
|
+
*/
|
|
142
|
+
maximum_files?: number
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type ISerializedField =
|
|
146
|
+
// Add type hints for final field interfaces
|
|
147
|
+
| SerializedTextField
|
|
148
|
+
| SerializedBooleanField
|
|
149
|
+
| SerializedNumberField
|
|
150
|
+
| SerializedDateField
|
|
151
|
+
| SerializedStringField
|
|
152
|
+
| SerializedSelectField
|
|
153
|
+
| SerializedFieldSection
|
|
154
|
+
| SerializedMultiStringField
|
|
155
|
+
| SerializedMultiSelectField
|
|
156
|
+
| SerializedUploadField
|
|
157
|
+
| SerializedQrField
|
|
158
|
+
|
|
159
|
+
// REASON: Sometimes, we don't accept a FieldSection
|
|
160
|
+
export type ISerializedOnlyField = Exclude<ISerializedField, SerializedFieldSection>
|
|
161
|
+
|
|
162
|
+
export type Severity = "danger" | "warning" | "info" | "success"
|