@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,386 @@
1
+ import {
2
+ Badge,
3
+ Button,
4
+ Checkbox,
5
+ IconButton,
6
+ Input,
7
+ Popover,
8
+ RiIcon,
9
+ Separator,
10
+ Text,
11
+ TextArea,
12
+ } from "@overmap-ai/blocks"
13
+ import { ISerializedOnlyField } from "@overmap-ai/core"
14
+ import { useFormikContext } from "formik"
15
+ import get from "lodash.get"
16
+ import { ChangeEvent, FC, memo, MouseEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"
17
+
18
+ import { SEVERITY_COLOR_MAPPING } from "../constants"
19
+ import { FullScreenImagePreview } from "../constantsJsx"
20
+ import { deserialize, FieldCreationSchemaObject, useFieldInput, useFieldInputs, valueIsFile } from "../fields"
21
+ import { BaseField, BaseFormElement, FieldSection } from "../fields"
22
+ import { PatchField } from "../renderer"
23
+ import { FieldTypeIdentifier, ISerializedField, SelectFieldOption, SerializedFieldSection } from "../typings"
24
+ import { hasKeys } from "../utils"
25
+ import { CompleteFieldTypeToClsMapping, formId } from "./constants"
26
+ import { useFieldTypeItems } from "./hooks"
27
+ import { FormikUserFormRevision, NestedFieldPath } from "./typings"
28
+ import { findFieldByIdentifier } from "./utils"
29
+
30
+ // Type guard to check if initial is a FieldSection. We access initial.conditional or initial.image a few times
31
+ // so the type guard lets TypeScript know that conditional or image exists on initial or not
32
+ const isSection = (field: ISerializedField): field is SerializedFieldSection => {
33
+ return field.type === "section"
34
+ }
35
+
36
+ interface FieldSettingsPopoverProps {
37
+ popoverInputs: ReactNode
38
+ hasError: boolean
39
+ }
40
+
41
+ const FieldSettingsPopover = memo((props: FieldSettingsPopoverProps) => {
42
+ const { popoverInputs, hasError } = props
43
+ return (
44
+ <Popover.Root>
45
+ <Popover.Trigger>
46
+ <Button
47
+ key="settings"
48
+ variant="soft"
49
+ size="sm"
50
+ aria-label="settings"
51
+ {...(hasError && { color: SEVERITY_COLOR_MAPPING.danger })}
52
+ >
53
+ <RiIcon icon="RiSettings2Line" />
54
+ <span>Settings</span>
55
+ </Button>
56
+ </Popover.Trigger>
57
+ <Popover.Content size="sm">
58
+ <div className="flex max-w-[240px] flex-col">{popoverInputs}</div>
59
+ </Popover.Content>
60
+ </Popover.Root>
61
+ )
62
+ })
63
+
64
+ FieldSettingsPopover.displayName = "FieldSettingsPopover"
65
+
66
+ export interface FieldBuilderProps {
67
+ index: number
68
+ parentPath: NestedFieldPath
69
+ initial: ISerializedField
70
+ conditionalSourceFields?: ISerializedField[]
71
+ }
72
+
73
+ export const FieldBuilder: FC<FieldBuilderProps> = memo((props: FieldBuilderProps) => {
74
+ const { parentPath, index, initial, conditionalSourceFields } = props
75
+ const { values, setFieldValue, errors } = useFormikContext<FormikUserFormRevision>()
76
+ const fieldTypeItems = useFieldTypeItems()
77
+
78
+ const containerRef = useRef<HTMLDivElement>(null)
79
+ // The min-width value is the same as $screen-sm-min for small tablets and large smartphones (landscape view) in _variables.sass of hemora-web
80
+ const RADIX_SM_MIN_WIDTH = 576 // px
81
+ const [isLargeContainer, setIsLargeContainer] = useState(
82
+ containerRef.current && containerRef.current.getBoundingClientRect().width >= RADIX_SM_MIN_WIDTH,
83
+ )
84
+
85
+ useEffect(() => {
86
+ const container = containerRef.current
87
+ if (container) {
88
+ const observer = new ResizeObserver((entries) => {
89
+ // There is only one entry since we only observe the container
90
+ if (entries[0]) {
91
+ setIsLargeContainer(entries[0].contentRect.width >= RADIX_SM_MIN_WIDTH)
92
+ }
93
+ })
94
+
95
+ observer.observe(container)
96
+
97
+ return () => {
98
+ observer.disconnect()
99
+ }
100
+ }
101
+ }, [])
102
+
103
+ const [resolvedImage, setResolvedImage] = useState<File | undefined>(undefined)
104
+ const [showImagePreview, setShowImagePreview] = useState<boolean>(false)
105
+
106
+ useEffect(() => {
107
+ // Images only exist on non-section fields
108
+ if (!isSection(initial)) {
109
+ if (initial.image instanceof Promise) {
110
+ initial.image.then(setResolvedImage).catch(console.error)
111
+ } else {
112
+ setResolvedImage(initial.image)
113
+ }
114
+ } else {
115
+ setResolvedImage(undefined)
116
+ }
117
+ }, [initial])
118
+
119
+ const resolvedImageURL = resolvedImage ? URL.createObjectURL(resolvedImage) : undefined
120
+
121
+ const handleImageDelete = useCallback(
122
+ (event: MouseEvent<HTMLButtonElement>) => {
123
+ event.stopPropagation()
124
+ // Remove image attribute from initial
125
+ const { image: _, ...fieldWithoutImage } = initial as ISerializedOnlyField
126
+ void setFieldValue(`${parentPath}.${index}`, fieldWithoutImage).then()
127
+ },
128
+ [index, initial, parentPath, setFieldValue],
129
+ )
130
+
131
+ // Set condition field of field section to null if conditional is set to false
132
+ useEffect(() => {
133
+ if (isSection(initial) && !initial.conditional) {
134
+ void setFieldValue(`${parentPath}.${index}.condition`, null).then()
135
+ }
136
+ }, [index, initial, parentPath, setFieldValue])
137
+
138
+ const conditionLabel = useMemo(
139
+ () =>
140
+ isSection(initial) ? findFieldByIdentifier(values.fields, initial.condition?.identifier)?.label : undefined,
141
+ [initial, values.fields],
142
+ )
143
+ const conditionComparison = isSection(initial)
144
+ ? Array.isArray(initial.condition?.value)
145
+ ? "contains all of"
146
+ : "equals"
147
+ : undefined
148
+
149
+ let conditionValue: string | undefined = undefined
150
+ if (isSection(initial)) {
151
+ if (valueIsFile(initial.condition?.value)) {
152
+ throw new Error("File values are not supported for conditions.")
153
+ }
154
+ conditionValue = Array.isArray(initial.condition?.value)
155
+ ? initial.condition.value
156
+ .map((v: string | SelectFieldOption) => (typeof v === "string" ? v : v.label))
157
+ .join(", ")
158
+ : initial.condition?.value?.toString()
159
+ }
160
+
161
+ // Needed for the growing input and text-areas to work. The functions are separated because TypeScript gives incompatible
162
+ // type errors when event is ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
163
+ const handleInputChangeForInput = useCallback((event: ChangeEvent<HTMLInputElement>) => {
164
+ if (event.target.parentNode instanceof HTMLElement) {
165
+ event.target.parentNode.dataset.replicatedValue = event.target.value
166
+ }
167
+ }, [])
168
+ const handleInputChangeForTextArea = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => {
169
+ if (event.target.parentNode instanceof HTMLElement) {
170
+ event.target.parentNode.dataset.replicatedValue = event.target.value
171
+ }
172
+ }, [])
173
+
174
+ const type = initial.type
175
+ const fieldCls = CompleteFieldTypeToClsMapping[type as FieldTypeIdentifier]
176
+ // TODO: TypeScript thinks directlyShownFields and popoverFields can be undefined even though that is untrue.
177
+ // Remove the many non-null assertion operators in this file used for directlyShownFields and popoverFields
178
+ const [directlyShownFields, popoverFields]: BaseFormElement[][] = useMemo<BaseFormElement[][]>(() => {
179
+ let directlyShownFields: BaseFormElement[] = []
180
+ let popoverFields: BaseFormElement[] = []
181
+ if (fieldCls === FieldSection) {
182
+ if (conditionalSourceFields === undefined) {
183
+ throw new Error("Conditional source fields must be provided when changing sections.")
184
+ }
185
+ const fieldObject = fieldCls.getFieldCreationSchema(conditionalSourceFields, `${parentPath}.${index}`)
186
+
187
+ directlyShownFields = directlyShownFields.concat(
188
+ fieldObject.filter((field) => field.showDirectly).map((field) => field.field),
189
+ )
190
+ popoverFields = popoverFields.concat(
191
+ fieldObject.filter((field) => !field.showDirectly).map((field) => field.field),
192
+ )
193
+ } else {
194
+ if (!(fieldCls.prototype instanceof BaseField)) {
195
+ throw new Error(`Field must be an instance of BaseField. Got ${fieldCls.toString()}.`)
196
+ }
197
+
198
+ // We now know that this is not a FieldSection. We need to tell TypeScript that.
199
+ type FieldClass = Exclude<typeof fieldCls, typeof FieldSection>
200
+ const fieldObject: FieldCreationSchemaObject[] = (fieldCls as FieldClass).getFieldCreationSchema(
201
+ `${parentPath}.${index}`,
202
+ )
203
+
204
+ if (isLargeContainer) {
205
+ directlyShownFields = [
206
+ ...directlyShownFields,
207
+ ...fieldObject.filter((field) => field.showDirectly).map((field) => field.field),
208
+ ]
209
+ popoverFields = [
210
+ ...popoverFields,
211
+ ...fieldObject.filter((field) => !field.showDirectly).map((field) => field.field),
212
+ ]
213
+ } else {
214
+ // Show everything as a popoverField
215
+ popoverFields = [...popoverFields, ...fieldObject.map((field) => field.field)]
216
+ }
217
+ }
218
+
219
+ return [directlyShownFields, popoverFields]
220
+ }, [fieldCls, conditionalSourceFields, parentPath, index, isLargeContainer])
221
+
222
+ const directlyShownInputs = useFieldInputs(directlyShownFields!, {
223
+ formId,
224
+ disabled: false,
225
+ internal: true,
226
+ ...(fieldCls === FieldSection && { size: "sm" }),
227
+ })
228
+ const popoverInputs = useFieldInputs(popoverFields!, {
229
+ formId,
230
+ disabled: false,
231
+ })
232
+
233
+ let showPopoverInputs = popoverFields!.length > 0
234
+ // For sections, don't show popoverInputs if its conditional is null or false since the popover would show an empty div
235
+ if (isSection(initial) && popoverFields!.length > 0) {
236
+ showPopoverInputs = initial.conditional
237
+ }
238
+
239
+ const popoverHasErrors = popoverFields!.some((field) => {
240
+ const error = get(errors, fieldCls === FieldSection ? `${parentPath}.${index}.condition` : field.getId()) as
241
+ | string
242
+ | object
243
+ return error && (typeof error !== "object" || hasKeys(error))
244
+ })
245
+ const color = popoverHasErrors ? SEVERITY_COLOR_MAPPING.danger : undefined
246
+
247
+ const deserializedField = useMemo(() => deserialize(initial), [initial])
248
+ const previewInput = useFieldInput(deserializedField, { formId, showInputOnly: false })
249
+
250
+ return (
251
+ <div className="flex grow items-center w-full" ref={containerRef}>
252
+ <div className="flex w-full flex-col gap-4">
253
+ {fieldCls === FieldSection && (
254
+ <div className="flex flex-col gap-2">
255
+ {directlyShownFields!.length > 0 && directlyShownInputs}
256
+ <div className="flex items-center gap-4">
257
+ {showPopoverInputs && (
258
+ <FieldSettingsPopover popoverInputs={popoverInputs} hasError={popoverHasErrors} />
259
+ )}
260
+ {isSection(initial) && initial.conditional && (
261
+ <span className="text-sm text-(--accent-a11)" data-accent-color={color}>
262
+ <em>
263
+ Display only if <strong>{conditionLabel}</strong> {conditionComparison}{" "}
264
+ <strong>{conditionValue}</strong>
265
+ </em>
266
+ </span>
267
+ )}
268
+ </div>
269
+ </div>
270
+ )}
271
+ {fieldCls !== FieldSection && (
272
+ <div className="flex gap-2 w-full justify-between">
273
+ <div className="flex gap-2 items-center">
274
+ <Badge className="w-fit" accentColor="base" variant="soft">
275
+ {fieldTypeItems.flat().find((item) => item.value === type)?.children}
276
+ </Badge>
277
+ <PatchField
278
+ name={`${parentPath}.${index}.required`}
279
+ render={({ setValue, value }) => (
280
+ <div className="flex items-center gap-2">
281
+ <Checkbox.Root
282
+ checked={value as boolean}
283
+ onCheckedChange={setValue}
284
+ variant="soft"
285
+ >
286
+ <Checkbox.Indicator>
287
+ <RiIcon icon="RiCheckLine" />
288
+ </Checkbox.Indicator>
289
+ </Checkbox.Root>
290
+ <Text size="sm" accentColor="base">
291
+ Required field
292
+ </Text>
293
+ </div>
294
+ )}
295
+ />
296
+ </div>
297
+ {showPopoverInputs && (
298
+ <FieldSettingsPopover popoverInputs={popoverInputs} hasError={popoverHasErrors} />
299
+ )}
300
+ </div>
301
+ )}
302
+ {resolvedImage && (
303
+ <>
304
+ <div className="group relative inline-block w-full min-w-[300px]">
305
+ <img
306
+ className="h-[100px] w-full min-w-[300px] cursor-pointer rounded-md object-cover"
307
+ src={resolvedImageURL}
308
+ alt={resolvedImage.name}
309
+ onClick={() => {
310
+ setShowImagePreview(true)
311
+ }}
312
+ />
313
+ <IconButton
314
+ className="absolute top-2 right-2 hidden group-hover:not-disabled:flex"
315
+ variant="solid"
316
+ accentColor={SEVERITY_COLOR_MAPPING.danger}
317
+ aria-label="delete"
318
+ onClick={handleImageDelete}
319
+ >
320
+ <RiIcon icon="RiDeleteBin2Line" />
321
+ </IconButton>
322
+ </div>
323
+ {showImagePreview && (
324
+ <FullScreenImagePreview
325
+ file={resolvedImage}
326
+ url={resolvedImageURL!}
327
+ name={resolvedImage.name}
328
+ setShowPreview={setShowImagePreview}
329
+ />
330
+ )}
331
+ </>
332
+ )}
333
+ <PatchField
334
+ name={`${parentPath}.${index}.label`}
335
+ render={({ setValue, value }) => (
336
+ <Input.Root size="md" variant="outline">
337
+ <Input.Field
338
+ placeholder={
339
+ type === "section" ? "Enter a section label (optional)" : "Enter your question"
340
+ }
341
+ value={value as string}
342
+ onChange={(event) => {
343
+ setValue(event.target.value)
344
+ }}
345
+ onInput={handleInputChangeForInput}
346
+ maxLength={200}
347
+ />
348
+ </Input.Root>
349
+ )}
350
+ />
351
+
352
+ <PatchField
353
+ name={`${parentPath}.${index}.description`}
354
+ render={({ setValue, value }) => (
355
+ <TextArea
356
+ className="field-sizing-content grow"
357
+ placeholder={`Enter a ${type === "section" ? "section" : "field"} description (optional)`}
358
+ value={value as string}
359
+ onChange={(event) => {
360
+ setValue(event.target.value)
361
+ }}
362
+ onInput={handleInputChangeForTextArea}
363
+ resize="none"
364
+ maxLength={1000}
365
+ variant="outline"
366
+ />
367
+ )}
368
+ />
369
+ {fieldCls !== FieldSection && directlyShownFields!.length > 0 && (
370
+ <div className="w-full">{directlyShownInputs}</div>
371
+ )}
372
+ {fieldCls !== FieldSection && (
373
+ <>
374
+ <Separator size="full" />
375
+ <div className="flex flex-col gap-2">
376
+ <Text accentColor="base">Field preview</Text>
377
+ <div>{previewInput}</div>
378
+ </div>
379
+ </>
380
+ )}
381
+ </div>
382
+ </div>
383
+ )
384
+ })
385
+
386
+ FieldBuilder.displayName = "FieldBuilder"
@@ -0,0 +1,260 @@
1
+ import { Draggable, Droppable } from "@hello-pangea/dnd"
2
+ import { Button, Card, Menu, RiIcon, useAlertDialog } from "@overmap-ai/blocks"
3
+ import { useFormikContext } from "formik"
4
+ import { memo, useCallback, useMemo } from "react"
5
+
6
+ import { FieldTypeToEmptyFieldMapping } from "../fields/constants"
7
+ import { FieldTypeIdentifier, SerializedFieldSection } from "../typings"
8
+ import { DropState } from "./DropDispatch"
9
+ import { FieldActions } from "./FieldActions"
10
+ import { FieldBuilder, FieldBuilderProps } from "./FieldBuilder"
11
+ import { FieldWithActions } from "./FieldWithActions"
12
+ import { useFieldTypeItems } from "./hooks"
13
+ import { FormikUserFormRevision } from "./typings"
14
+ import {
15
+ createNewField,
16
+ makeConditionalSourceFields,
17
+ makeIdentifier,
18
+ NewFieldInitialValues,
19
+ remove,
20
+ useFieldReordering,
21
+ } from "./utils"
22
+
23
+ interface FieldSectionWithActionsProps {
24
+ field: SerializedFieldSection
25
+ index: number
26
+ dropState: DropState
27
+ fieldsOnly: boolean
28
+ }
29
+
30
+ export const FieldSectionWithActions = memo((props: FieldSectionWithActionsProps) => {
31
+ const { field, index: sectionIndex, dropState, fieldsOnly } = props
32
+ const isDropDisabled = dropState[field.identifier]?.disabled
33
+ const { setFieldValue, values } = useFormikContext<FormikUserFormRevision>()
34
+ const alertDialog = useAlertDialog()
35
+ const { reorderSection } = useFieldReordering()
36
+
37
+ const removeSectionConditions = useCallback(
38
+ (sectionsToUpdate: SerializedFieldSection[], allSections: SerializedFieldSection[]) => {
39
+ for (const section of sectionsToUpdate) {
40
+ const sectionIndex = allSections.indexOf(section)
41
+ void setFieldValue(`fields.${sectionIndex}.condition`, null).then()
42
+ void setFieldValue(`fields.${sectionIndex}.conditional`, false).then()
43
+ }
44
+ },
45
+ [setFieldValue],
46
+ )
47
+
48
+ const makeRemoveFieldAction = useCallback(
49
+ (fieldIndex: number) => {
50
+ const removing = field.fields[fieldIndex]
51
+ if (!removing) throw new Error("Could not find field to remove.")
52
+
53
+ // check if field is being used as a condition
54
+ const sectionsWithMatchingCondition: SerializedFieldSection[] = []
55
+ for (const section of values.fields) {
56
+ if (section.condition?.identifier === removing.identifier) {
57
+ sectionsWithMatchingCondition.push(section)
58
+ }
59
+ }
60
+
61
+ return {
62
+ removing,
63
+ affectedSections: sectionsWithMatchingCondition,
64
+ action: () => setFieldValue(`fields.${sectionIndex}.fields`, remove(field.fields, fieldIndex)),
65
+ }
66
+ },
67
+ [field.fields, values.fields, setFieldValue, sectionIndex],
68
+ )
69
+
70
+ const removeField = useCallback(
71
+ (i: number) => {
72
+ const { affectedSections, action, removing } = makeRemoveFieldAction(i)
73
+
74
+ const cmd = () => {
75
+ void action().then()
76
+ removeSectionConditions(affectedSections, values.fields)
77
+ }
78
+
79
+ if (affectedSections.length > 0) {
80
+ const labels = affectedSections.map((section) => section.label).join(", ")
81
+ alertDialog({
82
+ title: "Remove condition?",
83
+ description: `${removing.label} is being used as a condition, deleting it will remove the condition from the ${labels} section(s).`,
84
+ action: "Remove",
85
+ onAction: cmd,
86
+ })
87
+ return
88
+ }
89
+
90
+ // not a condition, remove field
91
+ cmd()
92
+ },
93
+ [makeRemoveFieldAction, removeSectionConditions, values.fields, alertDialog],
94
+ )
95
+
96
+ const removeSection = useCallback(() => {
97
+ const fieldSideEffects = field.fields.map((_, i) => makeRemoveFieldAction(i))
98
+ const affectedSections = fieldSideEffects.flatMap((sideEffect) => sideEffect.affectedSections)
99
+
100
+ const title = affectedSections.length ? "Remove fields and conditions?" : "Remove fields?"
101
+ const numFields = field.fields.length
102
+ const sectionLabels = affectedSections.map((section) => section.label).join(", ")
103
+ const description = affectedSections.length
104
+ ? `Deleting this section will remove the ${numFields} field(s) it contains and will remove the conditions from following sections: ${sectionLabels}`
105
+ : `Deleting this section will remove the ${numFields} field(s) it contains.`
106
+
107
+ const updatedSections = remove(values.fields, sectionIndex)
108
+ const cmd = () => setFieldValue("fields", updatedSections)
109
+
110
+ if (affectedSections.length > 0) {
111
+ alertDialog({
112
+ title,
113
+ description,
114
+ action: "Remove",
115
+ onAction: () => {
116
+ // remove the section (and fields)
117
+ void cmd().then(() => {
118
+ // remove conditions from affected sections
119
+ removeSectionConditions(affectedSections, updatedSections)
120
+ })
121
+ },
122
+ })
123
+ return
124
+ }
125
+
126
+ // if no side effects for other sections, just remove the section
127
+ void cmd().then()
128
+ }, [
129
+ field.fields,
130
+ values.fields,
131
+ sectionIndex,
132
+ makeRemoveFieldAction,
133
+ setFieldValue,
134
+ alertDialog,
135
+ removeSectionConditions,
136
+ ])
137
+
138
+ const moveSection = useCallback(
139
+ (direction: "up" | "down") => {
140
+ const destinationIndex = direction === "up" ? sectionIndex - 1 : sectionIndex + 1
141
+ reorderSection(dropState, field.identifier, sectionIndex, destinationIndex, values, setFieldValue)
142
+ },
143
+ [sectionIndex, reorderSection, dropState, field.identifier, values, setFieldValue],
144
+ )
145
+
146
+ const editSectionProps: FieldBuilderProps = useMemo(
147
+ () => ({
148
+ index: sectionIndex,
149
+ parentPath: "fields",
150
+ initial: field,
151
+ conditionalSourceFields: makeConditionalSourceFields(values.fields, sectionIndex),
152
+ }),
153
+ [field, sectionIndex, values.fields],
154
+ )
155
+
156
+ const duplicateSection = useCallback(() => {
157
+ const fieldLabel = field.label ?? "Untitled section"
158
+ const newFields = field.fields.map((oldField) => {
159
+ // Make new identifiers now as they will not be made later
160
+ return {
161
+ ...oldField,
162
+ identifier: makeIdentifier(null, oldField.label),
163
+ }
164
+ })
165
+
166
+ const duplicatedField = { ...field, label: fieldLabel, fields: newFields }
167
+ createNewField("fields", sectionIndex + 1, duplicatedField, values, setFieldValue)
168
+ }, [field, sectionIndex, values, setFieldValue])
169
+
170
+ const handleCreateField = useCallback(
171
+ (type: Exclude<FieldTypeIdentifier, "section">) => {
172
+ createNewField(
173
+ `fields.${sectionIndex}.fields`,
174
+ field.fields.length,
175
+ FieldTypeToEmptyFieldMapping[type] as NewFieldInitialValues,
176
+ values,
177
+ setFieldValue,
178
+ )
179
+ },
180
+ [sectionIndex, field.fields.length, values, setFieldValue],
181
+ )
182
+
183
+ const fieldTypeItems = useFieldTypeItems(handleCreateField)
184
+
185
+ return (
186
+ <Draggable draggableId={field.identifier} index={sectionIndex}>
187
+ {(draggableProvided) => {
188
+ return (
189
+ <Card
190
+ // using margin bottom instead of flex gap to avoid a bug where the
191
+ // gap is not applied to dragged elements
192
+ ref={draggableProvided.innerRef}
193
+ {...draggableProvided.draggableProps}
194
+ {...draggableProvided.dragHandleProps}
195
+ variant="outline"
196
+ className="mb-4 w-full"
197
+ >
198
+ <div className="flex items-center justify-between gap-3 w-full">
199
+ <div className="flex grow flex-col gap-2 w-full">
200
+ {!fieldsOnly && <FieldBuilder {...editSectionProps} />}
201
+ <Droppable
202
+ droppableId={field.identifier}
203
+ type="SECTION"
204
+ isDropDisabled={isDropDisabled}
205
+ >
206
+ {(droppableProvided) => (
207
+ <div
208
+ className="flex flex-col gap-0 w-full"
209
+ ref={droppableProvided.innerRef}
210
+ {...droppableProvided.droppableProps}
211
+ >
212
+ {field.fields.map((child, i) => (
213
+ <FieldWithActions
214
+ key={child.identifier}
215
+ field={child}
216
+ index={i}
217
+ sectionIndex={sectionIndex}
218
+ remove={() => {
219
+ removeField(i)
220
+ }}
221
+ />
222
+ ))}
223
+ {droppableProvided.placeholder}
224
+ <Menu.Root>
225
+ <Menu.ClickTrigger>
226
+ <Button type="button" variant="solid">
227
+ <RiIcon icon="RiAddLine" /> Add field
228
+ </Button>
229
+ </Menu.ClickTrigger>
230
+ <Menu.Content>
231
+ {fieldTypeItems.flat().map((item) => (
232
+ <Menu.Item key={item.value} onSelect={item.onSelect}>
233
+ {item.leftSlot}
234
+ {item.children}
235
+ </Menu.Item>
236
+ ))}
237
+ </Menu.Content>
238
+ </Menu.Root>
239
+ </div>
240
+ )}
241
+ </Droppable>
242
+ </div>
243
+ {!fieldsOnly && (
244
+ <FieldActions
245
+ index={sectionIndex}
246
+ type={field.type}
247
+ remove={removeSection}
248
+ duplicate={duplicateSection}
249
+ move={moveSection}
250
+ />
251
+ )}
252
+ </div>
253
+ </Card>
254
+ )
255
+ }}
256
+ </Draggable>
257
+ )
258
+ })
259
+
260
+ FieldSectionWithActions.displayName = "FieldSectionWithActions"