@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,129 @@
|
|
|
1
|
+
import { Draggable } from "@hello-pangea/dnd"
|
|
2
|
+
import { Card, useToast } from "@overmap-ai/blocks"
|
|
3
|
+
import { useFormikContext } from "formik"
|
|
4
|
+
import { ChangeEvent, memo, useCallback, useMemo } from "react"
|
|
5
|
+
|
|
6
|
+
import { maxFileSizeB } from "../fields/constants"
|
|
7
|
+
import { ISerializedField } from "../typings"
|
|
8
|
+
import { FieldActions } from "./FieldActions"
|
|
9
|
+
import { FieldBuilder, FieldBuilderProps } from "./FieldBuilder"
|
|
10
|
+
import { FormikUserFormRevision, NestedFieldPath } from "./typings"
|
|
11
|
+
import { createNewField, useFieldReordering } from "./utils"
|
|
12
|
+
|
|
13
|
+
interface FieldWithActionsProps {
|
|
14
|
+
field: ISerializedField
|
|
15
|
+
index: number
|
|
16
|
+
sectionIndex: number
|
|
17
|
+
remove: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const FieldWithActions = memo((props: FieldWithActionsProps) => {
|
|
21
|
+
const { field, index, sectionIndex, remove } = props
|
|
22
|
+
const { setFieldValue, values } = useFormikContext<FormikUserFormRevision>()
|
|
23
|
+
const { reorderField } = useFieldReordering()
|
|
24
|
+
const { showError } = useToast()
|
|
25
|
+
|
|
26
|
+
const parentPath: NestedFieldPath = `fields.${sectionIndex}.fields`
|
|
27
|
+
|
|
28
|
+
const editFieldProps: FieldBuilderProps = useMemo(
|
|
29
|
+
() => ({
|
|
30
|
+
index,
|
|
31
|
+
parentPath,
|
|
32
|
+
initial: field,
|
|
33
|
+
}),
|
|
34
|
+
[field, index, parentPath],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const duplicateField = useCallback(() => {
|
|
38
|
+
const label = field.label ?? "Unlabelled field"
|
|
39
|
+
const duplicatedField = {
|
|
40
|
+
...field,
|
|
41
|
+
label,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
createNewField(parentPath, index + 1, duplicatedField, values, setFieldValue)
|
|
45
|
+
}, [field, parentPath, index, values, setFieldValue])
|
|
46
|
+
|
|
47
|
+
const moveField = useCallback(
|
|
48
|
+
(direction: "up" | "down") => {
|
|
49
|
+
const srcSectionIndex = sectionIndex
|
|
50
|
+
const srcSection = values.fields[srcSectionIndex]!
|
|
51
|
+
let destSectionIndex = sectionIndex
|
|
52
|
+
let destFieldIndex = direction === "up" ? index - 1 : index + 1
|
|
53
|
+
if (direction === "up" && index === 0) {
|
|
54
|
+
destSectionIndex = sectionIndex - 1
|
|
55
|
+
destFieldIndex = values.fields[destSectionIndex]!.fields.length
|
|
56
|
+
} else if (direction === "down" && index === srcSection.fields.length - 1) {
|
|
57
|
+
destSectionIndex = sectionIndex + 1
|
|
58
|
+
destFieldIndex = 0
|
|
59
|
+
}
|
|
60
|
+
const destSection = values.fields[destSectionIndex]
|
|
61
|
+
|
|
62
|
+
reorderField(
|
|
63
|
+
srcSection,
|
|
64
|
+
srcSectionIndex,
|
|
65
|
+
index,
|
|
66
|
+
destSection,
|
|
67
|
+
destSectionIndex,
|
|
68
|
+
destFieldIndex,
|
|
69
|
+
setFieldValue,
|
|
70
|
+
)
|
|
71
|
+
},
|
|
72
|
+
[sectionIndex, values.fields, index, reorderField, setFieldValue],
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const uploadImage = useCallback(
|
|
76
|
+
(event: ChangeEvent<HTMLInputElement>) => {
|
|
77
|
+
const { files } = event.target
|
|
78
|
+
if (!files || files.length !== 1) return
|
|
79
|
+
|
|
80
|
+
const file = files.item(0)
|
|
81
|
+
if (!file) return
|
|
82
|
+
|
|
83
|
+
// Don't add file if it exceeds the maximum file size
|
|
84
|
+
if (file.size > maxFileSizeB) {
|
|
85
|
+
showError({
|
|
86
|
+
title: "File upload error",
|
|
87
|
+
description: `The file ${file.name} exceeded the maximum file size`,
|
|
88
|
+
})
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
// Set image field but don't upload until form is submitted
|
|
92
|
+
void setFieldValue(`${parentPath}.${index}`, {
|
|
93
|
+
...field,
|
|
94
|
+
image: file,
|
|
95
|
+
}).then()
|
|
96
|
+
},
|
|
97
|
+
[field, index, parentPath, setFieldValue, showError],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<Draggable draggableId={field.identifier} index={index}>
|
|
102
|
+
{(draggableProvided) => (
|
|
103
|
+
<Card
|
|
104
|
+
ref={draggableProvided.innerRef}
|
|
105
|
+
{...draggableProvided.draggableProps}
|
|
106
|
+
{...draggableProvided.dragHandleProps}
|
|
107
|
+
// using margin bottom instead of flex gap to avoid a bug where the
|
|
108
|
+
// gap is not applied to dragged elements
|
|
109
|
+
className="mb-4"
|
|
110
|
+
>
|
|
111
|
+
<div className="flex items-center justify-between gap-4 w-full">
|
|
112
|
+
<FieldBuilder {...editFieldProps} />
|
|
113
|
+
<FieldActions
|
|
114
|
+
index={index}
|
|
115
|
+
type={field.type}
|
|
116
|
+
sectionIndex={sectionIndex}
|
|
117
|
+
remove={remove}
|
|
118
|
+
duplicate={duplicateField}
|
|
119
|
+
move={moveField}
|
|
120
|
+
upload={uploadImage}
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
</Card>
|
|
124
|
+
)}
|
|
125
|
+
</Draggable>
|
|
126
|
+
)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
FieldWithActions.displayName = "FieldWithActions"
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { DragDropContext, Droppable, OnDragEndResponder, OnDragStartResponder } from "@hello-pangea/dnd"
|
|
2
|
+
import { Button, RiIcon } from "@overmap-ai/blocks"
|
|
3
|
+
import { useFormikContext } from "formik"
|
|
4
|
+
import { Fragment, memo, useCallback, useEffect, useReducer } from "react"
|
|
5
|
+
|
|
6
|
+
import { SerializedFieldSection } from "../typings"
|
|
7
|
+
import { initializer, reducer } from "./DropDispatch"
|
|
8
|
+
import { FieldSectionWithActions } from "./FieldSectionWithActions"
|
|
9
|
+
import { FormikUserFormRevision } from "./typings"
|
|
10
|
+
import { createNewEmptySection, useFieldReordering } from "./utils"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* NOTE: Because this returns a new array instance on every call, it will cause re-renders if not destructured.
|
|
14
|
+
*
|
|
15
|
+
* BAD:
|
|
16
|
+
* ```
|
|
17
|
+
const section: [SerializedFieldSection, string] | undefined = findSection(values.fields, source.droppableId)
|
|
18
|
+
const myEffect = useEffect(() => { doSomethingWith(section) }, [section])
|
|
19
|
+
```
|
|
20
|
+
* GOOD:
|
|
21
|
+
```
|
|
22
|
+
const [section, i]: [SerializedFieldSection, string] | undefined = findSection(values.fields, source.droppableId)
|
|
23
|
+
const myEffect = useEffect(() => { doSomethingWith(section, index) }, [section, i])
|
|
24
|
+
```
|
|
25
|
+
*/
|
|
26
|
+
const findSection = (
|
|
27
|
+
fields: SerializedFieldSection[],
|
|
28
|
+
sectionId: string,
|
|
29
|
+
): [SerializedFieldSection, string] | undefined => {
|
|
30
|
+
for (const [i, section] of Object.entries(fields)) {
|
|
31
|
+
if (section.identifier === sectionId) return [section, i]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// TODO: Use the BasicFieldSection component when tackling the auto-scrolling issue that occurs when dragging very tall sections
|
|
36
|
+
// interface BasicFieldSectionProps {
|
|
37
|
+
// field: SerializedFieldSection
|
|
38
|
+
// provided: DraggableProvided | undefined
|
|
39
|
+
// }
|
|
40
|
+
//
|
|
41
|
+
// const BasicFieldSection = memo((props: BasicFieldSectionProps) => {
|
|
42
|
+
// const { field, provided } = props
|
|
43
|
+
// return (
|
|
44
|
+
// <Card
|
|
45
|
+
// ref={provided?.innerRef}
|
|
46
|
+
// {...provided?.draggableProps}
|
|
47
|
+
// {...provided?.dragHandleProps}
|
|
48
|
+
// style={{ ...provided?.draggableProps.style, height: "80px" }}
|
|
49
|
+
// >
|
|
50
|
+
// <Flex direction="column" gap="2">
|
|
51
|
+
// <Flex direction="row" gap="2">
|
|
52
|
+
// <Text size="4">{field.label}</Text>
|
|
53
|
+
// <Badge className={styles.typeBadge}>
|
|
54
|
+
// <Text>{field.fields.length} fields</Text>
|
|
55
|
+
// </Badge>
|
|
56
|
+
// </Flex>
|
|
57
|
+
// <Flex direction="row" gap="2">
|
|
58
|
+
// {field.fields.map((child) => {
|
|
59
|
+
// const childInfo = FieldTypeToClsMapping[child.type]
|
|
60
|
+
// const Icon = childInfo.Icon
|
|
61
|
+
// return (
|
|
62
|
+
// <Flex key={child.identifier} gap="3">
|
|
63
|
+
// <Badge className={styles.typeBadge}>
|
|
64
|
+
// <Icon />
|
|
65
|
+
// <Text>{childInfo.fieldTypeName}</Text>
|
|
66
|
+
// </Badge>
|
|
67
|
+
// </Flex>
|
|
68
|
+
// )
|
|
69
|
+
// })}
|
|
70
|
+
// </Flex>
|
|
71
|
+
// </Flex>
|
|
72
|
+
// </Card>
|
|
73
|
+
// )
|
|
74
|
+
// })
|
|
75
|
+
//
|
|
76
|
+
// BasicFieldSection.displayName = "BasicFieldSection"
|
|
77
|
+
|
|
78
|
+
interface FieldsEditorProps {
|
|
79
|
+
fieldsOnly: boolean
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const FieldsEditor = memo((props: FieldsEditorProps) => {
|
|
83
|
+
const { fieldsOnly } = props
|
|
84
|
+
const { values, setFieldValue } = useFormikContext<FormikUserFormRevision>()
|
|
85
|
+
// used to conditionally disable dropping on field sections so that a field
|
|
86
|
+
// using a condition cannot be dropped after the section referencing it
|
|
87
|
+
const [dropState, dispatch] = useReducer(reducer, values.fields, initializer)
|
|
88
|
+
const { reorderSection, reorderField } = useFieldReordering()
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
dispatch({ type: "update", state: initializer(values.fields) })
|
|
92
|
+
}, [dispatch, values.fields])
|
|
93
|
+
|
|
94
|
+
const handleDragStart = useCallback<OnDragStartResponder>((start) => {
|
|
95
|
+
// validation for ensuring a condition is before the section
|
|
96
|
+
// when dragging a section occurs in `handleDragEnd`
|
|
97
|
+
|
|
98
|
+
// if dragging a field between sections
|
|
99
|
+
if (start.type === "SECTION") {
|
|
100
|
+
dispatch({ type: "hold", fieldId: start.draggableId })
|
|
101
|
+
}
|
|
102
|
+
}, [])
|
|
103
|
+
|
|
104
|
+
const handleDragEnd = useCallback<OnDragEndResponder>(
|
|
105
|
+
(result) => {
|
|
106
|
+
const { source, destination, type, reason, draggableId } = result
|
|
107
|
+
dispatch({ type: "release" })
|
|
108
|
+
|
|
109
|
+
if (!destination || reason === "CANCEL") return
|
|
110
|
+
|
|
111
|
+
if (type === "ROOT") {
|
|
112
|
+
reorderSection(dropState, draggableId, source.index, destination.index, values, setFieldValue)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (type !== "SECTION") throw new Error("Unexpected droppable type.")
|
|
117
|
+
|
|
118
|
+
const [sourceSection, srcIndex] = findSection(values.fields, source.droppableId) ?? []
|
|
119
|
+
const [destinationSection, destIndex] = findSection(values.fields, destination.droppableId) ?? []
|
|
120
|
+
reorderField(
|
|
121
|
+
sourceSection,
|
|
122
|
+
srcIndex,
|
|
123
|
+
source.index,
|
|
124
|
+
destinationSection,
|
|
125
|
+
destIndex,
|
|
126
|
+
destination.index,
|
|
127
|
+
setFieldValue,
|
|
128
|
+
)
|
|
129
|
+
},
|
|
130
|
+
[values, reorderField, setFieldValue, reorderSection, dropState],
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
const handleCreateEmptySection = useCallback(
|
|
134
|
+
(index: number) => {
|
|
135
|
+
createNewEmptySection(index + 1, values, setFieldValue)
|
|
136
|
+
},
|
|
137
|
+
[values, setFieldValue],
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
|
142
|
+
<Droppable droppableId="droppable" type="ROOT">
|
|
143
|
+
{(droppableProvided) => (
|
|
144
|
+
<div
|
|
145
|
+
className="flex flex-col gap-0"
|
|
146
|
+
ref={droppableProvided.innerRef}
|
|
147
|
+
{...droppableProvided.droppableProps}
|
|
148
|
+
>
|
|
149
|
+
{values.fields.map((field, index) => (
|
|
150
|
+
<Fragment key={field.identifier}>
|
|
151
|
+
<FieldSectionWithActions
|
|
152
|
+
field={field}
|
|
153
|
+
index={index}
|
|
154
|
+
dropState={dropState}
|
|
155
|
+
fieldsOnly={fieldsOnly}
|
|
156
|
+
/>
|
|
157
|
+
{!fieldsOnly && (
|
|
158
|
+
<Button
|
|
159
|
+
className="mb-4"
|
|
160
|
+
type="button"
|
|
161
|
+
variant="soft"
|
|
162
|
+
accentColor="base"
|
|
163
|
+
onClick={() => {
|
|
164
|
+
handleCreateEmptySection(index)
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
<RiIcon icon="RiAddLine" /> Add section
|
|
168
|
+
</Button>
|
|
169
|
+
)}
|
|
170
|
+
</Fragment>
|
|
171
|
+
))}
|
|
172
|
+
{droppableProvided.placeholder}
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
</Droppable>
|
|
176
|
+
</DragDropContext>
|
|
177
|
+
)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
FieldsEditor.displayName = "FieldsEditor"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import { SerializedSelectField, SerializedUploadField } from "../typings"
|
|
4
|
+
import { FormBuilder } from "./FormBuilder"
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Builder/FormBuilder",
|
|
8
|
+
component: FormBuilder,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
} satisfies Meta<typeof FormBuilder>
|
|
11
|
+
|
|
12
|
+
export default meta
|
|
13
|
+
type Story = StoryObj<typeof meta>
|
|
14
|
+
|
|
15
|
+
export const BuildNewForm: Story = {
|
|
16
|
+
args: {
|
|
17
|
+
onSave: (form) => {
|
|
18
|
+
alert(JSON.stringify(form, null, 2))
|
|
19
|
+
},
|
|
20
|
+
hydrateRevisionWithImages: (revision) => revision,
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const EditExistingForm: Story = {
|
|
25
|
+
args: {
|
|
26
|
+
onSave: (form) => {
|
|
27
|
+
alert(JSON.stringify(form, null, 2))
|
|
28
|
+
},
|
|
29
|
+
hydrateRevisionWithImages: (revision) => revision,
|
|
30
|
+
revision: {
|
|
31
|
+
form: "1",
|
|
32
|
+
created_by: 1,
|
|
33
|
+
submitted_at: new Date().toISOString(),
|
|
34
|
+
revision: 1,
|
|
35
|
+
title: "Test form",
|
|
36
|
+
description: "This is a test form.",
|
|
37
|
+
offline_id: "offline_id",
|
|
38
|
+
fields: [
|
|
39
|
+
{
|
|
40
|
+
type: "string",
|
|
41
|
+
label: "String field",
|
|
42
|
+
description: "Hint: enter more than 1 characters",
|
|
43
|
+
required: true,
|
|
44
|
+
identifier: "string",
|
|
45
|
+
minimum_length: 2,
|
|
46
|
+
maximum_length: 10,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
type: "text",
|
|
50
|
+
label: "Text field",
|
|
51
|
+
description: null,
|
|
52
|
+
required: true,
|
|
53
|
+
identifier: "text",
|
|
54
|
+
maximum_length: 400,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
type: "boolean",
|
|
58
|
+
label: "Boolean field",
|
|
59
|
+
description: null,
|
|
60
|
+
required: true,
|
|
61
|
+
identifier: "boolean",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
type: "select",
|
|
65
|
+
label: "Select field",
|
|
66
|
+
description: null,
|
|
67
|
+
required: true,
|
|
68
|
+
identifier: "select",
|
|
69
|
+
options: ["option 1", "option 2", "option 3"].map((option) => ({
|
|
70
|
+
label: option,
|
|
71
|
+
value: option,
|
|
72
|
+
})),
|
|
73
|
+
} satisfies SerializedSelectField,
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const FormWithUploadField: Story = {
|
|
80
|
+
args: {
|
|
81
|
+
onSave: (form) => {
|
|
82
|
+
alert(JSON.stringify(form, null, 2))
|
|
83
|
+
},
|
|
84
|
+
hydrateRevisionWithImages: (revision) => revision,
|
|
85
|
+
revision: {
|
|
86
|
+
form: "1",
|
|
87
|
+
created_by: 1,
|
|
88
|
+
submitted_at: new Date().toISOString(),
|
|
89
|
+
revision: 1,
|
|
90
|
+
title: "Test form",
|
|
91
|
+
description: "This is a test form.",
|
|
92
|
+
offline_id: "offline_id",
|
|
93
|
+
fields: [
|
|
94
|
+
{
|
|
95
|
+
type: "upload",
|
|
96
|
+
label: "Upload field",
|
|
97
|
+
description: null,
|
|
98
|
+
required: true,
|
|
99
|
+
maximum_files: 2,
|
|
100
|
+
identifier: "upload",
|
|
101
|
+
} satisfies SerializedUploadField,
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { Button, Input, RiIcon, Tabs, TextArea, useToast } from "@overmap-ai/blocks"
|
|
2
|
+
import { UserFormRevision } from "@overmap-ai/core"
|
|
3
|
+
import { cx } from "class-variance-authority"
|
|
4
|
+
import { FormikErrors, FormikProvider, useFormik } from "formik"
|
|
5
|
+
import { forwardRef, memo, useCallback, useMemo } from "react"
|
|
6
|
+
|
|
7
|
+
import { SEVERITY_COLOR_MAPPING } from "../constants"
|
|
8
|
+
import { AnyField, BaseFormElement, formRevisionToSchema, InputWithHelpText, ISchema } from "../fields"
|
|
9
|
+
import { FormRenderer, PatchField } from "../renderer"
|
|
10
|
+
import { hasKeys, validateForm } from "../utils"
|
|
11
|
+
import { CompleteFieldTypeToClsMapping, formId } from "./constants"
|
|
12
|
+
import { FieldsEditor } from "./FieldsEditor"
|
|
13
|
+
import { FormBuilderSaveHandler, FormikUserFormRevision, NewForm } from "./typings"
|
|
14
|
+
import { emptySection, makeConditionalSourceFields, makeIdentifier, wrapRootFieldsWithFieldSection } from "./utils"
|
|
15
|
+
|
|
16
|
+
const previewSubmit = () => {
|
|
17
|
+
alert("This is a form preview, your data will not be saved.")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface FormBuilderProps {
|
|
21
|
+
onCancel?: () => void
|
|
22
|
+
onSave: FormBuilderSaveHandler
|
|
23
|
+
/** A revision of an existing form to edit. To create a new form, pass `undefined`. */
|
|
24
|
+
revision?: UserFormRevision
|
|
25
|
+
initialTitle?: string
|
|
26
|
+
/** @default true */
|
|
27
|
+
showExplainerText?: boolean
|
|
28
|
+
/** @default true */
|
|
29
|
+
showFormTitle?: boolean
|
|
30
|
+
/** Show and edit non-section fields only. Functionally, these fields will be in a single section with no label
|
|
31
|
+
* @default false
|
|
32
|
+
*/
|
|
33
|
+
fieldsOnly?: boolean
|
|
34
|
+
/** Show the Edit and Preview tabs at the top of the component. If false, the preview is entirely inaccessible.
|
|
35
|
+
* @default true
|
|
36
|
+
*/
|
|
37
|
+
showTabs?: boolean
|
|
38
|
+
tabsListClassName?: string
|
|
39
|
+
hydrateRevisionWithImages: (revision: UserFormRevision) => UserFormRevision
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const FormBuilder = memo(
|
|
43
|
+
forwardRef<HTMLDivElement, FormBuilderProps>((props, ref) => {
|
|
44
|
+
const {
|
|
45
|
+
onCancel,
|
|
46
|
+
onSave,
|
|
47
|
+
revision,
|
|
48
|
+
initialTitle,
|
|
49
|
+
showExplainerText = true,
|
|
50
|
+
showFormTitle = true,
|
|
51
|
+
fieldsOnly = false,
|
|
52
|
+
showTabs = true,
|
|
53
|
+
tabsListClassName,
|
|
54
|
+
hydrateRevisionWithImages,
|
|
55
|
+
} = props
|
|
56
|
+
const { showError } = useToast()
|
|
57
|
+
|
|
58
|
+
const validate = useCallback(
|
|
59
|
+
(form: FormikUserFormRevision) => {
|
|
60
|
+
const errors: FormikErrors<FormikUserFormRevision> = {}
|
|
61
|
+
if (!form.title) {
|
|
62
|
+
errors.title = "Title is required."
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!form.fields || form.fields.length === 0) {
|
|
66
|
+
errors.fields = "At least one field is required."
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let fieldsToValidate: BaseFormElement[] = []
|
|
70
|
+
for (const [sectionIndex, section] of form.fields.entries()) {
|
|
71
|
+
// Section settings (conditional and condition)
|
|
72
|
+
const fieldCls = CompleteFieldTypeToClsMapping.section
|
|
73
|
+
const sectionSettings = fieldCls
|
|
74
|
+
.getFieldCreationSchema(
|
|
75
|
+
makeConditionalSourceFields(form.fields, sectionIndex),
|
|
76
|
+
`fields.${sectionIndex}`,
|
|
77
|
+
)
|
|
78
|
+
.map((field) => field.field)
|
|
79
|
+
|
|
80
|
+
fieldsToValidate = [...fieldsToValidate, ...sectionSettings]
|
|
81
|
+
|
|
82
|
+
// Field settings (required, min/max values, options, etc.)
|
|
83
|
+
for (const [fieldIndex, field] of section.fields.entries()) {
|
|
84
|
+
const fieldCls = CompleteFieldTypeToClsMapping[field.type]
|
|
85
|
+
const fieldSettings: AnyField[] = fieldCls
|
|
86
|
+
.getFieldCreationSchema(`fields.${sectionIndex}.fields.${fieldIndex}`)
|
|
87
|
+
.map((field) => field.field)
|
|
88
|
+
|
|
89
|
+
fieldsToValidate = [...fieldsToValidate, ...fieldSettings]
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const fieldErrors = validateForm(
|
|
94
|
+
{
|
|
95
|
+
title: "Validate form builder",
|
|
96
|
+
fields: fieldsToValidate,
|
|
97
|
+
meta: { readonly: true },
|
|
98
|
+
},
|
|
99
|
+
form,
|
|
100
|
+
)
|
|
101
|
+
if (fieldErrors) {
|
|
102
|
+
errors.fields = fieldErrors.fields
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (hasKeys(errors)) {
|
|
106
|
+
showError({
|
|
107
|
+
title: "Some form settings are invalid",
|
|
108
|
+
description: "Please check settings highlighted in red.",
|
|
109
|
+
})
|
|
110
|
+
return errors
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
[showError],
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
const initialValues: NewForm = useMemo(
|
|
117
|
+
() => ({
|
|
118
|
+
title: initialTitle ?? "",
|
|
119
|
+
description: "",
|
|
120
|
+
fields: [{ ...emptySection(makeIdentifier(null, "")), label: "" }],
|
|
121
|
+
}),
|
|
122
|
+
[initialTitle],
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
// Add attachments as file promises to revision fields
|
|
126
|
+
const revisionWithImages = revision ? hydrateRevisionWithImages(revision) : undefined
|
|
127
|
+
|
|
128
|
+
const formik = useFormik<FormikUserFormRevision>({
|
|
129
|
+
initialValues: wrapRootFieldsWithFieldSection(revisionWithImages) ?? initialValues,
|
|
130
|
+
validate,
|
|
131
|
+
onSubmit: onSave,
|
|
132
|
+
validateOnChange: false,
|
|
133
|
+
validateOnBlur: false,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const previewSchema: ISchema = useMemo(() => formRevisionToSchema(formik.values), [formik.values])
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<Tabs.Root ref={ref} defaultValue="edit">
|
|
140
|
+
<div className="flex flex-col gap-2">
|
|
141
|
+
{showTabs && (
|
|
142
|
+
<Tabs.List
|
|
143
|
+
className={cx("sticky top-0 z-[2000] flex bg-(--color-background)", tabsListClassName)}
|
|
144
|
+
>
|
|
145
|
+
<Tabs.Trigger className="grow" value="edit">
|
|
146
|
+
<div className="flex items-center gap-2">
|
|
147
|
+
<RiIcon icon="RiPencilLine" />
|
|
148
|
+
Edit form
|
|
149
|
+
</div>
|
|
150
|
+
</Tabs.Trigger>
|
|
151
|
+
<Tabs.Trigger className="grow" value="preview">
|
|
152
|
+
<div className="flex items-center gap-2">
|
|
153
|
+
<RiIcon icon="RiEyeLine" />
|
|
154
|
+
Preview form
|
|
155
|
+
</div>
|
|
156
|
+
</Tabs.Trigger>
|
|
157
|
+
</Tabs.List>
|
|
158
|
+
)}
|
|
159
|
+
<Tabs.Content value="edit">
|
|
160
|
+
{showExplainerText && (
|
|
161
|
+
<span>
|
|
162
|
+
Create your form using various field types. Sections can be{" "}
|
|
163
|
+
<strong>conditionally rendered</strong> based on{" "}
|
|
164
|
+
<strong>answers to fields in preceding sections. </strong>
|
|
165
|
+
</span>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
<form className="mt-3 flex flex-col gap-2" id={formId} onSubmit={formik.handleSubmit}>
|
|
169
|
+
<FormikProvider value={formik}>
|
|
170
|
+
{showFormTitle && (
|
|
171
|
+
<>
|
|
172
|
+
<PatchField
|
|
173
|
+
name="title"
|
|
174
|
+
render={({ setValue, value, meta }) => (
|
|
175
|
+
<InputWithHelpText severity="danger" helpText={meta.error ?? null}>
|
|
176
|
+
<Input.Root
|
|
177
|
+
variant="outline"
|
|
178
|
+
size="lg"
|
|
179
|
+
accentColor={
|
|
180
|
+
meta.error ? SEVERITY_COLOR_MAPPING.danger : "primary"
|
|
181
|
+
}
|
|
182
|
+
>
|
|
183
|
+
<Input.Field
|
|
184
|
+
placeholder="Form title"
|
|
185
|
+
value={value as string}
|
|
186
|
+
onChange={(event) => {
|
|
187
|
+
setValue(event.target.value)
|
|
188
|
+
}}
|
|
189
|
+
maxLength={100}
|
|
190
|
+
/>
|
|
191
|
+
</Input.Root>
|
|
192
|
+
</InputWithHelpText>
|
|
193
|
+
)}
|
|
194
|
+
/>
|
|
195
|
+
<PatchField
|
|
196
|
+
name="description"
|
|
197
|
+
render={({ setValue, value }) => (
|
|
198
|
+
<TextArea
|
|
199
|
+
className="field-sizing-content"
|
|
200
|
+
placeholder="Explain the purpose of this form"
|
|
201
|
+
value={value as string}
|
|
202
|
+
onChange={(event) => {
|
|
203
|
+
setValue(event.target.value)
|
|
204
|
+
}}
|
|
205
|
+
resize="vertical"
|
|
206
|
+
maxLength={1000}
|
|
207
|
+
/>
|
|
208
|
+
)}
|
|
209
|
+
/>
|
|
210
|
+
</>
|
|
211
|
+
)}
|
|
212
|
+
<FieldsEditor fieldsOnly={fieldsOnly} />
|
|
213
|
+
<span
|
|
214
|
+
data-accent-color={SEVERITY_COLOR_MAPPING.danger}
|
|
215
|
+
className="text-xs text-(--accent-a11)"
|
|
216
|
+
>
|
|
217
|
+
{typeof formik.errors.fields === "string" && formik.errors.fields}
|
|
218
|
+
</span>
|
|
219
|
+
</FormikProvider>
|
|
220
|
+
<div className="flex items-center justify-end gap-2">
|
|
221
|
+
{onCancel && (
|
|
222
|
+
<Button type="button" variant="solid" accentColor="base" onClick={onCancel}>
|
|
223
|
+
Cancel
|
|
224
|
+
</Button>
|
|
225
|
+
)}
|
|
226
|
+
<Button type="submit">Save form</Button>
|
|
227
|
+
</div>
|
|
228
|
+
</form>
|
|
229
|
+
</Tabs.Content>
|
|
230
|
+
<Tabs.Content value="preview">
|
|
231
|
+
<FormRenderer schema={previewSchema} onSubmit={previewSubmit} hideTitle={!showFormTitle} />
|
|
232
|
+
</Tabs.Content>
|
|
233
|
+
</div>
|
|
234
|
+
</Tabs.Root>
|
|
235
|
+
)
|
|
236
|
+
}),
|
|
237
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { FieldSection } from "../fields"
|
|
2
|
+
import { FieldTypeToClsMapping } from "../fields/constants"
|
|
3
|
+
import { FieldTypeIdentifier } from "../typings"
|
|
4
|
+
|
|
5
|
+
export const formId = "form-builder"
|
|
6
|
+
|
|
7
|
+
export const fieldsToChoose = [
|
|
8
|
+
["string", "text"],
|
|
9
|
+
["select", "multi-select", "upload", "qr"],
|
|
10
|
+
["boolean", "date", "number", "multi-string"],
|
|
11
|
+
] satisfies FieldTypeIdentifier[][]
|
|
12
|
+
|
|
13
|
+
export const indexOfLastFieldGroup = fieldsToChoose.length - 1
|
|
14
|
+
|
|
15
|
+
export const CompleteFieldTypeToClsMapping = {
|
|
16
|
+
...FieldTypeToClsMapping,
|
|
17
|
+
section: FieldSection,
|
|
18
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ReactNode, useMemo } from "react"
|
|
2
|
+
|
|
3
|
+
import { FieldTypeToClsMapping } from "../fields/constants"
|
|
4
|
+
import { FieldTypeIdentifier } from "../typings"
|
|
5
|
+
import { fieldsToChoose } from "./constants"
|
|
6
|
+
|
|
7
|
+
export const useFieldTypeItems = (onSelect: (type: Exclude<FieldTypeIdentifier, "section">) => void = () => null) => {
|
|
8
|
+
return useMemo(() => {
|
|
9
|
+
return fieldsToChoose.map((fieldGroup) => {
|
|
10
|
+
return fieldGroup.map((identifier) => {
|
|
11
|
+
const field = FieldTypeToClsMapping[identifier]
|
|
12
|
+
const Icon = field.Icon as () => ReactNode
|
|
13
|
+
return {
|
|
14
|
+
children: field.fieldTypeName,
|
|
15
|
+
leftSlot: <Icon />,
|
|
16
|
+
value: identifier,
|
|
17
|
+
onSelect: () => {
|
|
18
|
+
onSelect(identifier)
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
}, [onSelect])
|
|
24
|
+
}
|