2024-01-18 17:23:50 +00:00
|
|
|
import {CellValue, GristType} from 'app/plugin/GristData';
|
|
|
|
import {MaybePromise} from 'app/plugin/gutil';
|
|
|
|
import _ from 'lodash';
|
2023-12-12 09:58:20 +00:00
|
|
|
import {marked} from 'marked';
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*/
|
|
|
|
|
2023-12-12 09:58:20 +00:00
|
|
|
/**
|
|
|
|
* All allowed boxes.
|
|
|
|
*/
|
2024-01-18 17:23:50 +00:00
|
|
|
export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' | 'Placeholder' | 'Layout' | 'Field' |
|
|
|
|
'Label';
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Number of fields to show in the form by default.
|
|
|
|
*/
|
|
|
|
export const INITIAL_FIELDS_COUNT = 9;
|
2023-12-12 09:58:20 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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 extends Record<string, any> {
|
|
|
|
type: BoxType,
|
|
|
|
children?: Array<Box>,
|
2024-01-18 17:23:50 +00:00
|
|
|
|
|
|
|
// Some properties used by some boxes (like form itself)
|
|
|
|
submitText?: string,
|
|
|
|
successURL?: string,
|
|
|
|
successText?: string,
|
|
|
|
anotherResponse?: boolean,
|
2023-12-12 09:58:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* When a form is rendered, it is given a context that can be used to access Grist data and sanitize HTML.
|
|
|
|
*/
|
|
|
|
export interface RenderContext {
|
2024-01-18 17:23:50 +00:00
|
|
|
root: Box;
|
2023-12-12 09:58:20 +00:00
|
|
|
field(id: number): FieldModel;
|
|
|
|
}
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
export interface FieldOptions {
|
|
|
|
formRequired?: boolean;
|
|
|
|
choices?: string[];
|
|
|
|
}
|
|
|
|
|
2023-12-12 09:58:20 +00:00
|
|
|
export interface FieldModel {
|
2024-01-18 17:23:50 +00:00
|
|
|
/**
|
|
|
|
* The question to ask. Fallbacks to column's label than column's id.
|
|
|
|
*/
|
2023-12-12 09:58:20 +00:00
|
|
|
question: string;
|
|
|
|
description: string;
|
|
|
|
colId: string;
|
|
|
|
type: string;
|
2024-01-18 17:23:50 +00:00
|
|
|
options: FieldOptions;
|
|
|
|
values(): MaybePromise<[number, CellValue][]>;
|
2023-12-12 09:58:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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 {
|
2024-01-18 17:23:50 +00:00
|
|
|
const ctr = elements[box.type] ?? Paragraph;
|
2023-12-12 09:58:20 +00:00
|
|
|
return new ctr(box, ctx);
|
|
|
|
}
|
|
|
|
|
|
|
|
constructor(protected box: Box, protected ctx: RenderContext) {
|
|
|
|
|
|
|
|
}
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
public async toHTML(): Promise<string> {
|
|
|
|
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'];
|
|
|
|
const cssClass = this.box['cssClass'] || '';
|
|
|
|
return `
|
|
|
|
<div class="grist-label ${cssClass}">${text || ''}</div>
|
|
|
|
`;
|
2023-12-12 09:58:20 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class Paragraph extends RenderBox {
|
2024-01-18 17:23:50 +00:00
|
|
|
public override async toHTML() {
|
2023-12-12 09:58:20 +00:00
|
|
|
const text = this.box['text'] || '**Lorem** _ipsum_ dolor';
|
2024-01-18 17:23:50 +00:00
|
|
|
const alignment = this.box['alignment'] || 'left';
|
2023-12-12 09:58:20 +00:00
|
|
|
const html = marked(text);
|
|
|
|
return `
|
2024-01-18 17:23:50 +00:00
|
|
|
<div class="grist-paragraph grist-text-${alignment}">${html}</div>
|
2023-12-12 09:58:20 +00:00
|
|
|
`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class Section extends RenderBox {
|
2024-01-18 17:23:50 +00:00
|
|
|
public override async toHTML() {
|
|
|
|
return `
|
|
|
|
<div class="grist-section">
|
|
|
|
${await super.toHTML()}
|
|
|
|
</div>
|
|
|
|
`;
|
|
|
|
}
|
2023-12-12 09:58:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
class Columns extends RenderBox {
|
2024-01-18 17:23:50 +00:00
|
|
|
public override async toHTML() {
|
|
|
|
const size = this.box.children?.length || 1;
|
|
|
|
const content = await super.toHTML();
|
2023-12-12 09:58:20 +00:00
|
|
|
return `
|
2024-01-18 17:23:50 +00:00
|
|
|
<div class="grist-columns" style='--grist-columns-count: ${size}'>
|
|
|
|
${content}
|
2023-12-12 09:58:20 +00:00
|
|
|
</div>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class Submit extends RenderBox {
|
2024-01-18 17:23:50 +00:00
|
|
|
public override async toHTML() {
|
|
|
|
const text = _.escape(this.ctx.root['submitText'] || 'Submit');
|
2023-12-12 09:58:20 +00:00
|
|
|
return `
|
2024-01-18 17:23:50 +00:00
|
|
|
<div class='grist-submit'>
|
|
|
|
<input type='submit' value='${text}' />
|
2023-12-12 09:58:20 +00:00
|
|
|
</div>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class Placeholder extends RenderBox {
|
2024-01-18 17:23:50 +00:00
|
|
|
public override async toHTML() {
|
2023-12-12 09:58:20 +00:00
|
|
|
return `
|
|
|
|
<div>
|
|
|
|
</div>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
public build(field: FieldModel, context: RenderContext) {
|
2023-12-12 09:58:20 +00:00
|
|
|
const ctr = (questions as any)[field.type as any] as { new(): Question } || Text;
|
2024-01-18 17:23:50 +00:00
|
|
|
return new ctr();
|
2023-12-12 09:58:20 +00:00
|
|
|
}
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
public async toHTML() {
|
2023-12-12 09:58:20 +00:00
|
|
|
const field = this.ctx.field(this.box['leaf']);
|
|
|
|
if (!field) {
|
|
|
|
return `<div class="grist-field">Field not found</div>`;
|
|
|
|
}
|
2024-01-18 17:23:50 +00:00
|
|
|
const renderer = this.build(field, this.ctx);
|
2023-12-12 09:58:20 +00:00
|
|
|
return `
|
|
|
|
<div class="grist-field">
|
2024-01-18 17:23:50 +00:00
|
|
|
${await renderer.toHTML(field, this.ctx)}
|
2023-12-12 09:58:20 +00:00
|
|
|
</div>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
interface Question {
|
2024-01-18 17:23:50 +00:00
|
|
|
toHTML(field: FieldModel, context: RenderContext): Promise<string>|string;
|
2023-12-12 09:58:20 +00:00
|
|
|
}
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
abstract class BaseQuestion implements Question {
|
|
|
|
public async toHTML(field: FieldModel, context: RenderContext): Promise<string> {
|
|
|
|
return `
|
|
|
|
<div class='grist-question'>
|
|
|
|
${this.label(field)}
|
|
|
|
<div class='grist-field-content'>
|
|
|
|
${await this.input(field, context)}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
`;
|
|
|
|
}
|
2023-12-12 09:58:20 +00:00
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
public label(field: FieldModel): string {
|
|
|
|
// This might be HTML.
|
|
|
|
const label = field.question;
|
|
|
|
const name = field.colId;
|
2023-12-12 09:58:20 +00:00
|
|
|
return `
|
2024-01-18 17:23:50 +00:00
|
|
|
<label class='grist-label' for='${name}'>${label}</label>
|
2023-12-12 09:58:20 +00:00
|
|
|
`;
|
|
|
|
}
|
2024-01-18 17:23:50 +00:00
|
|
|
|
|
|
|
public abstract input(field: FieldModel, context: RenderContext): string|Promise<string>;
|
2023-12-12 09:58:20 +00:00
|
|
|
}
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
class Text extends BaseQuestion {
|
|
|
|
public input(field: FieldModel, context: RenderContext): string {
|
|
|
|
const required = field.options.formRequired ? 'required' : '';
|
2023-12-12 09:58:20 +00:00
|
|
|
return `
|
2024-01-18 17:23:50 +00:00
|
|
|
<input type='text' name='${field.colId}' ${required}/>
|
2023-12-12 09:58:20 +00:00
|
|
|
`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
class Date extends BaseQuestion {
|
|
|
|
public input(field: FieldModel, context: RenderContext): string {
|
|
|
|
const required = field.options.formRequired ? 'required' : '';
|
2023-12-12 09:58:20 +00:00
|
|
|
return `
|
2024-01-18 17:23:50 +00:00
|
|
|
<input type='date' name='${field.colId}' ${required}/>
|
2023-12-12 09:58:20 +00:00
|
|
|
`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
class DateTime extends BaseQuestion {
|
|
|
|
public input(field: FieldModel, context: RenderContext): string {
|
|
|
|
const required = field.options.formRequired ? 'required' : '';
|
|
|
|
return `
|
|
|
|
<input type='datetime-local' name='${field.colId}' ${required}/>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class Choice extends BaseQuestion {
|
|
|
|
public input(field: FieldModel, context: RenderContext): string {
|
|
|
|
const required = field.options.formRequired ? 'required' : '';
|
2023-12-12 09:58:20 +00:00
|
|
|
const choices: string[] = field.options.choices || [];
|
|
|
|
return `
|
2024-01-18 17:23:50 +00:00
|
|
|
<select name='${field.colId}' ${required} >
|
2023-12-12 09:58:20 +00:00
|
|
|
${choices.map((choice) => `<option value='${choice}'>${choice}</option>`).join('')}
|
|
|
|
</select>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
class Bool extends BaseQuestion {
|
|
|
|
public async toHTML(field: FieldModel, context: RenderContext) {
|
|
|
|
return `
|
|
|
|
<div class='grist-question'>
|
|
|
|
<div class='grist-field-content'>
|
|
|
|
${this.input(field, context)}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
|
|
|
|
public input(field: FieldModel, context: RenderContext): string {
|
|
|
|
const required = field.options.formRequired ? 'required' : '';
|
|
|
|
const label = field.question ? field.question : field.colId;
|
2023-12-12 09:58:20 +00:00
|
|
|
return `
|
2024-01-18 17:23:50 +00:00
|
|
|
<label class='grist-switch'>
|
|
|
|
<input type='checkbox' name='${field.colId}' value="1" ${required} />
|
|
|
|
<div class="grist-widget_switch grist-switch_transition">
|
|
|
|
<div class="grist-switch_slider"></div>
|
|
|
|
<div class="grist-switch_circle"></div>
|
|
|
|
</div>
|
|
|
|
<span>${label}</span>
|
2023-12-12 09:58:20 +00:00
|
|
|
</label>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
class ChoiceList extends BaseQuestion {
|
|
|
|
public input(field: FieldModel, context: RenderContext): string {
|
|
|
|
const required = field.options.formRequired ? 'required' : '';
|
2023-12-12 09:58:20 +00:00
|
|
|
const choices: string[] = field.options.choices || [];
|
|
|
|
return `
|
2024-01-18 17:23:50 +00:00
|
|
|
<div name='${field.colId}' class='grist-choice-list ${required}'>
|
2023-12-12 09:58:20 +00:00
|
|
|
${choices.map((choice) => `
|
|
|
|
<label>
|
|
|
|
<input type='checkbox' name='${field.colId}[]' value='${choice}' />
|
2024-01-18 17:23:50 +00:00
|
|
|
<span>
|
|
|
|
${choice}
|
|
|
|
</span>
|
2023-12-12 09:58:20 +00:00
|
|
|
</label>
|
|
|
|
`).join('')}
|
|
|
|
</div>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-18 17:23:50 +00:00
|
|
|
class RefList extends BaseQuestion {
|
|
|
|
public async input(field: FieldModel, context: RenderContext) {
|
|
|
|
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 20 choices, TODO: make it dynamic.
|
|
|
|
choices.splice(20);
|
|
|
|
return `
|
|
|
|
<div name='${field.colId}' class='grist-ref-list'>
|
|
|
|
${choices.map((choice) => `
|
|
|
|
<label class='grist-checkbox'>
|
|
|
|
<input type='checkbox' name='${field.colId}[]' value='${String(choice[0])}' />
|
|
|
|
<span>
|
|
|
|
${String(choice[1] ?? '')}
|
|
|
|
</span>
|
|
|
|
</label>
|
|
|
|
`).join('')}
|
|
|
|
</div>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class Ref extends BaseQuestion {
|
|
|
|
public async input(field: FieldModel) {
|
|
|
|
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 1000 choices, TODO: make it dynamic.
|
|
|
|
choices.splice(1000);
|
|
|
|
// <option type='number' is not standard, we parse it ourselves.
|
|
|
|
return `
|
|
|
|
<select name='${field.colId}' class='grist-ref' data-grist-type='${field.type}'>
|
|
|
|
${choices.map((choice) => `<option value='${String(choice[0])}'>${String(choice[1] ?? '')}</option>`).join('')}
|
|
|
|
</select>
|
|
|
|
`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-12-12 09:58:20 +00:00
|
|
|
/**
|
|
|
|
* List of all available questions we will render of the form.
|
|
|
|
* TODO: add other renderers.
|
|
|
|
*/
|
|
|
|
const questions: Partial<Record<GristType, new () => Question>> = {
|
|
|
|
'Text': Text,
|
|
|
|
'Choice': Choice,
|
|
|
|
'Bool': Bool,
|
|
|
|
'ChoiceList': ChoiceList,
|
|
|
|
'Date': Date,
|
|
|
|
'DateTime': DateTime,
|
2024-01-18 17:23:50 +00:00
|
|
|
'Ref': Ref,
|
|
|
|
'RefList': RefList,
|
2023-12-12 09:58:20 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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,
|
2024-01-18 17:23:50 +00:00
|
|
|
'Label': Label,
|
2023-12-12 09:58:20 +00:00
|
|
|
};
|