@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,156 @@
1
+ import { ChangeEvent, ReactNode } from "react"
2
+ import { RiUpload2Line } from "react-icons/ri"
3
+
4
+ import { ISerializedField, SerializedUploadField } from "../../typings"
5
+ import { BaseField, ChildFieldOptions, emptyBaseField } from "../BaseField"
6
+ import { maxFileSizeMB } from "../constants"
7
+ import { NumberField } from "../NumberField"
8
+ import { MultiSelectField } from "../SelectField"
9
+ import { GetInputProps, InputFieldLevelValidator } from "../typings"
10
+ import { UploadInput } from "./UploadInput"
11
+
12
+ export interface UploadFieldOptions extends ChildFieldOptions<File[]> {
13
+ extensions?: string[]
14
+ maximum_size?: number | string
15
+ maximum_files?: number | string
16
+ }
17
+
18
+ export const emptyUploadField = {
19
+ ...emptyBaseField,
20
+ type: "upload",
21
+ extensions: [],
22
+ maximum_size: undefined,
23
+ maximum_files: 1,
24
+ }
25
+
26
+ export class UploadField extends BaseField<File[], "upload"> {
27
+ static readonly fieldTypeName = "Upload"
28
+ static readonly fieldTypeDescription = "Allows a file to be uploaded."
29
+
30
+ public readonly extensions?: string[]
31
+ public readonly maxFileSize: number | undefined
32
+ public readonly maxFiles: number
33
+ public readonly onlyValidateAfterTouched = false
34
+
35
+ static Icon: typeof RiUpload2Line = RiUpload2Line
36
+
37
+ constructor(options: UploadFieldOptions) {
38
+ const { extensions, maximum_files, maximum_size, ...base } = options
39
+ super({ ...base, type: "upload" })
40
+
41
+ this.maxFileSize = typeof maximum_size === "number" ? maximum_size : undefined
42
+ // if maximum_files is not a number or less than 1, default and clamp to 1
43
+ this.maxFiles = Math.max(typeof maximum_files === "number" ? maximum_files : 1, 1)
44
+
45
+ this.extensions = extensions
46
+ }
47
+
48
+ public getValueFromChangeEvent(event: ChangeEvent<HTMLInputElement>): File[] {
49
+ return Array.from(event.target.files || [])
50
+ }
51
+
52
+ protected isBlank(value: File[]): boolean {
53
+ return super.isBlank(value) || value.length === 0
54
+ }
55
+
56
+ static getFieldCreationSchema(parentPath = "") {
57
+ const path = parentPath && `${parentPath}.`
58
+ return [
59
+ {
60
+ field: new NumberField({
61
+ label: "How many files can be uploaded?",
62
+ description: "By default, only one file can be uploaded.",
63
+ required: false,
64
+ minimum: 1,
65
+ maximum: 10,
66
+ identifier: `${path}maximum_files`,
67
+ integers: true,
68
+ }),
69
+ showDirectly: false,
70
+ },
71
+ {
72
+ field: new NumberField({
73
+ // TODO: Default value
74
+ label: "What is the maximum size of each file?",
75
+ description: `Maximum file size in megabytes (between 1MB–${maxFileSizeMB}MB).`,
76
+ required: false,
77
+ identifier: `${path}maximum_size`,
78
+ minimum: 1,
79
+ maximum: maxFileSizeMB,
80
+ integers: true,
81
+ }),
82
+ showDirectly: false,
83
+ },
84
+ {
85
+ field: new MultiSelectField({
86
+ label: "Accepted file types",
87
+ description: "Types of allowed files to upload. If left blank, all files will be accepted.",
88
+ required: false,
89
+ identifier: `${path}extensions`,
90
+ options: [
91
+ {
92
+ value: "image/*",
93
+ label: "Images",
94
+ },
95
+ {
96
+ value: "audio/*",
97
+ label: "Audio files",
98
+ },
99
+ {
100
+ value: "video/*",
101
+ label: "Videos",
102
+ },
103
+ {
104
+ value: "text/*",
105
+ label: "Text files",
106
+ },
107
+ {
108
+ value: "application/*",
109
+ label: "Application files (includes PDFs and Word documents)",
110
+ },
111
+ ],
112
+ }),
113
+ showDirectly: false,
114
+ },
115
+ ]
116
+ }
117
+
118
+ getFieldValidators(): InputFieldLevelValidator<File[]>[] {
119
+ const validators = super.getFieldValidators()
120
+ const maxFileSizeInMB = this.maxFileSize ?? maxFileSizeMB
121
+ const maxFileSizeInB = maxFileSizeInMB * 1000 * 1000
122
+ const maxFiles = this.maxFiles || 1
123
+
124
+ validators.push((value) => {
125
+ if (value && value.some((file) => file.size > maxFileSizeInB)) {
126
+ return `Files must be at most ${maxFileSizeInMB}MB.`
127
+ }
128
+ })
129
+
130
+ validators.push((value) => {
131
+ if (value && value.length > maxFiles) {
132
+ return `You can only upload ${maxFiles} files.`
133
+ }
134
+ })
135
+
136
+ return validators
137
+ }
138
+
139
+ serialize(): SerializedUploadField {
140
+ return {
141
+ ...super._serialize(),
142
+ extensions: this.extensions,
143
+ maximum_size: this.maxFileSize,
144
+ maximum_files: this.maxFiles,
145
+ }
146
+ }
147
+
148
+ static deserialize(data: ISerializedField): UploadField {
149
+ if (data.type !== "upload") throw new Error("Type mismatch.")
150
+ return new UploadField(data)
151
+ }
152
+
153
+ getInput(props: GetInputProps<this>): ReactNode {
154
+ return <UploadInput field={this} {...props} />
155
+ }
156
+ }
@@ -0,0 +1,220 @@
1
+ import { Button, ButtonGroup, IconButton, RiIcon } from "@overmap-ai/blocks"
2
+ import { saveAs } from "file-saver"
3
+ import { memo, MouseEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"
4
+
5
+ import { FileCard } from "../../../FileCard"
6
+ import { useFileViewer } from "../../../FileViewer"
7
+ import { ImageCard } from "../../../ImageCard"
8
+ import { SEVERITY_COLOR_MAPPING } from "../../constants"
9
+ import { InputWithLabel, InputWithLabelAndHelpText, useFormikInput } from "../BaseField"
10
+ import { ComponentProps } from "../typings"
11
+ import { UploadField } from "./UploadField"
12
+ import { convertBytesToLargestUnit } from "./utils"
13
+
14
+ export const UploadInput = memo((props: ComponentProps<UploadField>) => {
15
+ const [{ inputId, labelId, size, severity, helpText, showInputOnly, field, fieldProps }, rest] =
16
+ useFormikInput(props)
17
+ const { onChange } = fieldProps
18
+ let [{ label }] = useFormikInput(props)
19
+ label = showInputOnly ? "" : label
20
+
21
+ const color = severity ? SEVERITY_COLOR_MAPPING[severity] : undefined
22
+ const input = useRef<HTMLInputElement>(null)
23
+ const { value } = fieldProps
24
+
25
+ const updatedHelpText = useMemo(() => {
26
+ if (showInputOnly) return null
27
+ if (helpText) return helpText
28
+ if (field.maxFileSize) {
29
+ return `Maximum file size: ${field.maxFileSize}MB`
30
+ }
31
+ return null
32
+ }, [field.maxFileSize, helpText, showInputOnly])
33
+
34
+ const handleClick = useCallback(() => {
35
+ input.current?.click()
36
+ }, [])
37
+
38
+ const handleRemove = useCallback(
39
+ (index: number) => {
40
+ const files = [...value]
41
+ files.splice(index, 1)
42
+
43
+ const event = { target: { files } }
44
+ onChange(event)
45
+ },
46
+ [value, onChange],
47
+ )
48
+
49
+ const multipleButtonText = value ? "Select new files" : "Select files"
50
+
51
+ const singleButtonText = value ? "Select new file" : "Select a file"
52
+ const buttonText = field.maxFiles > 1 ? multipleButtonText : singleButtonText
53
+
54
+ return (
55
+ <div className="flex flex-col gap-2">
56
+ <InputWithLabelAndHelpText helpText={updatedHelpText} severity={severity}>
57
+ <InputWithLabel
58
+ size={size}
59
+ severity={severity}
60
+ inputId={inputId}
61
+ labelId={labelId}
62
+ label={label}
63
+ image={showInputOnly ? undefined : field.image}
64
+ >
65
+ <div className="flex gap-2">
66
+ <Button
67
+ {...rest}
68
+ className="w-max"
69
+ variant="soft"
70
+ onClick={handleClick}
71
+ id="upload-input-upload-button"
72
+ >
73
+ <RiIcon icon="RiUpload2Line" /> {buttonText}
74
+ </Button>
75
+ </div>
76
+ <input
77
+ {...rest}
78
+ type="file"
79
+ ref={input}
80
+ id={inputId}
81
+ accept={field.extensions?.join(",")}
82
+ multiple={field.maxFiles > 1}
83
+ color={color}
84
+ style={{ display: "none" }}
85
+ {...fieldProps}
86
+ value=""
87
+ />
88
+ </InputWithLabel>
89
+ </InputWithLabelAndHelpText>
90
+ {Array.isArray(value) && value.length > 0 && (
91
+ <div className="flex h-max flex-col gap-2">
92
+ {value.map((file, index) => (
93
+ <DisplayFile
94
+ key={index}
95
+ field={field}
96
+ file={file}
97
+ onRemove={() => {
98
+ handleRemove(index)
99
+ }}
100
+ disabled={rest.disabled}
101
+ />
102
+ ))}
103
+ </div>
104
+ )}
105
+ </div>
106
+ )
107
+ })
108
+
109
+ UploadInput.displayName = "UploadInput"
110
+
111
+ interface DisplayFileProps {
112
+ file: File | Promise<File>
113
+ field: UploadField
114
+ disabled?: boolean
115
+ onRemove: () => void
116
+ }
117
+
118
+ const DisplayFile = memo((props: DisplayFileProps) => {
119
+ const { file, field, onRemove, disabled } = props
120
+ const [resolvedFile, setResolvedFile] = useState<File | null>(null)
121
+ const openFileViewer = useFileViewer()
122
+
123
+ const error = useMemo(() => resolvedFile && field.getError([resolvedFile]), [field, resolvedFile])
124
+
125
+ const { url, name } = useMemo(() => {
126
+ let url: string | null = null
127
+ let name: string
128
+ let size: string
129
+ // if the file is an image, create a URL for it
130
+ if (resolvedFile?.type.startsWith("image/")) {
131
+ url = URL.createObjectURL(resolvedFile)
132
+ }
133
+ if (resolvedFile) {
134
+ name = resolvedFile.name
135
+ size = convertBytesToLargestUnit(resolvedFile.size)
136
+ } else {
137
+ name = "Downloading..."
138
+ size = "..."
139
+ }
140
+ return { url, name, size }
141
+ }, [resolvedFile])
142
+
143
+ useEffect(() => {
144
+ if (file instanceof Promise) {
145
+ // failing to resolve file is not fatal (field would just say Downloading...)
146
+ file.then(setResolvedFile).catch(console.error)
147
+ } else {
148
+ setResolvedFile(file)
149
+ }
150
+ }, [file])
151
+
152
+ const handleDownload = useCallback(
153
+ (event: MouseEvent<HTMLButtonElement>) => {
154
+ event.stopPropagation()
155
+ if (!resolvedFile) {
156
+ throw new Error("Cannot download a file that is not resolved.")
157
+ }
158
+ const blob = new Blob([resolvedFile])
159
+ saveAs(blob, name)
160
+ },
161
+ [name, resolvedFile],
162
+ )
163
+
164
+ const handleDelete = useCallback(
165
+ (e: MouseEvent<HTMLButtonElement>) => {
166
+ e.stopPropagation()
167
+ onRemove()
168
+ },
169
+ [onRemove],
170
+ )
171
+
172
+ const handleCardClick = useCallback(() => {
173
+ if (!resolvedFile) return
174
+ openFileViewer((closeFileViewer) => ({
175
+ file: resolvedFile,
176
+ onDelete: !disabled
177
+ ? () => {
178
+ onRemove()
179
+ closeFileViewer()
180
+ }
181
+ : undefined,
182
+ }))
183
+ }, [disabled, onRemove, openFileViewer, resolvedFile])
184
+
185
+ const rightSlotContent: ReactNode = useMemo(
186
+ () => (
187
+ <ButtonGroup variant="ghost" accentColor="base" size="sm">
188
+ <IconButton aria-label={`Download ${name}`} onClick={handleDownload} disabled={!resolvedFile}>
189
+ <RiIcon icon="RiDownload2Line" />
190
+ </IconButton>
191
+ {!disabled && (
192
+ <IconButton aria-label={`Remove ${name}`} disabled={disabled} onClick={handleDelete}>
193
+ <RiIcon icon="RiCloseLargeLine" />
194
+ </IconButton>
195
+ )}
196
+ </ButtonGroup>
197
+ ),
198
+ [disabled, handleDelete, handleDownload, name, resolvedFile],
199
+ )
200
+
201
+ return url ? (
202
+ <ImageCard
203
+ style={{ cursor: "pointer" }}
204
+ onClick={handleCardClick}
205
+ file={resolvedFile}
206
+ error={error ?? undefined}
207
+ rightSlot={rightSlotContent}
208
+ />
209
+ ) : (
210
+ <FileCard
211
+ style={{ cursor: "pointer" }}
212
+ onClick={handleCardClick}
213
+ file={resolvedFile}
214
+ error={error ?? undefined}
215
+ rightSlot={rightSlotContent}
216
+ />
217
+ )
218
+ })
219
+
220
+ DisplayFile.displayName = "DisplayFile"
@@ -0,0 +1,2 @@
1
+ export * from "./UploadField"
2
+ export * from "./UploadInput"
@@ -0,0 +1,17 @@
1
+ export const convertBytesToLargestUnit = (bytes: number) => {
2
+ // all files must be less than 50 MB
3
+ const units = ["byte", "kilobyte", "megabyte"]
4
+ let sizeInUnit = bytes
5
+ let unitIndex = 0
6
+ while (sizeInUnit > 1000 && unitIndex < units.length - 1) {
7
+ sizeInUnit /= 1000
8
+ unitIndex++
9
+ }
10
+ const formatter = new Intl.NumberFormat([], {
11
+ // 0 for bytes and kilobytes, 1 for megabytes
12
+ maximumFractionDigits: Math.max(0, unitIndex - 1),
13
+ style: "unit",
14
+ unit: units[unitIndex],
15
+ })
16
+ return formatter.format(sizeInUnit)
17
+ }
@@ -0,0 +1,43 @@
1
+ import { BooleanField, emptyBooleanField } from "./BooleanField"
2
+ import { CustomField, emptyCustomField } from "./CustomField"
3
+ import { DateField, emptyDateField } from "./DateField"
4
+ import { emptyMultiStringField, MultiStringField } from "./MultiStringField"
5
+ import { emptyNumberField, NumberField } from "./NumberField"
6
+ import { emptyQrField, QrField } from "./QrField"
7
+ import { emptyMultiSelectField, emptySelectField, MultiSelectField, SelectField } from "./SelectField"
8
+ import { emptyStringField, emptyTextField, StringField, TextField } from "./StringOrTextFields"
9
+ import { emptyUploadField, UploadField } from "./UploadField"
10
+
11
+ export const FieldTypeToClsMapping = {
12
+ date: DateField,
13
+ number: NumberField,
14
+ boolean: BooleanField,
15
+ select: SelectField,
16
+ string: StringField,
17
+ text: TextField,
18
+ custom: CustomField,
19
+ upload: UploadField,
20
+ qr: QrField,
21
+ // TODO: Underscore
22
+ "multi-string": MultiStringField,
23
+ "multi-select": MultiSelectField,
24
+ } as const
25
+
26
+ export const FieldTypeToEmptyFieldMapping = {
27
+ date: emptyDateField,
28
+ number: emptyNumberField,
29
+ boolean: emptyBooleanField,
30
+ select: emptySelectField,
31
+ string: emptyStringField,
32
+ text: emptyTextField,
33
+ custom: emptyCustomField,
34
+ upload: emptyUploadField,
35
+ qr: emptyQrField,
36
+ // TODO: Underscore
37
+ "multi-string": emptyMultiStringField,
38
+ "multi-select": emptyMultiSelectField,
39
+ } as const
40
+
41
+ export const maxFileSizeMB = 50
42
+ export const maxFileSizeKB = maxFileSizeMB * 1000
43
+ export const maxFileSizeB = maxFileSizeKB * 1000
@@ -0,0 +1,26 @@
1
+ import { ReactNode, useMemo } from "react"
2
+
3
+ import { FieldValue } from "../typings"
4
+ import { BaseField, BaseFormElement } from "./BaseField"
5
+ import { GetInputProps } from "./typings"
6
+
7
+ export const useFieldInput = <TField extends BaseFormElement | null>(
8
+ field: TField,
9
+ props: TField extends BaseFormElement ? GetInputProps<TField> : null,
10
+ ): ReactNode => {
11
+ // TODO: Consider deep equality check on props
12
+ return useMemo(() => {
13
+ if (!props || !field) return null
14
+ return field.getInput(props)
15
+ }, [field, props])
16
+ }
17
+
18
+ export const useFieldInputs = (fields: BaseFormElement[], props: GetInputProps<BaseField<FieldValue>>): ReactNode => {
19
+ const inputs = useMemo(() => {
20
+ return fields.map((field) => {
21
+ return <div key={field.getId()}>{field.getInput(props)}</div>
22
+ })
23
+ }, [fields, props])
24
+
25
+ return <div className="flex flex-col gap-2">{inputs}</div>
26
+ }
@@ -0,0 +1,12 @@
1
+ export * from "./BaseField"
2
+ export * from "./BooleanField"
3
+ export * from "./DateField"
4
+ export * from "./FieldSection"
5
+ export * from "./hooks"
6
+ export * from "./MultiStringField"
7
+ export * from "./NumberField"
8
+ export * from "./QrField"
9
+ export * from "./SelectField"
10
+ export * from "./StringOrTextFields"
11
+ export * from "./typings"
12
+ export * from "./utils"
@@ -0,0 +1,45 @@
1
+ import { TextSize } from "@overmap-ai/blocks"
2
+ import { HTMLProps, ReactNode } from "react"
3
+
4
+ import { FormikUserFormRevision } from "../builder"
5
+ import { Form } from "../typings"
6
+ import { BaseField, BaseFormElement } from "./BaseField"
7
+
8
+ export interface SchemaMeta {
9
+ readonly: boolean
10
+ }
11
+
12
+ export interface ISchema {
13
+ id?: string
14
+ fields: BaseFormElement[]
15
+ meta: SchemaMeta
16
+ title: ReactNode
17
+ description?: ReactNode
18
+ }
19
+
20
+ export type InputFieldLevelValidator<TValue> = (value: TValue | undefined) => string | null | undefined
21
+
22
+ export type InputFormLevelValidator<TValue> = (
23
+ value: TValue | undefined,
24
+ allValues: Form | FormikUserFormRevision,
25
+ ) => string | null | undefined
26
+
27
+ export type InputValidator<TValue> = InputFieldLevelValidator<TValue> | InputFormLevelValidator<TValue>
28
+
29
+ export interface ComponentProps<TField extends BaseFormElement>
30
+ extends Omit<
31
+ HTMLProps<HTMLElement>,
32
+ "color" | "size" | "ref" | "type" | "onChange" | "onBlur" | "value" | "defaultValue" | "name" | "dir"
33
+ > {
34
+ field: TField
35
+ formId: string
36
+ size?: TextSize
37
+ showInputOnly?: boolean
38
+ internal?: boolean
39
+ }
40
+
41
+ export type GetInputProps<TField extends BaseFormElement> = Omit<ComponentProps<TField>, "field">
42
+
43
+ // REASON: Sometimes, we literally accept any field.
44
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
+ export type AnyField = BaseField<any, any>
@@ -0,0 +1,125 @@
1
+ import { ISerializedOnlyField, UserFormRevision } from "@overmap-ai/core"
2
+
3
+ import { FieldTypeIdentifier, FieldValue, ISerializedField, SelectFieldOption, SerializedCondition } from "../typings"
4
+ import { BaseField } from "./BaseField"
5
+ import { FieldTypeToClsMapping } from "./constants"
6
+ import { FieldSection } from "./FieldSection"
7
+ import { AnyField, ISchema, SchemaMeta } from "./typings"
8
+
9
+ // Note: the deserialization methods are separate to avoid circular dependencies.
10
+
11
+ /** Deserializes anything but a FieldSection.
12
+ * @see `deserialize` for most use cases
13
+ */
14
+ export const deserializeField = (serializedField: ISerializedOnlyField): AnyField => {
15
+ const fieldType: FieldTypeIdentifier = serializedField.type
16
+ const fieldCls = FieldTypeToClsMapping[fieldType]
17
+ return fieldCls.deserialize(serializedField)
18
+ }
19
+
20
+ /** Deserializes anything */
21
+ export const deserialize = (serialized: ISerializedField): AnyField | FieldSection => {
22
+ if (serialized.type === "section") {
23
+ return FieldSection.deserialize(serialized)
24
+ }
25
+ return deserializeField(serialized)
26
+ }
27
+
28
+ export type PartialFormRevision = Pick<UserFormRevision, "title" | "fields" | "description"> & Partial<UserFormRevision>
29
+
30
+ export function formRevisionToSchema(formRevision: PartialFormRevision, meta: Partial<SchemaMeta> = {}): ISchema {
31
+ // expanding the meta in order to set default values
32
+ const { readonly = false } = meta
33
+
34
+ return {
35
+ title: formRevision.title,
36
+ description: formRevision.description,
37
+ fields: formRevision.fields.map((serializedField: ISerializedField) => deserialize(serializedField)),
38
+ meta: { readonly },
39
+ }
40
+ }
41
+
42
+ export function flattenFields(schema: ISchema): AnyField[] {
43
+ const allFields: AnyField[] = []
44
+
45
+ for (const field of schema.fields) {
46
+ if (field instanceof FieldSection) {
47
+ for (const subField of field.fields) {
48
+ allFields.push(subField)
49
+ }
50
+ } else {
51
+ if (!(field instanceof BaseField)) {
52
+ throw new Error(`Invalid field type: ${field.type}`)
53
+ }
54
+ allFields.push(field)
55
+ }
56
+ }
57
+
58
+ return allFields
59
+ }
60
+
61
+ export function decodeFormValues(schema: ISchema, values: Record<string, string>): Record<string, FieldValue> {
62
+ const allFields: AnyField[] = flattenFields(schema)
63
+
64
+ const result: Record<string, FieldValue> = {}
65
+
66
+ for (const field of allFields) {
67
+ const value = values[field.identifier] ?? null
68
+
69
+ if (value !== null) {
70
+ result[field.identifier] = field.decodeJsonToValue(value) as FieldValue
71
+ } else {
72
+ result[field.identifier] = value
73
+ }
74
+ }
75
+
76
+ return result
77
+ }
78
+
79
+ export function encodeFormValues(schema: ISchema, values: Record<string, FieldValue>): Record<string, string> {
80
+ const allFields: AnyField[] = flattenFields(schema)
81
+
82
+ const result: Record<string, string> = {}
83
+
84
+ for (const field of allFields) {
85
+ const value = values[field.identifier]
86
+ result[field.identifier] = field.encodeValueToJson(value)
87
+ }
88
+
89
+ return result
90
+ }
91
+
92
+ export function valueIsFile(v: FieldValue | Promise<File>[] | undefined): v is File[] | Promise<File>[] {
93
+ return Array.isArray(v) && v.some((v: unknown) => v instanceof File || v instanceof Promise)
94
+ }
95
+
96
+ export function isConditionMet<TValue extends FieldValue | Promise<File>[]>(
97
+ condition: TValue extends FieldValue ? SerializedCondition<TValue> | null : null,
98
+ value: TValue,
99
+ ) {
100
+ // if no condition is provided, it is always met
101
+ if (!condition) return true
102
+
103
+ // conditions due not support file uploads, so we can assume that the value is not an array of File's
104
+ if (valueIsFile(value) || valueIsFile(condition.value)) throw new Error("Conditions do not support file uploads")
105
+
106
+ const valueAsPrimitive: Exclude<FieldValue, SelectFieldOption[] | File[]> = Array.isArray(value)
107
+ ? value.map((v: string | SelectFieldOption) => (typeof v === "string" ? v : v.value))
108
+ : value
109
+
110
+ const valueToCompare: Exclude<FieldValue, SelectFieldOption[] | File[]> = Array.isArray(condition.value)
111
+ ? condition.value.map((v: string | SelectFieldOption) => (typeof v === "string" ? v : v.value))
112
+ : condition.value
113
+
114
+ // if comparing arrays, check if any of the values match
115
+ if (Array.isArray(valueToCompare) && Array.isArray(valueAsPrimitive)) {
116
+ // ensure that every value in valueToCompare is in valueAsPrimitive
117
+ // though not necessarily the other way around
118
+ for (const v of valueToCompare) {
119
+ if (!valueAsPrimitive.includes(v)) return false
120
+ }
121
+ return true
122
+ }
123
+
124
+ return valueToCompare === value
125
+ }
@@ -0,0 +1,5 @@
1
+ export * from "./builder"
2
+ export * from "./fields"
3
+ export * from "./renderer"
4
+ export * from "./typings"
5
+ export { initialFormValues, validateForm } from "./utils"