@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.
Files changed (130) hide show
  1. package/.husky/pre-commit +6 -0
  2. package/.prettierrc.json +10 -0
  3. package/.storybook/StoryDecorator.tsx +22 -0
  4. package/.storybook/main.ts +20 -0
  5. package/.storybook/palettes/green.css +66 -0
  6. package/.storybook/palettes/red.css +66 -0
  7. package/.storybook/preview.css +39 -0
  8. package/.storybook/preview.tsx +31 -0
  9. package/.storybook/tailwind-theme/accentPalette.css +181 -0
  10. package/.storybook/tailwind-theme/backgrounds.css +11 -0
  11. package/.storybook/tailwind-theme/basePalette.css +178 -0
  12. package/dev/publish-alpha.sh +13 -0
  13. package/dev/publish-patch.sh +3 -0
  14. package/eslint.config.js +56 -0
  15. package/package.json +93 -0
  16. package/src/ColorPicker/ColorPicker.tsx +47 -0
  17. package/src/ColorPicker/index.ts +1 -0
  18. package/src/FileBadge/FileBadge.tsx +27 -0
  19. package/src/FileBadge/index.ts +1 -0
  20. package/src/FileCard/FileCard.stories.tsx +69 -0
  21. package/src/FileCard/FileCard.tsx +53 -0
  22. package/src/FileCard/index.ts +1 -0
  23. package/src/FileIcon/FileIcon.tsx +31 -0
  24. package/src/FileIcon/index.ts +1 -0
  25. package/src/FileViewer/FileViewerProvider.stories.tsx +50 -0
  26. package/src/FileViewer/FileViewerProvider.tsx +72 -0
  27. package/src/FileViewer/context.ts +11 -0
  28. package/src/FileViewer/index.ts +3 -0
  29. package/src/FileViewer/typings.ts +5 -0
  30. package/src/ImageCard/ImageCard.stories.tsx +94 -0
  31. package/src/ImageCard/ImageCard.tsx +82 -0
  32. package/src/ImageCard/index.ts +1 -0
  33. package/src/ImageMarkup/ImageMarkup.stories.tsx +65 -0
  34. package/src/ImageMarkup/ImageMarkup.tsx +268 -0
  35. package/src/ImageMarkup/index.ts +1 -0
  36. package/src/ImageViewer/ImageViewer.stories.tsx +57 -0
  37. package/src/ImageViewer/ImageViewer.tsx +124 -0
  38. package/src/ImageViewer/constants.ts +1 -0
  39. package/src/ImageViewer/index.ts +2 -0
  40. package/src/PDFViewer/PDFViewer.stories.tsx +55 -0
  41. package/src/PDFViewer/PDFViewer.tsx +170 -0
  42. package/src/PDFViewer/constants.ts +1 -0
  43. package/src/PDFViewer/index.ts +2 -0
  44. package/src/SpreadsheetViewer/SpreadsheetViewer.stories.tsx +55 -0
  45. package/src/SpreadsheetViewer/SpreadsheetViewer.tsx +162 -0
  46. package/src/SpreadsheetViewer/constants.ts +8 -0
  47. package/src/SpreadsheetViewer/index.ts +2 -0
  48. package/src/forms/builder/DropDispatch.ts +84 -0
  49. package/src/forms/builder/FieldActions.tsx +155 -0
  50. package/src/forms/builder/FieldBuilder.tsx +386 -0
  51. package/src/forms/builder/FieldSectionWithActions.tsx +260 -0
  52. package/src/forms/builder/FieldWithActions.tsx +129 -0
  53. package/src/forms/builder/FieldsEditor.tsx +180 -0
  54. package/src/forms/builder/FormBuilder.stories.tsx +105 -0
  55. package/src/forms/builder/FormBuilder.tsx +237 -0
  56. package/src/forms/builder/constants.ts +18 -0
  57. package/src/forms/builder/hooks.tsx +24 -0
  58. package/src/forms/builder/index.ts +2 -0
  59. package/src/forms/builder/typings.ts +18 -0
  60. package/src/forms/builder/utils.ts +229 -0
  61. package/src/forms/constants.ts +9 -0
  62. package/src/forms/constantsJsx.tsx +67 -0
  63. package/src/forms/fields/BaseField/BaseField.ts +152 -0
  64. package/src/forms/fields/BaseField/hooks.tsx +60 -0
  65. package/src/forms/fields/BaseField/index.ts +4 -0
  66. package/src/forms/fields/BaseField/layouts.tsx +100 -0
  67. package/src/forms/fields/BaseField/typings.ts +9 -0
  68. package/src/forms/fields/BooleanField/BooleanField.tsx +48 -0
  69. package/src/forms/fields/BooleanField/BooleanInput.tsx +54 -0
  70. package/src/forms/fields/BooleanField/index.ts +2 -0
  71. package/src/forms/fields/CustomField/CustomField.tsx +45 -0
  72. package/src/forms/fields/CustomField/FieldInputClonerField/FieldInputCloner.tsx +25 -0
  73. package/src/forms/fields/CustomField/FieldInputClonerField/FieldInputClonerField.tsx +26 -0
  74. package/src/forms/fields/CustomField/FieldInputClonerField/index.ts +3 -0
  75. package/src/forms/fields/CustomField/FieldInputClonerField/typings.ts +8 -0
  76. package/src/forms/fields/CustomField/index.ts +1 -0
  77. package/src/forms/fields/DateField/DateField.tsx +42 -0
  78. package/src/forms/fields/DateField/DateInput.tsx +39 -0
  79. package/src/forms/fields/DateField/index.ts +2 -0
  80. package/src/forms/fields/FieldSection/FieldSection.tsx +173 -0
  81. package/src/forms/fields/FieldSection/FieldSectionLayout.tsx +56 -0
  82. package/src/forms/fields/FieldSection/index.ts +1 -0
  83. package/src/forms/fields/MultiStringField/MultiStringField.tsx +90 -0
  84. package/src/forms/fields/MultiStringField/MultiStringInput.tsx +207 -0
  85. package/src/forms/fields/MultiStringField/index.ts +2 -0
  86. package/src/forms/fields/NumberField/NumberField.tsx +173 -0
  87. package/src/forms/fields/NumberField/NumberInput.tsx +44 -0
  88. package/src/forms/fields/NumberField/index.ts +2 -0
  89. package/src/forms/fields/QrField/QrField.tsx +38 -0
  90. package/src/forms/fields/QrField/QrInput.module.sass +5 -0
  91. package/src/forms/fields/QrField/QrInput.tsx +144 -0
  92. package/src/forms/fields/QrField/index.ts +2 -0
  93. package/src/forms/fields/SelectField/BaseSelectField.ts +73 -0
  94. package/src/forms/fields/SelectField/MultiSelectField.tsx +53 -0
  95. package/src/forms/fields/SelectField/MultiSelectInput.tsx +80 -0
  96. package/src/forms/fields/SelectField/SelectField.tsx +49 -0
  97. package/src/forms/fields/SelectField/SelectInput.tsx +69 -0
  98. package/src/forms/fields/SelectField/index.ts +4 -0
  99. package/src/forms/fields/StringOrTextFields/StringField/StringField.tsx +61 -0
  100. package/src/forms/fields/StringOrTextFields/StringField/StringInput.tsx +41 -0
  101. package/src/forms/fields/StringOrTextFields/StringField/index.ts +2 -0
  102. package/src/forms/fields/StringOrTextFields/StringOrTextField.ts +143 -0
  103. package/src/forms/fields/StringOrTextFields/TextField/TextField.tsx +52 -0
  104. package/src/forms/fields/StringOrTextFields/TextField/TextInput.tsx +42 -0
  105. package/src/forms/fields/StringOrTextFields/TextField/index.ts +2 -0
  106. package/src/forms/fields/StringOrTextFields/index.ts +2 -0
  107. package/src/forms/fields/UploadField/UploadField.tsx +156 -0
  108. package/src/forms/fields/UploadField/UploadInput.tsx +220 -0
  109. package/src/forms/fields/UploadField/index.ts +2 -0
  110. package/src/forms/fields/UploadField/utils.ts +17 -0
  111. package/src/forms/fields/constants.ts +43 -0
  112. package/src/forms/fields/hooks.tsx +26 -0
  113. package/src/forms/fields/index.ts +12 -0
  114. package/src/forms/fields/typings.ts +45 -0
  115. package/src/forms/fields/utils.ts +125 -0
  116. package/src/forms/index.ts +5 -0
  117. package/src/forms/renderer/FormRenderer/FormRenderer.stories.tsx +142 -0
  118. package/src/forms/renderer/FormRenderer/FormRenderer.tsx +135 -0
  119. package/src/forms/renderer/PatchForm/Field.tsx +41 -0
  120. package/src/forms/renderer/PatchForm/PatchForm.stories.tsx +91 -0
  121. package/src/forms/renderer/PatchForm/Provider.tsx +119 -0
  122. package/src/forms/renderer/PatchForm/index.ts +2 -0
  123. package/src/forms/renderer/index.ts +2 -0
  124. package/src/forms/typings.ts +162 -0
  125. package/src/forms/utils.ts +69 -0
  126. package/src/index.ts +11 -0
  127. package/src/vite-env.d.ts +1 -0
  128. package/tailwind.config.ts +8 -0
  129. package/tsconfig.json +26 -0
  130. 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
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./FormBuilder"
2
+ export * from "./typings"