(core) Forms feature

Summary:
A new widget type Forms. For now hidden behind GRIST_EXPERIMENTAL_PLUGINS().
This diff contains all the core moving parts as a serves as a base to extend this functionality
further.

Test Plan: New test added

Reviewers: georgegevoian

Reviewed By: georgegevoian

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D4130
This commit is contained in:
Jarosław Sadziński
2023-12-12 10:58:20 +01:00
parent 337757d0ba
commit a424450cbe
43 changed files with 4023 additions and 133 deletions

226
app/common/Forms.ts Normal file
View File

@@ -0,0 +1,226 @@
import {GristType} from 'app/plugin/GristData';
import {marked} from 'marked';
/**
* All allowed boxes.
*/
export type BoxType = 'Paragraph' | 'Section' | 'Columns' | 'Submit' | 'Placeholder' | 'Layout' | 'Field';
/**
* 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>,
}
/**
* When a form is rendered, it is given a context that can be used to access Grist data and sanitize HTML.
*/
export interface RenderContext {
field(id: number): FieldModel;
}
export interface FieldModel {
question: string;
description: string;
colId: string;
type: string;
options: Record<string, any>;
}
/**
* 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 {
console.assert(box, `Box is not defined`);
const ctr = elements[box.type];
console.assert(ctr, `Box ${box.type} is not defined`);
return new ctr(box, ctx);
}
constructor(protected box: Box, protected ctx: RenderContext) {
}
public toHTML(): string {
return (this.box.children || []).map((child) => RenderBox.new(child, this.ctx).toHTML()).join('');
}
}
class Paragraph extends RenderBox {
public override toHTML(): string {
const text = this.box['text'] || '**Lorem** _ipsum_ dolor';
const html = marked(text);
return `
<div class="grist-paragraph">${html}</div>
`;
}
}
class Section extends RenderBox {
/** Nothing, default is enough */
}
class Columns extends RenderBox {
public override toHTML(): string {
const kids = this.box.children || [];
return `
<div class="grist-columns" style='--grist-columns-count: ${kids.length}'>
${kids.map((child) => child.toHTML()).join('\n')}
</div>
`;
}
}
class Submit extends RenderBox {
public override toHTML() {
return `
<div>
<input type='submit' value='Submit' />
</div>
`;
}
}
class Placeholder extends RenderBox {
public override toHTML() {
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 {
public static render(field: FieldModel, context: RenderContext): string {
const ctr = (questions as any)[field.type as any] as { new(): Question } || Text;
return new ctr().toHTML(field, context);
}
public toHTML(): string {
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>`;
return `
<div class="grist-field">
<label for='${name}'>${label}</label>
${html}
${description}
</div>
`;
}
}
interface Question {
toHTML(field: FieldModel, context: RenderContext): string;
}
class Text implements Question {
public toHTML(field: FieldModel, context: RenderContext): string {
return `
<input type='text' name='${field.colId}' />
`;
}
}
class Date implements Question {
public toHTML(field: FieldModel, context: RenderContext): string {
return `
<input type='date' name='${field.colId}' />
`;
}
}
class DateTime implements Question {
public toHTML(field: FieldModel, context: RenderContext): string {
return `
<input type='datetime-local' name='${field.colId}' />
`;
}
}
class Choice implements Question {
public toHTML(field: FieldModel, context: RenderContext): string {
const choices: string[] = field.options.choices || [];
return `
<select name='${field.colId}'>
${choices.map((choice) => `<option value='${choice}'>${choice}</option>`).join('')}
</select>
`;
}
}
class Bool implements Question {
public toHTML(field: FieldModel, context: RenderContext): string {
return `
<label>
<input type='checkbox' name='${field.colId}' value="1" />
Yes
</label>
`;
}
}
class ChoiceList implements Question {
public toHTML(field: FieldModel, context: RenderContext): string {
const choices: string[] = field.options.choices || [];
return `
<div name='${field.colId}' class='grist-choice-list'>
${choices.map((choice) => `
<label>
<input type='checkbox' name='${field.colId}[]' value='${choice}' />
${choice}
</label>
`).join('')}
</div>
`;
}
}
/**
* 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,
};
/**
* 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,
};

View File

@@ -419,6 +419,10 @@ export interface UserAPI {
* is specific to Grist installation, and might not be supported.
*/
closeOrg(): Promise<void>;
/**
* Creates publicly shared URL for a rendered form.
*/
formUrl(docId: string, vsId: number): string;
}
/**
@@ -510,6 +514,10 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
super(_options);
}
public formUrl(docId: string, vsId: number): string {
return `${this._url}/api/docs/${docId}/forms/${vsId}`;
}
public forRemoved(): UserAPI {
const extraParameters = new Map<string, string>([['showRemoved', '1']]);
return new UserAPIImpl(this._homeUrl, {...this._options, extraParameters});

View File

@@ -1,5 +1,18 @@
import {BindableValue, DomElementMethod, IKnockoutReadObservable, ISubscribable, Listener, Observable,
subscribeElem, UseCB, UseCBOwner} from 'grainjs';
import {
BindableValue,
Computed,
DomElementMethod,
Holder,
IDisposableOwner,
IKnockoutReadObservable,
ISubscribable,
Listener,
MultiHolder,
Observable,
subscribeElem,
UseCB,
UseCBOwner
} from 'grainjs';
import {Observable as KoObservable} from 'knockout';
import identity = require('lodash/identity');
@@ -827,9 +840,9 @@ export async function waitGrainObs<T>(observable: Observable<T>,
// `dom.style` does not work here because custom css property (ie: `--foo`) needs to be set using
// `style.setProperty` (credit: https://vanseodesign.com/css/custom-properties-and-javascript/).
// TODO: consider making PR to fix `dom.style` in grainjs.
export function inlineStyle(property: string, valueObs: BindableValue<string>): DomElementMethod {
export function inlineStyle(property: string, valueObs: BindableValue<any>): DomElementMethod {
return (elem) => subscribeElem(elem, valueObs, (val) => {
elem.style.setProperty(property, val);
elem.style.setProperty(property, String(val ?? ''));
});
}
@@ -950,6 +963,24 @@ export const unwrap: UseCB = (obs: ISubscribable) => {
return (obs as ko.Observable).peek();
};
/**
* Subscribes to BindableValue
*/
export function useBindable<T>(use: UseCBOwner, obs: BindableValue<T>): T {
if (obs === null || obs === undefined) { return obs; }
const smth = obs as any;
// If knockout
if (typeof smth === 'function' && 'peek' in smth) { return use(smth) as T; }
// If grainjs Observable or Computed
if (typeof smth === 'object' && '_getDepItem' in smth) { return use(smth) as T; }
// If use function ComputedCallback
if (typeof smth === 'function') { return smth(use) as T; }
return obs as T;
}
/**
* Use helper for simple boolean negation.
*/
@@ -1006,3 +1037,20 @@ export function notSet(value: any) {
export function ifNotSet(value: any, def: any = null) {
return notSet(value) ? def : value;
}
/**
* Creates a computed observable with a nested owner that can be used to dispose,
* any disposables created inside the computed. Similar to domComputedOwned method.
*/
export function computedOwned<T>(
owner: IDisposableOwner,
func: (owner: IDisposableOwner, use: UseCBOwner) => T
): Computed<T> {
const holder = Holder.create(owner);
return Computed.create(owner, use => {
const computedOwner = MultiHolder.create(holder);
return func(computedOwner, use);
});
}
export type Constructor<T> = new (...args: any[]) => T;

View File

@@ -8,4 +8,4 @@ export const AttachedCustomWidgets = StringUnion('custom.calendar');
export type IAttachedCustomWidget = typeof AttachedCustomWidgets.type;
// all widget types
export type IWidgetType = 'record' | 'detail' | 'single' | 'chart' | 'custom' | IAttachedCustomWidget;
export type IWidgetType = 'record' | 'detail' | 'single' | 'chart' | 'custom' | 'form' | IAttachedCustomWidget;