@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,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"
|