import * as css from 'app/client/components/FormRendererCss'; import {makeT} from 'app/client/lib/localization'; import {FormField} from 'app/client/ui/FormAPI'; import {sanitizeHTML} from 'app/client/ui/sanitizeHTML'; import {dropdownWithSearch} from 'app/client/ui/searchDropdown'; import {isXSmallScreenObs} from 'app/client/ui2018/cssVars'; import {confirmModal} from 'app/client/ui2018/modals'; import {CellValue} from 'app/plugin/GristData'; import {Disposable, dom, DomContents, makeTestId, MutableObsArray, obsArray, Observable} from 'grainjs'; import {marked} from 'marked'; import {IPopupOptions, PopupControl} from 'popweasel'; const testId = makeTestId('test-form-'); const t = makeT('FormRenderer'); /** * A node in a recursive, tree-like hierarchy comprising the layout of a form. */ export interface FormLayoutNode { /** Unique ID of the node. Used by FormView. */ id: string; type: FormLayoutNodeType; children?: Array; // Used by Layout. submitText?: string; successURL?: string; successText?: string; anotherResponse?: boolean; // Used by Field. formRequired?: boolean; leaf?: number; // Used by Label and Paragraph. text?: string; // Used by Paragraph. alignment?: string; } export type FormLayoutNodeType = | 'Paragraph' | 'Section' | 'Columns' | 'Submit' | 'Placeholder' | 'Layout' | 'Field' | 'Label' | 'Separator' | 'Header'; /** * Context used by FormRenderer to build each node. */ export interface FormRendererContext { /** Field metadata, keyed by field id. */ fields: Record; /** The root of the FormLayoutNode tree. */ rootLayoutNode: FormLayoutNode; /** Disables the Submit node if true. */ disabled: Observable; /** Error to show above the Submit node. */ error: Observable; } /** * Returns a copy of `layoutSpec` with any leaf nodes that don't exist * in `fieldIds` removed. */ export function patchLayoutSpec( layoutSpec: FormLayoutNode, fieldIds: Set ): FormLayoutNode | null { if (layoutSpec.leaf && !fieldIds.has(layoutSpec.leaf)) { return null; } return { ...layoutSpec, children: layoutSpec.children ?.map(child => patchLayoutSpec(child, fieldIds)) .filter((child): child is FormLayoutNode => child !== null), }; } /** * A renderer for a form layout. * * Takes the root FormLayoutNode and additional context for each node, and returns * the DomContents of the rendered form. * * A closely related set of classes exist in `app/client/components/Forms/*`; those are * specifically used to render a version of a form that is suitable for displaying within * a Form widget, where submitting a form isn't possible. * * TODO: merge the two implementations or factor out what's common. */ export abstract class FormRenderer extends Disposable { public static new( layoutNode: FormLayoutNode, context: FormRendererContext, parent?: FormRenderer ): FormRenderer { const Renderer = FormRenderers[layoutNode.type] ?? ParagraphRenderer; return new Renderer(layoutNode, context, parent); } protected children: FormRenderer[]; constructor( protected layoutNode: FormLayoutNode, protected context: FormRendererContext, protected parent?: FormRenderer ) { super(); this.children = (this.layoutNode.children ?? []).map((child) => this.autoDispose(FormRenderer.new(child, this.context, this))); } public abstract render(): DomContents; /** * Reset the state of this layout node and all of its children. */ public reset() { this.children.forEach((child) => child.reset()); } } class LabelRenderer extends FormRenderer { public render() { return css.label(this.layoutNode.text ?? ''); } } class ParagraphRenderer extends FormRenderer { public render() { return css.paragraph( css.paragraph.cls(`-alignment-${this.layoutNode.alignment || 'left'}`), el => { el.innerHTML = sanitizeHTML(marked(this.layoutNode.text || '**Lorem** _ipsum_ dolor')); }, ); } } class SectionRenderer extends FormRenderer { public render() { return css.section( this.children.map((child) => child.render()), ); } } class ColumnsRenderer extends FormRenderer { public render() { return css.columns( {style: `--grist-columns-count: ${this._getColumnsCount()}`}, this.children.map((child) => child.render()), ); } private _getColumnsCount() { return this.children.length || 1; } } class SubmitRenderer extends FormRenderer { public render() { return [ css.error(dom.text(use => use(this.context.error) ?? '')), css.submitButtons( css.resetButton( t('Reset'), dom.boolAttr('disabled', this.context.disabled), {type: 'button'}, dom.on('click', () => { return confirmModal( 'Are you sure you want to reset your form?', 'Reset', () => this.parent?.reset() ); }), testId('reset'), ), css.submitButton( dom('input', dom.boolAttr('disabled', this.context.disabled), { type: 'submit', value: this.context.rootLayoutNode.submitText || t('Submit'), }, dom.on('click', () => validateRequiredLists()), ) ), ), ]; } } class PlaceholderRenderer extends FormRenderer { public render() { return dom('div'); } } class LayoutRenderer extends FormRenderer { public render() { return this.children.map((child) => child.render()); } } class FieldRenderer extends FormRenderer { public renderer: BaseFieldRenderer; public constructor(layoutNode: FormLayoutNode, context: FormRendererContext) { super(layoutNode, context); const field = this.layoutNode.leaf ? this.context.fields[this.layoutNode.leaf] : null; if (!field) { throw new Error(); } const Renderer = FieldRenderers[field.type as keyof typeof FieldRenderers] ?? TextRenderer; this.renderer = this.autoDispose(new Renderer(field, context)); } public render() { return this.renderer.render(); } public reset() { this.renderer.resetInput(); } } abstract class BaseFieldRenderer extends Disposable { public constructor(protected field: FormField, protected context: FormRendererContext) { super(); } public render() { return css.field( this.label(), dom('div', this.input()), ); } public name() { return this.field.colId; } public label() { return dom('label', css.label.cls(''), css.label.cls('-required', Boolean(this.field.options.formRequired)), {for: this.name()}, this.field.question, ); } public abstract input(): DomContents; public abstract resetInput(): void; } class TextRenderer extends BaseFieldRenderer { protected inputType = 'text'; private _format = this.field.options.formTextFormat ?? 'singleline'; private _lineCount = String(this.field.options.formTextLineCount || 3); private _value = Observable.create(this, ''); public input() { if (this._format === 'singleline') { return this._renderSingleLineInput(); } else { return this._renderMultiLineInput(); } } public resetInput(): void { this._value.setAndTrigger(''); } private _renderSingleLineInput() { return css.textInput( { type: this.inputType, name: this.name(), required: this.field.options.formRequired, }, dom.prop('value', this._value), preventSubmitOnEnter(), ); } private _renderMultiLineInput() { return css.textarea( { name: this.name(), required: this.field.options.formRequired, rows: this._lineCount, }, dom.prop('value', this._value), dom.on('input', (_e, elem) => this._value.set(elem.value)), ); } } class NumericRenderer extends BaseFieldRenderer { protected inputType = 'text'; private _format = this.field.options.formNumberFormat ?? 'text'; private _value = Observable.create(this, ''); private _spinnerValue = Observable.create(this, ''); public input() { if (this._format === 'text') { return this._renderTextInput(); } else { return this._renderSpinnerInput(); } } public resetInput(): void { this._value.setAndTrigger(''); this._spinnerValue.setAndTrigger(''); } private _renderTextInput() { return css.textInput( { type: this.inputType, name: this.name(), required: this.field.options.formRequired, }, dom.prop('value', this._value), preventSubmitOnEnter(), ); } private _renderSpinnerInput() { return css.spinner( this._spinnerValue, { setValueOnInput: true, inputArgs: [ { name: this.name(), required: this.field.options.formRequired, }, preventSubmitOnEnter(), ], } ); } } class DateRenderer extends TextRenderer { protected inputType = 'date'; } class DateTimeRenderer extends TextRenderer { protected inputType = 'datetime-local'; } export const selectPlaceholder = () => t('Select...'); class ChoiceRenderer extends BaseFieldRenderer { protected value: Observable; private _choices: string[]; private _selectElement: HTMLElement; private _ctl?: PopupControl; private _format = this.field.options.formSelectFormat ?? 'select'; private _alignment = this.field.options.formOptionsAlignment ?? 'vertical'; private _radioButtons: MutableObsArray<{ label: string; checked: Observable }> = this.autoDispose(obsArray()); public constructor(field: FormField, context: FormRendererContext) { super(field, context); const choices = this.field.options.choices; if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) { this._choices = []; } else { const sortOrder = this.field.options.formOptionsSortOrder ?? 'default'; if (sortOrder !== 'default') { choices.sort((a, b) => String(a).localeCompare(String(b))); if (sortOrder === 'descending') { choices.reverse(); } } // Support for 1000 choices. TODO: make limit dynamic. this._choices = choices.slice(0, 1000); } this.value = Observable.create(this, ''); this._radioButtons.set(this._choices.map(choice => ({ label: String(choice), checked: Observable.create(this, null), }))); } public input() { if (this._format === 'select') { return this._renderSelectInput(); } else { return this._renderRadioInput(); } } public resetInput() { this.value.set(''); this._radioButtons.get().forEach(radioButton => { radioButton.checked.set(null); }); } private _renderSelectInput() { return css.hybridSelect( this._selectElement = css.select( {name: this.name(), required: this.field.options.formRequired}, dom.on('input', (_e, elem) => this.value.set(elem.value)), dom('option', {value: ''}, selectPlaceholder()), this._choices.map((choice) => dom('option', {value: choice}, dom.prop('selected', use => use(this.value) === choice), choice )), dom.onKeyDown({ Enter$: (ev) => this._maybeOpenSearchSelect(ev), ' $': (ev) => this._maybeOpenSearchSelect(ev), ArrowUp$: (ev) => this._maybeOpenSearchSelect(ev), ArrowDown$: (ev) => this._maybeOpenSearchSelect(ev), Backspace$: () => this.value.set(''), }), preventSubmitOnEnter(), ), dom.maybe(use => !use(isXSmallScreenObs()), () => css.searchSelect( dom('div', dom.text(use => use(this.value) || selectPlaceholder())), dropdownWithSearch({ action: (value) => this.value.set(value), options: () => [ {label: selectPlaceholder(), value: '', placeholder: true}, ...this._choices.map((choice) => ({ label: choice, value: choice, }), )], onClose: () => { setTimeout(() => this._selectElement.focus()); }, placeholder: t('Search'), acOptions: {maxResults: 1000, keepOrder: true, showEmptyItems: true}, popupOptions: { trigger: [ 'click', (_el, ctl) => { this._ctl = ctl; }, ], }, matchTriggerElemWidth: true, }), css.searchSelectIcon('Collapse'), testId('search-select'), ), ), ); } private _renderRadioInput() { const required = this.field.options.formRequired; return css.radioList( css.radioList.cls('-horizontal', this._alignment === 'horizontal'), dom.cls('grist-radio-list'), dom.cls('required', Boolean(required)), {name: this.name(), required}, dom.forEach(this._radioButtons, (radioButton) => css.radio( dom('input', dom.prop('checked', radioButton.checked), dom.on('change', (_e, elem) => radioButton.checked.set(elem.value)), { type: 'radio', name: `${this.name()}`, value: radioButton.label, }, preventSubmitOnEnter(), ), dom('span', radioButton.label), ) ), ); } private _maybeOpenSearchSelect(ev: KeyboardEvent) { if (isXSmallScreenObs().get()) { return; } ev.preventDefault(); ev.stopPropagation(); this._ctl?.open(); } } class BoolRenderer extends BaseFieldRenderer { protected inputType = 'checkbox'; protected checked = Observable.create(this, false); private _format = this.field.options.formToggleFormat ?? 'switch'; public render() { return css.field( dom('div', this.input()), ); } public input() { if (this._format === 'switch') { return this._renderSwitchInput(); } else { return this._renderCheckboxInput(); } } public resetInput(): void { this.checked.set(false); } private _renderSwitchInput() { return css.toggleSwitch( dom('input', dom.prop('checked', this.checked), dom.prop('value', use => use(this.checked) ? '1' : '0'), dom.on('change', (_e, elem) => this.checked.set(elem.checked)), { type: this.inputType, name: this.name(), required: this.field.options.formRequired, }, preventSubmitOnEnter(), ), css.gristSwitch( css.gristSwitchSlider(), css.gristSwitchCircle(), ), css.toggleLabel( css.label.cls('-required', Boolean(this.field.options.formRequired)), this.field.question, ), ); } private _renderCheckboxInput() { return css.toggle( dom('input', dom.prop('checked', this.checked), dom.prop('value', use => use(this.checked) ? '1' : '0'), dom.on('change', (_e, elem) => this.checked.set(elem.checked)), { type: this.inputType, name: this.name(), required: this.field.options.formRequired, }, preventSubmitOnEnter(), ), css.toggleLabel( css.label.cls('-required', Boolean(this.field.options.formRequired)), this.field.question, ), ); } } class ChoiceListRenderer extends BaseFieldRenderer { protected checkboxes: MutableObsArray<{ label: string; checked: Observable }> = this.autoDispose(obsArray()); private _alignment = this.field.options.formOptionsAlignment ?? 'vertical'; public constructor(field: FormField, context: FormRendererContext) { super(field, context); let choices = this.field.options.choices; if (!Array.isArray(choices) || choices.some((choice) => typeof choice !== 'string')) { choices = []; } else { const sortOrder = this.field.options.formOptionsSortOrder ?? 'default'; if (sortOrder !== 'default') { choices.sort((a, b) => String(a).localeCompare(String(b))); if (sortOrder === 'descending') { choices.reverse(); } } // Support for 30 choices. TODO: make limit dynamic. choices = choices.slice(0, 30); } this.checkboxes.set(choices.map(choice => ({ label: choice, checked: Observable.create(this, null), }))); } public input() { const required = this.field.options.formRequired; return css.checkboxList( css.checkboxList.cls('-horizontal', this._alignment === 'horizontal'), dom.cls('grist-checkbox-list'), dom.cls('required', Boolean(required)), {name: this.name(), required}, dom.forEach(this.checkboxes, (checkbox) => css.checkbox( dom('input', dom.prop('checked', checkbox.checked), dom.on('change', (_e, elem) => checkbox.checked.set(elem.value)), { type: 'checkbox', name: `${this.name()}[]`, value: checkbox.label, }, preventSubmitOnEnter(), ), dom('span', checkbox.label), ) ), ); } public resetInput(): void { this.checkboxes.get().forEach(checkbox => { checkbox.checked.set(null); }); } } class RefListRenderer extends BaseFieldRenderer { protected checkboxes: MutableObsArray<{ label: string; value: string; checked: Observable }> = this.autoDispose(obsArray()); private _alignment = this.field.options.formOptionsAlignment ?? 'vertical'; public constructor(field: FormField, context: FormRendererContext) { super(field, context); const references = this.field.refValues ?? []; const sortOrder = this.field.options.formOptionsSortOrder; if (sortOrder !== 'default') { // Sort by the second value, which is the display value. references.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); if (sortOrder === 'descending') { references.reverse(); } } // Support for 30 choices. TODO: make limit dynamic. references.splice(30); this.checkboxes.set(references.map(reference => ({ label: String(reference[1]), value: String(reference[0]), checked: Observable.create(this, null), }))); } public input() { const required = this.field.options.formRequired; return css.checkboxList( css.checkboxList.cls('-horizontal', this._alignment === 'horizontal'), dom.cls('grist-checkbox-list'), dom.cls('required', Boolean(required)), {name: this.name(), required}, dom.forEach(this.checkboxes, (checkbox) => css.checkbox( dom('input', dom.prop('checked', checkbox.checked), dom.on('change', (_e, elem) => checkbox.checked.set(elem.value)), { type: 'checkbox', 'data-grist-type': this.field.type, name: `${this.name()}[]`, value: checkbox.value, }, preventSubmitOnEnter(), ), dom('span', checkbox.label), ) ), ); } public resetInput(): void { this.checkboxes.get().forEach(checkbox => { checkbox.checked.set(null); }); } } class RefRenderer extends BaseFieldRenderer { protected value = Observable.create(this, ''); private _format = this.field.options.formSelectFormat ?? 'select'; private _alignment = this.field.options.formOptionsAlignment ?? 'vertical'; private _choices: [number|string, CellValue][]; private _selectElement: HTMLElement; private _ctl?: PopupControl; private _radioButtons: MutableObsArray<{ label: string; value: string; checked: Observable }> = this.autoDispose(obsArray()); public constructor(field: FormField, context: FormRendererContext) { super(field, context); const choices: [number|string, CellValue][] = this.field.refValues ?? []; const sortOrder = this.field.options.formOptionsSortOrder ?? 'default'; if (sortOrder !== 'default') { // Sort by the second value, which is the display value. choices.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); if (sortOrder === 'descending') { choices.reverse(); } } // Support for 1000 choices. TODO: make limit dynamic. this._choices = choices.slice(0, 1000); this.value = Observable.create(this, ''); this._radioButtons.set(this._choices.map(reference => ({ label: String(reference[1]), value: String(reference[0]), checked: Observable.create(this, null), }))); } public input() { if (this._format === 'select') { return this._renderSelectInput(); } else { return this._renderRadioInput(); } } public resetInput(): void { this.value.set(''); this._radioButtons.get().forEach(radioButton => { radioButton.checked.set(null); }); } private _renderSelectInput() { return css.hybridSelect( this._selectElement = css.select( { name: this.name(), 'data-grist-type': this.field.type, required: this.field.options.formRequired, }, dom.on('input', (_e, elem) => this.value.set(elem.value)), dom('option', {value: ''}, selectPlaceholder(), dom.prop('selected', use => use(this.value) === ''), ), this._choices.map((choice) => dom('option', {value: String(choice[0])}, String(choice[1]), dom.prop('selected', use => use(this.value) === String(choice[0])), )), dom.onKeyDown({ Enter$: (ev) => this._maybeOpenSearchSelect(ev), ' $': (ev) => this._maybeOpenSearchSelect(ev), ArrowUp$: (ev) => this._maybeOpenSearchSelect(ev), ArrowDown$: (ev) => this._maybeOpenSearchSelect(ev), Backspace$: () => this.value.set(''), }), preventSubmitOnEnter(), ), dom.maybe(use => !use(isXSmallScreenObs()), () => css.searchSelect( dom('div', dom.text(use => { const choice = this._choices.find((c) => String(c[0]) === use(this.value)); return String(choice?.[1] || selectPlaceholder()); })), dropdownWithSearch({ action: (value) => this.value.set(value), options: () => [ {label: selectPlaceholder(), value: '', placeholder: true}, ...this._choices.map((choice) => ({ label: String(choice[1]), value: String(choice[0]), }), )], onClose: () => { setTimeout(() => this._selectElement.focus()); }, acOptions: {maxResults: 1000, keepOrder: true, showEmptyItems: true}, placeholder: 'Search', popupOptions: { trigger: [ 'click', (_el, ctl) => { this._ctl = ctl; }, ], }, matchTriggerElemWidth: true, }), css.searchSelectIcon('Collapse'), testId('search-select'), ), ) ); } private _renderRadioInput() { const required = this.field.options.formRequired; return css.radioList( css.radioList.cls('-horizontal', this._alignment === 'horizontal'), dom.cls('grist-radio-list'), dom.cls('required', Boolean(required)), {name: this.name(), required, 'data-grist-type': this.field.type}, dom.forEach(this._radioButtons, (radioButton) => css.radio( dom('input', dom.prop('checked', radioButton.checked), dom.on('change', (_e, elem) => radioButton.checked.set(elem.value)), { type: 'radio', name: `${this.name()}`, value: radioButton.value, }, preventSubmitOnEnter(), ), dom('span', radioButton.label), ) ), ); } private _maybeOpenSearchSelect(ev: KeyboardEvent) { if (isXSmallScreenObs().get()) { return; } ev.preventDefault(); ev.stopPropagation(); this._ctl?.open(); } } const FieldRenderers = { 'Text': TextRenderer, 'Numeric': NumericRenderer, 'Int': NumericRenderer, 'Choice': ChoiceRenderer, 'Bool': BoolRenderer, 'ChoiceList': ChoiceListRenderer, 'Date': DateRenderer, 'DateTime': DateTimeRenderer, 'Ref': RefRenderer, 'RefList': RefListRenderer, }; const FormRenderers = { 'Paragraph': ParagraphRenderer, 'Section': SectionRenderer, 'Columns': ColumnsRenderer, 'Submit': SubmitRenderer, 'Placeholder': PlaceholderRenderer, 'Layout': LayoutRenderer, 'Field': FieldRenderer, 'Label': LabelRenderer, // Aliases for Paragraph. 'Separator': ParagraphRenderer, 'Header': ParagraphRenderer, }; function preventSubmitOnEnter() { return dom.onKeyDown({Enter$: (ev) => ev.preventDefault()}); } /** * Validates the required attribute of checkbox and radio lists, such as those * used by Choice, Choice List, Reference, and Reference List fields. * * Since lists of checkboxes and radios don't natively support a required attribute, we * simulate it by marking the first checkbox/radio of each required list as being a * required input. Then, we make another pass and unmark all required checkbox/radio * inputs if they belong to a list where at least one checkbox/radio is checked. If any * inputs in a required are left as required, HTML validations that are triggered when * submitting a form will catch them and prevent the submission. */ function validateRequiredLists() { for (const type of ['checkbox', 'radio']) { const requiredLists = document .querySelectorAll(`.grist-${type}-list.required:not(:has(input:checked))`); Array.from(requiredLists).forEach(function(list) { const firstOption = list.querySelector(`input[type="${type}"]`); firstOption?.setAttribute('required', 'required'); }); const requiredListsWithCheckedOption = document .querySelectorAll(`.grist-${type}-list.required:has(input:checked`); Array.from(requiredListsWithCheckedOption).forEach(function(list) { const firstOption = list.querySelector(`input[type="${type}"]`); firstOption?.removeAttribute('required'); }); } }