@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,18 @@
|
|
|
1
|
+
import { UserFormRevision } from "@overmap-ai/core"
|
|
2
|
+
|
|
3
|
+
import { SerializedFieldSection } from "../typings"
|
|
4
|
+
|
|
5
|
+
export type NewForm = Pick<UserFormRevision<SerializedFieldSection>, "description" | "title" | "fields">
|
|
6
|
+
|
|
7
|
+
export type FormBuilderSaveHandler = (form: NewForm | UserFormRevision) => void
|
|
8
|
+
|
|
9
|
+
/** The `FormBuilder` can either be used to edit an existing form or create a new one.
|
|
10
|
+
*
|
|
11
|
+
* The only difference between the two is that new forms do not have `id` attributes.
|
|
12
|
+
*/
|
|
13
|
+
export type FormikUserFormRevision = UserFormRevision<SerializedFieldSection> | NewForm
|
|
14
|
+
|
|
15
|
+
// parentPath + "." + index = field path
|
|
16
|
+
/** Using `lodash.get` and `lodash.set`, we can set and get attributes of nested objects using a string path. This is
|
|
17
|
+
used to get and set attributes of the `fields` of a `SerializedFieldSection` object. */
|
|
18
|
+
export type NestedFieldPath = "fields" | `fields.${number}.fields`
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { useToast } from "@overmap-ai/blocks"
|
|
2
|
+
import { ISerializedOnlyField, UserFormRevision } from "@overmap-ai/core"
|
|
3
|
+
import { slugify } from "@overmap-ai/core"
|
|
4
|
+
import { FormikHelpers } from "formik"
|
|
5
|
+
import get from "lodash.get"
|
|
6
|
+
import { useCallback } from "react"
|
|
7
|
+
|
|
8
|
+
import { ISerializedField, SerializedFieldSection } from "../typings"
|
|
9
|
+
import { DropState } from "./DropDispatch"
|
|
10
|
+
import { FormikUserFormRevision, NestedFieldPath } from "./typings"
|
|
11
|
+
|
|
12
|
+
export const emptySection = (id = "", fields = [] as ISerializedOnlyField[]): SerializedFieldSection => ({
|
|
13
|
+
type: "section",
|
|
14
|
+
fields,
|
|
15
|
+
identifier: id,
|
|
16
|
+
label: null,
|
|
17
|
+
condition: null,
|
|
18
|
+
conditional: false,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export const wrapRootFieldsWithFieldSection = (
|
|
22
|
+
revision?: UserFormRevision,
|
|
23
|
+
): UserFormRevision<SerializedFieldSection> | undefined => {
|
|
24
|
+
if (!revision) return undefined
|
|
25
|
+
const fields = revision.fields
|
|
26
|
+
let pending: ISerializedOnlyField[] = []
|
|
27
|
+
const sections: SerializedFieldSection[] = []
|
|
28
|
+
|
|
29
|
+
for (const field of fields) {
|
|
30
|
+
if (field.type === "section") {
|
|
31
|
+
if (pending.length > 0) {
|
|
32
|
+
sections.push(emptySection(`AUTO_section-${fields.indexOf(field)}`, pending))
|
|
33
|
+
pending = []
|
|
34
|
+
}
|
|
35
|
+
sections.push(field)
|
|
36
|
+
} else {
|
|
37
|
+
pending.push(field)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (pending.length > 0) {
|
|
41
|
+
sections.push(emptySection("AUTO_section-last", pending))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ensure the description isn't null
|
|
45
|
+
return { ...revision, fields: sections, description: revision.description ?? "" }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function reorder<T>(list: T[], source: number, destination: number): T[] {
|
|
49
|
+
const result = Array.from(list)
|
|
50
|
+
const [removed] = result.splice(source, 1)
|
|
51
|
+
|
|
52
|
+
if (!removed) throw new Error("Could not find field to reorder.")
|
|
53
|
+
result.splice(destination, 0, removed)
|
|
54
|
+
|
|
55
|
+
return result
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function replace<T>(list: T[], index: number, value: T): T[] {
|
|
59
|
+
const result = Array.from(list)
|
|
60
|
+
result[index] = value
|
|
61
|
+
return result
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function insert<T>(list: T[] | undefined, index: number, value: T): T[] {
|
|
65
|
+
const result = Array.from(list ?? [])
|
|
66
|
+
result.splice(index, 0, value)
|
|
67
|
+
return result
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function remove<T>(list: T[], index: number): T[] {
|
|
71
|
+
const result = Array.from(list)
|
|
72
|
+
result.splice(index, 1)
|
|
73
|
+
return result
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const makeIdentifier = (existing: unknown, label: string): string => {
|
|
77
|
+
if (typeof existing === "string" && existing.length > 0) return existing
|
|
78
|
+
|
|
79
|
+
// add a timestamp to the end of the slug to make it unique
|
|
80
|
+
const now = new Date()
|
|
81
|
+
return `${slugify(label)}-${now.getTime()}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const findFieldByIdentifier = (fields: ISerializedField[], identifier?: string): ISerializedField | null => {
|
|
85
|
+
if (!identifier) return null
|
|
86
|
+
for (const field of fields) {
|
|
87
|
+
if (field.type === "section") {
|
|
88
|
+
const result = findFieldByIdentifier(field.fields, identifier)
|
|
89
|
+
if (result) return result
|
|
90
|
+
} else if (field.identifier === identifier) {
|
|
91
|
+
return field
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const makeConditionalSourceFields = (sections: SerializedFieldSection[], index: number) => {
|
|
98
|
+
return sections.filter((_, i) => i < index).flatMap((field) => field.fields)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type NewFieldInitialValues = Omit<ISerializedField, "identifier"> & {
|
|
102
|
+
label: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const createNewField = (
|
|
106
|
+
parentPath: NestedFieldPath,
|
|
107
|
+
index: number,
|
|
108
|
+
initialValues: NewFieldInitialValues,
|
|
109
|
+
values: FormikUserFormRevision,
|
|
110
|
+
setFieldValue: FormikHelpers<unknown>["setFieldValue"],
|
|
111
|
+
) => {
|
|
112
|
+
const { label } = initialValues
|
|
113
|
+
const newField = {
|
|
114
|
+
...initialValues,
|
|
115
|
+
identifier: makeIdentifier(null, label),
|
|
116
|
+
} as ISerializedField
|
|
117
|
+
|
|
118
|
+
const parent = get(values, parentPath) as SerializedFieldSection[] | undefined
|
|
119
|
+
if (parent === undefined) {
|
|
120
|
+
throw new Error("Parent path must point to an existing field.")
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!Array.isArray(parent)) {
|
|
124
|
+
throw new Error("Parent path must point to an array.")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const updatedFields = insert(parent, index, newField)
|
|
128
|
+
void setFieldValue(parentPath, updatedFields).then()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const createNewEmptySection = (
|
|
132
|
+
index: number,
|
|
133
|
+
values: FormikUserFormRevision,
|
|
134
|
+
setFieldValue: FormikHelpers<unknown>["setFieldValue"],
|
|
135
|
+
) => {
|
|
136
|
+
const initialValues = {
|
|
137
|
+
...emptySection(),
|
|
138
|
+
label: "",
|
|
139
|
+
}
|
|
140
|
+
createNewField("fields", index, initialValues, values, setFieldValue)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Custom hook that returns a function which attempts to reorder a section through drag and drop or arrow buttons.
|
|
144
|
+
export const useFieldReordering = () => {
|
|
145
|
+
const { showError } = useToast()
|
|
146
|
+
const reorderSection = useCallback(
|
|
147
|
+
(
|
|
148
|
+
dropState: DropState,
|
|
149
|
+
sectionId: string,
|
|
150
|
+
sectionIndex: number,
|
|
151
|
+
destinationIndex: number,
|
|
152
|
+
values: FormikUserFormRevision,
|
|
153
|
+
setFieldValue: FormikHelpers<unknown>["setFieldValue"],
|
|
154
|
+
) => {
|
|
155
|
+
const state = dropState[sectionId]
|
|
156
|
+
if (!state) throw new Error("Could not find section context.")
|
|
157
|
+
|
|
158
|
+
let dest =
|
|
159
|
+
typeof state.conditionIndex !== "undefined"
|
|
160
|
+
? // cannot move a section with a condition before the condition's field
|
|
161
|
+
Math.max(state.conditionIndex + 1, destinationIndex)
|
|
162
|
+
: destinationIndex
|
|
163
|
+
|
|
164
|
+
// check if this section contains another section's condition
|
|
165
|
+
for (const section of Object.values(dropState)) {
|
|
166
|
+
if (section.conditionIndex === sectionIndex) {
|
|
167
|
+
// ensure condition field is above the section referencing it
|
|
168
|
+
dest = Math.min(dest, section.index - 1)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// A section with conditions is above the fields it references
|
|
173
|
+
if (dest !== destinationIndex) {
|
|
174
|
+
showError({
|
|
175
|
+
title: "Could not reorder sections",
|
|
176
|
+
description: "Sections with conditions must be below the fields they reference.",
|
|
177
|
+
})
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Good to reorder
|
|
182
|
+
void setFieldValue("fields", reorder(values.fields, sectionIndex, dest))
|
|
183
|
+
},
|
|
184
|
+
[showError],
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
const reorderField = useCallback(
|
|
188
|
+
(
|
|
189
|
+
srcSection: SerializedFieldSection | undefined,
|
|
190
|
+
srcSectionIndex: string | number | undefined,
|
|
191
|
+
srcFieldIndex: number,
|
|
192
|
+
destSection: SerializedFieldSection | undefined,
|
|
193
|
+
destSectionIndex: string | number | undefined,
|
|
194
|
+
destFieldIndex: number,
|
|
195
|
+
setFieldValue: FormikHelpers<unknown>["setFieldValue"],
|
|
196
|
+
) => {
|
|
197
|
+
if (!srcSection?.fields || !destSection) throw new Error("Could not find section with fields.")
|
|
198
|
+
|
|
199
|
+
if (srcSection.identifier === destSection.identifier) {
|
|
200
|
+
void setFieldValue(
|
|
201
|
+
`fields.${srcSectionIndex}.fields`,
|
|
202
|
+
reorder(srcSection.fields, srcFieldIndex, destFieldIndex),
|
|
203
|
+
).then()
|
|
204
|
+
} else {
|
|
205
|
+
const removed = srcSection.fields[srcFieldIndex]
|
|
206
|
+
if (!removed) throw new Error("Could not find field to reorder.")
|
|
207
|
+
|
|
208
|
+
// Check if the field is being used as a condition in its new section
|
|
209
|
+
if (destSection.condition?.identifier === removed.identifier) {
|
|
210
|
+
showError({
|
|
211
|
+
title: "Could not reorder field",
|
|
212
|
+
description: "Field must be above the section whose condition references it.",
|
|
213
|
+
})
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Good to reorder
|
|
218
|
+
void setFieldValue(`fields.${srcSectionIndex}.fields`, remove(srcSection.fields, srcFieldIndex)).then()
|
|
219
|
+
void setFieldValue(
|
|
220
|
+
`fields.${destSectionIndex}.fields`,
|
|
221
|
+
insert(destSection.fields, destFieldIndex, removed),
|
|
222
|
+
).then()
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
[showError],
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
return { reorderSection, reorderField }
|
|
229
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const SHORT_TEXT_FIELD_MAX_LENGTH = 500
|
|
2
|
+
export const LONG_TEXT_FIELD_MAX_LENGTH = 10000
|
|
3
|
+
|
|
4
|
+
export const SEVERITY_COLOR_MAPPING: Record<"danger" | "warning" | "info" | "success", string> = {
|
|
5
|
+
danger: "danger",
|
|
6
|
+
warning: "warning",
|
|
7
|
+
info: "base",
|
|
8
|
+
success: "success",
|
|
9
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { IconButton, RiIcon } from "@overmap-ai/blocks"
|
|
2
|
+
import saveAs from "file-saver"
|
|
3
|
+
import { Dispatch, memo, MouseEvent, SetStateAction, useCallback } from "react"
|
|
4
|
+
|
|
5
|
+
interface FullScreenImagePreviewProps {
|
|
6
|
+
file: File
|
|
7
|
+
url: string
|
|
8
|
+
name: string
|
|
9
|
+
setShowPreview: Dispatch<SetStateAction<boolean>>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const FullScreenImagePreview = memo((props: FullScreenImagePreviewProps) => {
|
|
13
|
+
const { file, url, name, setShowPreview } = props
|
|
14
|
+
|
|
15
|
+
const handleDownload = useCallback(
|
|
16
|
+
(event: MouseEvent<HTMLButtonElement>) => {
|
|
17
|
+
event.stopPropagation()
|
|
18
|
+
const blob = new Blob([file])
|
|
19
|
+
saveAs(blob, name)
|
|
20
|
+
},
|
|
21
|
+
[name, file],
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<>
|
|
26
|
+
<button
|
|
27
|
+
className="fixed top-0 left-0 z-[5000] h-full w-full bg-[rgba(0,0,0,0.85)] bg-black"
|
|
28
|
+
type="button"
|
|
29
|
+
onClick={() => {
|
|
30
|
+
setShowPreview(false)
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
<img
|
|
34
|
+
className="absolute top-[50%] left-[50%] z-[5001] max-h-[calc(100%-120px)] max-w-svw translate-x-[-50%] translate-y-[50%] object-contain"
|
|
35
|
+
src={url}
|
|
36
|
+
alt={name}
|
|
37
|
+
onClick={(e) => {
|
|
38
|
+
e.stopPropagation()
|
|
39
|
+
}}
|
|
40
|
+
/>
|
|
41
|
+
</button>
|
|
42
|
+
<div className="fixed top-0 left-0 z-[5001] flex w-full items-center bg-(--color-background)">
|
|
43
|
+
<IconButton
|
|
44
|
+
className="min-w-[50px]"
|
|
45
|
+
variant="soft"
|
|
46
|
+
aria-label="Exit preview"
|
|
47
|
+
onClick={() => {
|
|
48
|
+
setShowPreview(false)
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
<RiIcon icon="RiArrowLeftLine" />
|
|
52
|
+
</IconButton>
|
|
53
|
+
<span className="grow text-center">{name}</span>
|
|
54
|
+
<IconButton
|
|
55
|
+
className="min-w-[50px]"
|
|
56
|
+
variant="soft"
|
|
57
|
+
aria-label={`Download ${name}`}
|
|
58
|
+
onClick={handleDownload}
|
|
59
|
+
>
|
|
60
|
+
<RiIcon icon="RiDownload2Line" />
|
|
61
|
+
</IconButton>
|
|
62
|
+
</div>
|
|
63
|
+
</>
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
FullScreenImagePreview.displayName = "FullScreenImagePreview"
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { ISerializedOnlyField } from "@overmap-ai/core"
|
|
2
|
+
import { ChangeEvent, ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
import { FormikUserFormRevision } from "../../builder"
|
|
5
|
+
import {
|
|
6
|
+
BaseSerializedField,
|
|
7
|
+
BaseSerializedObject,
|
|
8
|
+
FieldTypeIdentifier,
|
|
9
|
+
FieldValue,
|
|
10
|
+
Form,
|
|
11
|
+
ISerializedField,
|
|
12
|
+
} from "../../typings"
|
|
13
|
+
import { FieldSection } from "../FieldSection"
|
|
14
|
+
import { AnyField, GetInputProps, InputFieldLevelValidator, InputFormLevelValidator } from "../typings"
|
|
15
|
+
import { FieldOptions } from "./typings"
|
|
16
|
+
|
|
17
|
+
// TODO: These types redefine ISerializedField and related types.
|
|
18
|
+
// Looks like they can be merged.
|
|
19
|
+
|
|
20
|
+
// TODO: "Element" implies an instantiated component in React.
|
|
21
|
+
export abstract class BaseFormElement<TIdentifier extends FieldTypeIdentifier = FieldTypeIdentifier> {
|
|
22
|
+
public readonly type: TIdentifier
|
|
23
|
+
readonly identifier: string
|
|
24
|
+
public readonly description: string | null
|
|
25
|
+
|
|
26
|
+
protected constructor(options: BaseSerializedObject) {
|
|
27
|
+
const { description = null, identifier, type } = options
|
|
28
|
+
this.identifier = identifier
|
|
29
|
+
this.description = description
|
|
30
|
+
this.type = type as TIdentifier
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
getId(): string {
|
|
34
|
+
return this.identifier
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
abstract getInput(props: GetInputProps<this>): ReactNode
|
|
38
|
+
|
|
39
|
+
static deserialize(_data: ISerializedField): AnyField | FieldSection {
|
|
40
|
+
throw new Error(`${this.name} must implement deserialize.`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
protected _serialize(): BaseSerializedObject<TIdentifier> {
|
|
44
|
+
if (!this.identifier) {
|
|
45
|
+
throw new Error("Field identifier must be set before serializing.")
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
type: this.type,
|
|
49
|
+
identifier: this.identifier,
|
|
50
|
+
description: this.description,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const emptyBaseField = {
|
|
56
|
+
label: "",
|
|
57
|
+
description: "",
|
|
58
|
+
required: false,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface FieldCreationSchemaObject {
|
|
62
|
+
field: AnyField
|
|
63
|
+
showDirectly: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export abstract class BaseField<
|
|
67
|
+
TValue extends FieldValue,
|
|
68
|
+
TIdentifier extends FieldTypeIdentifier = FieldTypeIdentifier,
|
|
69
|
+
> extends BaseFormElement<TIdentifier> {
|
|
70
|
+
static readonly fieldTypeName: string
|
|
71
|
+
static readonly fieldTypeDescription: string
|
|
72
|
+
|
|
73
|
+
public readonly required: boolean
|
|
74
|
+
private readonly formValidators: InputFormLevelValidator<TValue>[]
|
|
75
|
+
private readonly fieldValidators: InputFieldLevelValidator<TValue>[]
|
|
76
|
+
public readonly label: string
|
|
77
|
+
public readonly image: File | Promise<File> | undefined
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* By default, validation doesn't execute on `onChange` events when editing fields
|
|
81
|
+
* until the field has been `touched`. This can be overridden by setting this to `false`
|
|
82
|
+
* if you want to validate on every `onChange` event. This is important for fields like booleans
|
|
83
|
+
* which don't have a `onBlur` event (which is used to set the `touched` state).
|
|
84
|
+
*/
|
|
85
|
+
public readonly onlyValidateAfterTouched: boolean = true
|
|
86
|
+
|
|
87
|
+
protected constructor(options: FieldOptions<TValue>) {
|
|
88
|
+
const { label, required, image, fieldValidators = [], formValidators = [], ...base } = options
|
|
89
|
+
super(base)
|
|
90
|
+
this.label = label
|
|
91
|
+
this.required = required
|
|
92
|
+
this.image = image
|
|
93
|
+
this.fieldValidators = fieldValidators
|
|
94
|
+
this.formValidators = formValidators
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
public static getFieldCreationSchema(): FieldCreationSchemaObject[] {
|
|
98
|
+
return []
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
protected isBlank(value: TValue | undefined): boolean {
|
|
102
|
+
return value === null || value === undefined || value === ""
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
public getValueFromChangeEvent(event: ChangeEvent<HTMLInputElement>): TValue {
|
|
106
|
+
return event.target.value as unknown as TValue
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
public getError(value: TValue, allValues?: Form | FormikUserFormRevision): string | undefined {
|
|
110
|
+
if (this.required && this.isBlank(value)) {
|
|
111
|
+
return "This field is required."
|
|
112
|
+
}
|
|
113
|
+
for (const validator of this.getFieldValidators()) {
|
|
114
|
+
const error = validator(value)
|
|
115
|
+
if (error) return error
|
|
116
|
+
}
|
|
117
|
+
if (allValues) {
|
|
118
|
+
for (const validator of this.getFormValidators()) {
|
|
119
|
+
const error = validator(value, allValues)
|
|
120
|
+
if (error) return error
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// TODO: We can probably combine _serialize and serialize.
|
|
126
|
+
protected _serialize(): BaseSerializedField<TIdentifier> {
|
|
127
|
+
return {
|
|
128
|
+
...super._serialize(),
|
|
129
|
+
label: this.label,
|
|
130
|
+
required: this.required,
|
|
131
|
+
image: this.image,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
abstract serialize(): ISerializedOnlyField
|
|
136
|
+
|
|
137
|
+
public getFieldValidators(): InputFieldLevelValidator<TValue>[] {
|
|
138
|
+
return [...this.fieldValidators]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
public getFormValidators(): InputFormLevelValidator<TValue>[] {
|
|
142
|
+
return [...this.formValidators]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
public encodeValueToJson(value: TValue) {
|
|
146
|
+
return JSON.stringify(value)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
public decodeJsonToValue(json: string): TValue {
|
|
150
|
+
return JSON.parse(json) as TValue
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useField } from "formik"
|
|
2
|
+
import { ChangeEventHandler, FocusEventHandler, useMemo } from "react"
|
|
3
|
+
|
|
4
|
+
import { Severity, ValueOfField } from "../../typings"
|
|
5
|
+
import { AnyField, ComponentProps } from "../typings"
|
|
6
|
+
|
|
7
|
+
// Wrapper for Formik's useField hook that returns the field's props, meta, and helpers.
|
|
8
|
+
export const useFormikInput = <TField extends AnyField>(props: ComponentProps<TField>) => {
|
|
9
|
+
const { id, field, formId, size, showInputOnly, internal, ...rest } = props
|
|
10
|
+
const [fieldProps, meta, helpers] = useField<ValueOfField<TField>>(field.getId())
|
|
11
|
+
const { touched } = meta
|
|
12
|
+
const helpText = meta.error ?? field.description
|
|
13
|
+
const severity: Severity | undefined = meta.error ? "danger" : undefined
|
|
14
|
+
const inputId = id ?? `${formId}-${field.getId()}-input`
|
|
15
|
+
const labelId = `${inputId}-label`
|
|
16
|
+
const label = field.required ? `${field.label} *` : field.label
|
|
17
|
+
|
|
18
|
+
// adds field-level validation to onChange and onBlur events
|
|
19
|
+
// this is necessary because Formik's useField hook does not support field-level validation
|
|
20
|
+
const fieldPropsWithValidation: typeof fieldProps = useMemo(() => {
|
|
21
|
+
const handleChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
|
22
|
+
const value = field.getValueFromChangeEvent(e) as ValueOfField<TField>
|
|
23
|
+
void helpers.setValue(value, false).then()
|
|
24
|
+
// only validate onChange if the field has been touched
|
|
25
|
+
if (touched || !field.onlyValidateAfterTouched) {
|
|
26
|
+
helpers.setError(field.getError(value))
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const handleBlur: FocusEventHandler<HTMLInputElement> = (e) => {
|
|
31
|
+
void helpers.setTouched(true, false).then()
|
|
32
|
+
helpers.setError(field.getError(field.getValueFromChangeEvent(e)))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
...fieldProps,
|
|
37
|
+
onChange: handleChange,
|
|
38
|
+
onBlur: handleBlur,
|
|
39
|
+
}
|
|
40
|
+
}, [field, fieldProps, helpers, touched])
|
|
41
|
+
|
|
42
|
+
console.debug("severity", severity)
|
|
43
|
+
|
|
44
|
+
return [
|
|
45
|
+
{
|
|
46
|
+
helpText,
|
|
47
|
+
size,
|
|
48
|
+
severity,
|
|
49
|
+
inputId,
|
|
50
|
+
labelId,
|
|
51
|
+
label,
|
|
52
|
+
showInputOnly,
|
|
53
|
+
internal,
|
|
54
|
+
fieldProps: fieldPropsWithValidation,
|
|
55
|
+
helpers,
|
|
56
|
+
field,
|
|
57
|
+
},
|
|
58
|
+
{ ...rest, "aria-labelledby": labelId },
|
|
59
|
+
] as const
|
|
60
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Text, TextSize } from "@overmap-ai/blocks"
|
|
2
|
+
import { cx } from "class-variance-authority"
|
|
3
|
+
import { ReactElement, ReactNode, useEffect, useState } from "react"
|
|
4
|
+
|
|
5
|
+
import { SEVERITY_COLOR_MAPPING } from "../../constants"
|
|
6
|
+
import { FullScreenImagePreview } from "../../constantsJsx"
|
|
7
|
+
import { Severity } from "../../typings"
|
|
8
|
+
|
|
9
|
+
interface InputWithLabelProps {
|
|
10
|
+
size?: TextSize
|
|
11
|
+
severity: Severity | undefined
|
|
12
|
+
inputId: string
|
|
13
|
+
labelId: string
|
|
14
|
+
label: string
|
|
15
|
+
image: File | Promise<File> | undefined
|
|
16
|
+
children: ReactNode
|
|
17
|
+
className?: string
|
|
18
|
+
}
|
|
19
|
+
export const InputWithLabel = (props: InputWithLabelProps) => {
|
|
20
|
+
const { className, label, children, size, severity, inputId, labelId, image } = props
|
|
21
|
+
const [resolvedImage, setResolvedImage] = useState<File | undefined>(undefined)
|
|
22
|
+
const [showImagePreview, setShowImagePreview] = useState<boolean>(false)
|
|
23
|
+
|
|
24
|
+
const color = severity ? SEVERITY_COLOR_MAPPING[severity] : "base"
|
|
25
|
+
|
|
26
|
+
console.debug(severity, color)
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (image instanceof Promise) {
|
|
30
|
+
image.then(setResolvedImage).catch(console.error)
|
|
31
|
+
} else {
|
|
32
|
+
setResolvedImage(image)
|
|
33
|
+
}
|
|
34
|
+
}, [image])
|
|
35
|
+
|
|
36
|
+
const resolvedImageURL = resolvedImage ? URL.createObjectURL(resolvedImage) : undefined
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="flex flex-col gap-2">
|
|
40
|
+
{resolvedImage && (
|
|
41
|
+
<>
|
|
42
|
+
<img
|
|
43
|
+
className="h-[100px] w-full min-w-[300px] cursor-pointer rounded-md object-cover"
|
|
44
|
+
src={resolvedImageURL}
|
|
45
|
+
alt={resolvedImage.name}
|
|
46
|
+
onClick={() => {
|
|
47
|
+
setShowImagePreview(true)
|
|
48
|
+
}}
|
|
49
|
+
/>
|
|
50
|
+
{showImagePreview && (
|
|
51
|
+
<FullScreenImagePreview
|
|
52
|
+
file={resolvedImage}
|
|
53
|
+
url={resolvedImageURL!}
|
|
54
|
+
name={resolvedImage.name}
|
|
55
|
+
setShowPreview={setShowImagePreview}
|
|
56
|
+
/>
|
|
57
|
+
)}
|
|
58
|
+
</>
|
|
59
|
+
)}
|
|
60
|
+
|
|
61
|
+
<label className={cx(className, "flex flex-col gap-1")} htmlFor={inputId}>
|
|
62
|
+
<Text accentColor={color} size={size} id={labelId} weight="medium">
|
|
63
|
+
{label}
|
|
64
|
+
</Text>
|
|
65
|
+
{children}
|
|
66
|
+
</label>
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface InputWithHelpTextProps {
|
|
72
|
+
severity: Severity | undefined
|
|
73
|
+
helpText: string | null
|
|
74
|
+
children: ReactElement
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const InputWithHelpText = (props: InputWithHelpTextProps) => {
|
|
78
|
+
const { helpText, children, severity } = props
|
|
79
|
+
|
|
80
|
+
const color = severity ? SEVERITY_COLOR_MAPPING[severity] : "base"
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className="flex flex-col gap-1">
|
|
84
|
+
{children}
|
|
85
|
+
<div className="flex flex-col w-full">
|
|
86
|
+
<Text accentColor={color} size="xs">
|
|
87
|
+
{helpText}
|
|
88
|
+
</Text>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface InputWithLabelAndHelpTextProps extends InputWithHelpTextProps {
|
|
95
|
+
children: ReactElement<InputWithLabelProps>
|
|
96
|
+
}
|
|
97
|
+
export const InputWithLabelAndHelpText = (props: InputWithLabelAndHelpTextProps) => {
|
|
98
|
+
const { children, ...restProps } = props
|
|
99
|
+
return <InputWithHelpText {...restProps}>{children}</InputWithHelpText>
|
|
100
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { BaseSerializedField } from "../../typings"
|
|
2
|
+
import { InputFieldLevelValidator, InputFormLevelValidator } from "../typings"
|
|
3
|
+
|
|
4
|
+
export interface FieldOptions<TValue> extends BaseSerializedField {
|
|
5
|
+
fieldValidators?: InputFieldLevelValidator<TValue>[]
|
|
6
|
+
formValidators?: InputFormLevelValidator<TValue>[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ChildFieldOptions<TValue> = Omit<FieldOptions<TValue>, "type">
|