2023-07-20 05:03:50 +00:00
import { z } from "zod" ;
import { fieldTypesConfigMap } from "./fieldTypes" ;
import { getVariantsConfig , preprocessNameFieldDataWithVariant } from "./utils" ;
const fieldTypeEnum = z . enum ( [
"name" ,
"text" ,
"textarea" ,
"number" ,
"email" ,
"phone" ,
"address" ,
"multiemail" ,
"select" ,
"multiselect" ,
"checkbox" ,
"radio" ,
"radioInput" ,
"boolean" ,
] ) ;
export type FieldType = z . infer < typeof fieldTypeEnum > ;
export const EditableSchema = z . enum ( [
"system" , // Can't be deleted, can't be hidden, name can't be edited, can't be marked optional
"system-but-optional" , // Can't be deleted. Name can't be edited. But can be hidden or be marked optional
"system-but-hidden" , // Can't be deleted, name can't be edited, will be shown
"user" , // Fully editable
"user-readonly" , // All fields are readOnly.
] ) ;
const baseFieldSchema = z . object ( {
name : z.string ( ) ,
type : fieldTypeEnum ,
// TODO: We should make at least one of `defaultPlaceholder` and `placeholder` required. Do the same for label.
label : z.string ( ) . optional ( ) ,
2023-07-21 18:00:28 +00:00
labelAsSafeHtml : z.string ( ) . optional ( ) ,
2023-07-20 05:03:50 +00:00
/ * *
* It is the default label that will be used when a new field is created .
* Note : It belongs in FieldsTypeConfig , so that changing defaultLabel in code can work for existing fields as well ( for fields that are using the default label ) .
* Supports translation
* /
defaultLabel : z.string ( ) . optional ( ) ,
placeholder : z.string ( ) . optional ( ) ,
/ * *
* It is the default placeholder that will be used when a new field is created .
* Note : Same as defaultLabel , it belongs in FieldsTypeConfig
* Supports translation
* /
defaultPlaceholder : z.string ( ) . optional ( ) ,
required : z.boolean ( ) . default ( false ) . optional ( ) ,
/ * *
* It is the list of options that is valid for a certain type of fields .
*
* /
options : z.array ( z . object ( { label : z.string ( ) , value : z.string ( ) } ) ) . optional ( ) ,
/ * *
* This is an alternate way to specify options when the options are stored elsewhere . Form Builder expects options to be present at ` dataStore[getOptionsAt] `
* This allows keeping a single source of truth in DB .
* /
getOptionsAt : z.string ( ) . optional ( ) ,
/ * *
* For ` radioInput ` type of questions , it stores the input that is shown based on the user option selected .
* e . g . If user is given a list of locations and he selects "Phone" , then he will be shown a phone input
* /
optionsInputs : z
. record (
z . object ( {
// Support all types as needed
// Must be a subset of `fieldTypeEnum`.TODO: Enforce it in TypeScript
type : z . enum ( [ "address" , "phone" , "text" ] ) ,
required : z.boolean ( ) . optional ( ) ,
placeholder : z.string ( ) . optional ( ) ,
} )
)
. optional ( ) ,
} ) ;
export const variantsConfigSchema = z . object ( {
variants : z.record (
z . object ( {
/ * *
* Variant Fields schema for a variant of the main field .
* It doesn ' t support non text fields as of now
* * /
fields : baseFieldSchema
. omit ( {
defaultLabel : true ,
defaultPlaceholder : true ,
options : true ,
getOptionsAt : true ,
optionsInputs : true ,
} )
. array ( ) ,
} )
) ,
} ) ;
export type ALL_VIEWS = "ALL_VIEWS" ;
// It is the config that is specific to a type and doesn't make sense in all fields individually. Any field with the type will automatically inherit this config.
// This allows making changes to the UI without having to make changes to the existing stored configs
export const fieldTypeConfigSchema = z
. object ( {
label : z.string ( ) ,
value : fieldTypeEnum ,
isTextType : z.boolean ( ) . default ( false ) . optional ( ) ,
systemOnly : z.boolean ( ) . default ( false ) . optional ( ) ,
needsOptions : z.boolean ( ) . default ( false ) . optional ( ) ,
propsType : z.enum ( [
"text" ,
"textList" ,
"select" ,
"multiselect" ,
"boolean" ,
"objectiveWithInput" ,
"variants" ,
] ) ,
// It is the config that can tweak what an existing or a new field shows in the App UI or booker UI.
variantsConfig : z
. object ( {
/ * *
* This is the default variant that will be used when a new field is created .
* /
defaultVariant : z.string ( ) ,
/ * *
* Used only when there are 2 variants , so that UI can be simplified by showing a switch ( with this label ) instead of a Select
* /
toggleLabel : z.string ( ) . optional ( ) ,
variants : z.record (
z . object ( {
/ * *
* That 's how the variant would be labelled in App UI. This label represents the field in booking questions' list
* Supports translation
* /
label : z.string ( ) ,
fieldsMap : z.record (
z . object ( {
/ * *
* Supports translation
* /
defaultLabel : z.string ( ) . optional ( ) ,
/ * *
* Supports translation
* /
defaultPlaceholder : z.string ( ) . optional ( ) ,
/ * *
* Decides if a variant field ' s required property can be changed or not
* /
canChangeRequirability : z.boolean ( ) . default ( true ) . optional ( ) ,
} )
) ,
} )
) ,
/ * *
* This is the default configuration for the field .
* /
defaultValue : variantsConfigSchema.optional ( ) ,
} )
. optional ( ) ,
} )
. refine ( ( data ) = > {
if ( ! data . variantsConfig ) {
return ;
}
const variantsConfig = data . variantsConfig ;
if ( ! variantsConfig . variants [ variantsConfig . defaultVariant ] ) {
throw new Error ( ` defaultVariant: ${ variantsConfig . defaultVariant } is not in variants ` ) ;
}
return true ;
} ) ;
/ * *
* Main field Schema
* /
export const fieldSchema = baseFieldSchema . merge (
z . object ( {
variant : z.string ( ) . optional ( ) ,
variantsConfig : variantsConfigSchema.optional ( ) ,
views : z
. object ( {
label : z.string ( ) ,
id : z.string ( ) ,
description : z.string ( ) . optional ( ) ,
} )
. array ( )
. optional ( ) ,
/ * *
* It is used to hide fields such as location when there are less than two options
* /
hideWhenJustOneOption : z.boolean ( ) . default ( false ) . optional ( ) ,
hidden : z.boolean ( ) . optional ( ) ,
editable : EditableSchema.default ( "user" ) . optional ( ) ,
sources : z
. array (
z . object ( {
// Unique ID for the `type`. If type is workflow, it's the workflow ID
id : z.string ( ) ,
type : z . union ( [ z . literal ( "user" ) , z . literal ( "system" ) , z . string ( ) ] ) ,
label : z.string ( ) ,
editUrl : z.string ( ) . optional ( ) ,
// Mark if a field is required by this source or not. This allows us to set `field.required` based on all the sources' fieldRequired value
fieldRequired : z.boolean ( ) . optional ( ) ,
} )
)
. optional ( ) ,
} )
) ;
export const fieldsSchema = z . array ( fieldSchema ) ;
export const fieldTypesSchemaMap : Partial <
Record <
FieldType ,
{
/ * *
* - preprocess the responses received through prefill query params
* - preprocess the values being filled in the booking form .
* - does not run for the responses received from DB
* /
preprocess : ( data : {
field : z.infer < typeof fieldSchema > ;
response : any ;
isPartialSchema : boolean ;
} ) = > unknown ;
/ * *
* - Validates the response received through prefill query params
* - Validates the values being filled in the booking form .
* - does not run for the responses received from DB
* /
superRefine : ( data : {
field : z.infer < typeof fieldSchema > ;
response : any ;
isPartialSchema : boolean ;
ctx : z.RefinementCtx ;
m : ( key : string ) = > string ;
} ) = > void ;
}
>
> = {
name : {
preprocess : ( { response , field } ) = > {
const fieldTypeConfig = fieldTypesConfigMap [ field . type ] ;
const variantInResponse = field . variant || fieldTypeConfig ? . variantsConfig ? . defaultVariant ;
let correctedVariant : "firstAndLastName" | "fullName" ;
if ( ! variantInResponse ) {
throw new Error ( "`variant` must be there for the field with `variantsConfig`" ) ;
}
if ( variantInResponse !== "firstAndLastName" && variantInResponse !== "fullName" ) {
correctedVariant = "fullName" ;
} else {
correctedVariant = variantInResponse ;
}
return preprocessNameFieldDataWithVariant ( correctedVariant , response ) ;
} ,
superRefine : ( { field , response , isPartialSchema , ctx , m } ) = > {
const stringSchema = z . string ( ) ;
const fieldTypeConfig = fieldTypesConfigMap [ field . type ] ;
const variantInResponse = field . variant || fieldTypeConfig ? . variantsConfig ? . defaultVariant ;
if ( ! variantInResponse ) {
throw new Error ( "`variant` must be there for the field with `variantsConfig`" ) ;
}
const variantsConfig = getVariantsConfig ( field ) ;
if ( ! variantsConfig ) {
throw new Error ( "variantsConfig must be there for `name` field" ) ;
}
const fields =
variantsConfig . variants [ variantInResponse as keyof typeof variantsConfig . variants ] . fields ;
const variantSupportedFields = [ "text" ] ;
if ( fields . length === 1 ) {
const field = fields [ 0 ] ;
if ( variantSupportedFields . includes ( field . type ) ) {
const schema = stringSchema ;
if ( ! schema . safeParse ( response ) . success ) {
ctx . addIssue ( { code : z.ZodIssueCode.custom , message : m ( "Invalid string" ) } ) ;
}
return ;
} else {
throw new Error ( ` Unsupported field.type with variants: ${ field . type } ` ) ;
}
}
fields . forEach ( ( subField ) = > {
const schema = stringSchema ;
if ( ! variantSupportedFields . includes ( subField . type ) ) {
throw new Error ( ` Unsupported field.type with variants: ${ subField . type } ` ) ;
}
const valueIdentified = response as unknown as Record < string , string > ;
if ( subField . required ) {
if ( ! schema . safeParse ( valueIdentified [ subField . name ] ) . success ) {
ctx . addIssue ( { code : z.ZodIssueCode.custom , message : m ( "Invalid string" ) } ) ;
return ;
}
if ( ! isPartialSchema && ! valueIdentified [ subField . name ] )
ctx . addIssue ( { code : z.ZodIssueCode.custom , message : m ( ` error_required_field ` ) } ) ;
}
} ) ;
} ,
} ,
} ;
/ * *
* DB Read schema has no field type based validation because user might change the type of a field from Type1 to Type2 after some data has been collected with Type1 .
* Parsing that type1 data with type2 schema will fail .
* So , we just validate that the response conforms to one of the field types ' schema .
* /
export const dbReadResponseSchema = z . union ( [
z . string ( ) ,
z . boolean ( ) ,
z . string ( ) . array ( ) ,
z . object ( {
optionValue : z.string ( ) ,
value : z.string ( ) ,
} ) ,
// For variantsConfig case
z . record ( z . string ( ) ) ,
] ) ;