Merge pull request #15655 from Budibase/ts/form

Client form to TS
This commit is contained in:
Adria Navarro 2025-03-20 12:46:40 +01:00 committed by GitHub
commit 0c35c2612f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 218 additions and 139 deletions

View File

@ -1,38 +1,39 @@
<script>
<script lang="ts">
import { getContext } from "svelte"
import InnerForm from "./InnerForm.svelte"
import { Helpers } from "@budibase/bbui"
import { writable } from "svelte/store"
import type { DataFetchDatasource, Table, TableSchema } from "@budibase/types"
export let dataSource
export let size
export let disabled = false
export let readonly = false
export let actionType = "Create"
export let initialFormStep = 1
export let dataSource: DataFetchDatasource
export let size: "Medium" | "Large"
export let disabled: boolean = false
export let readonly: boolean = false
export let actionType: "Create" = "Create"
export let initialFormStep: string | number = 1
// Not exposed as a builder setting. Used internally to disable validation
// for fields rendered in things like search blocks.
export let disableSchemaValidation = false
export let disableSchemaValidation: boolean = false
// Not exposed as a builder setting. Used internally to allow searching on
// auto columns.
export let editAutoColumns = false
export let editAutoColumns: boolean = false
const context = getContext("context")
const component = getContext("component")
const { fetchDatasourceSchema, fetchDatasourceDefinition } = getContext("sdk")
const getInitialFormStep = () => {
const parsedFormStep = parseInt(initialFormStep)
const parsedFormStep = parseInt(initialFormStep.toString())
if (isNaN(parsedFormStep)) {
return 1
}
return parsedFormStep
}
let definition
let schema
let definition: Table | undefined
let schema: TableSchema | undefined
let loaded = false
let currentStep = getContext("current-step") || writable(getInitialFormStep())
@ -49,7 +50,12 @@
)
// Returns the closes data context which isn't a built in context
const getInitialValues = (type, dataSource, path, context) => {
const getInitialValues = (
type: string,
dataSource: DataFetchDatasource,
path: string[],
context: Record<string, any>
) => {
// Only inherit values for update forms
if (type !== "Update") {
return {}
@ -82,11 +88,11 @@
}
// Fetches the form schema from this form's dataSource
const fetchSchema = async dataSource => {
const fetchSchema = async (dataSource: DataFetchDatasource) => {
try {
definition = await fetchDatasourceDefinition(dataSource)
} catch (error) {
definition = null
definition = undefined
}
const res = await fetchDatasourceSchema(dataSource)
schema = res || {}
@ -98,7 +104,7 @@
// Generates a predictable string that uniquely identifies a schema. We can't
// simply stringify the whole schema as there are array fields which have
// random order.
const generateSchemaKey = schema => {
const generateSchemaKey = (schema: TableSchema | undefined) => {
if (!schema) {
return null
}

View File

@ -1,31 +1,62 @@
<script>
<script lang="ts">
import { setContext, getContext } from "svelte"
import type { Readable, Writable } from "svelte/store"
import { derived, get, writable } from "svelte/store"
import { createValidatorFromConstraints } from "./validation"
import { Helpers } from "@budibase/bbui"
import type {
DataFetchDatasource,
FieldSchema,
FieldType,
Table,
TableSchema,
UIFieldValidationRule,
} from "@budibase/types"
export let dataSource = undefined
export let disabled = false
export let readonly = false
export let initialValues = undefined
export let size = undefined
export let schema = undefined
export let definition = undefined
export let disableSchemaValidation = false
export let editAutoColumns = false
type FieldInfo<T = any> = {
name: string
step: number
type: `${FieldType}`
fieldState: {
fieldId: string
value: T
defaultValue: T
disabled: boolean
readonly: boolean
validator: ((_value: T) => string | null) | null
error: string | null | undefined
lastUpdate: number
}
fieldApi: {
setValue(_value: T): void
validate(): boolean
reset(): void
}
fieldSchema: FieldSchema | {}
}
export let dataSource: DataFetchDatasource | undefined = undefined
export let disabled: boolean = false
export let readonly: boolean = false
export let initialValues: Record<string, any> | undefined = undefined
export let size: "Medium" | "Large" | undefined = undefined
export let schema: TableSchema | undefined = undefined
export let definition: Table | undefined = undefined
export let disableSchemaValidation: boolean = false
export let editAutoColumns: boolean = false
// For internal use only, to disable context when being used with standalone
// fields
export let provideContext = true
export let provideContext: boolean = true
// We export this store so that when we remount the inner form we can still
// persist what step we're on
export let currentStep
export let currentStep: Writable<number>
const component = getContext("component")
const { styleable, Provider, ActionTypes } = getContext("sdk")
let fields = []
let fields: Writable<FieldInfo>[] = []
const formState = writable({
values: {},
errors: {},
@ -75,19 +106,24 @@
// Generates a derived store from an array of fields, comprised of a map of
// extracted values from the field array
const deriveFieldProperty = (fieldStores, getProp) => {
const deriveFieldProperty = (
fieldStores: Readable<FieldInfo>[],
getProp: (_field: FieldInfo) => any
) => {
return derived(fieldStores, fieldValues => {
const reducer = (map, field) => ({ ...map, [field.name]: getProp(field) })
return fieldValues.reduce(reducer, {})
return fieldValues.reduce(
(map, field) => ({ ...map, [field.name]: getProp(field) }),
{}
)
})
}
// Derives any enrichments which need to be made so that bindings work for
// special data types like attachments. Relationships are currently not
// handled as we don't have the primaryDisplay field that is required.
const deriveBindingEnrichments = fieldStores => {
const deriveBindingEnrichments = (fieldStores: Readable<FieldInfo>[]) => {
return derived(fieldStores, fieldValues => {
let enrichments = {}
const enrichments: Record<string, string> = {}
fieldValues.forEach(field => {
if (field.type === "attachment") {
const value = field.fieldState.value
@ -104,7 +140,11 @@
// Derive the overall form value and deeply set all field paths so that we
// can support things like JSON fields.
const deriveFormValue = (initialValues, values, enrichments) => {
const deriveFormValue = (
initialValues: Record<string, any> | undefined,
values: Record<string, any>,
enrichments: Record<string, string>
) => {
let formValue = Helpers.cloneDeep(initialValues || {})
// We need to sort the keys to avoid a JSON field overwriting a nested field
@ -118,7 +158,7 @@
}
})
.sort((a, b) => {
return a.lastUpdate > b.lastUpdate
return a.lastUpdate - b.lastUpdate
})
// Merge all values and enrichments into a single value
@ -132,12 +172,16 @@
}
// Searches the field array for a certain field
const getField = name => {
return fields.find(field => get(field).name === name)
const getField = (name: string) => {
return fields.find(field => get(field).name === name)!
}
// Sanitises a value by ensuring it doesn't contain any invalid data
const sanitiseValue = (value, schema, type) => {
const sanitiseValue = (
value: any,
schema: FieldSchema | undefined,
type: `${FieldType}`
) => {
// Check arrays - remove any values not present in the field schema and
// convert any values supplied to strings
if (Array.isArray(value) && type === "array" && schema) {
@ -149,13 +193,13 @@
const formApi = {
registerField: (
field,
type,
defaultValue = null,
fieldDisabled = false,
fieldReadOnly = false,
validationRules,
step = 1
field: string,
type: FieldType,
defaultValue: string | null = null,
fieldDisabled: boolean = false,
fieldReadOnly: boolean = false,
validationRules: UIFieldValidationRule[],
step: number = 1
) => {
if (!field) {
return
@ -200,7 +244,7 @@
const isAutoColumn = !!schema?.[field]?.autocolumn
// Construct field info
const fieldInfo = writable({
const fieldInfo = writable<FieldInfo>({
name: field,
type,
step: step || 1,
@ -210,7 +254,8 @@
error: initialError,
disabled:
disabled || fieldDisabled || (isAutoColumn && !editAutoColumns),
readonly: readonly || fieldReadOnly || schema?.[field]?.readonly,
readonly:
readonly || fieldReadOnly || (schema?.[field] as any)?.readonly,
defaultValue,
validator,
lastUpdate: Date.now(),
@ -254,7 +299,13 @@
get(field).fieldApi.reset()
})
},
changeStep: ({ type, number }) => {
changeStep: ({
type,
number,
}: {
type: "next" | "prev" | "first" | "specific"
number: any
}) => {
if (type === "next") {
currentStep.update(step => step + 1)
} else if (type === "prev") {
@ -265,12 +316,12 @@
currentStep.set(parseInt(number))
}
},
setStep: step => {
setStep: (step: number) => {
if (step) {
currentStep.set(step)
}
},
setFieldValue: (fieldName, value) => {
setFieldValue: (fieldName: string, value: any) => {
const field = getField(fieldName)
if (!field) {
return
@ -278,7 +329,7 @@
const { fieldApi } = get(field)
fieldApi.setValue(value)
},
resetField: fieldName => {
resetField: (fieldName: string) => {
const field = getField(fieldName)
if (!field) {
return
@ -289,9 +340,9 @@
}
// Creates an API for a specific field
const makeFieldApi = field => {
const makeFieldApi = (field: string) => {
// Sets the value for a certain field and invokes validation
const setValue = (value, skipCheck = false) => {
const setValue = (value: any, skipCheck = false) => {
const fieldInfo = getField(field)
const { fieldState } = get(fieldInfo)
const { validator } = fieldState
@ -328,36 +379,6 @@
})
}
// Updates the validator rules for a certain field
const updateValidation = validationRules => {
const fieldInfo = getField(field)
const { fieldState } = get(fieldInfo)
const { value, error } = fieldState
// Create new validator
const schemaConstraints = disableSchemaValidation
? null
: schema?.[field]?.constraints
const validator = createValidatorFromConstraints(
schemaConstraints,
validationRules,
field,
definition
)
// Update validator
fieldInfo.update(state => {
state.fieldState.validator = validator
return state
})
// If there is currently an error, run the validator again in case
// the error should be cleared by the new validation rules
if (error) {
setValue(value, true)
}
}
// We don't want to actually remove the field state when deregistering, just
// remove any errors and validation
const deregister = () => {
@ -370,7 +391,7 @@
}
// Updates the disabled state of a certain field
const setDisabled = fieldDisabled => {
const setDisabled = (fieldDisabled: boolean) => {
const fieldInfo = getField(field)
// Auto columns are always disabled
@ -386,7 +407,6 @@
return {
setValue,
reset,
updateValidation,
setDisabled,
deregister,
validate: () => {
@ -412,7 +432,15 @@
// register their fields to step 1
setContext("form-step", writable(1))
const handleUpdateFieldValue = ({ type, field, value }) => {
const handleUpdateFieldValue = ({
type,
field,
value,
}: {
type: "set" | "reset"
field: string
value: any
}) => {
if (type === "set") {
formApi.setFieldValue(field, value)
} else {
@ -420,16 +448,19 @@
}
}
const handleScrollToField = ({ field }) => {
if (!field.fieldState) {
field = get(getField(field))
const handleScrollToField = (props: { field: FieldInfo | string }) => {
let field
if (typeof props.field === "string") {
field = get(getField(props.field))
} else {
field = props.field
}
const fieldId = field.fieldState.fieldId
const fieldElement = document.getElementById(fieldId)
if (fieldElement) {
fieldElement.focus({ preventScroll: true })
}
const label = document.querySelector(`label[for="${fieldId}"]`)
const label = document.querySelector<HTMLElement>(`label[for="${fieldId}"]`)
if (label) {
label.style.scrollMargin = "100px"
label.scrollIntoView({ behavior: "smooth", block: "nearest" })

View File

@ -1,6 +1,11 @@
import dayjs from "dayjs"
import { FieldTypes } from "../../../constants"
import { Helpers } from "@budibase/bbui"
import {
FieldConstraints,
FieldType,
Table,
UIFieldValidationRule,
} from "@budibase/types"
/**
* Creates a validation function from a combination of schema-level constraints
@ -12,19 +17,19 @@ import { Helpers } from "@budibase/bbui"
* @returns {function} a validator function which accepts test values
*/
export const createValidatorFromConstraints = (
schemaConstraints,
customRules,
field,
definition
schemaConstraints: FieldConstraints | null | undefined,
customRules: UIFieldValidationRule[],
field: string,
definition: Table | undefined
) => {
let rules = []
let rules: UIFieldValidationRule[] = []
// Convert schema constraints into validation rules
if (schemaConstraints) {
// Required constraint
if (
field === definition?.primaryDisplay ||
schemaConstraints.presence?.allowEmpty === false ||
(schemaConstraints.presence as any)?.allowEmpty === false ||
schemaConstraints.presence === true
) {
rules.push({
@ -106,7 +111,7 @@ export const createValidatorFromConstraints = (
rules = rules.concat(customRules || [])
// Evaluate each constraint
return value => {
return (value: any) => {
for (let rule of rules) {
const error = evaluateRule(rule, value)
if (error) {
@ -124,7 +129,7 @@ export const createValidatorFromConstraints = (
* @param value the value to validate against
* @returns {null|*} an error if validation fails or null if it passes
*/
const evaluateRule = (rule, value) => {
const evaluateRule = (rule: UIFieldValidationRule, value: any) => {
if (!rule) {
return null
}
@ -150,14 +155,14 @@ const evaluateRule = (rule, value) => {
* @param type the type to parse
* @returns {boolean|string|*|number|null|array} the parsed value, or null if invalid
*/
const parseType = (value, type) => {
const parseType = (value: any, type: `${FieldType}`) => {
// Treat nulls or empty strings as null
if (!exists(value) || !type) {
return null
}
// Parse as string
if (type === FieldTypes.STRING) {
if (type === FieldType.STRING) {
if (typeof value === "string" || Array.isArray(value)) {
return value
}
@ -168,7 +173,7 @@ const parseType = (value, type) => {
}
// Parse as number
if (type === FieldTypes.NUMBER) {
if (type === FieldType.NUMBER) {
if (isNaN(value)) {
return null
}
@ -176,7 +181,7 @@ const parseType = (value, type) => {
}
// Parse as date
if (type === FieldTypes.DATETIME) {
if (type === FieldType.DATETIME) {
if (value instanceof Date) {
return value.getTime()
}
@ -185,7 +190,7 @@ const parseType = (value, type) => {
}
// Parse as boolean
if (type === FieldTypes.BOOLEAN) {
if (type === FieldType.BOOLEAN) {
if (typeof value === "string") {
return value.toLowerCase() === "true"
}
@ -193,7 +198,7 @@ const parseType = (value, type) => {
}
// Parse attachments, treating no elements as null
if (type === FieldTypes.ATTACHMENTS) {
if (type === FieldType.ATTACHMENTS) {
if (!Array.isArray(value) || !value.length) {
return null
}
@ -202,8 +207,8 @@ const parseType = (value, type) => {
// Parse attachment/signature single, treating no key as null
if (
type === FieldTypes.ATTACHMENT_SINGLE ||
type === FieldTypes.SIGNATURE_SINGLE
type === FieldType.ATTACHMENT_SINGLE ||
type === FieldType.SIGNATURE_SINGLE
) {
if (!value?.key) {
return null
@ -212,7 +217,7 @@ const parseType = (value, type) => {
}
// Parse links, treating no elements as null
if (type === FieldTypes.LINK) {
if (type === FieldType.LINK) {
if (!Array.isArray(value) || !value.length) {
return null
}
@ -220,7 +225,7 @@ const parseType = (value, type) => {
}
// Parse array, treating no elements as null
if (type === FieldTypes.ARRAY) {
if (type === FieldType.ARRAY) {
if (!Array.isArray(value) || !value.length) {
return null
}
@ -229,7 +234,7 @@ const parseType = (value, type) => {
// For JSON we don't touch the value at all as we want to verify it in its
// raw form
if (type === FieldTypes.JSON) {
if (type === FieldType.JSON) {
return value
}
@ -238,69 +243,74 @@ const parseType = (value, type) => {
}
// Evaluates a required constraint
const requiredHandler = value => {
const requiredHandler = (value: any) => {
return value != null
}
// Evaluates a min length constraint
const minLengthHandler = (value, rule) => {
const minLengthHandler = (value: any, rule: UIFieldValidationRule) => {
const limit = parseType(rule.value, "number")
return value == null || value.length >= limit
}
// Evaluates a max length constraint
const maxLengthHandler = (value, rule) => {
const maxLengthHandler = (value: any, rule: UIFieldValidationRule) => {
const limit = parseType(rule.value, "number")
return value == null || value.length <= limit
}
// Evaluates a max file size (MB) constraint
const maxFileSizeHandler = (value, rule) => {
const maxFileSizeHandler = (value: any, rule: UIFieldValidationRule) => {
const limit = parseType(rule.value, "number")
const check = attachment => attachment.size / 1000000 > limit
const check = (attachment: { size: number }) =>
attachment.size / 1000000 > limit
return value == null || !(value?.key ? check(value) : value.some(check))
}
// Evaluates a max total upload size (MB) constraint
const maxUploadSizeHandler = (value, rule) => {
const limit = parseType(rule.value, "number")
const maxUploadSizeHandler = (value: any, rule: UIFieldValidationRule) => {
const limit: number = parseType(rule.value, "number")
return (
value == null ||
(value?.key
? value.size / 1000000 <= limit
: value.reduce((acc, currentItem) => acc + currentItem.size, 0) /
: value.reduce(
(acc: number, currentItem: { size: number }) =>
acc + currentItem.size,
0
) /
1000000 <=
limit)
)
}
// Evaluates a min value constraint
const minValueHandler = (value, rule) => {
const minValueHandler = (value: any, rule: UIFieldValidationRule) => {
// Use same type as the value so that things can be compared
const limit = parseType(rule.value, rule.type)
return value == null || value >= limit
}
// Evaluates a max value constraint
const maxValueHandler = (value, rule) => {
const maxValueHandler = (value: any, rule: UIFieldValidationRule) => {
// Use same type as the value so that things can be compared
const limit = parseType(rule.value, rule.type)
return value == null || value <= limit
}
// Evaluates an inclusion constraint
const inclusionHandler = (value, rule) => {
return value == null || rule.value.includes(value)
const inclusionHandler = (value: any, rule: UIFieldValidationRule) => {
return value == null || (rule.value as any).includes(value)
}
// Evaluates an equal constraint
const equalHandler = (value, rule) => {
const equalHandler = (value: any, rule: UIFieldValidationRule) => {
const ruleValue = parseType(rule.value, rule.type)
return value === ruleValue
}
// Evaluates a not equal constraint
const notEqualHandler = (value, rule) => {
const notEqualHandler = (value: any, rule: UIFieldValidationRule) => {
const ruleValue = parseType(rule.value, rule.type)
if (value == null && ruleValue == null) {
return true
@ -309,7 +319,7 @@ const notEqualHandler = (value, rule) => {
}
// Evaluates a regex constraint
const regexHandler = (value, rule) => {
const regexHandler = (value: any, rule: UIFieldValidationRule) => {
const regex = parseType(rule.value, "string")
if (!value) {
value = ""
@ -318,23 +328,23 @@ const regexHandler = (value, rule) => {
}
// Evaluates a not regex constraint
const notRegexHandler = (value, rule) => {
const notRegexHandler = (value: any, rule: UIFieldValidationRule) => {
return !regexHandler(value, rule)
}
// Evaluates a contains constraint
const containsHandler = (value, rule) => {
const containsHandler = (value: any, rule: UIFieldValidationRule) => {
const expectedValue = parseType(rule.value, "string")
return value && value.includes(expectedValue)
}
// Evaluates a not contains constraint
const notContainsHandler = (value, rule) => {
const notContainsHandler = (value: any, rule: UIFieldValidationRule) => {
return !containsHandler(value, rule)
}
// Evaluates a constraint that the value must be a valid json object
const jsonHandler = value => {
const jsonHandler = (value: any) => {
if (typeof value !== "object" || Array.isArray(value)) {
return false
}
@ -372,6 +382,6 @@ const handlerMap = {
* @param value the value to test
* @returns {boolean} whether the value exists or not
*/
const exists = value => {
const exists = <T = any>(value: T | null | undefined): value is T => {
return value != null && value !== ""
}

View File

@ -1,12 +1,12 @@
import { Writable } from "svelte"
import { Component, FieldGroupContext, FormContext } from "@/types"
import { Readable } from "svelte/store"
import { Component, Context, FieldGroupContext, FormContext } from "@/types"
import { SDK } from "@/index.ts"
declare module "svelte" {
export function getContext(key: "sdk"): SDK
export function getContext(key: "component"): Component
export function getContext(key: "context"): Readable<Record<string, any>>
export function getContext(key: "current-step"): Writable<number>
export function getContext(key: "context"): Context
export function getContext(key: "form"): FormContext | undefined
export function getContext(key: "form-step"): Writable<number> | undefined
export function getContext(key: "field-group"): FieldGroupContext | undefined

View File

@ -16,7 +16,6 @@ import {
} from "@/stores"
import { get } from "svelte/store"
import { initWebsocket } from "@/websocket"
import { Readable } from "svelte/store"
import {
Screen,
Theme,
@ -27,6 +26,8 @@ import {
Snippet,
UIComponentError,
CustomComponent,
Table,
DataFetchDatasource,
} from "@budibase/types"
import { ActionTypes } from "@/constants"
import { APIClient } from "@budibase/frontend-core"
@ -75,14 +76,13 @@ declare global {
}
}
export type Context = Readable<Record<string, any>>
export interface SDK {
API: APIClient
styleable: any
Provider: any
ActionTypes: typeof ActionTypes
fetchDatasourceSchema: any
fetchDatasourceDefinition: (datasource: DataFetchDatasource) => Promise<Table>
generateGoldenSample: any
builderStore: typeof builderStore
authStore: typeof authStore

View File

@ -6,4 +6,5 @@ export type Component = Readable<{
styles: any
editing: boolean
errorState: boolean
path: string[]
}>

View File

@ -1,3 +1,7 @@
import { Readable } from "svelte/store"
export * from "./components"
export * from "./fields"
export * from "./forms"
export type Context = Readable<Record<string, any>>

View File

@ -10,7 +10,7 @@ import { User } from "@budibase/types"
* @param key the key
* @return the value or null if a value was not found for this key
*/
export const deepGet = (obj: { [x: string]: any }, key: string) => {
export const deepGet = (obj: Record<string, any> | undefined, key: string) => {
if (!obj || !key) {
return null
}

View File

@ -3,7 +3,7 @@ import {
FieldConstraints,
type FieldSchema,
type FormulaResponseType,
} from "../"
} from "../../"
export interface UIField {
name: string

View File

@ -0,0 +1,2 @@
export * from "./fields"
export * from "./validationRules"

View File

@ -0,0 +1,25 @@
import { FieldType } from "../../documents"
export interface UIFieldValidationRule {
type: `${FieldType}`
constraint: FieldValidationRuleType
value?: string | number | string[]
error: string
}
export type FieldValidationRuleType =
| "required"
| "minLength"
| "maxLength"
| "minValue"
| "maxValue"
| "inclusion"
| "equal"
| "notEqual"
| "regex"
| "notRegex"
| "contains"
| "notContains"
| "json"
| "maxFileSize"
| "maxUploadSize"