@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,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,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
|
+
}
|