@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,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,4 @@
1
+ export * from "./BaseField"
2
+ export * from "./hooks"
3
+ export * from "./layouts"
4
+ export * from "./typings"
@@ -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">