import { action, computed, makeObservable, observable } from 'mobx'; import type { ToastConfig } from '@wix/bex-core'; import type { Field, SchemaConfig } from '../../types/SchemaConfig'; import { isPIISupportedByFieldType, isSelectableFieldType, } from './fieldTypeUtils'; import type { FieldProperties, FieldPropertiesState, FieldRules, SelectableFieldTypes, ValidationRule, } from './types'; export interface CmsFormStateParams { // Optional: the form itself never reads the schema (it's pure form state). It's // kept for callers that want to associate one, but a schema-less editor (e.g. // the pure FieldEditorModal) can omit it. schemaConfig?: SchemaConfig; getValidationRules: (t: SelectableFieldTypes) => ValidationRule[]; mode?: 'new' | 'edit'; editingFieldId?: string; } function defaultProperties(): FieldPropertiesState { return { type: undefined, name: '', isPii: false, helpText: '', }; } // TODO: confirm vs cms-web const NAME_MAX_LENGTH = 50; export class CmsFormState { readonly schemaConfig?: SchemaConfig; readonly getValidationRules: (t: SelectableFieldTypes) => ValidationRule[]; properties: FieldPropertiesState = defaultProperties(); fieldRules: FieldRules = { validationRules: {} }; mode: 'new' | 'edit'; editingFieldId: string | undefined; touchedFields: Set = new Set(); showValidationError: boolean = false; errorToastConfig: ToastConfig | undefined = undefined; constructor(params: CmsFormStateParams) { this.schemaConfig = params.schemaConfig; this.getValidationRules = params.getValidationRules; this.mode = params.mode ?? 'new'; this.editingFieldId = params.editingFieldId; makeObservable(this, { properties: observable.ref, fieldRules: observable.ref, mode: observable.ref, editingFieldId: observable.ref, touchedFields: observable, showValidationError: observable.ref, errorToastConfig: observable.ref, fieldTypeError: computed, nameError: computed, hasInvalidFields: computed, visibleErrors: computed, supportedValidationRules: computed, validationErrorMessage: computed, setProperty: action, setValidationRule: action.bound, blurField: action, enableShowValidationError: action, setMode: action, setErrorToastConfig: action, prefill: action, loadField: action, reset: action, hideValidationError: action.bound, changeType: action.bound, changeName: action.bound, setIsPii: action.bound, setHelpText: action.bound, }); } get fieldTypeError(): string | undefined { if (!this.properties.type) { return 'Field type is required'; } return undefined; } get nameError(): string | undefined { const name = this.properties.name.trim(); if (!name) { return 'Field name is required'; } if (name.length > NAME_MAX_LENGTH) { return `Field name must be ${NAME_MAX_LENGTH} characters or fewer`; } return undefined; } get hasInvalidFields(): boolean { return !!(this.fieldTypeError || this.nameError); } // Spec §3 specifies booleans; returning the error string directly is more useful for rendering // (one property read instead of two). Deviation is intentional — update spec if accepted. get visibleErrors(): { type: string | undefined; name: string | undefined; } { const { showValidationError, touchedFields } = this; return { type: showValidationError ? this.fieldTypeError : undefined, name: showValidationError || touchedFields.has('name') ? this.nameError : undefined, }; } get supportedValidationRules(): ValidationRule[] { return this.properties.type ? this.getValidationRules(this.properties.type) : []; } get validationErrorMessage(): string { return ( this.fieldTypeError || this.nameError || 'Please fix the errors before saving' ); } setProperty( key: K, value: FieldPropertiesState[K], ) { this.properties = { ...this.properties, [key]: value }; } setValidationRule(rule: ValidationRule, value: unknown) { this.fieldRules = { ...this.fieldRules, validationRules: { ...this.fieldRules.validationRules, [rule]: value }, }; } blurField(field: keyof FieldProperties) { this.touchedFields.add(field); } enableShowValidationError() { this.showValidationError = true; } setMode(mode: 'new' | 'edit') { this.mode = mode; } setErrorToastConfig(cfg: ToastConfig | undefined) { this.errorToastConfig = cfg; } reset() { this.properties = defaultProperties(); this.fieldRules = { validationRules: {} }; this.editingFieldId = undefined; this.touchedFields.clear(); this.showValidationError = false; this.errorToastConfig = undefined; } hideValidationError() { this.showValidationError = false; } changeType(v: SelectableFieldTypes) { this.setProperty('type', v); this._onTypeChange(v); } changeName(v: string) { this.setProperty('name', v); } setIsPii(v: boolean) { this.setProperty('isPii', v); } setHelpText(v: string) { this.setProperty('helpText', v); } // Populate the form from a field's values, without binding to a specific // existing field id. Used both by `loadField` (edit an existing field) and by // schema-less editors that pre-fill a draft (e.g. the import field editor). prefill(values: { type?: Field['type']; displayName?: string; isPii?: boolean; helpText?: string; validation?: Field['validation']; }) { const mappedType: SelectableFieldTypes | undefined = values.type && isSelectableFieldType(values.type) ? values.type : undefined; this.properties = { type: mappedType, name: values.displayName ?? '', isPii: values.isPii ?? false, helpText: values.helpText ?? '', }; this.fieldRules = { validationRules: { ...(values.validation?.required !== undefined && { required: values.validation.required, }), ...(values.validation?.stringLengthRange !== undefined && { charactersLimit: values.validation.stringLengthRange, }), }, }; this.errorToastConfig = undefined; } loadField(field: Field) { this.prefill(field); this.editingFieldId = field.id; } private _onTypeChange(newType: SelectableFieldTypes) { const supported = new Set(this.getValidationRules(newType)); this.fieldRules = { validationRules: Object.fromEntries( Object.entries(this.fieldRules.validationRules).filter(([k]) => supported.has(k as ValidationRule), ), ), }; if (!isPIISupportedByFieldType(newType)) { this.properties = { ...this.properties, isPii: false }; } this.touchedFields.clear(); } }