@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,48 @@
|
|
|
1
|
+
import { ChangeEvent, ReactNode } from "react"
|
|
2
|
+
import { RiCheckboxCircleLine } from "react-icons/ri"
|
|
3
|
+
|
|
4
|
+
import { ISerializedField, SerializedBooleanField } from "../../typings"
|
|
5
|
+
import { BaseField, ChildFieldOptions, emptyBaseField } from "../BaseField"
|
|
6
|
+
import { ComponentProps } from "../typings"
|
|
7
|
+
import { BooleanInput } from "./BooleanInput"
|
|
8
|
+
|
|
9
|
+
export const emptyBooleanField = {
|
|
10
|
+
...emptyBaseField,
|
|
11
|
+
type: "boolean",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class BooleanField extends BaseField<boolean, "boolean"> {
|
|
15
|
+
static readonly fieldTypeName = "Checkbox"
|
|
16
|
+
static readonly fieldTypeDescription = "Perfect for both optional and required yes/no questions."
|
|
17
|
+
|
|
18
|
+
public readonly onlyValidateAfterTouched = false
|
|
19
|
+
|
|
20
|
+
static Icon: typeof RiCheckboxCircleLine = RiCheckboxCircleLine
|
|
21
|
+
|
|
22
|
+
public constructor(options: ChildFieldOptions<boolean>) {
|
|
23
|
+
super({ ...options, type: "boolean" })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// if a BooleanField is required, `false` is considered blank
|
|
27
|
+
protected isBlank(value: boolean): boolean {
|
|
28
|
+
return this.required && !value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public getValueFromChangeEvent(event: ChangeEvent<HTMLInputElement> | boolean): boolean {
|
|
32
|
+
if (typeof event === "boolean") return event
|
|
33
|
+
return event.target.checked
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
serialize(): SerializedBooleanField {
|
|
37
|
+
return super._serialize()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
static deserialize(data: ISerializedField): BooleanField {
|
|
41
|
+
if (data.type !== "boolean") throw new Error("Type mismatch.")
|
|
42
|
+
return new BooleanField(data)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
getInput(props: ComponentProps<BooleanField>): ReactNode {
|
|
46
|
+
return <BooleanInput {...props} field={this} />
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Checkbox, RiIcon } from "@overmap-ai/blocks"
|
|
2
|
+
import { memo } from "react"
|
|
3
|
+
|
|
4
|
+
import { SEVERITY_COLOR_MAPPING } from "../../constants"
|
|
5
|
+
import { InputWithLabel, InputWithLabelAndHelpText } from "../BaseField"
|
|
6
|
+
import { useFormikInput } from "../BaseField"
|
|
7
|
+
import { ComponentProps } from "../typings"
|
|
8
|
+
import { BooleanField } from "./BooleanField"
|
|
9
|
+
|
|
10
|
+
const truthyValues = [true, "true"]
|
|
11
|
+
|
|
12
|
+
export const BooleanInput = memo((props: ComponentProps<BooleanField>) => {
|
|
13
|
+
const [{ inputId, labelId, size, severity, showInputOnly, field, fieldProps }, rest] = useFormikInput(props)
|
|
14
|
+
let [{ helpText, label }] = useFormikInput(props)
|
|
15
|
+
helpText = showInputOnly ? null : helpText
|
|
16
|
+
label = showInputOnly ? "" : label
|
|
17
|
+
|
|
18
|
+
const color = severity ? SEVERITY_COLOR_MAPPING[severity] : undefined
|
|
19
|
+
const value = truthyValues.includes(fieldProps.value)
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<InputWithLabelAndHelpText helpText={helpText} severity={severity}>
|
|
23
|
+
<InputWithLabel
|
|
24
|
+
size={size}
|
|
25
|
+
severity={severity}
|
|
26
|
+
inputId={inputId}
|
|
27
|
+
labelId={labelId}
|
|
28
|
+
label={label}
|
|
29
|
+
image={showInputOnly ? undefined : field.image}
|
|
30
|
+
className="align-center flex-row-reverse justify-end gap-2"
|
|
31
|
+
>
|
|
32
|
+
<Checkbox.Root
|
|
33
|
+
{...rest}
|
|
34
|
+
{...fieldProps}
|
|
35
|
+
id={inputId}
|
|
36
|
+
accentColor={color}
|
|
37
|
+
value={value.toString()}
|
|
38
|
+
checked={value}
|
|
39
|
+
onCheckedChange={fieldProps.onChange}
|
|
40
|
+
// disabled onChange and onBlur as that is handled by onCheckedChange
|
|
41
|
+
onChange={undefined}
|
|
42
|
+
onBlur={undefined}
|
|
43
|
+
variant="soft"
|
|
44
|
+
>
|
|
45
|
+
<Checkbox.Indicator>
|
|
46
|
+
<RiIcon icon="RiCheckLine" />
|
|
47
|
+
</Checkbox.Indicator>
|
|
48
|
+
</Checkbox.Root>
|
|
49
|
+
</InputWithLabel>
|
|
50
|
+
</InputWithLabelAndHelpText>
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
BooleanInput.displayName = "BooleanInput"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ISerializedOnlyField } from "@overmap-ai/core"
|
|
2
|
+
import { FC, ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
import { FieldTypeIdentifier, FieldValue } from "../../typings"
|
|
5
|
+
import { BaseField, ChildFieldOptions, emptyBaseField } from "../BaseField"
|
|
6
|
+
import { GetInputProps } from "../typings"
|
|
7
|
+
|
|
8
|
+
export type CustomFieldOptions<TValue> = ChildFieldOptions<TValue>
|
|
9
|
+
|
|
10
|
+
export const emptyCustomField = {
|
|
11
|
+
...emptyBaseField,
|
|
12
|
+
type: "custom",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class CustomField<
|
|
16
|
+
TValue extends FieldValue,
|
|
17
|
+
/** The options passed to constructor */
|
|
18
|
+
TFieldOptions extends CustomFieldOptions<TValue>,
|
|
19
|
+
/** The props passed to the custom component */
|
|
20
|
+
TComponentProps extends GetInputProps<CustomField<TValue, TFieldOptions, TComponentProps>>,
|
|
21
|
+
TIdentifier extends FieldTypeIdentifier = FieldTypeIdentifier,
|
|
22
|
+
> extends BaseField<TValue, TIdentifier> {
|
|
23
|
+
static readonly fieldTypeName = "Custom"
|
|
24
|
+
static readonly fieldTypeDescription = "Allows re-rendering of field already in the form"
|
|
25
|
+
|
|
26
|
+
public readonly Component: FC<TComponentProps>
|
|
27
|
+
|
|
28
|
+
// identifier of the field whose value is the label of the field to re-render
|
|
29
|
+
public readonly options: TFieldOptions
|
|
30
|
+
|
|
31
|
+
constructor(options: TFieldOptions, Component: FC<TComponentProps>) {
|
|
32
|
+
super({ ...options, type: "custom" })
|
|
33
|
+
this.options = options
|
|
34
|
+
this.Component = Component
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
serialize(): ISerializedOnlyField {
|
|
38
|
+
throw new Error("Serializing only supported for public input types.")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
getInput(props: TComponentProps): ReactNode {
|
|
42
|
+
const CustomInput = this.Component
|
|
43
|
+
return <CustomInput field={this} {...props} />
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useField } from "formik"
|
|
2
|
+
import { memo, useMemo } from "react"
|
|
3
|
+
|
|
4
|
+
import { deserialize, useFieldInput } from "../../index"
|
|
5
|
+
import { FieldInputClonerProps } from "./typings"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Used to dynamically "clone" a field's input for use in conditional sections. When a field is selected for a
|
|
9
|
+
* condition, we need to render the same input in the condition section, so the user can input a value for the
|
|
10
|
+
* condition.
|
|
11
|
+
*/
|
|
12
|
+
export const FieldInputCloner = memo((props: FieldInputClonerProps) => {
|
|
13
|
+
const { field, ...rest } = props
|
|
14
|
+
const [{ value: identifier }] = useField<string>(field.options.clonedFieldIdentifier)
|
|
15
|
+
|
|
16
|
+
const deserializedField = useMemo(() => {
|
|
17
|
+
const options = field.options.getFieldToClone(identifier)
|
|
18
|
+
if (!options) return null
|
|
19
|
+
return deserialize(options)
|
|
20
|
+
}, [field.options, identifier])
|
|
21
|
+
|
|
22
|
+
return useFieldInput(deserializedField, rest)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
FieldInputCloner.displayName = "FieldInputCloner"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { FieldValue, ISerializedField } from "../../../typings"
|
|
2
|
+
import { CustomField, CustomFieldOptions } from "../CustomField"
|
|
3
|
+
import { FieldInputCloner, FieldInputClonerProps } from "./index"
|
|
4
|
+
|
|
5
|
+
export interface FieldInputClonerFieldOptions extends CustomFieldOptions<unknown> {
|
|
6
|
+
/** Given an identifier, should return the options of the field with the
|
|
7
|
+
* corresponding identifier (the field being cloned) */
|
|
8
|
+
getFieldToClone: (identifier: string) => ISerializedField | null
|
|
9
|
+
/** The identifier of the field to clone */
|
|
10
|
+
clonedFieldIdentifier: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The purpose of this is to display a value input field in the condition of a section. The input field will look like
|
|
15
|
+
* the input field of the condition. For example, when specifying the conditional value of a SelectField, a SelectInput
|
|
16
|
+
* will be rendered.
|
|
17
|
+
*/
|
|
18
|
+
export class FieldInputClonerField extends CustomField<
|
|
19
|
+
FieldValue,
|
|
20
|
+
FieldInputClonerFieldOptions,
|
|
21
|
+
FieldInputClonerProps
|
|
22
|
+
> {
|
|
23
|
+
constructor(options: FieldInputClonerFieldOptions) {
|
|
24
|
+
super(options, FieldInputCloner)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { FieldValue } from "../../../typings"
|
|
2
|
+
import { AnyField, ComponentProps, GetInputProps } from "../../typings"
|
|
3
|
+
import { CustomField } from "../CustomField"
|
|
4
|
+
import { FieldInputClonerFieldOptions } from "./FieldInputClonerField"
|
|
5
|
+
|
|
6
|
+
export type FieldInputClonerProps = ComponentProps<
|
|
7
|
+
CustomField<FieldValue, FieldInputClonerFieldOptions, GetInputProps<AnyField>>
|
|
8
|
+
>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./CustomField"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ChangeEvent, ReactNode } from "react"
|
|
2
|
+
import { RiCalendarLine } from "react-icons/ri"
|
|
3
|
+
|
|
4
|
+
import { ISerializedField, SerializedDateField } from "../../typings"
|
|
5
|
+
import { BaseField, ChildFieldOptions, emptyBaseField } from "../BaseField"
|
|
6
|
+
import { GetInputProps } from "../typings"
|
|
7
|
+
import { DateInput } from "./DateInput"
|
|
8
|
+
|
|
9
|
+
export const emptyDateField = {
|
|
10
|
+
...emptyBaseField,
|
|
11
|
+
type: "date",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class DateField extends BaseField<string, "date"> {
|
|
15
|
+
static readonly fieldTypeName = "Date"
|
|
16
|
+
static readonly fieldTypeDescription = "Allows specifying a date."
|
|
17
|
+
|
|
18
|
+
static Icon: typeof RiCalendarLine = RiCalendarLine
|
|
19
|
+
|
|
20
|
+
public readonly onlyValidateAfterTouched = false
|
|
21
|
+
|
|
22
|
+
public constructor(options: ChildFieldOptions<string>) {
|
|
23
|
+
super({ ...options, type: "date" })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
serialize(): SerializedDateField {
|
|
27
|
+
return super._serialize()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public getValueFromChangeEvent(event: ChangeEvent<HTMLInputElement>): string {
|
|
31
|
+
return new Date(event.target.value).toISOString()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static deserialize(data: ISerializedField): DateField {
|
|
35
|
+
if (data.type !== "date") throw new Error("Type mismatch.")
|
|
36
|
+
return new DateField(data)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
getInput(props: GetInputProps<this>): ReactNode {
|
|
40
|
+
return <DateInput field={this} {...props} />
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
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 { DateField } from "./DateField"
|
|
8
|
+
|
|
9
|
+
export const DateInput = memo((props: ComponentProps<DateField>) => {
|
|
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
|
+
// TODO: Add timezone info
|
|
18
|
+
// remove the time from the date
|
|
19
|
+
const value: string = fieldProps.value ? fieldProps.value.split("T")[0]! : ""
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<InputWithLabelAndHelpText helpText={helpText} severity={severity}>
|
|
23
|
+
<InputWithLabel
|
|
24
|
+
size={size}
|
|
25
|
+
severity={severity}
|
|
26
|
+
inputId={inputId}
|
|
27
|
+
labelId={labelId}
|
|
28
|
+
label={label}
|
|
29
|
+
image={showInputOnly ? undefined : field.image}
|
|
30
|
+
>
|
|
31
|
+
<Input.Root accentColor={color} variant="soft">
|
|
32
|
+
<Input.Field {...rest} type="date" id={inputId} color={color} value={value} />
|
|
33
|
+
</Input.Root>
|
|
34
|
+
</InputWithLabel>
|
|
35
|
+
</InputWithLabelAndHelpText>
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
DateInput.displayName = "DateInput"
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import get from "lodash.get"
|
|
2
|
+
import set from "lodash.set"
|
|
3
|
+
import { ReactNode } from "react"
|
|
4
|
+
|
|
5
|
+
import { FormikUserFormRevision } from "../../builder"
|
|
6
|
+
import {
|
|
7
|
+
BaseSerializedObject,
|
|
8
|
+
Form,
|
|
9
|
+
ISerializedField,
|
|
10
|
+
SelectFieldOption,
|
|
11
|
+
SerializedCondition,
|
|
12
|
+
SerializedFieldSection,
|
|
13
|
+
} from "../../typings"
|
|
14
|
+
import { FieldInputClonerField, FieldInputClonerFieldOptions } from "../CustomField/FieldInputClonerField"
|
|
15
|
+
import { AnyField, BaseFormElement, BooleanField, deserializeField, GetInputProps, SelectField } from "../index"
|
|
16
|
+
import { FieldSectionLayout } from "./FieldSectionLayout"
|
|
17
|
+
|
|
18
|
+
interface FieldSectionOptions extends Omit<BaseSerializedObject, "type"> {
|
|
19
|
+
label?: string | null
|
|
20
|
+
conditional?: boolean
|
|
21
|
+
condition?: SerializedCondition | null
|
|
22
|
+
fields: AnyField[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class FieldSection extends BaseFormElement<"section"> {
|
|
26
|
+
static readonly fieldTypeName = "Section"
|
|
27
|
+
static readonly fieldTypeDescription =
|
|
28
|
+
"Sections can be useful for grouping fields together. They can also be conditionally shown or hidden."
|
|
29
|
+
|
|
30
|
+
public readonly label: string | null
|
|
31
|
+
public readonly fields: AnyField[]
|
|
32
|
+
public readonly condition: SerializedCondition | null
|
|
33
|
+
|
|
34
|
+
public constructor(options: FieldSectionOptions) {
|
|
35
|
+
const { label = null, fields, condition = null, conditional, ...base } = options
|
|
36
|
+
|
|
37
|
+
super({ ...base, type: "section" })
|
|
38
|
+
this.fields = fields
|
|
39
|
+
this.condition = condition
|
|
40
|
+
this.label = label
|
|
41
|
+
|
|
42
|
+
// handle case when condition is removed
|
|
43
|
+
if (conditional === false) {
|
|
44
|
+
this.condition = null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static getFieldCreationSchema(options: ISerializedField[], parentPath = "") {
|
|
49
|
+
// conditions require at least one field to be present
|
|
50
|
+
if (options.length === 0) return []
|
|
51
|
+
|
|
52
|
+
const path = parentPath && `${parentPath}.`
|
|
53
|
+
return [
|
|
54
|
+
{
|
|
55
|
+
field: new BooleanField({
|
|
56
|
+
label: "Conditionally render section",
|
|
57
|
+
identifier: `${path}conditional`,
|
|
58
|
+
required: false,
|
|
59
|
+
}),
|
|
60
|
+
showDirectly: true,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
// Declare a section that will hold options for the condition (if any).
|
|
64
|
+
field: new FieldSection({
|
|
65
|
+
label: "Conditional settings",
|
|
66
|
+
identifier: `${path}conditional-settings`,
|
|
67
|
+
// This section will only be rendered if the above "Conditional" field is checked.
|
|
68
|
+
condition: {
|
|
69
|
+
identifier: `${path}conditional`,
|
|
70
|
+
value: true,
|
|
71
|
+
},
|
|
72
|
+
// These are the options of the condition.
|
|
73
|
+
fields: [
|
|
74
|
+
// Declare a select field that will be used to select the field against which we will check the
|
|
75
|
+
// condition. This must be selected before the next field is rendered.
|
|
76
|
+
new SelectField({
|
|
77
|
+
label: "Field",
|
|
78
|
+
description: "The field to use for the condition.",
|
|
79
|
+
// The options (for the field against which we will check the condition) are all the labels of
|
|
80
|
+
// the fields in the previous section(s) (or fields declared before with no section) that
|
|
81
|
+
// support conditions. We pass in both the label and the identifier of each supported field. The
|
|
82
|
+
// identifier becomes the value of the option.
|
|
83
|
+
options: options
|
|
84
|
+
.map((option): SelectFieldOption | null => {
|
|
85
|
+
// If the field doesn't have a label, it can't be used for a condition.
|
|
86
|
+
if (!option.label) return null
|
|
87
|
+
// Upload fields cannot be used for conditions.
|
|
88
|
+
if (option.type === "upload") return null
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
label: option.label,
|
|
92
|
+
value: option.identifier,
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
.filter((option): option is SelectFieldOption => !!option),
|
|
96
|
+
identifier: `${path}condition.identifier`,
|
|
97
|
+
required: true,
|
|
98
|
+
}),
|
|
99
|
+
// Declare a custom field that will be used to input a value for the condition. The value of the
|
|
100
|
+
// conditional field selected in the previous step must be equal to the value the user inputs into
|
|
101
|
+
// this field for the section to be rendered.
|
|
102
|
+
new FieldInputClonerField({
|
|
103
|
+
label: "Value",
|
|
104
|
+
identifier: `${path}condition.value`,
|
|
105
|
+
required: true,
|
|
106
|
+
clonedFieldIdentifier: `${path}condition.identifier`,
|
|
107
|
+
getFieldToClone(identifier: string) {
|
|
108
|
+
if (!identifier) {
|
|
109
|
+
// No field has been chosen yet.
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
// Find the field options with a matching identifier.
|
|
113
|
+
const option = options.find((option) => option.identifier === identifier)
|
|
114
|
+
if (!option) {
|
|
115
|
+
console.error("Could not find field with identifier", identifier)
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
...option,
|
|
120
|
+
// Override some options to make it make sense in the context and to make it work with the framework.
|
|
121
|
+
label: "Value",
|
|
122
|
+
identifier: `${path}condition.value`,
|
|
123
|
+
description: "The value to compare against.",
|
|
124
|
+
required: option.type !== "boolean",
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
} satisfies FieldInputClonerFieldOptions),
|
|
128
|
+
],
|
|
129
|
+
}),
|
|
130
|
+
showDirectly: false,
|
|
131
|
+
},
|
|
132
|
+
]
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
static deserialize(data: ISerializedField): FieldSection {
|
|
136
|
+
if (data.type !== "section") throw new Error("Invalid type")
|
|
137
|
+
// REASON: Handling an edge case
|
|
138
|
+
|
|
139
|
+
if (!Array.isArray(data.fields)) throw new Error(`Invalid fields: ${data.fields} (not an array)`)
|
|
140
|
+
const fields = data.fields.map(deserializeField)
|
|
141
|
+
return new FieldSection({ ...data, fields })
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
conditional(): this is { condition: SerializedCondition } {
|
|
145
|
+
return this.condition !== null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
serialize(): SerializedFieldSection {
|
|
149
|
+
return {
|
|
150
|
+
...super._serialize(),
|
|
151
|
+
label: this.label,
|
|
152
|
+
condition: this.condition,
|
|
153
|
+
conditional: this.conditional(),
|
|
154
|
+
fields: this.fields.map((field) => field.serialize()),
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
getErrors(allValues: Form | FormikUserFormRevision): Record<string, string> {
|
|
159
|
+
const errors: Record<string, string> = {}
|
|
160
|
+
for (const field of this.fields) {
|
|
161
|
+
const id = field.getId()
|
|
162
|
+
const error = field.getError(get(allValues, id), allValues)
|
|
163
|
+
if (error) {
|
|
164
|
+
set(errors, field.getId(), error)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return errors
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
getInput(props: GetInputProps<this>): ReactNode {
|
|
171
|
+
return <FieldSectionLayout field={this} {...props} />
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Card, Heading, Text } from "@overmap-ai/blocks"
|
|
2
|
+
import { useFormikContext } from "formik"
|
|
3
|
+
import get from "lodash.get"
|
|
4
|
+
import { memo, useEffect, useMemo } from "react"
|
|
5
|
+
|
|
6
|
+
import { FieldValue } from "../../typings"
|
|
7
|
+
import { useFieldInputs } from "../hooks"
|
|
8
|
+
import { ComponentProps } from "../typings"
|
|
9
|
+
import { isConditionMet } from "../utils"
|
|
10
|
+
import { FieldSection } from "./FieldSection"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Used by FieldSection to render a section of fields.
|
|
14
|
+
*/
|
|
15
|
+
export const FieldSectionLayout = memo((props: ComponentProps<FieldSection>) => {
|
|
16
|
+
const { field: section, ...rest } = props
|
|
17
|
+
const { label, description, fields, condition } = section
|
|
18
|
+
const { values, setFieldValue } = useFormikContext()
|
|
19
|
+
|
|
20
|
+
const conditionValue = condition?.identifier ? (get(values, condition.identifier) as FieldValue) : undefined
|
|
21
|
+
// sections without a condition are always met
|
|
22
|
+
const conditionMet = useMemo(
|
|
23
|
+
() => conditionValue === undefined || isConditionMet(condition, conditionValue),
|
|
24
|
+
[condition, conditionValue],
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
// reset all fields in section if condition is not met
|
|
29
|
+
if (!conditionMet) {
|
|
30
|
+
for (const childField of fields) {
|
|
31
|
+
void setFieldValue(childField.getId(), "").then()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}, [conditionMet, fields, setFieldValue])
|
|
35
|
+
|
|
36
|
+
const inputs = useFieldInputs(fields, rest)
|
|
37
|
+
|
|
38
|
+
// if condition is not met, do not render section
|
|
39
|
+
if (!conditionMet) {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Card>
|
|
45
|
+
<div className="flex flex-col gap-6">
|
|
46
|
+
<div className="flex flex-col">
|
|
47
|
+
<Heading size="lg">{label}</Heading>
|
|
48
|
+
<Text accentColor="base">{description}</Text>
|
|
49
|
+
</div>
|
|
50
|
+
{inputs}
|
|
51
|
+
</div>
|
|
52
|
+
</Card>
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
FieldSectionLayout.displayName = "FieldSectionLayout"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./FieldSection"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { ChangeEvent, ReactNode } from "react"
|
|
2
|
+
import { RiListCheck } from "react-icons/ri"
|
|
3
|
+
|
|
4
|
+
import { ISerializedField, SerializedMultiStringField } from "../../typings"
|
|
5
|
+
import { BaseField, ChildFieldOptions, emptyBaseField } from "../BaseField"
|
|
6
|
+
import { GetInputProps, InputFieldLevelValidator } from "../typings"
|
|
7
|
+
import { MultiStringInput } from "./MultiStringInput"
|
|
8
|
+
|
|
9
|
+
type MultiStringFieldOptions = ChildFieldOptions<string[]> & {
|
|
10
|
+
minimum_length?: number
|
|
11
|
+
maximum_length?: number
|
|
12
|
+
placeholder?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const emptyMultiStringField = {
|
|
16
|
+
...emptyBaseField,
|
|
17
|
+
type: "multi-string",
|
|
18
|
+
minimum_length: 0,
|
|
19
|
+
maximum_length: null,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A field that lets the user input multiple strings. Each string must be unique. Useful for things like:
|
|
24
|
+
* - Specifying the options of a SelectField (used in `SelectField.getFieldCreationSchema`
|
|
25
|
+
* - Listing serial numbers and similar
|
|
26
|
+
*/
|
|
27
|
+
export class MultiStringField extends BaseField<string[], "multi-string"> {
|
|
28
|
+
static readonly fieldTypeName = "Multi-string"
|
|
29
|
+
static readonly fieldTypeDescription = "Allows the user to provide multiple unique strings."
|
|
30
|
+
public readonly minLength: number
|
|
31
|
+
public readonly maxLength: number
|
|
32
|
+
public readonly onlyValidateAfterTouched = false
|
|
33
|
+
public readonly placeholder: string
|
|
34
|
+
|
|
35
|
+
static Icon: typeof RiListCheck = RiListCheck
|
|
36
|
+
|
|
37
|
+
constructor(options: MultiStringFieldOptions) {
|
|
38
|
+
const { minimum_length, maximum_length, placeholder, ...rest } = options
|
|
39
|
+
super({ ...rest, type: "multi-string" })
|
|
40
|
+
this.minLength = minimum_length ?? 0
|
|
41
|
+
this.maxLength = maximum_length ?? Infinity
|
|
42
|
+
this.placeholder = placeholder ?? "Press enter to add a new option"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public getValueFromChangeEvent(event: ChangeEvent<HTMLInputElement> | string[]): string[] {
|
|
46
|
+
if (Array.isArray(event)) return event
|
|
47
|
+
|
|
48
|
+
throw new Error("Expected an array.")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getInput(props: GetInputProps<this>): ReactNode {
|
|
52
|
+
return <MultiStringInput field={this} {...props} />
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
serialize(): SerializedMultiStringField {
|
|
56
|
+
return {
|
|
57
|
+
...super._serialize(),
|
|
58
|
+
minimum_length: this.minLength,
|
|
59
|
+
maximum_length: this.maxLength,
|
|
60
|
+
placeholder: this.placeholder,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
protected isBlank(value: string[]): boolean {
|
|
65
|
+
return super.isBlank(value) || value.length === 0
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public getFieldValidators(): InputFieldLevelValidator<string[]>[] {
|
|
69
|
+
const validators = super.getFieldValidators()
|
|
70
|
+
|
|
71
|
+
validators.push((value) => {
|
|
72
|
+
if (Array.isArray(value) && value.length < this.minLength) {
|
|
73
|
+
return `Must have at least ${this.minLength} options.`
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
validators.push((value) => {
|
|
78
|
+
if (Array.isArray(value) && value.length > this.maxLength) {
|
|
79
|
+
return `Must have at most ${this.maxLength} options.`
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
return validators
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static deserialize(data: ISerializedField): MultiStringField {
|
|
87
|
+
if (data.type !== "multi-string") throw new Error("Type mismatch.")
|
|
88
|
+
return new MultiStringField(data)
|
|
89
|
+
}
|
|
90
|
+
}
|