import { html, nothing, PropertyValueMap, unsafeCSS } from "lit"; import { customElement, property } from "lit/decorators.js"; import { FormBuilderField, FFormInputElements, FormBuilderValues, FormBuilderValidationPromise, ValidationResults, FormBuilderState, FormBuilderLabel, FormBuilderCategory, FormBuilderGap, FormBuilderSize, FormBuilderVariant, CanValidateFields, FormBuilderBaseField } from "../../types"; import eleStyle from "./f-form-builder.scss?inline"; import globalStyle from "./f-form-builder-global.scss?inline"; import { FDiv, FRoot } from "@nonfx/flow-core"; import { Ref, createRef } from "lit/directives/ref.js"; import fieldRenderer from "./fields"; import { extractValidationState, validateField } from "../../modules/validation/validator"; import { debounce } from "lodash-es"; import { SimpleSubject } from "@nonfx/flow-core-config"; import { getEssentialFlowCoreStyles, propogateProperties } from "../../modules/helpers"; import { cloneDeep, isEqual } from "lodash-es"; import { injectCss } from "@nonfx/flow-core-config"; import { ifDefined } from "lit/directives/if-defined.js"; import formArrayGlobalStyles from "./../f-form-array/f-form-array-global.scss?inline"; injectCss("f-form-builder", globalStyle); @customElement("f-form-builder") export class FFormBuilder extends FRoot { /** * css loaded from scss file */ static styles = [ unsafeCSS(eleStyle), unsafeCSS(formArrayGlobalStyles), unsafeCSS(globalStyle), ...getEssentialFlowCoreStyles() ]; /** * @attribute formbuilder name */ @property({ type: String, reflect: true }) name!: string; /** * @attribute formbuilder config */ @property({ type: Object, reflect: false }) label?: FormBuilderLabel; /** * @attribute formbuilder config */ @property({ type: Object, reflect: false }) field?: FormBuilderField; /** * @attribute key value pair of values */ @property({ type: Object, reflect: false, hasChanged(newVal: FormBuilderValues, oldVal: FormBuilderValues) { return !isEqual(newVal, oldVal); } }) values?: FormBuilderValues; /** * @attribute Controls size of all input elements within the form */ @property({ reflect: true, type: String }) size?: FormBuilderSize = "medium"; /** * @attribute Variants are various visual representations of all elements inside form. */ @property({ reflect: true, type: String }) variant?: FormBuilderVariant = "curved"; /** * @attribute Categories are various visual representations of all elements inside form. */ @property({ reflect: true, type: String }) category?: FormBuilderCategory = "fill"; /** * @attribute Gap is used to define the gap between the elements */ @property({ reflect: true, type: String }) gap?: FormBuilderGap = "medium"; /** * @attribute group separator */ @property({ reflect: true, type: Boolean }) separator?: boolean = false; fieldRef: Ref = createRef(); state: FormBuilderState = { get isValid() { return this.errors?.length === 0; }, isChanged: false }; lastState?: FormBuilderState; showWhenSubject!: SimpleSubject; inputTimeout!: ReturnType; /** * responsible for rendering form */ render() { return html` ${this.label ? html` ${this.label?.title} ${this.label?.iconTooltip ? html` ` : ""} ${this.label?.description ? html` ${this.label.description} ` : ""} ` : ``} ${this.field ? fieldRenderer[this.field.type](this.name, this.field, this.fieldRef, this.values) : nothing} `; } /** * Check if submit is triggerred by external element * @param event */ checkSubmit(event: MouseEvent) { if ((event.target as HTMLElement).getAttribute("type") === "submit") { this.submit(); } } handleKeyUp(event: KeyboardEvent) { if (event.code === "Enter" || event.key === "Enter") { this.submit(); } } /** * Form's submit event handler * @param event */ onSubmit(event: SubmitEvent) { event.stopPropagation(); event.preventDefault(); this.submit(); } /** * Emit submit event with data if validaiton is successful */ submit() { this.validateForm() .then(all => { this.updateValidationState(all); if (this.state.errors?.length === 0) { const event = new CustomEvent("submit", { detail: this.values, bubbles: true, composed: true }); this.dispatchEvent(event); } }) .catch(error => { console.error("Error validating form", error); }); } updateValidationState(all: ValidationResults) { this.state.errors = extractValidationState(all); this.dispatchStateChangeEvent(); } /** * updated hook of lit element * @param _changedProperties */ protected async updated( _changedProperties: PropertyValueMap | Map ): Promise { super.updated(_changedProperties); await this.updateComplete; /** * this subject is created for `showWhen` implementation */ this.showWhenSubject = new SimpleSubject(); const ref = this.fieldRef; if (ref.value) { /** * this subject is propogated for `showWhen` implementation */ ref.value.showWhenSubject = this.showWhenSubject; const fieldValidation = async (event: Event) => { event.stopPropagation(); /** * update values */ if (this.values && this.field && this.field.type === "array") { (this.values as []).splice( 0, (this.values as []).length, ...((ref.value as FFormInputElements).value as []) ); } else if (this.values && this.field && this.field.type === "object") { Object.assign( this.values, (ref.value as FFormInputElements).value as Record ); } else { this.values = ref.value?.value as FormBuilderValues; } /** * update isChanged prop in state to let user know that form is changed */ this.state.isChanged = true; /** * dispatch input event for consumer * FLOW-903 moving up to avoid race condition */ if (event.type !== "blur") { this.dispatchInputEvent(); } /** * validate current field */ await validateField( this.field as CanValidateFields, ref.value as FFormInputElements, false ); /** * if current field is of type array or object then then also validate form anyway */ await this.validateForm(true).then(all => { this.updateValidationState(all); }); }; ref.value.oninput = fieldValidation; ref.value.onblur = fieldValidation; if (this.field && this.field.showWhen) { /** * subsscribe to show when subject, whenever new values are there in formbuilder then show when will execute */ this.showWhenSubject.subscribe(values => { if (this.field && this.field.showWhen && ref.value) { const showField = this.field.showWhen(values); if (!showField) { ref.value.dataset.hidden = "true"; if ((this.field as FormBuilderBaseField).layout === "label-left") { const wrapper = ref.value.closest(".label-left-layout"); if (wrapper) { wrapper.dataset.hidden = "true"; } } } else { ref.value.dataset.hidden = "false"; if ((this.field as FormBuilderBaseField).layout === "label-left") { const wrapper = ref.value.closest(".label-left-layout"); if (wrapper) { wrapper.dataset.hidden = "false"; } } } this.onShowWhenExecution(); } }); /** * execute showWhen for values given by consumer */ this.dispatchShowWhenEvent(); } } /** * silent validation and store in state */ void this.validateForm(true).then(all => { this.updateValidationState(all); }); await propogateProperties(this); } /** * showWhen handler */ onShowWhen() { this.showWhenSubject.next(this.values ?? {}); } onShowWhenExecution() { this.validateForm(true) .then(all => { this.updateValidationState(all); }) .catch(error => { console.error("Error validating form", error); }); } /** * Validation of whole form * @param silent whether to display validaiton message or not */ async validateForm(silent = false) { const allValidations: FormBuilderValidationPromise[] = []; if ( this.field && (this.field.type === "object" || this.field.type === "array") && this.fieldRef.value ) { allValidations.push(this.fieldRef.value.validate(silent)); allValidations.push( validateField(this.field as CanValidateFields, this.fieldRef.value, silent) ); } else if (this.field) { allValidations.push( validateField( this.field as CanValidateFields, this.fieldRef.value as FFormInputElements, silent ) ); } return Promise.all(allValidations); } /** * dispatching form-builder input event */ dispatchInputEvent() { this.showWhenSubject.next(this.values ?? {}); const input = new CustomEvent("input", { detail: cloneDeep(this.values), bubbles: true, composed: true }); this.dispatchEvent(input); } /** * dispatching `state-change` event for consumer */ dispatchStateChangeEvent() { if ((this.lastState && !isEqual(this.lastState, this.state)) || this.lastState === undefined) { this.lastState = cloneDeep(this.state); const stateChange = new CustomEvent("state-change", { detail: this.lastState, bubbles: true, composed: true }); this.dispatchEvent(stateChange); } } /** * dispatch showWhen event so that root will publish new form values */ dispatchShowWhenEvent() { const showWhen = new CustomEvent("show-when", { detail: true, bubbles: true, composed: true }); this.dispatchEvent(showWhen); } /** * Whenever form removed from DOM */ disconnectedCallback(): void { try { super.disconnectedCallback(); } catch (e) { /** * Nothing to worry! * catching weird lit error while disconnected hook in storybook stories */ } } }