import {isHiddenCol} from 'app/common/gristTypes'; import {CellValue, GristType} from 'app/plugin/GristData'; import {MaybePromise} from 'app/plugin/gutil'; import _ from 'lodash'; import {marked} from 'marked'; /** * This file is a part of the Forms project. It contains a logic to render an HTML form from a JSON definition. * TODO: Client version has its own implementation, we should merge them but it is hard to tell currently * what are the similarities and differences as a Client code should also support browsing. */ /** * All allowed boxes. */ export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' | 'Placeholder' | 'Layout' | 'Field' | 'Label' | 'Separator' | 'Header' ; /** * Number of fields to show in the form by default. */ export const INITIAL_FIELDS_COUNT = 9; export const CHOOSE_TEXT = '— Choose —'; /** * Box model is a JSON that represents a form element. Every element can be converted to this element and every * ViewModel should be able to read it and built itself from it. */ export interface Box { type: BoxType, children?: Array, // Some properties used by some boxes (like form itself) submitText?: string, successURL?: string, successText?: string, anotherResponse?: boolean, // Unique ID of the field, used only in UI. id?: string, // Some properties used by fields and stored in the column/field. formRequired?: boolean, // Used by Label and Paragraph. text?: string, // Used by Paragraph. alignment?: string, // Used by Field. leaf?: number, } /** * When a form is rendered, it is given a context that can be used to access Grist data and sanitize HTML. */ export interface RenderContext { root: Box; field(id: number): FieldModel; } export interface FieldOptions { formRequired?: boolean; choices?: string[]; } export interface FieldModel { /** * The question to ask. Fallbacks to column's label than column's id. */ question: string; description: string; colId: string; type: string; isFormula: boolean; options: FieldOptions; values(): MaybePromise<[number, CellValue][]>; } /** * The RenderBox is the main building block for the form. Each main block has its own, and is responsible for * rendering itself and its children. */ export class RenderBox { public static new(box: Box, ctx: RenderContext): RenderBox { const ctr = elements[box.type] ?? Paragraph; return new ctr(box, ctx); } constructor(protected box: Box, protected ctx: RenderContext) { } public async toHTML(): Promise { const proms = (this.box.children || []).map((child) => RenderBox.new(child, this.ctx).toHTML()); const parts = await Promise.all(proms); return parts.join(''); } } class Label extends RenderBox { public override async toHTML() { const text = this.box.text || ''; return `
${text || ''}
`; } } class Paragraph extends RenderBox { public override async toHTML() { const text = this.box['text'] || '**Lorem** _ipsum_ dolor'; const alignment = this.box['alignment'] || 'left'; const html = marked(text); return `
${html}
`; } } class Section extends RenderBox { public override async toHTML() { return `
${await super.toHTML()}
`; } } class Columns extends RenderBox { public override async toHTML() { const size = this.box.children?.length || 1; const content = await super.toHTML(); return `
${content}
`; } } class Submit extends RenderBox { public override async toHTML() { const text = _.escape(this.ctx.root['submitText'] || 'Submit'); return `
`; } } class Placeholder extends RenderBox { public override async toHTML() { return `
`; } } class Layout extends RenderBox { /** Nothing, default is enough */ } /** * Field is a special kind of box, as it renders a Grist field (a Question). It provides a default frame, like label and * description, and then renders the field itself in same way as the main Boxes where rendered. */ class Field extends RenderBox { public build(field: FieldModel, context: RenderContext) { const ctr = (questions as any)[field.type as any] as { new(): Question } || Text; return new ctr(); } public async toHTML() { const field = this.box.leaf ? this.ctx.field(this.box.leaf) : null; if (!field) { return `
Field not found
`; } const renderer = this.build(field, this.ctx); return `
${await renderer.toHTML(field, this.ctx)}
`; } } interface Question { toHTML(field: FieldModel, context: RenderContext): Promise|string; } abstract class BaseQuestion implements Question { public async toHTML(field: FieldModel, context: RenderContext): Promise { return `
${this.label(field)}
${await this.input(field, context)}
`; } public name(field: FieldModel): string { const excludeFromFormData = ( field.isFormula || field.type === 'Attachments' || isHiddenCol(field.colId) ); return `${excludeFromFormData ? '_' : ''}${field.colId}`; } public label(field: FieldModel): string { // This might be HTML. const label = field.question; const name = this.name(field); const required = field.options.formRequired ? 'grist-label-required' : ''; return ` `; } public abstract input(field: FieldModel, context: RenderContext): string|Promise; } class Text extends BaseQuestion { public input(field: FieldModel, context: RenderContext): string { const required = field.options.formRequired ? 'required' : ''; return ` `; } } class Date extends BaseQuestion { public input(field: FieldModel, context: RenderContext): string { const required = field.options.formRequired ? 'required' : ''; return ` `; } } class DateTime extends BaseQuestion { public input(field: FieldModel, context: RenderContext): string { const required = field.options.formRequired ? 'required' : ''; return ` `; } } class Choice extends BaseQuestion { public input(field: FieldModel, context: RenderContext): string { const required = field.options.formRequired ? 'required' : ''; const choices: Array = field.options.choices || []; // Insert empty option. choices.unshift(null); return ` `; } } class Bool extends BaseQuestion { public async toHTML(field: FieldModel, context: RenderContext) { return `
${this.input(field, context)}
`; } public input(field: FieldModel, context: RenderContext): string { const requiredLabel = field.options.formRequired ? 'grist-label-required' : ''; const required = field.options.formRequired ? 'required' : ''; const label = field.question ? field.question : field.colId; return ` `; } } class ChoiceList extends BaseQuestion { public input(field: FieldModel, context: RenderContext): string { const required = field.options.formRequired ? 'required' : ''; const choices: string[] = field.options.choices || []; return `
${choices.map((choice) => ` `).join('')}
`; } } class RefList extends BaseQuestion { public async input(field: FieldModel, context: RenderContext) { const required = field.options.formRequired ? 'required' : ''; const choices: [number, CellValue][] = (await field.values()) ?? []; // Sort by the second value, which is the display value. choices.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); // Support for 30 choices, TODO: make it dynamic. choices.splice(30); return `
${choices.map((choice) => ` `).join('')}
`; } } class Ref extends BaseQuestion { public async input(field: FieldModel) { const choices: [number|string, CellValue][] = (await field.values()) ?? []; // Sort by the second value, which is the display value. choices.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); // Support for 1000 choices, TODO: make it dynamic. choices.splice(1000); // Insert empty option. choices.unshift(['', CHOOSE_TEXT]); // `).join('')} `; } } /** * List of all available questions we will render of the form. * TODO: add other renderers. */ const questions: Partial Question>> = { 'Text': Text, 'Choice': Choice, 'Bool': Bool, 'ChoiceList': ChoiceList, 'Date': Date, 'DateTime': DateTime, 'Ref': Ref, 'RefList': RefList, }; /** * List of all available boxes we will render of the form. */ const elements = { 'Paragraph': Paragraph, 'Section': Section, 'Columns': Columns, 'Submit': Submit, 'Placeholder': Placeholder, 'Layout': Layout, 'Field': Field, 'Label': Label, // Those are just aliases for Paragraph. 'Separator': Paragraph, 'Header': Paragraph, };