(core) Forms improvements

Summary:
Forms improvements and following new design
- New headers
- New UI
- New right panel options

Test Plan: Tests updated

Reviewers: georgegevoian, dsagal

Reviewed By: georgegevoian

Subscribers: dsagal, paulfitz

Differential Revision: https://phab.getgrist.com/D4158
This commit is contained in:
Jarosław Sadziński
2024-01-18 18:23:50 +01:00
parent b82209b458
commit 0aad09a4ed
55 changed files with 3468 additions and 1410 deletions

View File

@@ -1,10 +1,24 @@
import {GristType} from 'app/plugin/GristData';
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';
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;
/**
* Box model is a JSON that represents a form element. Every element can be converted to this element and every
@@ -13,21 +27,37 @@ export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' | 'Placehol
export interface Box extends Record<string, any> {
type: BoxType,
children?: Array<Box>,
// Some properties used by some boxes (like form itself)
submitText?: string,
successURL?: string,
successText?: string,
anotherResponse?: boolean,
}
/**
* 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;
options: Record<string, any>;
options: FieldOptions;
values(): MaybePromise<[number, CellValue][]>;
}
/**
@@ -36,9 +66,7 @@ export interface FieldModel {
*/
export class RenderBox {
public static new(box: Box, ctx: RenderContext): RenderBox {
console.assert(box, `Box is not defined`);
const ctr = elements[box.type];
console.assert(ctr, `Box ${box.type} is not defined`);
const ctr = elements[box.type] ?? Paragraph;
return new ctr(box, ctx);
}
@@ -46,48 +74,69 @@ export class RenderBox {
}
public toHTML(): string {
return (this.box.children || []).map((child) => RenderBox.new(child, this.ctx).toHTML()).join('');
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>
`;
}
}
class Paragraph extends RenderBox {
public override toHTML(): string {
public override async toHTML() {
const text = this.box['text'] || '**Lorem** _ipsum_ dolor';
const alignment = this.box['alignment'] || 'left';
const html = marked(text);
return `
<div class="grist-paragraph">${html}</div>
<div class="grist-paragraph grist-text-${alignment}">${html}</div>
`;
}
}
class Section extends RenderBox {
/** Nothing, default is enough */
public override async toHTML() {
return `
<div class="grist-section">
${await super.toHTML()}
</div>
`;
}
}
class Columns extends RenderBox {
public override toHTML(): string {
const kids = this.box.children || [];
public override async toHTML() {
const size = this.box.children?.length || 1;
const content = await super.toHTML();
return `
<div class="grist-columns" style='--grist-columns-count: ${kids.length}'>
${kids.map((child) => child.toHTML()).join('\n')}
<div class="grist-columns" style='--grist-columns-count: ${size}'>
${content}
</div>
`;
}
}
class Submit extends RenderBox {
public override toHTML() {
public override async toHTML() {
const text = _.escape(this.ctx.root['submitText'] || 'Submit');
return `
<div>
<input type='submit' value='Submit' />
<div class='grist-submit'>
<input type='submit' value='${text}' />
</div>
`;
}
}
class Placeholder extends RenderBox {
public override toHTML() {
public override async toHTML() {
return `
<div>
</div>
@@ -105,93 +154,131 @@ class Layout extends RenderBox {
*/
class Field extends RenderBox {
public static render(field: FieldModel, context: RenderContext): string {
public build(field: FieldModel, context: RenderContext) {
const ctr = (questions as any)[field.type as any] as { new(): Question } || Text;
return new ctr().toHTML(field, context);
return new ctr();
}
public toHTML(): string {
public async toHTML() {
const field = this.ctx.field(this.box['leaf']);
if (!field) {
return `<div class="grist-field">Field not found</div>`;
}
const label = field.question ? field.question : field.colId;
const name = field.colId;
let description = field.description || '';
if (description) {
description = `<div class='grist-field-description'>${description}</div>`;
}
const html = `<div class='grist-field-content'>${Field.render(field, this.ctx)}</div>`;
const renderer = this.build(field, this.ctx);
return `
<div class="grist-field">
<label for='${name}'>${label}</label>
${html}
${description}
${await renderer.toHTML(field, this.ctx)}
</div>
`;
}
}
interface Question {
toHTML(field: FieldModel, context: RenderContext): string;
toHTML(field: FieldModel, context: RenderContext): Promise<string>|string;
}
class Text implements Question {
public toHTML(field: FieldModel, context: RenderContext): string {
abstract class BaseQuestion implements Question {
public async toHTML(field: FieldModel, context: RenderContext): Promise<string> {
return `
<input type='text' name='${field.colId}' />
<div class='grist-question'>
${this.label(field)}
<div class='grist-field-content'>
${await this.input(field, context)}
</div>
</div>
`;
}
public label(field: FieldModel): string {
// This might be HTML.
const label = field.question;
const name = field.colId;
return `
<label class='grist-label' for='${name}'>${label}</label>
`;
}
public abstract input(field: FieldModel, context: RenderContext): string|Promise<string>;
}
class Text extends BaseQuestion {
public input(field: FieldModel, context: RenderContext): string {
const required = field.options.formRequired ? 'required' : '';
return `
<input type='text' name='${field.colId}' ${required}/>
`;
}
}
class Date implements Question {
public toHTML(field: FieldModel, context: RenderContext): string {
class Date extends BaseQuestion {
public input(field: FieldModel, context: RenderContext): string {
const required = field.options.formRequired ? 'required' : '';
return `
<input type='date' name='${field.colId}' />
<input type='date' name='${field.colId}' ${required}/>
`;
}
}
class DateTime implements Question {
public toHTML(field: FieldModel, context: RenderContext): string {
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}' />
<input type='datetime-local' name='${field.colId}' ${required}/>
`;
}
}
class Choice implements Question {
public toHTML(field: FieldModel, context: RenderContext): string {
class Choice extends BaseQuestion {
public input(field: FieldModel, context: RenderContext): string {
const required = field.options.formRequired ? 'required' : '';
const choices: string[] = field.options.choices || [];
return `
<select name='${field.colId}'>
<select name='${field.colId}' ${required} >
${choices.map((choice) => `<option value='${choice}'>${choice}</option>`).join('')}
</select>
`;
}
}
class Bool implements Question {
public toHTML(field: FieldModel, context: RenderContext): string {
class Bool extends BaseQuestion {
public async toHTML(field: FieldModel, context: RenderContext) {
return `
<label>
<input type='checkbox' name='${field.colId}' value="1" />
Yes
<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;
return `
<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>
</label>
`;
}
}
class ChoiceList implements Question {
public toHTML(field: FieldModel, context: RenderContext): string {
class ChoiceList extends BaseQuestion {
public input(field: FieldModel, context: RenderContext): string {
const required = field.options.formRequired ? 'required' : '';
const choices: string[] = field.options.choices || [];
return `
<div name='${field.colId}' class='grist-choice-list'>
<div name='${field.colId}' class='grist-choice-list ${required}'>
${choices.map((choice) => `
<label>
<input type='checkbox' name='${field.colId}[]' value='${choice}' />
${choice}
<span>
${choice}
</span>
</label>
`).join('')}
</div>
@@ -199,6 +286,44 @@ class ChoiceList implements Question {
}
}
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>
`;
}
}
/**
* List of all available questions we will render of the form.
* TODO: add other renderers.
@@ -210,6 +335,8 @@ const questions: Partial<Record<GristType, new () => Question>> = {
'ChoiceList': ChoiceList,
'Date': Date,
'DateTime': DateTime,
'Ref': Ref,
'RefList': RefList,
};
/**
@@ -223,4 +350,5 @@ const elements = {
'Placeholder': Placeholder,
'Layout': Layout,
'Field': Field,
'Label': Label,
};

View File

@@ -982,7 +982,7 @@ export function useBindable<T>(use: UseCBOwner, obs: BindableValue<T>): T {
}
/**
* Use helper for simple boolean negation.
* Useful helper for simple boolean negation.
*/
export const not = (obs: Observable<any>|IKnockoutReadObservable<any>) => (use: UseCBOwner) => !use(obs);

View File

@@ -9,3 +9,12 @@ export type IAttachedCustomWidget = typeof AttachedCustomWidgets.type;
// all widget types
export type IWidgetType = 'record' | 'detail' | 'single' | 'chart' | 'custom' | 'form' | IAttachedCustomWidget;
export enum WidgetType {
Table = 'record',
Card = 'single',
CardList = 'detail',
Chart = 'chart',
Custom = 'custom',
Form = 'form',
Calendar = 'custom.calendar',
}