gristlabs_grist-core/app/client/components/FormRenderer.ts
George Gevoian da6c39aa50 (core) Add new home page cards
Summary:
New cards on the home page link to useful resources like the welcome
video, tutorial, webinars, and the Help Center. They are shown by
default to new and exisiting users, and may be hidden via a toggle.

Test Plan: Browser tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4340
2024-09-17 17:32:10 -04:00

900 lines
26 KiB
TypeScript

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 {toggleSwitch} from 'app/client/ui2018/toggleSwitch';
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<FormLayoutNode>;
// 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<number, FormField>;
/** The root of the FormLayoutNode tree. */
rootLayoutNode: FormLayoutNode;
/** Disables the Submit node if true. */
disabled: Observable<boolean>;
/** Error to show above the Submit node. */
error: Observable<string|null>;
}
/**
* Returns a copy of `layoutSpec` with any leaf nodes that don't exist
* in `fieldIds` removed.
*/
export function patchLayoutSpec(
layoutSpec: FormLayoutNode,
fieldIds: Set<number>
): 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', {
async: false,
}));
},
);
}
}
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<string>(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<string>(this, '');
private _spinnerValue = Observable.create<number|''>(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<string>;
private _choices: string[];
private _selectElement: HTMLElement;
private _ctl?: PopupControl<IPopupOptions>;
private _format = this.field.options.formSelectFormat ?? 'select';
private _alignment = this.field.options.formOptionsAlignment ?? 'vertical';
private _radioButtons: MutableObsArray<{
label: string;
checked: Observable<string|null>
}> = 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<string>(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<string>({
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<boolean>(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 toggleSwitch(this. checked, {
label: this.field.question,
inputArgs: [
{name: this.name(), required: this.field.options.formRequired},
preventSubmitOnEnter(),
],
labelArgs: [
css.label.cls('-required', Boolean(this.field.options.formRequired)),
],
});
}
private _renderCheckboxInput() {
return css.toggle(
css.checkboxInput(
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<string|null>
}> = 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(
css.checkboxInput(
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<string|null>
}> = 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(
css.checkboxInput(
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<IPopupOptions>;
private _radioButtons: MutableObsArray<{
label: string;
value: string;
checked: Observable<string|null>
}> = 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<string>(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<string>({
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');
});
}
}