@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,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,2 @@
1
+ export * from "./MultiStringField"
2
+ export * from "./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,2 @@
1
+ export * from "./NumberField"
2
+ export * from "./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,5 @@
1
+ .QrScannerWrapper
2
+ background: var(--color-background)
3
+
4
+ svg
5
+ stroke: #29A383 !important
@@ -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,2 @@
1
+ export * from "./QrField"
2
+ export * from "./QrInput"
@@ -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
+ }