@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,207 @@
|
|
|
1
|
+
import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd"
|
|
2
|
+
import { Badge, IconButton, Input, RiIcon } from "@overmap-ai/blocks"
|
|
3
|
+
import { ChangeEventHandler, KeyboardEvent, memo, useCallback, useMemo, useState } from "react"
|
|
4
|
+
|
|
5
|
+
import { remove, reorder } from "../../builder/utils"
|
|
6
|
+
import { SEVERITY_COLOR_MAPPING } from "../../constants"
|
|
7
|
+
import { SelectFieldOption } from "../../typings"
|
|
8
|
+
import { InputWithLabel, InputWithLabelAndHelpText, useFormikInput } from "../BaseField"
|
|
9
|
+
import { ComponentProps } from "../typings"
|
|
10
|
+
import { MultiStringField } from "./MultiStringField"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Allows the user to create an array of unique strings and customize the order.
|
|
14
|
+
* User to generate options for the Select field.
|
|
15
|
+
*/
|
|
16
|
+
export const MultiStringInput = memo((props: ComponentProps<MultiStringField>) => {
|
|
17
|
+
const [{ inputId, labelId, size, severity, showInputOnly, field, fieldProps, internal }, rest] =
|
|
18
|
+
useFormikInput<MultiStringField>(props)
|
|
19
|
+
let [{ helpText, label }] = useFormikInput(props)
|
|
20
|
+
helpText = showInputOnly ? null : helpText
|
|
21
|
+
label = showInputOnly ? "" : label
|
|
22
|
+
|
|
23
|
+
const color = severity ? SEVERITY_COLOR_MAPPING[severity] : undefined
|
|
24
|
+
|
|
25
|
+
const value: string[] = useMemo(() => (Array.isArray(fieldProps.value) ? fieldProps.value : []), [fieldProps.value])
|
|
26
|
+
const { onChange, onBlur } = fieldProps
|
|
27
|
+
const droppableId = `${inputId}-droppable`
|
|
28
|
+
const { disabled } = rest
|
|
29
|
+
|
|
30
|
+
const [intermediateValue, setIntermediateValue] = useState("")
|
|
31
|
+
const [internalError, setInternalError] = useState("")
|
|
32
|
+
|
|
33
|
+
const updatedHelpText = internalError || helpText
|
|
34
|
+
const updatedColor = internalError ? SEVERITY_COLOR_MAPPING.danger : color
|
|
35
|
+
|
|
36
|
+
const setValueAndTouched = useCallback(
|
|
37
|
+
(newValue: string[]) => {
|
|
38
|
+
onChange(newValue)
|
|
39
|
+
onBlur(newValue)
|
|
40
|
+
},
|
|
41
|
+
[onChange, onBlur],
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
// handle change to the input
|
|
45
|
+
const handleChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
|
|
46
|
+
(e) => {
|
|
47
|
+
if (value.findIndex((option: string): boolean => option === e.target.value.trim()) >= 0) {
|
|
48
|
+
// There is already an option with this value.
|
|
49
|
+
setInternalError("All options must be unique")
|
|
50
|
+
} else if (!e.target.value) {
|
|
51
|
+
setInternalError("Option cannot be empty")
|
|
52
|
+
} else {
|
|
53
|
+
setInternalError("")
|
|
54
|
+
}
|
|
55
|
+
setIntermediateValue(e.target.value)
|
|
56
|
+
},
|
|
57
|
+
[setIntermediateValue, value],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const addOption = useCallback(() => {
|
|
61
|
+
if (internalError) return
|
|
62
|
+
|
|
63
|
+
if (!intermediateValue.trim()) {
|
|
64
|
+
setInternalError("Option cannot be empty")
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const trimmedValue = intermediateValue.trim()
|
|
69
|
+
// value and label are the same for user-defined options.
|
|
70
|
+
setValueAndTouched([...value, trimmedValue])
|
|
71
|
+
setIntermediateValue("")
|
|
72
|
+
}, [intermediateValue, internalError, setValueAndTouched, value])
|
|
73
|
+
|
|
74
|
+
// moves the intermediate value into the value array
|
|
75
|
+
const handleKeyDown = useCallback(
|
|
76
|
+
(e: KeyboardEvent<HTMLInputElement>) => {
|
|
77
|
+
if (e.key === "Enter") {
|
|
78
|
+
// don't try and submit the form
|
|
79
|
+
e.preventDefault()
|
|
80
|
+
|
|
81
|
+
addOption()
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
[addOption],
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
// delete an existing option
|
|
88
|
+
const handleDeleteOption = useCallback(
|
|
89
|
+
(index: number) => {
|
|
90
|
+
setValueAndTouched(remove(value, index))
|
|
91
|
+
},
|
|
92
|
+
[value, setValueAndTouched],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
// change the order of existing options
|
|
96
|
+
const handleDragEnd = useCallback(
|
|
97
|
+
(result: DropResult) => {
|
|
98
|
+
if (!result.destination) return
|
|
99
|
+
|
|
100
|
+
const sourceIndex = result.source.index
|
|
101
|
+
const destinationIndex = result.destination.index
|
|
102
|
+
|
|
103
|
+
setValueAndTouched(reorder(value, sourceIndex, destinationIndex))
|
|
104
|
+
},
|
|
105
|
+
[setValueAndTouched, value],
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<DragDropContext onDragEnd={handleDragEnd}>
|
|
110
|
+
<div className="flex flex-col gap-2">
|
|
111
|
+
<InputWithLabelAndHelpText helpText={updatedHelpText} severity={severity}>
|
|
112
|
+
<InputWithLabel
|
|
113
|
+
size={size}
|
|
114
|
+
severity={severity}
|
|
115
|
+
inputId={inputId}
|
|
116
|
+
labelId={labelId}
|
|
117
|
+
label={label}
|
|
118
|
+
image={showInputOnly ? undefined : field.image}
|
|
119
|
+
>
|
|
120
|
+
{/* Do not show input if disabled and options are defined */}
|
|
121
|
+
{(!disabled || value.length === 0) && (
|
|
122
|
+
<div className="flex gap-2">
|
|
123
|
+
<div className="grow">
|
|
124
|
+
<Input.Root accentColor={updatedColor} variant={internal ? "outline" : "soft"}>
|
|
125
|
+
<Input.Field
|
|
126
|
+
{...rest}
|
|
127
|
+
{...fieldProps}
|
|
128
|
+
value={intermediateValue}
|
|
129
|
+
onChange={handleChange}
|
|
130
|
+
onKeyDown={handleKeyDown}
|
|
131
|
+
id={inputId}
|
|
132
|
+
placeholder={field.placeholder}
|
|
133
|
+
onBlur={undefined}
|
|
134
|
+
/>
|
|
135
|
+
</Input.Root>
|
|
136
|
+
</div>
|
|
137
|
+
<IconButton
|
|
138
|
+
type="button"
|
|
139
|
+
aria-label="Add option"
|
|
140
|
+
disabled={!!internalError || disabled}
|
|
141
|
+
onClick={addOption}
|
|
142
|
+
>
|
|
143
|
+
<RiIcon icon="RiAddLine" />
|
|
144
|
+
</IconButton>
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
</InputWithLabel>
|
|
148
|
+
</InputWithLabelAndHelpText>
|
|
149
|
+
<Droppable droppableId={droppableId}>
|
|
150
|
+
{(droppableProvided) => (
|
|
151
|
+
<div
|
|
152
|
+
className="flex flex-col"
|
|
153
|
+
{...droppableProvided.droppableProps}
|
|
154
|
+
ref={droppableProvided.innerRef}
|
|
155
|
+
>
|
|
156
|
+
{value.map((option: string, index) => (
|
|
157
|
+
<Draggable
|
|
158
|
+
draggableId={`${option}-draggable`}
|
|
159
|
+
index={index}
|
|
160
|
+
key={option}
|
|
161
|
+
isDragDisabled={disabled}
|
|
162
|
+
>
|
|
163
|
+
{({ draggableProps, dragHandleProps, innerRef }) => (
|
|
164
|
+
<Badge
|
|
165
|
+
{...dragHandleProps}
|
|
166
|
+
{...draggableProps}
|
|
167
|
+
ref={innerRef}
|
|
168
|
+
className="mb-1 flex items-center justify-between gap-2"
|
|
169
|
+
accentColor="base"
|
|
170
|
+
size="md"
|
|
171
|
+
variant="soft"
|
|
172
|
+
>
|
|
173
|
+
<span>
|
|
174
|
+
{
|
|
175
|
+
// TODO: remove this, its just a saftey check for old compatibility of what was acceptable as a value for multi string
|
|
176
|
+
typeof option === "object" && "label" in option
|
|
177
|
+
? (option as SelectFieldOption).label
|
|
178
|
+
: option
|
|
179
|
+
}
|
|
180
|
+
</span>
|
|
181
|
+
<IconButton
|
|
182
|
+
size="sm"
|
|
183
|
+
variant="ghost"
|
|
184
|
+
type="button"
|
|
185
|
+
aria-label="Delete option"
|
|
186
|
+
accentColor="base"
|
|
187
|
+
disabled={disabled}
|
|
188
|
+
onClick={() => {
|
|
189
|
+
handleDeleteOption(index)
|
|
190
|
+
}}
|
|
191
|
+
>
|
|
192
|
+
<RiIcon icon="RiCloseLargeLine" />
|
|
193
|
+
</IconButton>
|
|
194
|
+
</Badge>
|
|
195
|
+
)}
|
|
196
|
+
</Draggable>
|
|
197
|
+
))}
|
|
198
|
+
{droppableProvided.placeholder}
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
</Droppable>
|
|
202
|
+
</div>
|
|
203
|
+
</DragDropContext>
|
|
204
|
+
)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
MultiStringInput.displayName = "MultiStringInput"
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import get from "lodash.get"
|
|
2
|
+
import { ChangeEvent, ReactNode } from "react"
|
|
3
|
+
import { RiHashtag } from "react-icons/ri"
|
|
4
|
+
|
|
5
|
+
import { FormikUserFormRevision } from "../../builder"
|
|
6
|
+
import { Form, ISerializedField, SerializedNumberField } from "../../typings"
|
|
7
|
+
import { BaseField, ChildFieldOptions, emptyBaseField } from "../BaseField"
|
|
8
|
+
import { BooleanField } from "../BooleanField"
|
|
9
|
+
import { GetInputProps, InputFieldLevelValidator, InputValidator } from "../typings"
|
|
10
|
+
import { NumberInput } from "./NumberInput"
|
|
11
|
+
|
|
12
|
+
// empty number fields are represented by an empty string
|
|
13
|
+
export type NumberFieldValue = number | ""
|
|
14
|
+
|
|
15
|
+
export const emptyNumberField = {
|
|
16
|
+
...emptyBaseField,
|
|
17
|
+
type: "number",
|
|
18
|
+
minimum: Number.MIN_SAFE_INTEGER,
|
|
19
|
+
maximum: Number.MAX_SAFE_INTEGER,
|
|
20
|
+
integers: false,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// NOTE: If changing, also change it in StringOrTextField.ts (avoid circular imports)
|
|
24
|
+
const valueIsFormikUserFormRevision = (form: FormikUserFormRevision | Form): form is FormikUserFormRevision => {
|
|
25
|
+
return "fields" in form
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface NumberFieldOptions extends ChildFieldOptions<NumberFieldValue> {
|
|
29
|
+
maximum?: number
|
|
30
|
+
minimum?: number
|
|
31
|
+
integers?: boolean
|
|
32
|
+
placeholder?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class NumberField extends BaseField<NumberFieldValue, "number"> {
|
|
36
|
+
static readonly fieldTypeName = "Number"
|
|
37
|
+
static readonly fieldTypeDescription = "Allows specifying a number within a given range."
|
|
38
|
+
|
|
39
|
+
public readonly minimum: number | undefined
|
|
40
|
+
public readonly maximum: number | undefined
|
|
41
|
+
public readonly integers: boolean
|
|
42
|
+
public readonly placeholder: string
|
|
43
|
+
|
|
44
|
+
static Icon: typeof RiHashtag = RiHashtag
|
|
45
|
+
|
|
46
|
+
constructor(options: NumberFieldOptions) {
|
|
47
|
+
const {
|
|
48
|
+
minimum = Number.MIN_SAFE_INTEGER,
|
|
49
|
+
maximum = Number.MAX_SAFE_INTEGER,
|
|
50
|
+
integers = false,
|
|
51
|
+
placeholder = "Enter a number",
|
|
52
|
+
...base
|
|
53
|
+
} = options
|
|
54
|
+
super({ ...base, type: "number" })
|
|
55
|
+
this.minimum = minimum
|
|
56
|
+
this.maximum = maximum
|
|
57
|
+
this.integers = integers
|
|
58
|
+
this.placeholder = placeholder
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public getValueFromChangeEvent(event: ChangeEvent<HTMLInputElement>): NumberFieldValue {
|
|
62
|
+
const number = Number.parseFloat(event.target.value)
|
|
63
|
+
|
|
64
|
+
if (Number.isNaN(number)) return ""
|
|
65
|
+
return number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static _validateMin: (path: string) => InputValidator<NumberFieldValue> = (path: string) => (value, allValues) => {
|
|
69
|
+
const field = valueIsFormikUserFormRevision(allValues)
|
|
70
|
+
? (get(allValues, path) as SerializedNumberField)
|
|
71
|
+
: allValues
|
|
72
|
+
if (typeof field.maximum === "number" && typeof value === "number" && field.maximum < value) {
|
|
73
|
+
return "Minimum cannot be greater than minimum."
|
|
74
|
+
}
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
static _validateMax: (path: string) => InputValidator<NumberFieldValue> = (path: string) => (value, allValues) => {
|
|
79
|
+
const field = valueIsFormikUserFormRevision(allValues)
|
|
80
|
+
? (get(allValues, path) as SerializedNumberField)
|
|
81
|
+
: allValues
|
|
82
|
+
if (typeof field.minimum === "number" && typeof value === "number" && field.minimum > value) {
|
|
83
|
+
return "Maximum cannot be less than minimum."
|
|
84
|
+
}
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
static getFieldCreationSchema(parentPath = "") {
|
|
89
|
+
const path = parentPath && `${parentPath}.`
|
|
90
|
+
return [
|
|
91
|
+
{
|
|
92
|
+
field: new NumberField({
|
|
93
|
+
label: "Minimum",
|
|
94
|
+
description: "Minimum value",
|
|
95
|
+
integers: true,
|
|
96
|
+
required: false,
|
|
97
|
+
identifier: `${path}minimum`,
|
|
98
|
+
formValidators: [this._validateMin(parentPath)],
|
|
99
|
+
}),
|
|
100
|
+
showDirectly: false,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
field: new NumberField({
|
|
104
|
+
label: "Maximum",
|
|
105
|
+
description: "Maximum value",
|
|
106
|
+
integers: true,
|
|
107
|
+
required: false,
|
|
108
|
+
identifier: `${path}maximum`,
|
|
109
|
+
formValidators: [this._validateMax(parentPath)],
|
|
110
|
+
}),
|
|
111
|
+
showDirectly: false,
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
field: new BooleanField({
|
|
115
|
+
label: "Integers",
|
|
116
|
+
description: "Whole numbers only",
|
|
117
|
+
required: false,
|
|
118
|
+
identifier: `${path}integers`,
|
|
119
|
+
}),
|
|
120
|
+
showDirectly: false,
|
|
121
|
+
},
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
getFieldValidators(): InputFieldLevelValidator<NumberFieldValue>[] {
|
|
126
|
+
const validators = super.getFieldValidators()
|
|
127
|
+
const min = this.minimum
|
|
128
|
+
const max = this.maximum
|
|
129
|
+
|
|
130
|
+
if (typeof min === "number") {
|
|
131
|
+
validators.push((value) => {
|
|
132
|
+
if (typeof value === "number" && value < min) {
|
|
133
|
+
return `Must be at least ${this.minimum}.`
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
if (typeof max === "number") {
|
|
138
|
+
validators.push((value) => {
|
|
139
|
+
if (typeof value === "number" && value > max) {
|
|
140
|
+
return `Must be at most ${this.maximum}.`
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
if (this.integers) {
|
|
145
|
+
validators.push((value) => {
|
|
146
|
+
if (typeof value === "number" && !Number.isInteger(value)) {
|
|
147
|
+
return "Must be a whole number."
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return validators
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
serialize(): SerializedNumberField {
|
|
156
|
+
return {
|
|
157
|
+
...super._serialize(),
|
|
158
|
+
minimum: this.minimum,
|
|
159
|
+
maximum: this.maximum,
|
|
160
|
+
integers: this.integers,
|
|
161
|
+
placeholder: this.placeholder,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
static deserialize(data: ISerializedField): NumberField {
|
|
166
|
+
if (data.type !== "number") throw new Error("Type mismatch.")
|
|
167
|
+
return new NumberField(data)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
getInput(props: GetInputProps<this>): ReactNode {
|
|
171
|
+
return <NumberInput field={this} {...props} />
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Input } from "@overmap-ai/blocks"
|
|
2
|
+
import { memo } from "react"
|
|
3
|
+
|
|
4
|
+
import { SEVERITY_COLOR_MAPPING } from "../../constants"
|
|
5
|
+
import { InputWithLabel, InputWithLabelAndHelpText, useFormikInput } from "../BaseField"
|
|
6
|
+
import { ComponentProps } from "../typings"
|
|
7
|
+
import { NumberField } from "./NumberField"
|
|
8
|
+
|
|
9
|
+
export const NumberInput = memo((props: ComponentProps<NumberField>) => {
|
|
10
|
+
const [{ inputId, labelId, size, severity, showInputOnly, field, fieldProps }, rest] = useFormikInput(props)
|
|
11
|
+
let [{ helpText, label }] = useFormikInput(props)
|
|
12
|
+
helpText = showInputOnly ? null : helpText
|
|
13
|
+
label = showInputOnly ? "" : label
|
|
14
|
+
|
|
15
|
+
const color = severity ? SEVERITY_COLOR_MAPPING[severity] : undefined
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<InputWithLabelAndHelpText helpText={helpText} severity={severity}>
|
|
19
|
+
<InputWithLabel
|
|
20
|
+
size={size}
|
|
21
|
+
severity={severity}
|
|
22
|
+
inputId={inputId}
|
|
23
|
+
labelId={labelId}
|
|
24
|
+
label={label}
|
|
25
|
+
image={showInputOnly ? undefined : field.image}
|
|
26
|
+
>
|
|
27
|
+
<Input.Root accentColor={color} variant="soft">
|
|
28
|
+
<Input.Field
|
|
29
|
+
{...rest}
|
|
30
|
+
{...fieldProps}
|
|
31
|
+
type="number"
|
|
32
|
+
id={inputId}
|
|
33
|
+
placeholder={field.placeholder}
|
|
34
|
+
min={field.minimum}
|
|
35
|
+
max={field.maximum}
|
|
36
|
+
step={field.integers ? 1 : 0.1}
|
|
37
|
+
/>
|
|
38
|
+
</Input.Root>
|
|
39
|
+
</InputWithLabel>
|
|
40
|
+
</InputWithLabelAndHelpText>
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
NumberInput.displayName = "NumberInput"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ReactNode } from "react"
|
|
2
|
+
import { RiQrCodeLine } from "react-icons/ri"
|
|
3
|
+
|
|
4
|
+
import { ISerializedField, SerializedQrField } from "../../typings"
|
|
5
|
+
import { BaseField, ChildFieldOptions, emptyBaseField } from "../BaseField"
|
|
6
|
+
import { ComponentProps } from "../typings"
|
|
7
|
+
import { QrInput } from "./QrInput"
|
|
8
|
+
|
|
9
|
+
export const emptyQrField = {
|
|
10
|
+
...emptyBaseField,
|
|
11
|
+
type: "qr",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class QrField extends BaseField<string, "qr"> {
|
|
15
|
+
static readonly fieldTypeName = "QR"
|
|
16
|
+
static readonly fieldTypeDescription = "Used for scanning/reading QR codes."
|
|
17
|
+
|
|
18
|
+
public readonly onlyValidateAfterTouched = false
|
|
19
|
+
|
|
20
|
+
static Icon: typeof RiQrCodeLine = RiQrCodeLine
|
|
21
|
+
|
|
22
|
+
public constructor(options: ChildFieldOptions<string>) {
|
|
23
|
+
super({ ...options, type: "qr" })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
serialize(): SerializedQrField {
|
|
27
|
+
return super._serialize()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static deserialize(data: ISerializedField): QrField {
|
|
31
|
+
if (data.type !== "qr") throw new Error("Type mismatch.")
|
|
32
|
+
return new QrField(data)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
getInput(props: ComponentProps<QrField>): ReactNode {
|
|
36
|
+
return <QrInput {...props} field={this} />
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { Button, Card, IconButton, RiIcon, Spinner } from "@overmap-ai/blocks"
|
|
2
|
+
import * as RadixDialog from "@radix-ui/react-dialog"
|
|
3
|
+
import QrScannerAPI from "qr-scanner"
|
|
4
|
+
import { memo, useCallback, useEffect, useRef, useState } from "react"
|
|
5
|
+
|
|
6
|
+
import { InputWithLabel, InputWithLabelAndHelpText, useFormikInput } from "../BaseField"
|
|
7
|
+
import { ComponentProps } from "../typings"
|
|
8
|
+
import { QrField } from "./QrField"
|
|
9
|
+
|
|
10
|
+
export const QrInput = memo((props: ComponentProps<QrField>) => {
|
|
11
|
+
const [{ inputId, labelId, label, helpText, size, severity, showInputOnly, field, fieldProps }, rest] =
|
|
12
|
+
useFormikInput(props)
|
|
13
|
+
const [showQrScanner, setShowQrScanner] = useState<boolean>(false)
|
|
14
|
+
|
|
15
|
+
const value = fieldProps.value
|
|
16
|
+
|
|
17
|
+
const handleQrScan = useCallback(
|
|
18
|
+
(data: string) => {
|
|
19
|
+
// we have to "mock" out a change event here since its what expected from the BaseField
|
|
20
|
+
fieldProps.onChange({ target: { value: data } })
|
|
21
|
+
// closing the scanner on succesfull scan
|
|
22
|
+
setShowQrScanner(false)
|
|
23
|
+
},
|
|
24
|
+
[fieldProps],
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
const handleClearScanResult = useCallback(() => {
|
|
28
|
+
fieldProps.onChange({ target: { value: "" } })
|
|
29
|
+
}, [fieldProps])
|
|
30
|
+
|
|
31
|
+
const handleScanButtonClicked = useCallback(() => {
|
|
32
|
+
setShowQrScanner(true)
|
|
33
|
+
}, [])
|
|
34
|
+
|
|
35
|
+
const handleQrScannerClose = useCallback(() => {
|
|
36
|
+
setShowQrScanner(false)
|
|
37
|
+
}, [])
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<InputWithLabelAndHelpText helpText={helpText} severity={severity}>
|
|
41
|
+
<InputWithLabel
|
|
42
|
+
size={size}
|
|
43
|
+
severity={severity}
|
|
44
|
+
inputId={inputId}
|
|
45
|
+
labelId={labelId}
|
|
46
|
+
label={label}
|
|
47
|
+
image={showInputOnly ? undefined : field.image}
|
|
48
|
+
className="flex-col items-start justify-start gap-1"
|
|
49
|
+
>
|
|
50
|
+
<RadixDialog.Root open={showQrScanner} onOpenChange={setShowQrScanner}>
|
|
51
|
+
<RadixDialog.Portal>
|
|
52
|
+
<RadixDialog.Overlay className="light:bg-(--black-a6) fixed inset-0 dark:bg-(--black-a8)" />
|
|
53
|
+
<RadixDialog.Content className="fixed inset-0">
|
|
54
|
+
<QrScanner onQrScan={handleQrScan} onClose={handleQrScannerClose} />
|
|
55
|
+
</RadixDialog.Content>
|
|
56
|
+
</RadixDialog.Portal>
|
|
57
|
+
</RadixDialog.Root>
|
|
58
|
+
|
|
59
|
+
<div className="flex w-max items-center gap-1">
|
|
60
|
+
<Button {...rest} variant="soft" onClick={handleScanButtonClicked}>
|
|
61
|
+
<RiIcon icon="RiQrCodeLine" />
|
|
62
|
+
Scan
|
|
63
|
+
</Button>
|
|
64
|
+
{value && (
|
|
65
|
+
<span className="text-xs text-(--accent-a11)" data-accent-color="primary">
|
|
66
|
+
<RiIcon icon="RiCheckLine" style={{ verticalAlign: "bottom" }} />
|
|
67
|
+
</span>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
{!!value && (
|
|
72
|
+
<Card>
|
|
73
|
+
<div className="w-max items-center gap-2">
|
|
74
|
+
<code className="bg-(--base-a3)">{value}</code>
|
|
75
|
+
<IconButton
|
|
76
|
+
accentColor="base"
|
|
77
|
+
variant="ghost"
|
|
78
|
+
aria-label="delete"
|
|
79
|
+
size="sm"
|
|
80
|
+
onClick={handleClearScanResult}
|
|
81
|
+
>
|
|
82
|
+
<RiIcon icon="RiCloseLine" />
|
|
83
|
+
</IconButton>
|
|
84
|
+
</div>
|
|
85
|
+
</Card>
|
|
86
|
+
)}
|
|
87
|
+
</InputWithLabel>
|
|
88
|
+
</InputWithLabelAndHelpText>
|
|
89
|
+
)
|
|
90
|
+
})
|
|
91
|
+
QrInput.displayName = "QrInput"
|
|
92
|
+
|
|
93
|
+
interface QrScannerProps {
|
|
94
|
+
onQrScan: (data: string) => void
|
|
95
|
+
onClose: () => void
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const QrScanner = memo((props: QrScannerProps) => {
|
|
99
|
+
const { onQrScan, onClose } = props
|
|
100
|
+
const videoRef = useRef<HTMLVideoElement>(null)
|
|
101
|
+
const [isScannerLoading, setIsScannerLoading] = useState<boolean>(false)
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (!videoRef.current) return
|
|
105
|
+
|
|
106
|
+
const qrScanner = new QrScannerAPI(
|
|
107
|
+
videoRef.current,
|
|
108
|
+
(result) => {
|
|
109
|
+
const data = result.data
|
|
110
|
+
onQrScan(data)
|
|
111
|
+
qrScanner.destroy()
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
highlightCodeOutline: true,
|
|
115
|
+
highlightScanRegion: true,
|
|
116
|
+
maxScansPerSecond: 1,
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
setIsScannerLoading(true)
|
|
120
|
+
// returns a promise when the scanner is ready
|
|
121
|
+
void qrScanner.start().finally(() => {
|
|
122
|
+
setIsScannerLoading(false)
|
|
123
|
+
})
|
|
124
|
+
}, [onQrScan])
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div className="relative flex h-full w-full flex-col justify-center gap-2 bg-(--color-background)">
|
|
128
|
+
<div className="absolute top-0 flex w-full p-2">
|
|
129
|
+
<IconButton aria-label="close" variant="soft" accentColor="base" onClick={onClose}>
|
|
130
|
+
<RiIcon icon="RiCloseLine" />
|
|
131
|
+
</IconButton>
|
|
132
|
+
</div>
|
|
133
|
+
<div className="relative max-h-full max-w-full">
|
|
134
|
+
<video ref={videoRef} style={{ width: "100%", height: "100%" }}></video>
|
|
135
|
+
{isScannerLoading && (
|
|
136
|
+
<div className="absolute inset-0 flex items-center justify-center bg-(--color-background)">
|
|
137
|
+
<Spinner />
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
)
|
|
143
|
+
})
|
|
144
|
+
QrScanner.displayName = "QrScanner"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { FieldValue, SelectFieldOption } from "../../typings"
|
|
2
|
+
import { BaseField, ChildFieldOptions } from "../BaseField"
|
|
3
|
+
import { MultiStringField } from "../MultiStringField"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The options passed to the constructor of SelectField.
|
|
7
|
+
*/
|
|
8
|
+
export interface BaseSelectFieldOptions<TValue, TIdentifier extends "select" | "multi-select">
|
|
9
|
+
extends ChildFieldOptions<TValue> {
|
|
10
|
+
/** When instantiating a SelectField, you can either pass an array of strings or an array of objects. User-created
|
|
11
|
+
* forms only support arrays of strings. For more complex internal purposes, you can provide an array of objects
|
|
12
|
+
* where the `label` is the text to display to the user and the `value` is the value handled by Formik.*/
|
|
13
|
+
options: string[] | SelectFieldOption[]
|
|
14
|
+
type: TIdentifier
|
|
15
|
+
placeholder?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export abstract class BaseSelectField<
|
|
19
|
+
TValue extends FieldValue,
|
|
20
|
+
TIdentifier extends "select" | "multi-select",
|
|
21
|
+
> extends BaseField<TValue, TIdentifier> {
|
|
22
|
+
public readonly options: SelectFieldOption[]
|
|
23
|
+
public readonly onlyValidateAfterTouched = false
|
|
24
|
+
public readonly placeholder: string
|
|
25
|
+
|
|
26
|
+
protected constructor(options: BaseSelectFieldOptions<TValue, TIdentifier>) {
|
|
27
|
+
const { placeholder = "", ...base } = options
|
|
28
|
+
super(base)
|
|
29
|
+
this.placeholder = placeholder
|
|
30
|
+
// SelectField supports two types of options: string[] and { value: string, identifier: string }[]. If
|
|
31
|
+
const encounteredIds = new Set<string>()
|
|
32
|
+
this.options = options.options.map((option: string | SelectFieldOption): SelectFieldOption => {
|
|
33
|
+
if (typeof option === "string") {
|
|
34
|
+
option = { label: option, value: option }
|
|
35
|
+
}
|
|
36
|
+
encounteredIds.add(option.label)
|
|
37
|
+
return option
|
|
38
|
+
})
|
|
39
|
+
if (encounteredIds.size !== options.options.length) {
|
|
40
|
+
// TODO: Determine if we need to prevent this.
|
|
41
|
+
console.error(
|
|
42
|
+
`${
|
|
43
|
+
options.options.length - encounteredIds.size
|
|
44
|
+
} duplicate identifiers found in options. This may cause unexpected behavior. Options:`,
|
|
45
|
+
options.options,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
protected _serialize() {
|
|
51
|
+
return {
|
|
52
|
+
...super._serialize(),
|
|
53
|
+
options: this.options,
|
|
54
|
+
placeholder: this.placeholder,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static getFieldCreationSchema(parentPath = "") {
|
|
59
|
+
const path = parentPath && `${parentPath}.`
|
|
60
|
+
return [
|
|
61
|
+
{
|
|
62
|
+
field: new MultiStringField({
|
|
63
|
+
label: "Options",
|
|
64
|
+
description: "List possible options for the user to select from.",
|
|
65
|
+
required: true,
|
|
66
|
+
identifier: `${path}options`,
|
|
67
|
+
minimum_length: 2,
|
|
68
|
+
}),
|
|
69
|
+
showDirectly: true,
|
|
70
|
+
},
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
}
|