import { Injectable } from '@angular/core' import { UntypedFormArray, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, ValidatorFn, Validators, } from '@angular/forms' import { IST, utils } from '@start9labs/start-sdk' import Mustache from 'mustache' @Injectable({ providedIn: 'root', }) export class FormService { constructor(private readonly formBuilder: UntypedFormBuilder) {} createForm( spec: IST.InputSpec, current: Record = {}, ): UntypedFormGroup { return this.getFormGroup(spec, [], current) } getUnionSelectSpec( spec: IST.ValueSpecUnion, selection: string | null, ): IST.ValueSpecSelect { return { ...spec, type: 'select', default: selection, values: Object.fromEntries( Object.entries(spec.variants).map(([key, { name }]) => [key, name]), ), } } getUnionObject(spec: IST.ValueSpecUnion, value: any): UntypedFormGroup { const valid = spec.variants[value?.selection] const selected = valid ? value?.selection : spec.default const selection = this.getUnionSelectSpec(spec, selected) const group = this.getFormGroup({ selection }) const control = selected ? spec.variants[selected]?.spec : {} group.setControl( 'value', this.getFormGroup(control || {}, [], value?.value), ) return group } getListItem(spec: IST.ValueSpecList, entry?: any) { if (IST.isValueSpecListOf(spec, 'text')) { return this.formBuilder.control(entry, [ ...stringValidators(spec.spec), Validators.required, ]) } else if (IST.isValueSpecListOf(spec, 'object')) { return this.getFormGroup(spec.spec.spec, [], entry) } } getFormGroup( config: IST.InputSpec, validators: ValidatorFn[] = [], current?: Record | null, ): UntypedFormGroup { let group: Record< string, UntypedFormGroup | UntypedFormArray | UntypedFormControl > = {} Object.entries(config).map(([key, spec]) => { group[key] = this.getFormEntry(spec, current?.[key]) }) return this.formBuilder.group(group, { validators }) } private getFormEntry( spec: IST.ValueSpec, currentValue?: any, ): UntypedFormGroup | UntypedFormArray | UntypedFormControl { let value: any switch (spec.type) { case 'text': if (currentValue !== undefined) { value = currentValue } else { value = spec.default ? utils.getDefaultString(spec.default) : null } return this.formBuilder.control(value, stringValidators(spec)) case 'textarea': value = currentValue || null return this.formBuilder.control(value, textareaValidators(spec)) case 'number': if (currentValue !== undefined) { value = currentValue } else { value = spec.default || null } return this.formBuilder.control(value, numberValidators(spec)) case 'color': if (currentValue !== undefined) { value = currentValue } else { value = spec.default || null } return this.formBuilder.control(value, colorValidators(spec)) case 'datetime': if (currentValue !== undefined) { value = currentValue } else { value = spec.default || null } return this.formBuilder.control(value, datetimeValidators(spec)) case 'object': return this.getFormGroup(spec.spec, [], currentValue) case 'list': const array = Array.isArray(currentValue) ? currentValue : spec.default const length = Math.max(array.length, spec.minLength || 0) return this.formBuilder.array( Array.from({ length }).map((_, index) => this.getListItem(spec, array[index]), ), listValidators(spec), ) case 'union': return this.getUnionObject(spec, currentValue) case 'toggle': value = currentValue === undefined ? spec.default : currentValue return this.formBuilder.control(value) case 'select': value = currentValue === undefined ? spec.default : currentValue return this.formBuilder.control(value, [Validators.required]) case 'multiselect': value = currentValue === undefined ? spec.default : currentValue return this.formBuilder.control(value, multiselectValidators(spec)) default: return this.formBuilder.control(null) } } } // function getListItemValidators(spec: IST.ValueSpecList) { // if (IST.isValueSpecListOf(spec, 'text')) { // return stringValidators(spec.spec) // } // } function stringValidators( spec: IST.ValueSpecText | IST.ListValueSpecText, ): ValidatorFn[] { const validators: ValidatorFn[] = [] if ((spec as IST.ValueSpecText).required) { validators.push(Validators.required) } validators.push(textLengthInRange(spec.minLength, spec.maxLength)) if (spec.patterns.length) { spec.patterns.forEach(p => validators.push(Validators.pattern(p.regex))) } return validators } function textareaValidators(spec: IST.ValueSpecTextarea): ValidatorFn[] { const validators: ValidatorFn[] = [] if (spec.required) { validators.push(Validators.required) } validators.push(textLengthInRange(spec.minLength, spec.maxLength)) return validators } function colorValidators({ required }: IST.ValueSpecColor): ValidatorFn[] { const validators: ValidatorFn[] = [Validators.pattern(/^#[0-9a-f]{6}$/i)] if (required) { validators.push(Validators.required) } return validators } function datetimeValidators({ required, min, max, }: IST.ValueSpecDatetime): ValidatorFn[] { const validators: ValidatorFn[] = [] if (required) { validators.push(Validators.required) } if (min) { validators.push(datetimeMin(min)) } if (max) { validators.push(datetimeMax(max)) } return validators } function numberValidators(spec: IST.ValueSpecNumber): ValidatorFn[] { const validators: ValidatorFn[] = [] validators.push(isNumber()) if ((spec as IST.ValueSpecNumber).required) { validators.push(Validators.required) } if (spec.integer) { validators.push(isInteger()) } validators.push(numberInRange(spec.min, spec.max)) return validators } function multiselectValidators(spec: IST.ValueSpecMultiselect): ValidatorFn[] { const validators: ValidatorFn[] = [] validators.push(listInRange(spec.minLength, spec.maxLength)) return validators } function listValidators(spec: IST.ValueSpecList): ValidatorFn[] { const validators: ValidatorFn[] = [] validators.push(listInRange(spec.minLength, spec.maxLength)) validators.push(listItemIssue()) return validators } // function fileValidators(spec: IST.ValueSpecFile): ValidatorFn[] { // const validators: ValidatorFn[] = [] // if (spec.required) { // validators.push(Validators.required) // } // return validators // } export function numberInRange( min: number | null, max: number | null, ): ValidatorFn { return control => { const value = control.value if (typeof value !== 'number') return null if (min && value < min) return { numberNotInRange: `Number must be greater than or equal to ${min}`, } if (max && value > max) return { numberNotInRange: `Number must be less than or equal to ${max}` } return null } } export function isNumber(): ValidatorFn { return ({ value }) => !value || value == Number(value) ? null : { notNumber: 'Must be a number' } } export function isInteger(): ValidatorFn { return ({ value }) => !value || value == Math.trunc(value) ? null : { numberNotInteger: 'Must be an integer' } } export function listInRange( minLength: number | null, maxLength: number | null, ): ValidatorFn { return control => { const length = control.value.length if (minLength && length < minLength) return { listNotInRange: `List must contain at least ${minLength} entries`, } if (maxLength && length > maxLength) return { listNotInRange: `List cannot contain more than ${maxLength} entries`, } return null } } export function datetimeMin(min: string): ValidatorFn { return ({ value }) => { if (!value) return null const date = new Date(value.length === 5 ? `2000-01-01T${value}` : value) const minDate = new Date(min.length === 5 ? `2000-01-01T${min}` : min) return date < minDate ? { datetimeMin: `Minimum is ${min}` } : null } } export function datetimeMax(max: string): ValidatorFn { return ({ value }) => { if (!value) return null const date = new Date(value.length === 5 ? `2000-01-01T${value}` : value) const maxDate = new Date(max.length === 5 ? `2000-01-01T${max}` : max) return date > maxDate ? { datetimeMin: `Maximum is ${max}` } : null } } export function textLengthInRange( minLength: number | null, maxLength: number | null, ): ValidatorFn { return control => { const value = control.value if (value === null || value === undefined) return null const length = value.length if (minLength && length < minLength) return { listNotInRange: `Must be at least ${minLength} characters` } if (maxLength && length > maxLength) return { listNotInRange: `Cannot be great than ${maxLength} characters` } return null } } export function listItemIssue(): ValidatorFn { return parentControl => { const { controls } = parentControl as UntypedFormArray const problemChild = controls.find(c => c.invalid) if (problemChild) { return { listItemIssue: 'Invalid entries' } } else { return null } } } export function listUnique(spec: IST.ValueSpecList): ValidatorFn { return control => { const list = control.value for (let idx = 0; idx < list.length; idx++) { for (let idx2 = idx + 1; idx2 < list.length; idx2++) { if (listItemEquals(spec, list[idx], list[idx2])) { const objSpec = spec.spec let display1: string let display2: string let uniqueMessage = isListObject(objSpec) ? uniqueByMessageWrapper(objSpec.uniqueBy, objSpec) : '' if (isListObject(objSpec) && objSpec.displayAs) { display1 = `"${Mustache.render(objSpec.displayAs, list[idx])}"` display2 = `"${Mustache.render(objSpec.displayAs, list[idx2])}"` } else { display1 = `Entry ${idx + 1}` display2 = `Entry ${idx2 + 1}` } return { listNotUnique: `${display1} and ${display2} are not unique.${uniqueMessage}`, } } } } return null } } function listItemEquals( spec: IST.ValueSpecList, val1: any, val2: any, ): boolean { switch (spec.spec.type) { case 'text': return val1 == val2 case 'object': const obj = spec.spec return listObjEquals(obj.uniqueBy, obj, val1, val2) default: return false } } function listObjEquals( uniqueBy: IST.UniqueBy, spec: IST.ListValueSpecObject, val1: any, val2: any, ): boolean { if (!uniqueBy) { return false } else if (typeof uniqueBy === 'string') { const uniqueBySpec = spec.spec[uniqueBy] return ( !!uniqueBySpec && uniqueByEquals(uniqueBySpec, val1[uniqueBy], val2[uniqueBy]) ) } else if ('any' in uniqueBy) { for (let unique of uniqueBy.any) { if (listObjEquals(unique, spec, val1, val2)) { return true } } return false } else if ('all' in uniqueBy) { for (let unique of uniqueBy.all) { if (!listObjEquals(unique, spec, val1, val2)) { return false } } return true } return false } function uniqueByEquals(spec: IST.ValueSpec, val1: any, val2: any): boolean { switch (spec.type) { case 'text': case 'textarea': case 'number': case 'toggle': case 'select': case 'color': case 'datetime': return val1 == val2 case 'list': if (val1.length !== val2.length) { return false } for (let idx = 0; idx < val1.length; idx++) { if (listItemEquals(spec, val1[idx], val2[idx])) { return false } } return true default: return false } } function uniqueByMessageWrapper( uniqueBy: IST.UniqueBy, spec: IST.ListValueSpecObject, ) { let configSpec = spec.spec const message = uniqueByMessage(uniqueBy, configSpec) if (message) { return ' Must be unique by: ' + message } } function uniqueByMessage( uniqueBy: IST.UniqueBy, configSpec: IST.InputSpec, outermost = true, ): string { let joinFunc const subSpecs: string[] = [] if (!uniqueBy) { return '' } else if (typeof uniqueBy === 'string') { return configSpec[uniqueBy] ? (configSpec[uniqueBy] as IST.ValueSpecObject).name : uniqueBy } else if ('any' in uniqueBy) { joinFunc = ' OR ' for (let subSpec of uniqueBy.any) { subSpecs.push(uniqueByMessage(subSpec, configSpec, false)) } } else if ('all' in uniqueBy) { joinFunc = ' AND ' for (let subSpec of uniqueBy.all) { subSpecs.push(uniqueByMessage(subSpec, configSpec, false)) } } const ret = subSpecs.filter(Boolean).join(joinFunc) return outermost || subSpecs.filter(ss => ss).length === 1 ? ret : '(' + ret + ')' } function isListObject( spec: IST.ListValueSpecOf, ): spec is IST.ListValueSpecObject { // only lists of objects have uniqueBy return 'uniqueBy' in spec } export function convertValuesRecursive( configSpec: IST.InputSpec, group: UntypedFormGroup, ) { Object.entries(configSpec).forEach(([key, valueSpec]) => { const control = group.get(key) if (!control) return if (valueSpec.type === 'number') { control.setValue( control.value || control.value === 0 ? Number(control.value) : null, ) } else if (valueSpec.type === 'text' || valueSpec.type === 'textarea') { if (!control.value) control.setValue(null) } else if (valueSpec.type === 'object') { convertValuesRecursive(valueSpec.spec, group.get(key) as UntypedFormGroup) } else if (valueSpec.type === 'union') { const formGr = group.get(key) as UntypedFormGroup const value = formGr.controls['selection']?.value const spec = !!value && valueSpec.variants[value]?.spec if (spec) { convertValuesRecursive(spec, formGr) } } else if (valueSpec.type === 'list') { const formArr = group.get(key) as UntypedFormArray const { controls } = formArr if (valueSpec.spec.type === 'text') { controls.forEach(control => { if (!control.value) control.setValue(null) }) } else if (valueSpec.spec.type === 'object') { controls.forEach(formGroup => { const objectSpec = valueSpec.spec as IST.ListValueSpecObject convertValuesRecursive(objectSpec.spec, formGroup as UntypedFormGroup) }) } } }) }