(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
pull/805/head
Jarosław Sadziński 5 months ago
parent 337757d0ba
commit a424450cbe

@ -0,0 +1,176 @@
import * as style from 'app/client/components/Forms/styles';
import {Box, BoxModel, RenderContext} from 'app/client/components/Forms/Model';
import {makeTestId} from 'app/client/lib/domUtils';
import {icon} from 'app/client/ui2018/icons';
import * as menus from 'app/client/ui2018/menus';
import {inlineStyle, not} from 'app/common/gutil';
import {bundleChanges, Computed, dom, MultiHolder, Observable, styled} from 'grainjs';
const testId = makeTestId('test-forms-');
export class ColumnsModel extends BoxModel {
private _columnCount = Computed.create(this, use => use(this.children).length);
public removeChild(box: BoxModel) {
if (box.type.get() === 'Placeholder') {
// Make sure we have at least one rendered.
if (this.children.get().length <= 1) {
return;
}
return super.removeChild(box);
}
// We will replace this box with a placeholder.
this.replace(box, Placeholder());
}
// Dropping a box on a column will replace it.
public drop(dropped: Box): BoxModel {
if (!this.parent) { throw new Error('No parent'); }
// We need to remove it from the parent, so find it first.
const droppedId = dropped.id;
const droppedRef = this.root().find(droppedId);
// Now we simply insert it after this box.
droppedRef?.removeSelf();
return this.parent.replace(this, dropped);
}
public render(context: RenderContext): HTMLElement {
context.overlay.set(false);
// Now render the dom.
const renderedDom = style.cssColumns(
// Pass column count as a css variable (to style the grid).
inlineStyle(`--css-columns-count`, this._columnCount),
// Render placeholders as children.
dom.forEach(this.children, (child) => {
return this.view.renderBox(
this.children,
child || BoxModel.new(Placeholder(), this),
testId('column')
);
}),
// Append + button at the end.
dom('div',
testId('add'),
icon('Plus'),
dom.on('click', () => this.placeAfterListChild()(Placeholder())),
style.cssColumn.cls('-add-button')
),
);
return renderedDom;
}
}
export class PlaceholderModel extends BoxModel {
public render(context: RenderContext): HTMLElement {
const [box, view, overlay] = [this, this.view, context.overlay];
const scope = new MultiHolder();
overlay.set(false);
const liveIndex = Computed.create(scope, (use) => {
if (!box.parent) { return -1; }
const parentChildren = use(box.parent.children);
return parentChildren.indexOf(box);
});
const boxModelAt = Computed.create(scope, (use) => {
const index = use(liveIndex);
if (index === null) { return null; }
const childBox = use(box.children)[index];
if (!childBox) {
return null;
}
return childBox;
});
const dragHover = Observable.create(scope, false);
return cssPlaceholder(
style.cssDrag(),
testId('placeholder'),
dom.autoDispose(scope),
style.cssColumn.cls('-drag-over', dragHover),
style.cssColumn.cls('-empty', not(boxModelAt)),
style.cssColumn.cls('-selected', use => use(view.selectedBox) === box),
view.buildAddMenu(insertBox, {
customItems: [menus.menuItem(removeColumn, menus.menuIcon('Remove'), 'Remove Column')],
}),
dom.on('contextmenu', (ev) => {
ev.stopPropagation();
}),
dom.on('dragleave', (ev) => {
ev.stopPropagation();
ev.preventDefault();
// Just remove the style and stop propagation.
dragHover.set(false);
}),
dom.on('dragover', (ev) => {
// As usual, prevent propagation.
ev.stopPropagation();
ev.preventDefault();
// Here we just change the style of the element.
ev.dataTransfer!.dropEffect = "move";
dragHover.set(true);
}),
dom.on('drop', (ev) => {
ev.stopPropagation();
ev.preventDefault();
dragHover.set(false);
// Get the box that was dropped.
const dropped = JSON.parse(ev.dataTransfer!.getData('text/plain'));
// We need to remove it from the parent, so find it first.
const droppedId = dropped.id;
const droppedRef = box.root().find(droppedId);
if (!droppedRef) { return; }
// Now we simply insert it after this box.
bundleChanges(() => {
droppedRef.removeSelf();
const parent = box.parent!;
parent.replace(box, dropped);
parent.save().catch(reportError);
});
}),
dom.maybeOwned(boxModelAt, (mscope, child) => view.renderBox(mscope, child)),
dom.maybe(use => !use(boxModelAt) && use(view.isEdit), () => {
return dom('span', `Column `, dom.text(use => String(use(liveIndex) + 1)));
}),
);
function insertBox(childBox: Box) {
// Make sure we have at least as many columns as the index we are inserting at.
if (!box.parent) { throw new Error('No parent'); }
return box.parent.replace(box, childBox);
}
function removeColumn() {
box.removeSelf();
}
}
}
export function Placeholder(): Box {
return {type: 'Placeholder'};
}
export function Columns(): Box {
return {type: 'Columns', children: [Placeholder(), Placeholder()]};
}
const cssPlaceholder = styled('div', `
position: relative;
`);

@ -0,0 +1,210 @@
import {FormView} from 'app/client/components/Forms/FormView';
import {Box, BoxModel, ignoreClick, RenderContext} from 'app/client/components/Forms/Model';
import * as style from 'app/client/components/Forms/styles';
import {ViewFieldRec} from 'app/client/models/DocModel';
import {Constructor} from 'app/common/gutil';
import {BindableValue, Computed, Disposable, dom, DomContents,
IDomComponent, makeTestId, Observable, toKo} from 'grainjs';
import * as ko from 'knockout';
const testId = makeTestId('test-forms-');
/**
* Base class for all field models.
*/
export class FieldModel extends BoxModel {
public fieldRef = this.autoDispose(ko.pureComputed(() => toKo(ko, this.leaf)()));
public field = this.view.gristDoc.docModel.viewFields.createFloatingRowModel(this.fieldRef);
public question = Computed.create(this, (use) => {
return use(this.field.question) || use(this.field.origLabel);
});
public description = Computed.create(this, (use) => {
return use(this.field.description);
});
public colType = Computed.create(this, (use) => {
return use(use(this.field.column).pureType);
});
public get leaf() {
return this.props['leaf'] as Observable<number>;
}
public renderer = Computed.create(this, (use) => {
const ctor = fieldConstructor(use(this.colType));
const instance = new ctor(this.field);
use.owner.autoDispose(instance);
return instance;
});
constructor(box: Box, parent: BoxModel | null, view: FormView) {
super(box, parent, view);
}
public async onDrop() {
await super.onDrop();
if (typeof this.leaf.get() === 'string') {
this.leaf.set(await this.view.showColumn(this.leaf.get()));
}
}
public render(context: RenderContext) {
const model = this;
return dom('div',
testId('question'),
style.cssLabel(
testId('label'),
dom.text(model.question)
),
testType(this.colType),
dom.domComputed(this.renderer, (renderer) => renderer.buildDom()),
dom.maybe(model.description, (description) => [
style.cssDesc(description, testId('description')),
]),
);
}
public async deleteSelf() {
const rowId = this.field.getRowId();
const view = this.view;
this.removeSelf();
// The order here matters for undo.
await this.save();
// We are disposed at this point, be still can access the view.
if (rowId) {
await view.viewSection.removeField(rowId);
}
}
}
export abstract class Question extends Disposable implements IDomComponent {
constructor(public field: ViewFieldRec) {
super();
}
public abstract buildDom(): DomContents;
}
class TextModel extends Question {
public buildDom() {
return style.cssInput(
dom.prop('name', this.field.colId),
{type: 'text', tabIndex: "-1"},
ignoreClick
);
}
}
class ChoiceModel extends Question {
public buildDom() {
const field = this.field;
const choices: Computed<string[]> = Computed.create(this, use => {
return use(use(field.origCol).widgetOptionsJson.prop('choices')) || [];
});
return style.cssSelect(
{tabIndex: "-1"},
ignoreClick,
dom.prop('name', this.field.colId),
dom.forEach(choices, (choice) => dom('option', choice, {value: choice})),
);
}
}
class ChoiceListModel extends Question {
public buildDom() {
const field = this.field;
const choices: Computed<string[]> = Computed.create(this, use => {
return use(use(field.origCol).widgetOptionsJson.prop('choices')) || [];
});
return dom('div',
dom.prop('name', this.field.colId),
dom.forEach(choices, (choice) => style.cssLabel(
dom('input',
dom.prop('name', this.field.colId),
{type: 'checkbox', value: choice, style: 'margin-right: 5px;'}
),
choice
)),
dom.maybe(use => use(choices).length === 0, () => [
dom('div', 'No choices defined'),
]),
);
}
}
class BoolModel extends Question {
public buildDom() {
return dom('div',
style.cssLabel(
{style: 'display: flex; align-items: center; gap: 8px;'},
dom('input',
dom.prop('name', this.field.colId),
{type: 'checkbox', name: 'choice', style: 'margin: 0px; padding: 0px;'}
),
'Yes'
),
);
}
}
class DateModel extends Question {
public buildDom() {
return dom('div',
dom('input',
dom.prop('name', this.field.colId),
{type: 'date', style: 'margin-right: 5px; width: 100%;'
}),
);
}
}
class DateTimeModel extends Question {
public buildDom() {
return dom('div',
dom('input',
dom.prop('name', this.field.colId),
{type: 'datetime-local', style: 'margin-right: 5px; width: 100%;'}
),
dom.style('width', '100%'),
);
}
}
// TODO: decide which one we need and implement rest.
const AnyModel = TextModel;
const NumericModel = TextModel;
const IntModel = TextModel;
const RefModel = TextModel;
const RefListModel = TextModel;
const AttachmentsModel = TextModel;
function fieldConstructor(type: string): Constructor<Question> {
switch (type) {
case 'Any': return AnyModel;
case 'Bool': return BoolModel;
case 'Choice': return ChoiceModel;
case 'ChoiceList': return ChoiceListModel;
case 'Date': return DateModel;
case 'DateTime': return DateTimeModel;
case 'Int': return IntModel;
case 'Numeric': return NumericModel;
case 'Ref': return RefModel;
case 'RefList': return RefListModel;
case 'Attachments': return AttachmentsModel;
default: return TextModel;
}
}
/**
* Creates a hidden input element with element type. Used in tests.
*/
function testType(value: BindableValue<string>) {
return dom('input', {type: 'hidden'}, dom.prop('value', value), testId('type'));
}

@ -0,0 +1,679 @@
import BaseView from 'app/client/components/BaseView';
import * as commands from 'app/client/components/commands';
import {allCommands} from 'app/client/components/commands';
import {Cursor} from 'app/client/components/Cursor';
import * as components from 'app/client/components/Forms/elements';
import {Box, BoxModel, BoxType, LayoutModel, parseBox, Place} from 'app/client/components/Forms/Model';
import * as style from 'app/client/components/Forms/styles';
import {GristDoc} from 'app/client/components/GristDoc';
import {Disposable} from 'app/client/lib/dispose';
import {makeTestId} from 'app/client/lib/domUtils';
import {FocusLayer} from 'app/client/lib/FocusLayer';
import DataTableModel from 'app/client/models/DataTableModel';
import {ViewSectionRec} from 'app/client/models/DocModel';
import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec';
import {SortedRowSet} from 'app/client/models/rowset';
import {getColumnTypes as getNewColumnTypes} from 'app/client/ui/GridViewMenus';
import {cssButton} from 'app/client/ui2018/buttons';
import {icon} from 'app/client/ui2018/icons';
import * as menus from 'app/client/ui2018/menus';
import {not} from 'app/common/gutil';
import {Events as BackboneEvents} from 'backbone';
import {Computed, dom, Holder, IDisposableOwner, IDomArgs, MultiHolder, Observable} from 'grainjs';
import defaults from 'lodash/defaults';
import isEqual from 'lodash/isEqual';
import {v4 as uuidv4} from 'uuid';
const testId = makeTestId('test-forms-');
export class FormView extends Disposable {
public viewPane: HTMLElement;
public gristDoc: GristDoc;
public viewSection: ViewSectionRec;
public isEdit: Observable<boolean>;
public selectedBox: Observable<BoxModel | null>;
protected sortedRows: SortedRowSet;
protected tableModel: DataTableModel;
protected cursor: Cursor;
protected menuHolder: Holder<any>;
protected bundle: (clb: () => Promise<void>) => Promise<void>;
private _autoLayout: Computed<Box>;
private _root: BoxModel;
private _savedLayout: any;
private _saving: boolean = false;
private _url: Computed<string>;
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false});
this.isEdit = Observable.create(this, true);
this.menuHolder = Holder.create(this);
this.bundle = (clb) => this.gristDoc.docData.bundleActions('Saving form layout', clb, {nestInActiveBundle: true});
this.selectedBox = Observable.create(this, null);
this.selectedBox.addListener((v) => {
if (!v) { return; }
const colRef = Number(v.prop('leaf').get());
if (!colRef || typeof colRef !== 'number') { return; }
const fieldIndex = this.viewSection.viewFields().all().findIndex(f => f.getRowId() === colRef);
if (fieldIndex === -1) { return; }
this.cursor.setCursorPos({fieldIndex});
});
this._autoLayout = Computed.create(this, use => {
// If the layout is already there, don't do anything.
const existing = use(this.viewSection.layoutSpecObj);
if (!existing || !existing.id) {
// Else create a temporary one.
const fields = use(use(this.viewSection.viewFields).getObservable());
const children: Box[] = fields.map(f => {
return {
type: 'Field',
leaf: use(f.id),
};
});
children.push({type: 'Submit'});
return {
type: 'Layout',
children,
};
}
return existing;
});
this._root = this.autoDispose(new LayoutModel(this._autoLayout.get(), null, async () => {
await this._saveNow();
}, this));
this._autoLayout.addListener((v) => {
if (this._saving) {
console.error('Layout changed while saving');
return;
}
// When the layout has changed, we will update the root, but only when it is not the same
// as the one we just saved.
if (isEqual(v, this._savedLayout)) { return; }
if (this._savedLayout) {
this._savedLayout = v;
}
this._root.update(v);
});
const keyboardActions = {
copy: () => {
const selected = this.selectedBox.get();
if (!selected) { return; }
// Add this box as a json to clipboard.
const json = selected.toJSON();
navigator.clipboard.writeText(JSON.stringify({
...json,
id: uuidv4(),
})).catch(reportError);
},
cut: () => {
const selected = this.selectedBox.get();
if (!selected) { return; }
selected.cutSelf().catch(reportError);
},
paste: () => {
const doPast = async () => {
const boxInClipboard = parseBox(await navigator.clipboard.readText());
if (!boxInClipboard) { return; }
if (!this.selectedBox.get()) {
this.selectedBox.set(this._root.insert(boxInClipboard, 0));
} else {
this.selectedBox.set(this.selectedBox.get()!.insertBefore(boxInClipboard));
}
// Remove the orginal box from the clipboard.
const cutted = this._root.find(boxInClipboard.id);
cutted?.removeSelf();
await this._root.save();
await navigator.clipboard.writeText('');
};
doPast().catch(reportError);
},
nextField: () => {
const current = this.selectedBox.get();
const all = [...this._root.list()];
if (!all.length) { return; }
if (!current) {
this.selectedBox.set(all[0]);
} else {
const next = all[all.indexOf(current) + 1];
if (next) {
this.selectedBox.set(next);
} else {
this.selectedBox.set(all[0]);
}
}
},
prevField: () => {
const current = this.selectedBox.get();
const all = [...this._root.list()];
if (!all.length) { return; }
if (!current) {
this.selectedBox.set(all[all.length - 1]);
} else {
const next = all[all.indexOf(current) - 1];
if (next) {
this.selectedBox.set(next);
} else {
this.selectedBox.set(all[all.length - 1]);
}
}
},
lastField: () => {
const all = [...this._root.list()];
if (!all.length) { return; }
this.selectedBox.set(all[all.length - 1]);
},
firstField: () => {
const all = [...this._root.list()];
if (!all.length) { return; }
this.selectedBox.set(all[0]);
},
edit: () => {
const selected = this.selectedBox.get();
if (!selected) { return; }
(selected as any)?.edit?.set(true); // TODO: hacky way
},
clearValues: () => {
const selected = this.selectedBox.get();
if (!selected) { return; }
keyboardActions.nextField();
this.bundle(async () => {
await selected.deleteSelf();
}).catch(reportError);
},
insertFieldBefore: (type: {field: BoxType} | {structure: BoxType}) => {
const selected = this.selectedBox.get();
if (!selected) { return; }
if ('field' in type) {
this.addNewQuestion(selected.placeBeforeMe(), type.field).catch(reportError);
} else {
selected.insertBefore(components.defaultElement(type.structure));
}
},
insertFieldAfter: (type: {field: BoxType} | {structure: BoxType}) => {
const selected = this.selectedBox.get();
if (!selected) { return; }
if ('field' in type) {
this.addNewQuestion(selected.placeAfterMe(), type.field).catch(reportError);
} else {
selected.insertAfter(components.defaultElement(type.structure));
}
},
showColumns: (colIds: string[]) => {
this.bundle(async () => {
const boxes: Box[] = [];
for (const colId of colIds) {
const fieldRef = await this.viewSection.showColumn(colId);
const field = this.viewSection.viewFields().all().find(f => f.getRowId() === fieldRef);
if (!field) { continue; }
const box = {
type: field.pureType.peek() as BoxType,
leaf: fieldRef,
};
boxes.push(box);
}
boxes.forEach(b => this._root.append(b));
await this._saveNow();
}).catch(reportError);
},
};
this.autoDispose(commands.createGroup({
...keyboardActions,
cursorDown: keyboardActions.nextField,
cursorUp: keyboardActions.prevField,
cursorLeft: keyboardActions.prevField,
cursorRight: keyboardActions.nextField,
shiftDown: keyboardActions.lastField,
shiftUp: keyboardActions.firstField,
editField: keyboardActions.edit,
deleteFields: keyboardActions.clearValues,
}, this, this.viewSection.hasFocus));
this._url = Computed.create(this, use => {
const doc = use(this.gristDoc.docPageModel.currentDoc);
if (!doc) { return ''; }
const url = this.gristDoc.app.topAppModel.api.formUrl(doc.id, use(this.viewSection.id));
return url;
});
// Last line, build the dom.
this.viewPane = this.autoDispose(this.buildDom());
}
public insertColumn(colId?: string | null, options?: InsertColOptions) {
return this.viewSection.insertColumn(colId, {...options, nestInActiveBundle: true});
}
public showColumn(colRef: number|string, index?: number) {
return this.viewSection.showColumn(colRef, index);
}
public buildDom() {
return dom('div.flexauto.flexvbox',
this._buildSwitcher(),
style.cssFormEdit.cls('-preview', not(this.isEdit)),
style.cssFormEdit.cls('', this.isEdit),
testId('preview', not(this.isEdit)),
testId('editor', this.isEdit),
dom.maybe(this.isEdit, () => [
style.cssFormContainer(
dom.forEach(this._root.children, (child) => {
if (!child) {
// This shouldn't happen, and it is bad design, as columns allow nulls, where other container
// don't. But for now, just ignore it.
return dom('div', 'Empty node');
}
const element = this.renderBox(this._root.children, child);
if (Array.isArray(element)) {
throw new Error('Element is an array');
}
if (!(element instanceof HTMLElement)) {
throw new Error('Element is not an HTMLElement');
}
return element;
}),
this.buildDropzone(this, this._root.placeAfterListChild()),
),
]),
dom.maybe(not(this.isEdit), () => [
style.cssPreview(
dom.prop('src', this._url),
)
]),
dom.on('click', () => this.selectedBox.set(null))
);
}
public renderBox(owner: IDisposableOwner, box: BoxModel, ...args: IDomArgs<HTMLElement>): HTMLElement {
const overlay = Observable.create(owner, true);
return this.buildEditor(owner, {box, overlay},
dom.domComputedOwned(box.type, (scope, type) => {
const renderedElement = box.render({overlay});
const element = renderedElement;
return dom.update(
element,
testId('element'),
testId(box.type),
...args,
);
})
);
}
public buildDropzone(owner: IDisposableOwner, insert: Place, ...args: IDomArgs) {
const dragHover = Observable.create(owner, false);
const forceShow = Observable.create(owner, false);
return style.cssAddElement(
testId('dropzone'),
style.cssDrag(),
style.cssAddText(),
this.buildAddMenu(insert, {
onOpen: () => forceShow.set(true),
onClose: () => forceShow.set(false),
}),
style.cssAddElement.cls('-hover', use => use(dragHover)),
// And drop zone handlers
dom.on('drop', async (ev) => {
ev.stopPropagation();
ev.preventDefault();
dragHover.set(false);
// Get the box that was dropped.
const dropped = parseBox(ev.dataTransfer!.getData('text/plain'));
// We need to remove it from the parent, so find it first.
const droppedId = dropped.id;
const droppedRef = this._root.find(droppedId);
await this.bundle(async () => {
// Save the layout if it is not saved yet.
await this._saveNow();
// Remove the orginal box from the clipboard.
droppedRef?.removeSelf();
await insert(dropped).onDrop();
// Save the change.
await this._saveNow();
});
}),
dom.on('dragover', (ev) => {
ev.preventDefault();
ev.dataTransfer!.dropEffect = "move";
dragHover.set(true);
}),
dom.on('dragleave', (ev) => {
ev.preventDefault();
dragHover.set(false);
}),
style.cssAddElement.cls('-hover', dragHover),
...args,
);
}
public buildFieldPanel() {
return dom('div', 'Hello there');
}
public buildEditor(
owner: IDisposableOwner | null,
options: {
box: BoxModel,
overlay: Observable<boolean>
}
,
...args: IDomArgs
) {
const {box, overlay} = options;
const myOwner = new MultiHolder();
if (owner) {
owner.autoDispose(myOwner);
}
let element: HTMLElement;
const dragHover = Observable.create(myOwner, false);
myOwner.autoDispose(this.selectedBox.addListener(v => {
if (v !== box) { return; }
if (!element) { return; }
element.scrollIntoView({behavior: 'smooth', block: 'center', inline: 'center'});
}));
const isSelected = Computed.create(myOwner, use => {
if (!this.viewSection || this.viewSection.isDisposed()) { return false; }
if (use(this.selectedBox) === box) {
// We are only selected when the section is also selected.
return use(this.viewSection.hasFocus);
}
return false;
});
return style.cssFieldEditor(
testId('editor'),
style.cssDrag(),
dom.maybe(overlay, () => this.buildOverlay(myOwner, box)),
owner ? null : dom.autoDispose(myOwner),
(el) => { element = el; },
// Control panel
style.cssControls(
style.cssControlsLabel(dom.text(box.type)),
),
// Turn on active like state when we clicked here.
style.cssFieldEditor.cls('-selected', isSelected),
style.cssFieldEditor.cls('-cut', use => use(box.cut)),
testId('field-editor-selected', isSelected),
// Select on click.
(el) => {
dom.onElem(el, 'click', (ev) => {
// Only if the click was in this element.
const target = ev.target as HTMLElement;
if (!target.closest) { return; }
// Make sure that the closest editor is this one.
const closest = target.closest(`.${style.cssFieldEditor.className}`);
if (closest !== el) { return; }
// It looks like we clicked somewhere in this editor, and not inside any other inside.
this.selectedBox.set(box);
ev.stopPropagation();
ev.preventDefault();
ev.stopImmediatePropagation();
});
},
// Attach menu
menus.menu((ctl) => {
this.menuHolder.autoDispose(ctl);
this.selectedBox.set(box);
const field = (type: string) => ({field: type});
const struct = (structure: string) => ({structure});
const above = (el: {field: string} | {structure: string}) => () => allCommands.insertFieldBefore.run(el);
const below: typeof above = (el) => () => allCommands.insertFieldAfter.run(el);
const quick = ['Text', 'Numeric', 'Choice', 'Date'];
const commonTypes = () => getNewColumnTypes(this.gristDoc, this.viewSection.tableId());
const isQuick = ({colType}: {colType: string}) => quick.includes(colType);
const notQuick = ({colType}: {colType: string}) => !quick.includes(colType);
const insertMenu = (where: typeof above) => () => {
return [
menus.menuSubHeader('New question'),
...commonTypes()
.filter(isQuick)
.map(ct => menus.menuItem(where(field(ct.colType)), menus.menuIcon(ct.icon!), ct.displayName))
,
menus.menuItemSubmenu(
() => commonTypes()
.filter(notQuick)
.map(ct => menus.menuItem(where(field(ct.colType)), menus.menuIcon(ct.icon!), ct.displayName)),
{},
menus.menuIcon('Dots'),
dom('span', "More", dom.style('margin-right', '8px'))
),
menus.menuDivider(),
menus.menuSubHeader('Static element'),
menus.menuItem(where(struct('Section')), menus.menuIcon('Page'), "Section",),
menus.menuItem(where(struct('Columns')), menus.menuIcon('TypeCell'), "Columns"),
menus.menuItem(where(struct('Paragraph')), menus.menuIcon('Page'), "Paragraph",),
// menus.menuItem(where(struct('Button')), menus.menuIcon('Tick'), "Button", ),
];
};
return [
menus.menuItemSubmenu(insertMenu(above), {action: above(field('Text'))}, "Insert question above"),
menus.menuItemSubmenu(insertMenu(below), {action: below(field('Text'))}, "Insert question below"),
menus.menuDivider(),
menus.menuItemCmd(allCommands.contextMenuCopy, "Copy"),
menus.menuItemCmd(allCommands.contextMenuCut, "Cut"),
menus.menuItemCmd(allCommands.contextMenuPaste, "Paste"),
menus.menuDivider(),
menus.menuItemCmd(allCommands.deleteFields, "Hide"),
];
}, {trigger: ['contextmenu']}),
dom.on('contextmenu', (ev) => {
ev.stopPropagation();
ev.preventDefault();
}),
// And now drag and drop support.
{draggable: "true"},
// When started, we just put the box into the dataTransfer as a plain text.
// TODO: this might be very sofisticated in the future.
dom.on('dragstart', (ev) => {
// Prevent propagation, as we might be in a nested editor.
ev.stopPropagation();
ev.dataTransfer?.setData('text/plain', JSON.stringify(box.toJSON()));
ev.dataTransfer!.dropEffect = "move";
}),
dom.on('dragover', (ev) => {
// As usual, prevent propagation.
ev.stopPropagation();
ev.preventDefault();
// Here we just change the style of the element.
ev.dataTransfer!.dropEffect = "move";
dragHover.set(true);
}),
dom.on('dragleave', (ev) => {
ev.stopPropagation();
ev.preventDefault();
// Just remove the style and stop propagation.
dragHover.set(false);
}),
dom.on('drop', async (ev) => {
ev.stopPropagation();
ev.preventDefault();
dragHover.set(false);
const dropped = parseBox(ev.dataTransfer!.getData('text/plain'));
// We need to remove it from the parent, so find it first.
const droppedId = dropped.id;
if (droppedId === box.id) { return; }
const droppedRef = this._root.find(droppedId);
await this.bundle(async () => {
await this._root.save();
droppedRef?.removeSelf();
await box.drop(dropped)?.onDrop();
await this._saveNow();
});
}),
style.cssFieldEditor.cls('-drag-hover', dragHover),
...args,
);
}
public buildOverlay(owner: IDisposableOwner, box: BoxModel) {
return style.cssSelectedOverlay(
);
}
public async addNewQuestion(insert: Place, type: string) {
await this.gristDoc.docData.bundleActions(`Saving form layout`, async () => {
// First save the layout, so that
await this._saveNow();
// Now that the layout is saved, we won't be bottered with autogenerated layout,
// and we can safely insert to column.
const {fieldRef} = await this.insertColumn(null, {
colInfo: {
type,
}
});
// And add it into the layout.
this.selectedBox.set(insert({
leaf: fieldRef,
type: 'Field'
}));
await this._root.save();
}, {nestInActiveBundle: true});
}
public buildAddMenu(insert: Place, {
onClose: onClose = () => {},
onOpen: onOpen = () => {},
customItems = [] as Element[],
} = {}) {
return menus.menu(
(ctl) => {
onOpen();
ctl.onDispose(onClose);
const field = (colType: BoxType) => ({field: colType});
const struct = (structure: BoxType) => ({structure});
const where = (el: {field: string} | {structure: BoxType}) => () => {
if ('field' in el) {
return this.addNewQuestion(insert, el.field);
} else {
insert(components.defaultElement(el.structure));
return this._root.save();
}
};
const quick = ['Text', 'Numeric', 'Choice', 'Date'];
const commonTypes = () => getNewColumnTypes(this.gristDoc, this.viewSection.tableId());
const isQuick = ({colType}: {colType: string}) => quick.includes(colType);
const notQuick = ({colType}: {colType: string}) => !quick.includes(colType);
return [
menus.menuSubHeader('New question'),
...commonTypes()
.filter(isQuick)
.map(ct => menus.menuItem(where(field(ct.colType as BoxType)), menus.menuIcon(ct.icon!), ct.displayName))
,
menus.menuItemSubmenu(
() => commonTypes()
.filter(notQuick)
.map(ct => menus.menuItem(where(field(ct.colType as BoxType)), menus.menuIcon(ct.icon!), ct.displayName)),
{},
menus.menuIcon('Dots'),
dom('span', "More", dom.style('margin-right', '8px'))
),
menus.menuDivider(),
menus.menuSubHeader('Static element'),
menus.menuItem(where(struct('Section')), menus.menuIcon('Page'), "Section",),
menus.menuItem(where(struct('Columns')), menus.menuIcon('TypeCell'), "Columns"),
menus.menuItem(where(struct('Paragraph')), menus.menuIcon('Page'), "Paragraph",),
// menus.menuItem(where(struct('Button')), menus.menuIcon('Tick'), "Button", ),
elem => void FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}),
customItems.length ? menus.menuDivider(dom.style('min-width', '200px')) : null,
...customItems,
];
},
{
selectOnOpen: true,
trigger: [
'click',
],
}
);
}
private async _saveNow() {
try {
this._saving = true;
const newVersion = {...this._root.toJSON()};
// If nothing has changed, don't bother.
if (isEqual(newVersion, this._savedLayout)) { return; }
this._savedLayout = newVersion;
await this.viewSection.layoutSpecObj.setAndSave(newVersion);
} finally {
this._saving = false;
}
}
private _buildSwitcher() {
const toggle = (val: boolean) => () => {
this.isEdit.set(val);
this._saveNow().catch(reportError);
};
return style.cssButtonGroup(
style.cssIconButton(
icon('Pencil'),
testId('edit'),
dom('div', 'Editor'),
cssButton.cls('-primary', this.isEdit),
style.cssIconButton.cls('-standard', not(this.isEdit)),
dom.on('click', toggle(true))
),
style.cssIconButton(
icon('EyeShow'),
dom('div', 'Preview'),
testId('preview'),
cssButton.cls('-primary', not(this.isEdit)),
style.cssIconButton.cls('-standard', (this.isEdit)),
dom.on('click', toggle(false))
),
style.cssIconLink(
icon('FieldAttachment'),
testId('link'),
dom('div', 'Link'),
dom.prop('href', this._url),
{target: '_blank'}
),
);
}
}
// Getting an ES6 class to work with old-style multiple base classes takes a little hacking. Credits: ./ChartView.ts
defaults(FormView.prototype, BaseView.prototype);
Object.assign(FormView.prototype, BackboneEvents);

@ -0,0 +1,140 @@
import {allCommands} from 'app/client/components/commands';
import {makeT} from 'app/client/lib/localization';
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
import {cssButton} from 'app/client/ui2018/buttons';
import {theme, vars} from 'app/client/ui2018/cssVars';
import {cssDragger} from 'app/client/ui2018/draggableList';
import {icon} from 'app/client/ui2018/icons';
import {Disposable, dom, fromKo, makeTestId, styled} from 'grainjs';
import * as ko from 'knockout';
const testId = makeTestId('test-vfc-');
const t = makeT('VisibleFieldsConfig');
/**
* This is a component used in the RightPanel. It replaces hidden fields section on other views, and adds
* the ability to drag and drop fields onto the form.
*/
export class HiddenQuestionConfig extends Disposable {
constructor(private _section: ViewSectionRec) {
super();
}
public buildDom() {
const hiddenColumns = fromKo(this.autoDispose(ko.pureComputed(() => {
const fields = new Set(this._section.viewFields().map(f => f.colId()).all());
return this._section.table().visibleColumns().filter(c => !fields.has(c.colId()));
})));
return [
cssHeader(
cssFieldListHeader(dom.text(t("Hidden fields"))),
),
dom('div',
testId('hidden-fields'),
dom.forEach(hiddenColumns, (field) => {
return this._buildHiddenFieldItem(field);
})
)
];
}
private _buildHiddenFieldItem(column: ColumnRec) {
return cssDragRow(
testId('hidden-field'),
{draggable: "true"},
dom.on('dragstart', (ev) => {
// Prevent propagation, as we might be in a nested editor.
ev.stopPropagation();
ev.dataTransfer?.setData('text/plain', JSON.stringify({
type: 'Field',
leaf: column.colId.peek(), // TODO: convert to Field
}));
ev.dataTransfer!.dropEffect = "move";
}),
cssSimpleDragger(),
cssFieldEntry(
cssFieldLabel(dom.text(column.label)),
cssHideIcon('EyeShow',
testId('hide'),
dom.on('click', () => {
allCommands.showColumns.run([column.colId.peek()]);
}),
),
),
);
}
}
// TODO: reuse them
const cssDragRow = styled('div', `
display: flex !important;
align-items: center;
margin: 0 16px 0px 0px;
margin-bottom: 2px;
cursor: grab;
`);
const cssFieldEntry = styled('div', `
display: flex;
background-color: ${theme.hover};
border-radius: 2px;
margin: 0 8px 0 0;
padding: 4px 8px;
cursor: default;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1 1 auto;
--icon-color: ${theme.lightText};
`);
const cssSimpleDragger = styled(cssDragger, `
cursor: grab;
.${cssDragRow.className}:hover & {
visibility: visible;
}
`);
const cssHideIcon = styled(icon, `
--icon-color: ${theme.lightText};
display: none;
cursor: pointer;
flex: none;
margin-right: 8px;
.${cssFieldEntry.className}:hover & {
display: block;
}
`);
const cssFieldLabel = styled('span', `
color: ${theme.text};
flex: 1 1 auto;
text-overflow: ellipsis;
overflow: hidden;
`);
const cssFieldListHeader = styled('span', `
color: ${theme.text};
flex: 1 1 0px;
font-size: ${vars.xsmallFontSize};
text-transform: uppercase;
`);
const cssRow = styled('div', `
display: flex;
margin: 16px;
overflow: hidden;
--icon-color: ${theme.lightText};
& > .${cssButton.className} {
margin-right: 8px;
}
`);
const cssHeader = styled(cssRow, `
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
`);

@ -0,0 +1,378 @@
import {FormView} from 'app/client/components/Forms/FormView';
import * as elements from 'app/client/components/Forms/elements';
import {
bundleChanges, Computed, Disposable, dom, DomContents,
MultiHolder, MutableObsArray, obsArray, Observable
} from 'grainjs';
import {v4 as uuidv4} from 'uuid';
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>,
}
/**
* A place where to insert a box.
*/
export type Place = (box: Box) => BoxModel;
/**
* View model constructed from a box JSON structure.
*/
export abstract class BoxModel extends Disposable {
/**
* A factory method that creates a new BoxModel from a Box JSON by picking the right class based on the type.
*/
public static new(box: Box, parent: BoxModel | null, view: FormView | null = null): BoxModel {
const subClassName = `${box.type.split(':')[0]}Model`;
const factories = elements as any;
const factory = factories[subClassName];
// If we have a factory, use it.
if (factory) {
return new factory(box, parent, view || parent!.view);
}
// Otherwise, use the default.
return new DefaultBoxModel(box, parent, view || parent!.view);
}
/**
* The id of the created box. The value here is not important. It is only used as a plain old pointer to this
* element. Every new box will get a new id in constructor. Even if this is the same box as before. We just need
* it as box are serialized to JSON and put into clipboard, and we need to be able to find them back.
*/
public id: string;
/**
* Type of the box. As the type is bounded to the class that is used to render the box, it is possible
* to change the type of the box just by changing this value. The box is then replaced in the parent.
*/
public type: Observable<string>;
/**
* List of children boxes.
*/
public children: MutableObsArray<BoxModel>;
/**
* Any other dynamically added properties (that are not concrete fields in the derived classes)
*/
public props: Record<string, Observable<any>> = {};
/**
* Publicly exposed state if the element was just cut.
* TODO: this should be moved to FormView, as this model doesn't care about that.
*/
public cut = Observable.create(this, false);
/**
* Don't use it directly, use the BoxModel.new factory method instead.
*/
constructor(box: Box, public parent: BoxModel | null, public view: FormView) {
super();
// Store "pointer" to this element.
this.id = uuidv4();
// Create observables for all properties.
this.type = Observable.create(this, box.type);
this.children = this.autoDispose(obsArray([]));
// And now update this and all children based on the box JSON.
bundleChanges(() => {
this.update(box);
});
// Some boxes need to do some work after initialization, so we call this method.
// Of course, they also can override the constructor, but this is a bit easier.
this.onCreate();
}
/**
* Public method that should be called when this box is dropped somewhere. In derived classes
* this method can send some actions to the server, or do some other work. In particular Field
* will insert or reveal a column.
*/
public async onDrop() {
}
/**
* The only method that derived classes need to implement. It should return a DOM element that
* represents this box.
*/
public abstract render(context: RenderContext): HTMLElement;
public removeChild(box: BoxModel) {
const myIndex = this.children.get().indexOf(box);
if (myIndex < 0) { throw new Error('Cannot remove box that is not in parent'); }
this.children.splice(myIndex, 1);
}
/**
* Remove self from the parent without saving.
*/
public removeSelf() {
this.parent?.removeChild(this);
}
/**
* Remove self from the parent and save. Use to bundle layout save with any other changes.
* See Fields for the implementation.
* TODO: this is needed as action bundling is very limited.
*/
public async deleteSelf() {
const parent = this.parent;
this.removeSelf();
await parent!.save();
}
/**
* Cuts self and puts it into clipboard.
*/
public async cutSelf() {
[...this.root().list()].forEach(box => box?.cut.set(false));
// Add this box as a json to clipboard.
await navigator.clipboard.writeText(JSON.stringify(this.toJSON()));
this.cut.set(true);
}
/**
* Accepts box from clipboard and inserts it before this box or if this is a container box, then
* as a first child. Default implementation is to insert before self.
*/
public drop(dropped: Box) {
// Get the box that was dropped.
if (!dropped) { return null; }
if (dropped.id === this.id) {
return null;
}
// We need to remove it from the parent, so find it first.
const droppedId = dropped.id;
const droppedRef = this.root().find(droppedId);
if (droppedRef) {
droppedRef.removeSelf();
}
return this.placeBeforeMe()(dropped);
}
public prop(name: string, defaultValue?: any) {
if (!this.props[name]) {
this.props[name] = Observable.create(this, defaultValue ?? null);
}
return this.props[name];
}
public async save(): Promise<void> {
if (!this.parent) { throw new Error('Cannot save detached box'); }
return this.parent.save();
}
/**
* Replaces children at index.
*/
public replaceAtIndex(box: Box, index: number) {
const newOne = BoxModel.new(box, this);
this.children.splice(index, 1, newOne);
return newOne;
}
public append(box: Box) {
const newOne = BoxModel.new(box, this);
this.children.push(newOne);
return newOne;
}
public insert(box: Box, index: number) {
const newOne = BoxModel.new(box, this);
this.children.splice(index, 0, newOne);
return newOne;
}
/**
* Replaces existing box with a new one, whenever it is found.
*/
public replace(existing: BoxModel, newOne: Box|BoxModel) {
const index = this.children.get().indexOf(existing);
if (index < 0) { throw new Error('Cannot replace box that is not in parent'); }
const model = newOne instanceof BoxModel ? newOne : BoxModel.new(newOne, this);
model.parent = this;
model.view = this.view;
this.children.splice(index, 1, model);
return model;
}
/**
* Creates a place to insert a box before this box.
*/
public placeBeforeFirstChild() {
return (box: Box) => this.insert(box, 0);
}
// Some other places.
public placeAfterListChild() {
return (box: Box) => this.insert(box, this.children.get().length);
}
public placeAt(index: number) {
return (box: Box) => this.insert(box, index);
}
public placeAfterChild(child: BoxModel) {
return (box: Box) => this.insert(box, this.children.get().indexOf(child) + 1);
}
public placeAfterMe() {
return this.parent!.placeAfterChild(this);
}
public placeBeforeMe() {
return this.parent!.placeAt(this.parent!.children.get().indexOf(this));
}
public insertAfter(json: any) {
return this.parent!.insert(json, this.parent!.children.get().indexOf(this) + 1);
}
public insertBefore(json: any) {
return this.parent!.insert(json, this.parent!.children.get().indexOf(this));
}
public root() {
let root: BoxModel = this;
while (root.parent) { root = root.parent; }
return root;
}
/**
* Finds a box with a given id in the tree.
*/
public find(droppedId: string): BoxModel | null {
for (const child of this.kids()) {
if (child.id === droppedId) { return child; }
const found = child.find(droppedId);
if (found) { return found; }
}
return null;
}
public kids() {
return this.children.get().filter(Boolean);
}
/**
* The core responsibility of this method is to update this box and all children based on the box JSON.
* This is counterpart of the FloatingRowModel, that enables this instance to point to a different box.
*/
public update(boxDef: Box) {
// If we have a type and the type is changed, then we need to replace the box.
if (this.type.get() && boxDef.type !== this.type.get()) {
this.parent!.replace(this, BoxModel.new(boxDef, this.parent));
return;
}
// Update all properties of self.
for (const key in boxDef) {
if (key === 'id' || key === 'type' || key === 'children') { continue; }
if (!boxDef.hasOwnProperty(key)) { continue; }
if (this.prop(key).get() === boxDef[key]) { continue; }
this.prop(key).set(boxDef[key]);
}
// Add or delete any children that were removed or added.
const myLength = this.children.get().length;
const newLength = boxDef.children ? boxDef.children.length : 0;
if (myLength > newLength) {
this.children.splice(newLength, myLength - newLength);
} else if (myLength < newLength) {
for (let i = myLength; i < newLength; i++) {
const toPush = boxDef.children![i];
this.children.push(toPush && BoxModel.new(toPush, this));
}
}
// Update those that indices are the same.
const min = Math.min(myLength, newLength);
for (let i = 0; i < min; i++) {
const atIndex = this.children.get()[i];
const atIndexDef = boxDef.children![i];
atIndex.update(atIndexDef);
}
}
/**
* Serialize this box to JSON.
*/
public toJSON(): Box {
return {
id: this.id,
type: this.type.get() as BoxType,
children: this.children.get().map(child => child?.toJSON() || null),
...(Object.fromEntries(Object.entries(this.props).map(([key, val]) => [key, val.get()]))),
};
}
public * list(): IterableIterator<BoxModel> {
for (const child of this.kids()) {
yield child;
yield* child.list();
}
}
protected onCreate() {
}
}
export class LayoutModel extends BoxModel {
constructor(box: Box, public parent: BoxModel | null, public _save: () => Promise<void>, public view: FormView) {
super(box, parent, view);
}
public async save() {
return await this._save();
}
public render(): HTMLElement {
throw new Error('Method not implemented.');
}
}
class DefaultBoxModel extends BoxModel {
public render(): HTMLElement {
return dom('div', `Unknown box type ${this.type.get()}`);
}
}
export interface RenderContext {
overlay: Observable<boolean>,
}
export type Builder = (owner: MultiHolder, options: {
box: BoxModel,
view: FormView,
overlay: Observable<boolean>,
}) => DomContents;
export const ignoreClick = dom.on('click', (ev) => {
ev.stopPropagation();
ev.preventDefault();
});
export function unwrap<T>(val: T | Computed<T>): T {
return val instanceof Computed ? val.get() : val;
}
export function parseBox(text: string) {
try {
const json = JSON.parse(text);
return json && typeof json === 'object' && json.type ? json : null;
} catch (e) {
return null;
}
}

@ -0,0 +1,94 @@
import * as css from './styles';
import {BoxModel, RenderContext} from 'app/client/components/Forms/Model';
import {textarea} from 'app/client/ui/inputs';
import {theme} from 'app/client/ui2018/cssVars';
import {Computed, dom, Observable, styled} from 'grainjs';
export class ParagraphModel extends BoxModel {
public edit = Observable.create(this, false);
public render(context: RenderContext) {
const box = this;
context.overlay.set(false);
const editMode = box.edit;
let element: HTMLElement;
const text = this.prop('text', '**Lorem** _ipsum_ dolor') as Observable<string|undefined>;
const properText = Computed.create(this, (use) => {
const savedText = use(text);
if (!savedText) { return ''; }
if (typeof savedText !== 'string') { return ''; }
return savedText;
});
properText.onWrite((val) => {
if (typeof val !== 'string') { return; }
text.set(val);
this.parent?.save().catch(reportError);
});
box.edit.addListener((val) => {
if (!val) { return; }
setTimeout(() => element.focus(), 0);
});
return css.cssStaticText(
css.markdown(use => use(properText) || '', dom.cls('_preview'), dom.hide(editMode)),
dom.maybe(use => !use(properText) && !use(editMode), () => cssEmpty('(empty)')),
dom.on('dblclick', () => {
editMode.set(true);
}),
css.cssStaticText.cls('-edit', editMode),
dom.maybe(editMode, () => [
cssTextArea(properText, {},
(el) => {
element = el;
},
dom.onKeyDown({
Enter$: (ev) => {
// if shift ignore
if (ev.shiftKey) {
return;
}
ev.stopPropagation();
ev.preventDefault();
editMode.set(false);
},
Escape$: (ev) => {
ev.stopPropagation();
ev.preventDefault();
editMode.set(false);
}
}),
dom.on('blur', () => {
editMode.set(false);
}),
),
])
);
}
}
const cssTextArea = styled(textarea, `
color: ${theme.inputFg};
background-color: ${theme.mainPanelBg};
border: 0px;
width: 100%;
padding: 3px 6px;
outline: none;
max-height: 300px;
min-height: calc(3em * 1.5);
resize: none;
border-radius: 3px;
&::placeholder {
color: ${theme.inputPlaceholderFg};
}
&[readonly] {
background-color: ${theme.inputDisabledBg};
color: ${theme.inputDisabledFg};
}
`);
const cssEmpty = styled('div', `
color: ${theme.inputPlaceholderFg};
font-style: italic;
`);

@ -0,0 +1,25 @@
import * as style from './styles';
import {BoxModel, RenderContext} from 'app/client/components/Forms/Model';
import {dom} from 'grainjs';
/**
* Component that renders a section of the form.
*/
export class SectionModel extends BoxModel {
public render(context: RenderContext) {
const children = this.children;
context.overlay.set(false);
const view = this.view;
const box = this;
const element = style.cssSection(
style.cssDrag(),
dom.forEach(children, (child) =>
child ? view.renderBox(children, child) : dom('div', 'Empty')
),
view.buildDropzone(children, box.placeAfterListChild()),
);
return element;
}
}

@ -0,0 +1,10 @@
import {BoxModel, RenderContext} from 'app/client/components/Forms/Model';
import {makeTestId} from 'app/client/lib/domUtils';
import {primaryButton} from 'app/client/ui2018/buttons';
const testId = makeTestId('test-forms-');
export class SubmitModel extends BoxModel {
public render(context: RenderContext) {
return primaryButton('Submit', testId('submit'));
}
}

@ -0,0 +1,26 @@
import * as style from './styles';
import {Builder, ignoreClick} from 'app/client/components/Forms/Model';
import {Computed, dom, IDisposableOwner, makeTestId} from 'grainjs';
const testId = makeTestId('test-forms-');
export const buildTextField: Builder = (owner: IDisposableOwner, {box, view}) => {
const field = Computed.create(owner, use => {
return view.gristDoc.docModel.viewFields.getRowModel(use(box.prop('leaf')));
});
return dom('div',
testId('question'),
testId('question-Text'),
style.cssLabel(
testId('label'),
dom.text(use => use(use(field).question) || use(use(field).origLabel))
),
style.cssInput(
testId('input'),
{type: 'text', tabIndex: "-1"},
ignoreClick),
dom.maybe(use => use(use(field).description), (description) => [
style.cssDesc(description, testId('description')),
]),
);
};

@ -0,0 +1,20 @@
import {Columns, Placeholder} from 'app/client/components/Forms/Columns';
import {Box, BoxType} from 'app/client/components/Forms/Model';
/**
* Add any other element you whish to use in the form here.
* FormView will look for any exported BoxModel derived class in format `type` + `Model`, and use It
* to render and manage the element.
*/
export * from "./Paragraph";
export * from "./Section";
export * from './Field';
export * from './Columns';
export * from './Submit';
export function defaultElement(type: BoxType): Box {
switch(type) {
case 'Columns': return Columns();
case 'Placeholder': return Placeholder();
default: return {type};
}
}

@ -0,0 +1,350 @@
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
import {basicButton, basicButtonLink} from 'app/client/ui2018/buttons';
import {colors, theme, vars} from 'app/client/ui2018/cssVars';
import {BindableValue, dom, IDomArgs, styled, subscribeBindable} from 'grainjs';
import {marked} from 'marked';
export {
cssLabel,
cssDesc,
cssInput,
cssFieldEditor,
cssSelectedOverlay,
cssControls,
cssControlsLabel,
cssAddElement,
cssAddText,
cssFormContainer,
cssFormEdit,
cssSection,
cssStaticText,
};
const cssFormEdit = styled('div', `
color: ${theme.text};
background-color: ${theme.leftPanelBg};
display: flex;
flex-direction: column;
flex-basis: 0px;
align-items: center;
padding-top: 52px;
position: relative;
padding-bottom: 32px;
--section-background: #739bc31f; /* On white background this is almost #f1f5f9 (slate-100 on tailwind palette) */
&, &-preview {
overflow: auto;
min-height: 100%;
width: 100%;
position: relative;
flex-basis: 0px;
}
`);
const cssLabel = styled('label', `
font-size: 15px;
font-weight: normal;
margin-bottom: 8px;
user-select: none;
display: block;
`);
const cssDesc = styled('div', `
font-size: 10px;
font-weight: 400;
margin-top: 4px;
color: ${colors.slate};
white-space: pre-wrap;
`);
const cssInput = styled('input', `
flex: auto;
width: 100%;
font-size: inherit;
padding: 4px 8px;
border: 1px solid #D9D9D9;
border-radius: 3px;
outline: none;
cursor-events: none;
&-invalid {
color: red;
}
`);
export const cssSelect = styled('select', `
flex: auto;
width: 100%;
font-size: inherit;
padding: 4px 8px;
border: 1px solid #D9D9D9;
border-radius: 3px;
outline: none;
cursor-events: none;
&-invalid {
color: red;
}
`);
const cssFieldEditor = styled('div._cssFieldEditor', `
position: relative;
cursor: pointer;
user-select: none;
outline: none;
&:hover:not(:has(&:hover)), &-selected {
outline: 1px solid ${colors.lightGreen};
}
&:active:not(:has(&:active)) {
outline: 1px solid ${colors.darkGreen};
}
&-drag-hover {
outline: 2px dashed ${colors.lightGreen};
outline-offset: 2px;
}
&-cut {
outline: 2px dashed ${colors.orange};
outline-offset: 2px;
}
.${cssFormEdit.className}-preview & {
outline: 0px !import;
}
`);
const cssSelectedOverlay = styled('div', `
background: ${colors.selection};
inset: 0;
position: absolute;
opacity: 0;
outline: none;
.${cssFieldEditor.className}-selected > & {
opacity: 1;
}
.${cssFormEdit.className}-preview & {
display: none;
}
`);
const cssControls = styled('div', `
display: none;
position: absolute;
margin-top: -18px;
margin-left: -1px;
.${cssFieldEditor.className}:hover:not(:has(.${cssFieldEditor.className}:hover)) > &,
.${cssFieldEditor.className}:active:not(:has(.${cssFieldEditor.className}:active)) > &,
.${cssFieldEditor.className}-selected > & {
display: flex;
}
.${cssFormEdit.className}-preview & {
display: none !important;
}
`);
const cssControlsLabel = styled('div', `
background: ${colors.lightGreen};
color: ${colors.light};
padding: 1px 2px;
min-width: 24px;
`);
const cssAddElement = styled('div', `
position: relative;
min-height: 32px;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
padding-right: 8px;
--icon-color: ${colors.lightGreen};
align-self: stretch;
border: 2px dashed ${colors.darkGrey};
background: ${colors.lightGrey};
opacity: 0.7;
&:hover {
border: 2px dashed ${colors.darkGrey};
background: ${colors.lightGrey};
opacity: 1;
}
&-hover {
outline: 2px dashed ${colors.lightGreen};
outline-offset: 2px;
}
`);
const cssAddText = styled('div', `
color: ${colors.slate};
border-radius: 4px;
padding: 2px 4px;
font-size: 12px;
z-index: 1;
&:before {
content: "Add a field";
}
.${cssAddElement.className}-hover &:before {
content: "Drop here";
}
`);
const cssSection = styled('div', `
position: relative;
background-color: var(--section-background);
color: ${theme.text};
align-self: center;
margin: 0px auto;
border-radius: 8px;
display: flex;
flex-direction: column;
min-height: 50px;
padding: 10px;
.${cssFormEdit.className}-preview & {
background: transparent;
border-radius: unset;
padding: 0px;
min-height: auto;
}
`);
export const cssColumns = styled('div', `
--css-columns-count: 2;
background-color: var(--section-background);
display: grid;
grid-template-columns: repeat(var(--css-columns-count), 1fr) 32px;
gap: 8px;
padding: 12px 4px;
.${cssFormEdit.className}-preview & {
background: transparent;
border-radius: unset;
padding: 0px;
grid-template-columns: repeat(var(--css-columns-count), 1fr);
min-height: auto;
}
`);
export const cssColumn = styled('div', `
position: relative;
&-empty, &-add-button {
position: relative;
min-height: 32px;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
padding-right: 8px;
--icon-color: ${colors.slate};
align-self: stretch;
transition: height 0.2s ease-in-out;
border: 2px dashed ${colors.darkGrey};
background: ${colors.lightGrey};
color: ${colors.slate};
border-radius: 4px;
padding: 2px 4px;
font-size: 12px;
}
&-selected {
border: 2px dashed ${colors.slate};
}
&-empty:hover, &-add-button:hover {
border: 2px dashed ${colors.slate};
}
&-drag-over {
outline: 2px dashed ${colors.lightGreen};
}
&-add-button {
align-self: flex-end;
}
.${cssFormEdit.className}-preview &-add-button {
display: none;
}
.${cssFormEdit.className}-preview &-empty {
background: transparent;
border-radius: unset;
padding: 0px;
min-height: auto;
border: 0px;
}
`);
const cssFormContainer = styled('div', `
padding: 32px;
background-color: ${theme.mainPanelBg};
border: 1px solid ${theme.menuBorder};
color: ${theme.text};
width: 640px;
align-self: center;
margin: 0px auto;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 16px;
max-width: calc(100% - 32px);
`);
export const cssButtonGroup = styled('div', `
position: absolute;
top: 18px;
left: 24px;
right: 24px;
display: flex;
justify-content: center;
gap: 8px;
`);
export const cssIconButton = styled(basicButton, `
padding: 3px 8px;
font-size: ${vars.smallFontSize};
display: flex;
align-items: center;
gap: 4px;
&-standard {
background-color: ${theme.leftPanelBg};
}
`);
export const cssIconLink = styled(basicButtonLink, `
padding: 3px 8px;
font-size: ${vars.smallFontSize};
display: flex;
align-items: center;
gap: 4px;
background-color: ${theme.leftPanelBg};
`);
const cssStaticText = styled('div', `
min-height: 1.5rem;
`);
export function markdown(obs: BindableValue<string>, ...args: IDomArgs<HTMLDivElement>) {
return dom('div', el => {
dom.autoDisposeElem(el, subscribeBindable(obs, val => {
el.innerHTML = sanitizeHTML(marked(val));
}));
}, ...args);
}
export const cssDrag = styled('div.test-forms-drag', `
position: absolute;
pointer-events: none;
top: 2px;
left: 2px;
width: 1px;
height: 1px;
`);
export const cssPreview = styled('iframe', `
height: 100%;
border: 0px;
`);

@ -913,8 +913,18 @@ export class GristDoc extends DisposableWithEvents {
if (name === undefined) {
return;
}
const result = await this.docData.sendAction(['AddEmptyTable', name]);
await this.openDocPage(result.views[0].id);
let newViewId: IDocPage;
if (val.type === 'record') {
const result = await this.docData.sendAction(['AddEmptyTable', name]);
newViewId = result.views[0].id;
} else {
// This will create a new table and page.
const result = await this.docData.sendAction(
['CreateViewSection', /* new table */0, 0, val.type, null, name]
);
newViewId = result.viewRef;
}
await this.openDocPage(newViewId);
} else {
let result: any;
await this.docData.bundleActions(`Add new page`, async () => {

@ -5,6 +5,7 @@ import * as commands from 'app/client/components/commands';
import {CustomCalendarView} from "app/client/components/CustomCalendarView";
import {CustomView} from 'app/client/components/CustomView';
import * as DetailView from 'app/client/components/DetailView';
import {FormView} from 'app/client/components/Forms/FormView';
import * as GridView from 'app/client/components/GridView';
import {GristDoc} from 'app/client/components/GristDoc';
import {BoxSpec, Layout} from 'app/client/components/Layout';
@ -44,6 +45,7 @@ const viewSectionTypes: {[key: string]: any} = {
chart: ChartView,
single: DetailView,
custom: CustomView,
form: FormView,
'custom.calendar': CustomCalendarView,
};

@ -114,6 +114,7 @@ export type CommandName =
| 'detachEditor'
| 'activateAssistant'
| 'viewAsCard'
| 'showColumns'
;
@ -125,6 +126,11 @@ export interface CommandDef {
deprecated?: boolean;
}
export interface MenuCommand {
humanKeys: string[];
run: (...args: any[]) => any;
}
export interface CommendGroupDef {
group: string;
commands: CommandDef[];
@ -595,7 +601,11 @@ export const groups: CommendGroupDef[] = [{
name: 'duplicateRows',
keys: ['Mod+Shift+d'],
desc: 'Duplicate selected rows'
},
}, {
name: 'showColumns',
keys: [],
desc: 'Show hidden columns'
}
],
}, {
group: 'Sorting',

@ -1,9 +1,16 @@
import {useBindable} from 'app/common/gutil';
import {BindableValue, dom} from 'grainjs';
/**
* Version of makeTestId that can be appended conditionally.
* TODO: update grainjs typings, as this is already supported there.
*/
export function makeTestId(prefix: string) {
return (id: string, obs?: BindableValue<boolean>) => dom.cls(prefix + id, obs ?? true);
return (id: BindableValue<string>, obs?: BindableValue<boolean>) => {
return dom.cls(use => {
if (obs !== undefined && !useBindable(use, obs)) {
return '';
}
return `${useBindable(use, prefix)}${useBindable(use, id)}`;
});
};
}

@ -54,6 +54,16 @@ MetaRowModel.prototype._assignColumn = function(colName) {
MetaRowModel.Floater = function(tableModel, rowIdObs) {
this._table = tableModel;
this.rowIdObs = rowIdObs;
// Some tsc error prevents me from adding this at the module level.
// This method is part of the interface of MetaRowModel.
// TODO: Fix the tsc error and move this to the module level.
if (!this.constructor.prototype.getRowId) {
this.constructor.prototype.getRowId = function() {
return this.rowIdObs();
}
}
// Note that ._index isn't supported because it doesn't make sense for a floating row model.
this._underlyingRowModel = this.autoDispose(ko.computed(function() {

@ -17,7 +17,9 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field">, R
widthPx: ko.Computed<string>;
column: ko.Computed<ColumnRec>;
origLabel: ko.Computed<string>;
origCol: ko.Computed<ColumnRec>;
pureType: ko.Computed<string>;
colId: ko.Computed<string>;
label: ko.Computed<string>;
description: modelUtil.KoSaveableObservable<string>;
@ -101,6 +103,9 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field">, R
// `formatter` formats actual cell values, e.g. a whole list from the display column.
formatter: ko.Computed<BaseFormatter>;
/** Label in FormView. By default FormView uses label, use this to override it. */
question: modelUtil.KoSaveableObservable<string|undefined>;
createValueParser(): (value: string) => any;
// Helper which adds/removes/updates field's displayCol to match the formula.
@ -111,11 +116,13 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
this.viewSection = refRecord(docModel.viewSections, this.parentId);
this.widthDef = modelUtil.fieldWithDefault(this.width, () => this.viewSection().defaultWidth());
this.widthPx = ko.pureComputed(() => this.widthDef() + 'px');
this.column = refRecord(docModel.columns, this.colRef);
this.origCol = ko.pureComputed(() => this.column().origCol());
this.colId = ko.pureComputed(() => this.column().colId());
this.label = ko.pureComputed(() => this.column().label());
this.widthPx = this.autoDispose(ko.pureComputed(() => this.widthDef() + 'px'));
this.column = this.autoDispose(refRecord(docModel.columns, this.colRef));
this.origCol = this.autoDispose(ko.pureComputed(() => this.column().origCol()));
this.pureType = this.autoDispose(ko.pureComputed(() => this.column().pureType()));
this.colId = this.autoDispose(ko.pureComputed(() => this.column().colId()));
this.label = this.autoDispose(ko.pureComputed(() => this.column().label()));
this.origLabel = this.autoDispose(ko.pureComputed(() => this.origCol().label()));
this.description = modelUtil.savingComputed({
read: () => this.column().description(),
write: (setter, val) => setter(this.column().description, val)
@ -249,6 +256,7 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
this.headerFontUnderline = this.widgetOptionsJson.prop('headerFontUnderline');
this.headerFontItalic = this.widgetOptionsJson.prop('headerFontItalic');
this.headerFontStrikethrough = this.widgetOptionsJson.prop('headerFontStrikethrough');
this.question = this.widgetOptionsJson.prop('question');
this.documentSettings = ko.pureComputed(() => docModel.docInfoRow.documentSettingsJson());
this.style = ko.pureComputed({

@ -37,6 +37,7 @@ import defaults = require('lodash/defaults');
export interface InsertColOptions {
colInfo?: ColInfo;
index?: number;
nestInActiveBundle?: boolean;
}
export interface ColInfo {
@ -54,6 +55,10 @@ export interface NewColInfo {
colRef: number;
}
export interface NewFieldInfo extends NewColInfo {
fieldRef: number;
}
// Represents a section of user views, now also known as a "page widget" (e.g. a view may contain
// a grid section and a chart section).
export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleOwner {
@ -103,7 +108,7 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
borderWidthPx: ko.Computed<string>;
layoutSpecObj: modelUtil.ObjObservable<any>;
layoutSpecObj: modelUtil.SaveableObjObservable<any>;
_savedFilters: ko.Computed<KoArray<FilterRec>>;
@ -268,9 +273,11 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
// Saves custom definition (bundles change)
saveCustomDef(): Promise<void>;
insertColumn(colId?: string|null, options?: InsertColOptions): Promise<NewColInfo>;
insertColumn(colId?: string|null, options?: InsertColOptions): Promise<NewFieldInfo>;
showColumn(col: number|string, index?: number): Promise<number>
showColumn(colRef: number, index?: number): Promise<void>
removeField(colRef: number): Promise<void>;
}
export type WidgetMappedColumn = number|number[]|null;
@ -834,7 +841,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
...colInfo,
'_position': parentPos,
}];
let newColInfo: NewColInfo;
let newColInfo: NewFieldInfo;
await docModel.docData.bundleActions('Insert column', async () => {
newColInfo = await docModel.dataTables[this.tableId.peek()].sendTableAction(action);
if (!this.isRaw.peek() && !this.isRecordCard.peek()) {
@ -843,19 +850,28 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
parentId: this.id.peek(),
parentPos,
};
await docModel.viewFields.sendTableAction(['AddRecord', null, fieldInfo]);
const fieldRef = await docModel.viewFields.sendTableAction(['AddRecord', null, fieldInfo]);
newColInfo.fieldRef = fieldRef;
}
});
}, {nestInActiveBundle: options.nestInActiveBundle});
return newColInfo!;
};
this.showColumn = async (colRef: number, index = this.viewFields().peekLength) => {
this.showColumn = async (col: string|number, index = this.viewFields().peekLength) => {
const parentPos = fieldInsertPositions(this.viewFields(), index, 1)[0];
const colRef = typeof col === 'string'
? this.table().columns().all().find(c => c.colId() === col)?.getRowId()
: col;
const colInfo = {
colRef,
parentId: this.id.peek(),
parentPos,
};
await docModel.viewFields.sendTableAction(['AddRecord', null, colInfo]);
return await docModel.viewFields.sendTableAction(['AddRecord', null, colInfo]);
};
this.removeField = async (fieldRef: number) => {
const action = ['RemoveRecord', fieldRef];
await docModel.viewFields.sendTableAction(action);
};
}

@ -33,3 +33,7 @@ export function PERMITTED_CUSTOM_WIDGETS(): Observable<string[]> {
}
return G.window.PERMITTED_CUSTOM_WIDGETS;
}
export function GRIST_FORMS_FEATURE() {
return Boolean(getGristConfig().experimentalPlugins);
}

@ -1,50 +1,102 @@
import {makeT} from 'app/client/lib/localization';
import {KoSaveableObservable} from 'app/client/models/modelUtil';
import {autoGrow} from 'app/client/ui/forms';
import {textarea} from 'app/client/ui/inputs';
import {textarea, textInput} from 'app/client/ui/inputs';
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {testId, theme} from 'app/client/ui2018/cssVars';
import {CursorPos} from 'app/plugin/GristAPI';
import {dom, fromKo, MultiHolder, styled} from 'grainjs';
import {dom, DomArg, fromKo, MultiHolder, styled} from 'grainjs';
const t = makeT('DescriptionConfig');
export function buildDescriptionConfig(
owner: MultiHolder,
description: KoSaveableObservable<string>,
options: {
cursor: ko.Computed<CursorPos>,
testPrefix: string,
},
) {
owner: MultiHolder,
description: KoSaveableObservable<string>,
options: {
cursor: ko.Computed<CursorPos>,
testPrefix: string,
},
) {
// We will listen to cursor position and force a blur event on
// the text input, which will trigger save before the column observable
// will change its value.
// Otherwise, blur will be invoked after column change and save handler will
// update a different column.
let editor: HTMLTextAreaElement | undefined;
owner.autoDispose(
options.cursor.subscribe(() => {
editor?.blur();
})
);
// We will listen to cursor position and force a blur event on
// the text input, which will trigger save before the column observable
// will change its value.
// Otherwise, blur will be invoked after column change and save handler will
// update a different column.
let editor: HTMLTextAreaElement | undefined;
owner.autoDispose(
options.cursor.subscribe(() => {
editor?.blur();
})
);
return [
cssLabel(t("DESCRIPTION")),
cssRow(
editor = cssTextArea(fromKo(description),
{ onInput: false },
{ rows: '3' },
dom.on('blur', async (e, elem) => {
await description.saveOnly(elem.value);
}),
testId(`${options.testPrefix}-description`),
autoGrow(fromKo(description))
)
return [
cssLabel(t("DESCRIPTION")),
cssRow(
editor = cssTextArea(fromKo(description),
{ onInput: false },
{ rows: '3' },
dom.on('blur', async (e, elem) => {
await description.saveOnly(elem.value);
}),
testId(`${options.testPrefix}-description`),
autoGrow(fromKo(description))
)
),
];
}
/**
* A generic version of buildDescriptionConfig that can be used for any text input.
*/
export function buildTextInput(
owner: MultiHolder,
options: {
value: KoSaveableObservable<any>,
cursor: ko.Computed<CursorPos>,
label: string,
placeholder?: ko.Computed<string>,
},
...args: DomArg[]
) {
owner.autoDispose(
options.cursor.subscribe(() => {
options.value.save().catch(reportError);
})
);
return [
cssLabel(options.label),
cssRow(
cssTextInput(fromKo(options.value),
dom.on('blur', () => {
return options.value.save();
}),
dom.prop('placeholder', options.placeholder || ''),
...args
),
];
),
];
}
const cssTextInput = styled(textInput, `
color: ${theme.inputFg};
background-color: ${theme.mainPanelBg};
border: 1px solid ${theme.inputBorder};
width: 100%;
outline: none;
border-radius: 3px;
height: 28px;
border-radius: 3px;
padding: 0px 6px;
&::placeholder {
color: ${theme.inputPlaceholderFg};
}
&[readonly] {
background-color: ${theme.inputDisabledBg};
color: ${theme.inputDisabledFg};
}
`);
const cssTextArea = styled(textarea, `
color: ${theme.inputFg};

@ -1,4 +1,5 @@
import {allCommands} from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
import GridView from 'app/client/components/GridView';
import {makeT} from 'app/client/lib/localization';
import {ColumnRec} from "app/client/models/entities/ColumnRec";
@ -44,6 +45,36 @@ export function buildAddColumnMenu(gridView: GridView, index?: number) {
];
}
export function getColumnTypes(gristDoc: GristDoc, tableId: string, pure = false) {
const typeNames = [
"Text",
"Numeric",
"Int",
"Bool",
"Date",
`DateTime:${gristDoc.docModel.docInfoRow.timezone()}`,
"Choice",
"ChoiceList",
`Ref:${tableId}`,
`RefList:${tableId}`,
"Attachments"];
return typeNames.map(type => ({type, obj: UserType.typeDefs[type.split(':')[0]]}))
.map((ct): { displayName: string, colType: string, testIdName: string, icon: IconName | undefined } => ({
displayName: t(ct.obj.label),
colType: ct.type,
testIdName: ct.obj.label.toLowerCase().replace(' ', '-'),
icon: ct.obj.icon
})).map(ct => {
if (!pure) { return ct; }
else {
return {
...ct,
colType: ct.colType.split(':')[0]
};
}
});
}
function buildAddNewColumMenuSection(gridView: GridView, index?: number): DomElementArg[] {
function buildEmptyNewColumMenuItem() {
return menuItem(
@ -56,24 +87,7 @@ function buildAddNewColumMenuSection(gridView: GridView, index?: number): DomEle
}
function BuildNewColumnWithTypeSubmenu() {
const columnTypes = [
"Text",
"Numeric",
"Int",
"Bool",
"Date",
`DateTime:${gridView.gristDoc.docModel.docInfoRow.timezone()}`,
"Choice",
"ChoiceList",
`Ref:${gridView.tableModel.tableMetaRow.tableId()}`,
`RefList:${gridView.tableModel.tableMetaRow.tableId()}`,
"Attachments"].map(type => ({type, obj: UserType.typeDefs[type.split(':')[0]]}))
.map((ct): { displayName: string, colType: string, testIdName: string, icon: IconName | undefined } => ({
displayName: t(ct.obj.label),
colType: ct.type,
testIdName: ct.obj.label.toLowerCase().replace(' ', '-'),
icon: ct.obj.icon
}));
const columnTypes = getColumnTypes(gridView.gristDoc, gridView.tableModel.tableMetaRow.tableId());
return menuItemSubmenu(
(ctl) => [

@ -3,7 +3,7 @@ import {GristDoc} from 'app/client/components/GristDoc';
import {makeT} from 'app/client/lib/localization';
import {reportError} from 'app/client/models/AppModel';
import {ColumnRec, TableRec, ViewSectionRec} from 'app/client/models/DocModel';
import {PERMITTED_CUSTOM_WIDGETS} from "app/client/models/features";
import {GRIST_FORMS_FEATURE, PERMITTED_CUSTOM_WIDGETS} from "app/client/models/features";
import {GristTooltips} from 'app/client/ui/GristTooltips';
import {linkId, NoLink} from 'app/client/ui/selectBy';
import {overflowTooltip, withInfoTooltip} from 'app/client/ui/tooltips';
@ -35,7 +35,7 @@ import without = require('lodash/without');
const t = makeT('PageWidgetPicker');
type TableId = number|'New Table'|null;
type TableRef = number|'New Table'|null;
// Describes a widget selection.
export interface IPageWidget {
@ -44,7 +44,7 @@ export interface IPageWidget {
type: IWidgetType;
// The table (one of the listed tables or 'New Table')
table: TableId;
table: TableRef;
// Whether to summarize the table (not available for "New Table").
summarize: boolean;
@ -89,22 +89,26 @@ export interface IOptions extends ISelectOptions {
const testId = makeTestId('test-wselect-');
function maybeForms(): Array<'form'> {
return GRIST_FORMS_FEATURE() ? ['form'] : [];
}
// The picker disables some choices that do not make much sense. This function return the list of
// compatible types given the tableId and whether user is creating a new page or not.
function getCompatibleTypes(tableId: TableId, isNewPage: boolean|undefined): IWidgetType[] {
function getCompatibleTypes(tableId: TableRef, isNewPage: boolean|undefined): IWidgetType[] {
if (tableId !== 'New Table') {
return ['record', 'single', 'detail', 'chart', 'custom', 'custom.calendar'];
return ['record', 'single', 'detail', 'chart', 'custom', 'custom.calendar', ...maybeForms()];
} else if (isNewPage) {
// New view + new table means we'll be switching to the primary view.
return ['record'];
return ['record', ...maybeForms()];
} else {
// The type 'chart' makes little sense when creating a new table.
return ['record', 'single', 'detail'];
return ['record', 'single', 'detail', ...maybeForms()];
}
}
// Whether table and type make for a valid selection whether the user is creating a new page or not.
function isValidSelection(table: TableId, type: IWidgetType, isNewPage: boolean|undefined) {
function isValidSelection(table: TableRef, type: IWidgetType, isNewPage: boolean|undefined) {
return table !== null && getCompatibleTypes(table, isNewPage).includes(type);
}
@ -262,7 +266,7 @@ const permittedCustomWidgets: IAttachedCustomWidget[] = PERMITTED_CUSTOM_WIDGETS
const finalListOfCustomWidgetToShow = permittedCustomWidgets.filter(a=>
registeredCustomWidgets.includes(a));
const sectionTypes: IWidgetType[] = [
'record', 'single', 'detail', 'chart', ...finalListOfCustomWidgetToShow, 'custom'
'record', 'single', 'detail', ...maybeForms(), 'chart', ...finalListOfCustomWidgetToShow, 'custom'
];
@ -425,7 +429,7 @@ export class PageWidgetSelect extends Disposable {
this._value.type.set(type);
}
private _selectTable(tid: TableId) {
private _selectTable(tid: TableRef) {
if (tid !== this._value.table.get()) {
this._value.link.set(NoLink);
}
@ -437,7 +441,7 @@ export class PageWidgetSelect extends Disposable {
return el.classList.contains(cssEntry.className + '-selected');
}
private _selectPivot(tid: TableId, pivotEl: HTMLElement) {
private _selectPivot(tid: TableRef, pivotEl: HTMLElement) {
if (this._isSelected(pivotEl)) {
this._closeSummarizePanel();
} else {
@ -456,7 +460,7 @@ export class PageWidgetSelect extends Disposable {
this._value.columns.set(newIds);
}
private _isTypeDisabled(type: IWidgetType, table: TableId) {
private _isTypeDisabled(type: IWidgetType, table: TableRef) {
if (table === null) {
return false;
}

@ -15,6 +15,7 @@
*/
import * as commands from 'app/client/components/commands';
import {HiddenQuestionConfig} from 'app/client/components/Forms/HiddenQuestionConfig';
import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc';
import {EmptyFilterState} from "app/client/components/LinkingState";
import {RefSelect} from 'app/client/components/RefSelect';
@ -26,7 +27,7 @@ import {createSessionObs, isBoolean, SessionObs} from 'app/client/lib/sessionObs
import {reportError} from 'app/client/models/AppModel';
import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel';
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig';
import {buildDescriptionConfig, buildTextInput} from 'app/client/ui/DescriptionConfig';
import {BuildEditorOptions} from 'app/client/ui/FieldConfig';
import {GridOptions} from 'app/client/ui/GridOptions';
import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
@ -263,6 +264,18 @@ export class RightPanel extends Disposable {
// Builder for the reference display column multiselect.
const refSelect = RefSelect.create(owner, {docModel, origColumn, fieldBuilder});
// The original selected field model.
const fieldRef = owner.autoDispose(ko.pureComputed(() => {
return ((fieldBuilder()?.field)?.id()) ?? 0;
}));
const selectedField = owner.autoDispose(docModel.viewFields.createFloatingRowModel(fieldRef));
// For forms we will show some extra options.
const isForm = owner.autoDispose(ko.computed(() => {
const vs = this._gristDoc.viewModel.activeSection();
return vs.parentKey() === 'form';
}));
// build cursor position observable
const cursor = owner.autoDispose(ko.computed(() => {
const vsi = this._gristDoc.viewModel.activeSection?.().viewInstance();
@ -276,6 +289,14 @@ export class RightPanel extends Disposable {
cssSection(
dom.create(buildNameConfig, origColumn, cursor, isMultiSelect),
),
dom.maybe(isForm, () => [
cssSection(
dom.create(buildTextInput, {
cursor, label: 'Question', value: selectedField.question,
placeholder: selectedField.origLabel
}),
),
]),
cssSection(
dom.create(buildDescriptionConfig, origColumn.description, { cursor, "testPrefix": "column" }),
),
@ -430,6 +451,19 @@ export class RightPanel extends Disposable {
cssSeparator(dom.hide(activeSection.isRecordCard)),
dom.domComputed(use => {
const vs = use(activeSection.viewInstance);
if (!vs || use(activeSection.parentKey) !== 'form') { return null; }
return [
cssRow(
primaryButton(t("Reset form"), dom.on('click', () => {
activeSection.layoutSpecObj.setAndSave(null).catch(reportError);
})),
cssRow.cls('-top-space')
),
];
}),
dom.maybe((use) => ['detail', 'single'].includes(use(this._pageWidgetType)!), () => [
cssLabel(t("Theme")),
dom('div',
@ -486,11 +520,16 @@ export class RightPanel extends Disposable {
use(hasCustomMapping) ||
use(this._pageWidgetType) === 'chart' ||
use(activeSection.isRaw)
),
) && use(activeSection.parentKey) !== 'form',
() => [
cssSeparator(),
dom.create(VisibleFieldsConfig, this._gristDoc, activeSection),
]),
dom.maybe(use => use(activeSection.parentKey) === 'form', () => [
cssSeparator(),
dom.create(HiddenQuestionConfig, activeSection),
]),
]);
}

@ -276,14 +276,7 @@ export class VisibleFieldsConfig extends Disposable {
}
public async removeField(field: IField) {
const existing = this._section.viewFields.peek().peek()
.find((f) => f.column.peek().getRowId() === field.origCol.peek().id.peek());
if (!existing) {
return;
}
const id = existing.id.peek();
const action = ['RemoveRecord', id];
await this._gristDoc.docModel.viewFields.sendTableAction(action);
await this._section.removeField(field.getRowId());
}
public async addField(column: IField, nextField: ViewFieldRec|null = null) {

@ -39,9 +39,9 @@ export const cssInput = styled('input', `
/**
* Builds a text input that updates `obs` as you type.
*/
export function textInput(obs: Observable<string>, ...args: DomElementArg[]): HTMLInputElement {
export function textInput(obs: Observable<string|undefined>, ...args: DomElementArg[]): HTMLInputElement {
return cssInput(
dom.prop('value', obs),
dom.prop('value', u => u(obs) || ''),
dom.on('input', (_e, elem) => obs.set(elem.value)),
...args,
);
@ -67,4 +67,4 @@ export function textarea(
options.onInput ? dom.on('input', (e, elem) => setValue(elem)) : null,
dom.on('change', (e, elem) => setValue(elem)),
);
}
}

@ -7,6 +7,7 @@ export const widgetTypesMap = new Map<IWidgetType, IWidgetTypeInfo>([
['single', {label: 'Card', icon: 'TypeCard'}],
['detail', {label: 'Card List', icon: 'TypeCardList'}],
['chart', {label: 'Chart', icon: 'TypeChart'}],
['form', {label: 'Form', icon: 'Board'}],
['custom', {label: 'Custom', icon: 'TypeCustom'}],
['custom.calendar', {label: 'Calendar', icon: 'TypeCalendar'}],
]);

@ -56,10 +56,10 @@ export const colors = {
darkBg: new CustomProp('color-dark-bg', '#262633'),
slate: new CustomProp('color-slate', '#929299'),
lighterGreen: new CustomProp('color-lighter-green', '#b1ffe2'),
lightGreen: new CustomProp('color-light-green', '#16B378'),
darkGreen: new CustomProp('color-dark-green', '#009058'),
darkerGreen: new CustomProp('color-darker-green', '#007548'),
lighterGreen: new CustomProp('color-lighter-green', '#b1ffe2'),
lighterBlue: new CustomProp('color-lighter-blue', '#87b2f9'),
lightBlue: new CustomProp('color-light-blue', '#3B82F6'),

@ -1,4 +1,4 @@
import { Command } from 'app/client/components/commands';
import { MenuCommand } from 'app/client/components/commandList';
import { FocusLayer } from 'app/client/lib/FocusLayer';
import { makeT } from 'app/client/lib/localization';
import { NeedUpgradeError, reportError } from 'app/client/models/errors';
@ -575,7 +575,7 @@ export const menuItemAsync: typeof weasel.menuItem = function(action, ...args) {
};
export function menuItemCmd(
cmd: Command,
cmd: MenuCommand,
label: string | (() => DomContents),
...args: DomElementArg[]
) {
@ -787,8 +787,9 @@ const cssUpgradeTextButton = styled(textButton, `
const cssMenuItemSubmenu = styled('div', `
position: relative;
justify-content: flex-start;
color: ${theme.menuItemFg};
--icon-color: ${theme.menuItemFg};
--icon-color: ${theme.accentIcon};
.${weasel.cssMenuItem.className}-sel {
color: ${theme.menuItemSelectedFg};
--icon-color: ${theme.menuItemSelectedFg};

@ -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,
};

@ -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});

@ -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;

@ -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;

@ -62,6 +62,7 @@ export class DocApiForwarder {
app.use('/api/docs/:docId/webhooks', withDoc);
app.use('/api/docs/:docId/assistant', withDoc);
app.use('/api/docs/:docId/sql', withDoc);
app.use('/api/docs/:docId/forms/:id', withDoc);
app.use('^/api/docs$', withoutDoc);
}

@ -1,5 +1,6 @@
import {concatenateSummaries, summarizeAction} from "app/common/ActionSummarizer";
import {createEmptyActionSummary} from "app/common/ActionSummary";
import {QueryFilters} from 'app/common/ActiveDocAPI';
import {ApiError, LimitType} from 'app/common/ApiError';
import {BrowserSettings} from "app/common/BrowserSettings";
import {
@ -11,8 +12,9 @@ import {
UserAction
} from 'app/common/DocActions';
import {isRaisedException} from "app/common/gristTypes";
import {Box, RenderBox, RenderContext} from "app/common/Forms";
import {buildUrlId, parseUrlId} from "app/common/gristUrls";
import {isAffirmative, timeoutReached} from "app/common/gutil";
import {isAffirmative, safeJsonParse, timeoutReached} from "app/common/gutil";
import {SchemaTypes} from "app/common/schema";
import {SortFunc} from 'app/common/SortFunc';
import {Sort} from 'app/common/SortSpec';
@ -60,6 +62,7 @@ import {GristServer} from 'app/server/lib/GristServer';
import {HashUtil} from 'app/server/lib/HashUtil';
import {makeForkIds} from "app/server/lib/idUtils";
import log from 'app/server/lib/log';
import {getAppPathTo} from 'app/server/lib/places';
import {
getDocId,
getDocScope,
@ -81,9 +84,11 @@ import {fetchDoc, globalUploadSet, handleOptionalUpload, handleUpload,
import * as assert from 'assert';
import contentDisposition from 'content-disposition';
import {Application, NextFunction, Request, RequestHandler, Response} from "express";
import jsesc from 'jsesc';
import * as _ from "lodash";
import LRUCache from 'lru-cache';
import * as moment from 'moment';
import * as fse from 'fs-extra';
import fetch from 'node-fetch';
import * as path from 'path';
import * as t from "ts-interface-checker";
@ -163,7 +168,8 @@ export class DocWorkerApi {
constructor(private _app: Application, private _docWorker: DocWorker,
private _docWorkerMap: IDocWorkerMap, private _docManager: DocManager,
private _dbManager: HomeDBManager, private _grist: GristServer) {}
private _dbManager: HomeDBManager, private _grist: GristServer,
private _staticPath: string) {}
/**
* Adds endpoints for the doc api.
@ -215,14 +221,18 @@ export class DocWorkerApi {
res.json(await activeDoc.applyUserActions(docSessionFromRequest(req), req.body, {parseStrings}));
}));
async function getTableData(activeDoc: ActiveDoc, req: RequestWithLogin, optTableId?: string) {
const filters = req.query.filter ? JSON.parse(String(req.query.filter)) : {};
async function readTable(
req: RequestWithLogin,
activeDoc: ActiveDoc,
tableId: string,
filters: QueryFilters,
params: QueryParameters & {immediate?: boolean}) {
// Option to skip waiting for document initialization.
const immediate = isAffirmative(req.query.immediate);
const immediate = isAffirmative(params.immediate);
if (!Object.keys(filters).every(col => Array.isArray(filters[col]))) {
throw new ApiError("Invalid query: filter values must be arrays", 400);
}
const tableId = await getRealTableId(optTableId || req.params.tableId, {activeDoc, req});
const session = docSessionFromRequest(req);
const {tableData} = await handleSandboxError(tableId, [], activeDoc.fetchQuery(
session, {tableId, filters}, !immediate));
@ -230,16 +240,22 @@ export class DocWorkerApi {
const isMetaTable = tableId.startsWith('_grist');
const columns = isMetaTable ? null :
await handleSandboxError('', [], activeDoc.getTableCols(session, tableId, true));
const params = getQueryParameters(req);
// Apply sort/limit parameters, if set. TODO: move sorting/limiting into data engine
// and sql.
return applyQueryParameters(fromTableDataAction(tableData), params, columns);
}
async function getTableRecords(
activeDoc: ActiveDoc, req: RequestWithLogin, opts?: { optTableId?: string; includeHidden?: boolean }
): Promise<TableRecordValue[]> {
const columnData = await getTableData(activeDoc, req, opts?.optTableId);
async function getTableData(activeDoc: ActiveDoc, req: RequestWithLogin, optTableId?: string) {
const filters = req.query.filter ? JSON.parse(String(req.query.filter)) : {};
// Option to skip waiting for document initialization.
const immediate = isAffirmative(req.query.immediate);
const tableId = await getRealTableId(optTableId || req.params.tableId, {activeDoc, req});
const params = getQueryParameters(req);
return await readTable(req, activeDoc, tableId, filters, {...params, immediate});
}
function asRecords(
columnData: TableColValues, opts?: { optTableId?: string; includeHidden?: boolean }): TableRecordValue[] {
const fieldNames = Object.keys(columnData).filter((k) => {
if (k === "id") {
return false;
@ -266,6 +282,13 @@ export class DocWorkerApi {
});
}
async function getTableRecords(
activeDoc: ActiveDoc, req: RequestWithLogin, opts?: { optTableId?: string; includeHidden?: boolean }
): Promise<TableRecordValue[]> {
const columnData = await getTableData(activeDoc, req, opts?.optTableId);
return asRecords(columnData, opts);
}
// Get the specified table in column-oriented format
this._app.get('/api/docs/:docId/tables/:tableId/data', canView,
withDoc(async (activeDoc, req, res) => {
@ -1343,6 +1366,99 @@ export class DocWorkerApi {
return res.status(200).json(docId);
}));
// Get the specified table in record-oriented format
this._app.get('/api/docs/:docId/forms/:id', canView,
withDoc(async (activeDoc, req, res) => {
// Get the viewSection record for the specified id.
const id = integerParam(req.params.id, 'id');
const records = asRecords(await readTable(
req, activeDoc, '_grist_Views_section', { id: [id] }, { }
));
const vs = records.find(r => r.id === id);
if (!vs) {
throw new ApiError(`ViewSection ${id} not found`, 404);
}
// Prepare the context that will be needed for rendering this form.
const fields = asRecords(await readTable(
req, activeDoc, '_grist_Views_section_field', { parentId: [id] }, { }
));
const cols = asRecords(await readTable(
req, activeDoc, '_grist_Tables_column', { parentId: [vs.fields.tableRef] }, { }
));
// Read the box specs
const spec = vs.fields.layoutSpec;
let box: Box = safeJsonParse(spec ? String(spec) : '', null);
if (!box) {
const editable = fields.filter(f => {
const col = cols.find(c => c.id === f.fields.colRef);
// Can't do attachments and formulas.
return col && !(col.fields.isFormula && col.fields.formula) && col.fields.type !== 'Attachment';
});
box = {
type: 'Layout',
children: editable.map(f => ({
type: 'Field',
leaf: f.id
}))
};
box.children!.push({
type: 'Submit'
});
}
const context: RenderContext = {
field(fieldRef: number) {
const field = fields.find(f => f.id === fieldRef);
if (!field) { throw new Error(`Field ${fieldRef} not found`); }
const col = cols.find(c => c.id === field.fields.colRef);
if (!col) { throw new Error(`Column ${field.fields.colRef} not found`); }
const fieldOptions = safeJsonParse(field.fields.widgetOptions as string, {});
const colOptions = safeJsonParse(col.fields.widgetOptions as string, {});
const options = {...colOptions, ...fieldOptions};
return {
colId: col.fields.colId as string,
description: options.description,
question: options.question,
type: (col.fields.type as string).split(':')[0],
options,
};
}
};
// Now render the box to HTML.
const html = RenderBox.new(box, context).toHTML();
// The html will be inserted into a form as a replacement for:
// document.write(sanitize(`<!-- INSERT CONTENT -->`))
// We need to properly escape `
const escaped = jsesc(html, {isScriptContext: true, quotes: 'backtick'});
// And wrap it with the form template.
const form = await fse.readFile(path.join(getAppPathTo(this._staticPath, 'static'),
'forms/form.html'), 'utf8');
// TODO: externalize css. Currently the redirect mechanism depends on the relative base URL, so
// we can't change it at this moment. But once custom success page will be implemented this should
// be possible.
const staticOrigin = process.env.APP_STATIC_URL || "";
const staticBaseUrl = `${staticOrigin}/v/${this._grist.getTag()}/`;
// Fill out the blanks and send the result.
const doc = await this._dbManager.getDoc(req);
const docUrl = await this._grist.getResourceUrl(doc, 'html');
const tableId = await getRealTableId(String(vs.fields.tableRef), {activeDoc, req});
res.status(200).send(form
.replace('<!-- INSERT CONTENT -->', escaped || '')
.replace("<!-- INSERT BASE -->", `<base href="${staticBaseUrl}">`)
.replace('<!-- INSERT DOC URL -->', docUrl)
.replace('<!-- INSERT TABLE ID -->', tableId)
);
// Return the HTML if it exists, otherwise return 404.
res.send(html);
})
);
}
private async _copyDocToWorkspace(req: Request, options: {
@ -1877,9 +1993,9 @@ export class DocWorkerApi {
export function addDocApiRoutes(
app: Application, docWorker: DocWorker, docWorkerMap: IDocWorkerMap, docManager: DocManager, dbManager: HomeDBManager,
grist: GristServer
grist: GristServer, staticPath: string
) {
const api = new DocWorkerApi(app, docWorker, docWorkerMap, docManager, dbManager, grist);
const api = new DocWorkerApi(app, docWorker, docWorkerMap, docManager, dbManager, grist, staticPath);
api.addEndpoints();
}

@ -1281,7 +1281,7 @@ export class FlexServer implements GristServer {
this._addSupportPaths(docAccessMiddleware);
if (!isSingleUserMode()) {
addDocApiRoutes(this.app, docWorker, this._docWorkerMap, docManager, this._dbManager, this);
addDocApiRoutes(this.app, docWorker, this._docWorkerMap, docManager, this._dbManager, this, this.appRoot);
}
}

@ -152,7 +152,6 @@
"i18next": "21.9.1",
"i18next-http-middleware": "3.3.2",
"image-size": "0.6.3",
"isomorphic-dompurify": "1.11.0",
"jquery": "3.5.0",
"js-yaml": "3.14.1",
"jsdom": "^23.0.0",

@ -0,0 +1,156 @@
<!doctype html>
<html>
<head>
<meta charset="utf8">
<!-- INSERT BASE -->
<style>
html,
body {
padding: 0px;
margin: 0px;
background-color: #f7f7f7;
line-height: 1.42857143;
}
* {
box-sizing: border-box;
}
</style>
<script src="forms/grist-form-submit.js"></script>
<script src="forms/purify.min.js"></script>
<style>
.grist-form-container {
color: #262633;
background-color: #f7f7f7;
min-height: 100%;
width: 100%;
padding-top: 52px;
padding-bottom: 32px;
font-size: 13px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Liberation Sans", Helvetica, Arial, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
}
.grist-form-container .grist-form-confirm {
text-align: center;
}
form.grist-form {
padding: 32px;
margin: 0px auto;
background-color: white;
border: 1px solid #E8E8E8;
width: 640px;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 16px;
max-width: calc(100% - 32px);
}
form.grist-form .grist-field {
display: flex;
flex-direction: column;
}
form.grist-form .grist-field label {
font-size: 15px;
margin-bottom: 8px;
font-weight: normal;
}
form.grist-form .grist-field .grist-field-description {
font-size: 10px;
font-weight: 400;
margin-top: 4px;
color: #929299;
white-space: pre-wrap;
}
form.grist-form .grist-field input[type="text"] {
padding: 4px 8px;
border-radius: 3px;
border: 1px solid #D9D9D9;
font-size: 13px;
outline-color: #16b378;
outline-width: 1px;
line-height: inherit;
width: 100%;
}
form.grist-form input[type="submit"] {
background-color: #16b378;
border: 1px solid #16b378;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
line-height: inherit;
}
form.grist-form input[type="datetime-local"] {
width: 100%;
line-height: inherit;
}
form.grist-form input[type="date"] {
width: 100%;
line-height: inherit;
}
form.grist-form input[type="submit"]:hover {
border-color: #009058;
background-color: #009058;
}
form.grist-form input[type="checkbox"] {
margin: 0px;
}
form.grist-form .grist-columns {
display: grid;
grid-template-columns: repeat(var(--grist-columns-count), 1fr);
gap: 4px;
}
form.grist-form select {
padding: 4px 8px;
border-radius: 3px;
border: 1px solid #D9D9D9;
font-size: 13px;
outline-color: #16b378;
outline-width: 1px;
background: white;
line-height: inherit;
flex: auto;
width: 100%;
}
form.grist-form .grist-choice-list {
display: flex;
flex-direction: column;
gap: 4px;
}
</style>
</head>
<body>
<main class='grist-form-container'>
<form class='grist-form'
onsubmit="event.target.parentElement.querySelector('.grist-form-confirm').style.display = 'block', event.target.style.display = 'none'"
data-grist-doc="<!-- INSERT DOC URL -->"
data-grist-table="<!-- INSERT TABLE ID -->">
<script>
document.write(DOMPurify.sanitize(`<!-- INSERT CONTENT -->`));
</script>
</form>
<div class='grist-form-confirm' style='display: none'>
Thank you! Your response has been recorded.
</div>
</main>
</body>
</html>

@ -0,0 +1,169 @@
// If the script is loaded multiple times, only register the handlers once.
if (!window.gristFormSubmit) {
(function() {
/**
* gristFormSubmit(gristDocUrl, gristTableId, formData)
* - `gristDocUrl` should be the URL of the Grist document, from step 1 of the setup instructions.
* - `gristTableId` should be the table ID from step 2.
* - `formData` should be a [FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
* object, typically obtained as `new FormData(formElement)`. Inside the `submit` event handler, it
* can be convenient to use `new FormData(event.target)`.
*
* This function sends values from `formData` to add a new record in the specified Grist table. It
* returns a promise for the result of the add-record API call. In case of an error, the promise
* will be rejected with an error message.
*/
async function gristFormSubmit(docUrl, tableId, formData) {
// Pick out the server and docId from the docUrl.
const match = /^(https?:\/\/[^\/]+(?:\/o\/[^\/]+)?)\/(?:doc\/([^\/?#]+)|([^\/?#]{12,})\/)/.exec(docUrl);
if (!match) { throw new Error("Invalid Grist doc URL " + docUrl); }
const server = match[1];
const docId = match[2] || match[3];
// Construct the URL to use for the add-record API endpoint.
const destUrl = server + "/api/docs/" + docId + "/tables/" + tableId + "/records";
const payload = {records: [{fields: formDataToJson(formData)}]};
const options = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
};
const resp = await window.fetch(destUrl, options);
if (resp.status !== 200) {
// Try to report a helpful error.
let body = '', error, match;
try { body = await resp.json(); } catch (e) {}
if (typeof body.error === 'string' && (match = /KeyError '(.*)'/.exec(body.error))) {
error = 'No column "' + match[1] + '" in table "' + tableId + '". ' +
'Be sure to use column ID rather than column label';
} else {
error = body.error || String(body);
}
throw new Error('Failed to add record: ' + error);
}
return await resp.json();
}
// Convert FormData into a mapping of Grist fields. Skips any keys starting with underscore.
// For fields with multiple values (such as to populate ChoiceList), use field names like `foo[]`
// (with the name ending in a pair of empty square brackets).
function formDataToJson(f) {
const keys = Array.from(f.keys()).filter(k => !k.startsWith("_"));
return Object.fromEntries(keys.map(k =>
k.endsWith('[]') ? [k.slice(0, -2), ['L', ...f.getAll(k)]] : [k, f.get(k)]));
}
// Handle submissions for plain forms that include special data-grist-* attributes.
async function handleSubmitPlainForm(ev) {
if (!['data-grist-doc', 'data-grist-table']
.some(attr => ev.target.hasAttribute(attr))) {
// This form isn't configured for Grist at all; don't interfere with it.
return;
}
ev.preventDefault();
try {
const docUrl = ev.target.getAttribute('data-grist-doc');
const tableId = ev.target.getAttribute('data-grist-table');
if (!docUrl) { throw new Error("Missing attribute data-grist-doc='GRIST_DOC_URL'"); }
if (!tableId) { throw new Error("Missing attribute data-grist-table='GRIST_TABLE_ID'"); }
const successUrl = ev.target.getAttribute('data-grist-success-url');
await gristFormSubmit(docUrl, tableId, new FormData(ev.target));
// On success, redirect to the requested URL.
if (successUrl) {
window.location.href = successUrl;
}
} catch (err) {
reportSubmitError(ev, err);
}
}
function reportSubmitError(ev, err) {
console.warn("grist-form-submit error:", err.message);
// Find an element to use for the validation message to alert the user.
let scapegoat = null;
(
(scapegoat = ev.submitter)?.setCustomValidity ||
(scapegoat = ev.target.querySelector('input[type=submit]'))?.setCustomValidity ||
(scapegoat = ev.target.querySelector('button'))?.setCustomValidity ||
(scapegoat = [...ev.target.querySelectorAll('input')].pop())?.setCustomValidity
)
scapegoat?.setCustomValidity("Form misconfigured: " + err.message);
ev.target.reportValidity();
}
// Handle submissions for Contact Form 7 forms.
async function handleSubmitWPCF7(ev) {
try {
const formId = ev.detail.contactFormId;
const docUrl = ev.target.querySelector('[data-grist-doc]')?.getAttribute('data-grist-doc');
const tableId = ev.target.querySelector('[data-grist-table]')?.getAttribute('data-grist-table');
if (!docUrl) { throw new Error("Missing attribute data-grist-doc='GRIST_DOC_URL'"); }
if (!tableId) { throw new Error("Missing attribute data-grist-table='GRIST_TABLE_ID'"); }
await gristFormSubmit(docUrl, tableId, new FormData(ev.target));
console.log("grist-form-submit WPCF7 Form %s: Added record", formId);
} catch (err) {
console.warn("grist-form-submit WPCF7 Form %s misconfigured:", formId, err.message);
}
}
function setUpGravityForms(options) {
// Use capture to get the event before GravityForms processes it.
document.addEventListener('submit', ev => handleSubmitGravityForm(ev, options), true);
}
gristFormSubmit.setUpGravityForms = setUpGravityForms;
async function handleSubmitGravityForm(ev, options) {
try {
ev.preventDefault();
ev.stopPropagation();
const docUrl = options.docUrl;
const tableId = options.tableId;
if (!docUrl) { throw new Error("setUpGravityForm: missing docUrl option"); }
if (!tableId) { throw new Error("setUpGravityForm: missing tableId option"); }
const f = new FormData(ev.target);
for (const key of Array.from(f.keys())) {
// Skip fields other than input fields.
if (!key.startsWith("input_")) {
f.delete(key);
continue;
}
// Rename multiple fields to use "[]" convention rather than ".N" convention.
const multi = key.split(".");
if (multi.length > 1) {
f.append(multi[0] + "[]", f.get(key));
f.delete(key);
}
}
console.warn("Processed FormData", f);
await gristFormSubmit(docUrl, tableId, f);
// Follow through by doing the form submission normally.
ev.target.submit();
} catch (err) {
reportSubmitError(ev, err);
return;
}
}
window.gristFormSubmit = gristFormSubmit;
document.addEventListener('submit', handleSubmitPlainForm);
document.addEventListener('wpcf7mailsent', handleSubmitWPCF7);
})();
}

File diff suppressed because one or more lines are too long

@ -0,0 +1,847 @@
import {UserAPI} from 'app/common/UserAPI';
import {addToRepl, assert, driver, Key, WebElement, WebElementPromise} from 'mocha-webdriver';
import * as gu from 'test/nbrowser/gristUtils';
import {setupTestSuite} from 'test/nbrowser/testUtils';
describe('FormView', function() {
this.timeout('90s');
let api: UserAPI;
let docId: string;
const cleanup = setupTestSuite();
gu.withEnvironmentSnapshot({
'GRIST_EXPERIMENTAL_PLUGINS': '1'
});
addToRepl('question', question);
addToRepl('labels', readLabels);
addToRepl('questionType', questionType);
const clipboard = gu.getLockableClipboard();
afterEach(() => gu.checkForErrors());
before(async function() {
const session = await gu.session().login();
docId = await session.tempNewDoc(cleanup);
api = session.createHomeApi();
});
async function createFormWith(type: string, more = false) {
await gu.addNewSection('Form', 'Table1');
assert.isUndefined(await api.getTable(docId, 'Table1').then(t => t.D));
// Add a text question
await drop().click();
if (more) {
await clickMenu('More');
}
await clickMenu(type);
await gu.waitForServer();
// Make sure we see this new question (D).
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
// Now open the form in external window.
const formUrl = await driver.find(`.test-forms-link`).getAttribute('href');
return formUrl;
}
async function removeForm() {
// Remove this section.
await gu.openSectionMenu('viewLayout');
await driver.find('.test-section-delete').click();
await gu.waitForServer();
// Remove record.
await gu.sendActions([
['RemoveRecord', 'Table1', 1],
['RemoveColumn', 'Table1', 'D']
]);
}
async function waitForConfirm() {
await gu.waitToPass(async () => {
assert.isTrue(await driver.findWait('.grist-form-confirm', 1000).isDisplayed());
});
}
async function expectSingle(value: any) {
assert.deepEqual(await api.getTable(docId, 'Table1').then(t => t.D), [value]);
}
async function expect(values: any[]) {
assert.deepEqual(await api.getTable(docId, 'Table1').then(t => t.D), values);
}
it('can submit a form with Text field', async function() {
const formUrl = await createFormWith('Text');
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 1000).click();
await gu.sendKeys('Hello World');
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
// Make sure we see the new record.
await expectSingle('Hello World');
await removeForm();
});
it('can submit a form with Numeric field', async function() {
const formUrl = await createFormWith('Numeric');
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 1000).click();
await gu.sendKeys('1984');
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
// Make sure we see the new record.
await expectSingle(1984);
await removeForm();
});
it('can submit a form with Date field', async function() {
const formUrl = await createFormWith('Date');
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 1000).click();
await driver.executeScript(
() => (document.querySelector('input[name="D"]') as HTMLInputElement).value = '2000-01-01'
);
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
// Make sure we see the new record.
await expectSingle(/* 2000-01-01 */946684800);
await removeForm();
});
it('can submit a form with Choice field', async function() {
const formUrl = await createFormWith('Choice');
// Add some options.
await gu.openColumnPanel();
await gu.choicesEditor.edit();
await gu.choicesEditor.add('Foo');
await gu.choicesEditor.add('Bar');
await gu.choicesEditor.add('Baz');
await gu.choicesEditor.save();
await gu.toggleSidePanel('right', 'close');
// We need to press preview, as form is not saved yet.
await gu.scrollActiveViewTop();
await gu.waitToPass(async () => {
assert.isTrue(await driver.find('.test-forms-preview').isDisplayed());
});
await driver.find('.test-forms-preview').click();
await gu.waitForServer();
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
// Make sure options are there.
assert.deepEqual(await driver.findAll('select[name="D"] option', e => e.getText()), ['Foo', 'Bar', 'Baz']);
await driver.findWait('select[name="D"]', 1000).click();
await driver.find("option[value='Bar']").click();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
await expectSingle('Bar');
await removeForm();
});
it('can submit a form with Integer field', async function() {
const formUrl = await createFormWith('Integer', true);
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 1000).click();
await gu.sendKeys('1984');
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
// Make sure we see the new record.
await expectSingle(1984);
await removeForm();
});
it('can submit a form with Toggle field', async function() {
const formUrl = await createFormWith('Toggle', true);
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D"]', 1000).click();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
await expectSingle(true);
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
await expect([true, false]);
// Remove the additional record added just now.
await gu.sendActions([
['RemoveRecord', 'Table1', 2],
]);
await removeForm();
});
it('can submit a form with ChoiceList field', async function() {
const formUrl = await createFormWith('Choice List', true);
// Add some options.
await gu.openColumnPanel();
await gu.choicesEditor.edit();
await gu.choicesEditor.add('Foo');
await gu.choicesEditor.add('Bar');
await gu.choicesEditor.add('Baz');
await gu.choicesEditor.save();
await gu.toggleSidePanel('right', 'close');
// We are in a new window.
await gu.onNewTab(async () => {
await driver.get(formUrl);
await driver.findWait('input[name="D[]"][value="Foo"]', 1000).click();
await driver.findWait('input[name="D[]"][value="Baz"]', 1000).click();
await driver.find('input[type="submit"]').click();
await waitForConfirm();
});
await expectSingle(['L', 'Foo', 'Baz']);
await removeForm();
});
it('can create a form for a blank table', async function() {
// Add new page and select form.
await gu.addNewPage('Form', 'New Table', {
tableName: 'Form'
});
// Make sure we see a form editor.
assert.isTrue(await driver.find('.test-forms-editor').isDisplayed());
// With 3 questions A, B, C.
for (const label of ['A', 'B', 'C']) {
assert.isTrue(
await driver.findContent('.test-forms-question .test-forms-label', gu.exactMatch(label)).isDisplayed()
);
}
// And a submit button.
assert.isTrue(await driver.findContent('.test-forms-submit', gu.exactMatch('Submit')).isDisplayed());
});
it('doesnt generates fields when they are added', async function() {
await gu.sendActions([
['AddVisibleColumn', 'Form', 'Choice',
{type: 'Choice', widgetOption: JSON.stringify({choices: ['A', 'B', 'C']})}],
]);
// Make sure we see a form editor.
assert.isTrue(await driver.find('.test-forms-editor').isDisplayed());
await driver.sleep(100);
assert.isFalse(
await driver.findContent('.test-forms-question-choice .test-forms-label', gu.exactMatch('Choice')).isPresent()
);
});
it('supports basic drag and drop', async function() {
// Make sure the order is right.
assert.deepEqual(
await readLabels(), ['A', 'B', 'C']
);
await driver.withActions(a =>
a.move({origin: questionDrag('B')})
.press()
.move({origin: questionDrag('A')})
.release()
);
await gu.waitForServer();
// Make sure the order is right.
assert.deepEqual(
await readLabels(), ['B', 'A', 'C']
);
await driver.withActions(a =>
a.move({origin: questionDrag('C')})
.press()
.move({origin: questionDrag('B')})
.release()
);
await gu.waitForServer();
// Make sure the order is right.
assert.deepEqual(
await readLabels(), ['C', 'B', 'A']
);
// Now move A on A and make sure nothing changes.
await driver.withActions(a =>
a.move({origin: questionDrag('A')})
.press()
.move({origin: questionDrag('A'), x: 50})
.release()
);
await gu.waitForServer();
assert.deepEqual(await readLabels(), ['C', 'B', 'A']);
});
it('can undo drag and drop', async function() {
await gu.undo();
assert.deepEqual(await readLabels(), ['B', 'A', 'C']);
await gu.undo();
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
});
it('adds new question at the end', async function() {
// We should see single drop zone.
assert.equal((await drops()).length, 1);
// Move the A over there.
await driver.withActions(a =>
a.move({origin: questionDrag('A')})
.press()
.move({origin: drop().drag()})
.release()
);
await gu.waitForServer();
assert.deepEqual(await readLabels(), ['B', 'C', 'A']);
await gu.undo();
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
// Now add a new question.
await drop().click();
await clickMenu('Text');
await gu.waitForServer();
// We should have new column D or type text.
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
assert.equal(await questionType('D'), 'Text');
await gu.undo();
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
});
it('adds question in the middle', async function() {
await driver.withActions(a => a.contextClick(question('B')));
await clickMenu('Insert question above');
await gu.waitForServer();
assert.deepEqual(await readLabels(), ['A', 'D', 'B', 'C']);
// Now below C.
await driver.withActions(a => a.contextClick(question('B')));
await clickMenu('Insert question below');
await gu.waitForServer();
assert.deepEqual(await readLabels(), ['A', 'D', 'B', 'E', 'C']);
// Make sure they are draggable.
// Move D infront of C.
await driver.withActions(a =>
a.move({origin: questionDrag('D')})
.press()
.move({origin: questionDrag('C')})
.release()
);
await gu.waitForServer();
assert.deepEqual(await readLabels(), ['A', 'B', 'E', 'D', 'C']);
// Remove 3 times.
await gu.undo(3);
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
});
it('selection works', async function() {
// Click on A.
await question('A').click();
// Now A is selected.
assert.equal(await selectedLabel(), 'A');
// Click B.
await question('B').click();
// Now B is selected.
assert.equal(await selectedLabel(), 'B');
// Click on the dropzone.
await drop().click();
await gu.sendKeys(Key.ESCAPE);
// Now nothing is selected.
assert.isFalse(await isSelected());
// When we add new question, it is automatically selected.
await drop().click();
await clickMenu('Text');
await gu.waitForServer();
// Now D is selected.
assert.equal(await selectedLabel(), 'D');
await gu.undo();
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
await question('A').click();
});
it('hiding and revealing works', async function() {
await gu.toggleSidePanel('left', 'close');
await gu.openWidgetPanel();
// We have only one hidden column.
assert.deepEqual(await hiddenColumns(), ['Choice']);
// Now move it to the form on B
await driver.withActions(a =>
a.move({origin: hiddenColumn('Choice')})
.press()
.move({origin: questionDrag('B')})
.release()
);
await gu.waitForServer();
// It should be after A.
await gu.waitToPass(async () => {
assert.deepEqual(await readLabels(), ['A', 'Choice', 'B', 'C']);
}, 500);
// Undo to make sure it is bundled.
await gu.undo();
// It should be hidden again.
assert.deepEqual(await hiddenColumns(), ['Choice']);
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
// And redo.
await gu.redo();
assert.deepEqual(await readLabels(), ['A', 'Choice', 'B', 'C']);
assert.deepEqual(await hiddenColumns(), []);
// Now hide it using menu.
await question('Choice').rightClick();
await clickMenu('Hide');
await gu.waitForServer();
// It should be hidden again.
assert.deepEqual(await hiddenColumns(), ['Choice']);
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
// And undo.
await gu.undo();
assert.deepEqual(await readLabels(), ['A', 'Choice', 'B', 'C']);
assert.deepEqual(await hiddenColumns(), []);
// Now hide it using Delete key.
await question('Choice').click();
await gu.sendKeys(Key.DELETE);
await gu.waitForServer();
// It should be hidden again.
assert.deepEqual(await hiddenColumns(), ['Choice']);
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
await gu.toggleSidePanel('right', 'close');
});
it('basic keyboard navigation works', async function() {
await question('A').click();
assert.equal(await selectedLabel(), 'A');
// Move down.
await gu.sendKeys(Key.ARROW_DOWN);
assert.equal(await selectedLabel(), 'B');
// Move up.
await gu.sendKeys(Key.ARROW_UP);
assert.equal(await selectedLabel(), 'A');
// Move down to C.
await gu.sendKeys(Key.ARROW_DOWN);
await gu.sendKeys(Key.ARROW_DOWN);
assert.equal(await selectedLabel(), 'C');
// Move down we should be at A (past the submit button).
await gu.sendKeys(Key.ARROW_DOWN);
await gu.sendKeys(Key.ARROW_DOWN);
assert.equal(await selectedLabel(), 'A');
// Do the same with Left and Right.
await gu.sendKeys(Key.ARROW_RIGHT);
assert.equal(await selectedLabel(), 'B');
await gu.sendKeys(Key.ARROW_LEFT);
assert.equal(await selectedLabel(), 'A');
await gu.sendKeys(Key.ARROW_RIGHT);
await gu.sendKeys(Key.ARROW_RIGHT);
assert.equal(await selectedLabel(), 'C');
});
it('cutting works', async function() {
const revert = await gu.begin();
await question('A').click();
// Send copy command.
await clipboard.lockAndPerform(async (cb) => {
await cb.cut();
await gu.sendKeys(Key.ARROW_DOWN); // Focus on B.
await gu.sendKeys(Key.ARROW_DOWN); // Focus on C.
await cb.paste();
});
await gu.waitForServer();
assert.deepEqual(await readLabels(), ['B', 'A', 'C']);
await gu.undo();
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
// To the same for paragraph.
await drop().click();
await clickMenu('Paragraph');
await gu.waitForServer();
await element('Paragraph').click();
await clipboard.lockAndPerform(async (cb) => {
await cb.cut();
// Go over A and paste there.
await gu.sendKeys(Key.ARROW_UP); // Focus on button
await gu.sendKeys(Key.ARROW_UP); // Focus on C.
await gu.sendKeys(Key.ARROW_UP); // Focus on B.
await gu.sendKeys(Key.ARROW_UP); // Focus on A.
await cb.paste();
});
await gu.waitForServer();
// Paragraph should be the first one now.
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
let elements = await driver.findAll('.test-forms-element');
assert.isTrue(await elements[0].matches('.test-forms-Paragraph'));
// Put it back using undo.
await gu.undo();
elements = await driver.findAll('.test-forms-element');
assert.isTrue(await elements[0].matches('.test-forms-question'));
// 0 - A, 1 - B, 2 - C, 3 - submit button.
assert.isTrue(await elements[4].matches('.test-forms-Paragraph'));
await revert();
});
const checkInitial = async () => assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
const checkNewCol = async () => {
assert.equal(await selectedLabel(), 'D');
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
await gu.undo();
await checkInitial();
};
const checkFieldsAtFirstLevel = (menuText: string) => {
it(`can add ${menuText} elements from the menu`, async function() {
await drop().click();
await clickMenu(menuText);
await gu.waitForServer();
await checkNewCol();
});
};
checkFieldsAtFirstLevel('Text');
checkFieldsAtFirstLevel('Numeric');
checkFieldsAtFirstLevel('Date');
checkFieldsAtFirstLevel('Choice');
const checkFieldInMore = (menuText: string) => {
it(`can add ${menuText} elements from the menu`, async function() {
await drop().click();
await clickMenu('More');
await clickMenu(menuText);
await gu.waitForServer();
await checkNewCol();
});
};
checkFieldInMore('Integer');
checkFieldInMore('Toggle');
checkFieldInMore('DateTime');
checkFieldInMore('Choice List');
checkFieldInMore('Reference');
checkFieldInMore('Reference List');
checkFieldInMore('Attachment');
const testStruct = (type: string) => {
it(`can add structure ${type} element`, async function() {
assert.equal(await elementCount(type), 0);
await drop().click();
await clickMenu(type);
await gu.waitForServer();
assert.equal(await elementCount(type), 1);
await gu.undo();
assert.equal(await elementCount(type), 0);
});
};
testStruct('Section');
testStruct('Columns');
testStruct('Paragraph');
it('basic section', async function() {
const revert = await gu.begin();
// Add structure.
await drop().click();
await clickMenu('Section');
await gu.waitForServer();
assert.equal(await elementCount('Section'), 1);
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
// There is a drop in that section, click it to add a new question.
await element('Section').element('dropzone').click();
await clickMenu('Text');
await gu.waitForServer();
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
// And the question is inside a section.
assert.equal(await element('Section').element('label').getText(), 'D');
// Make sure we can move that question around.
await driver.withActions(a =>
a.move({origin: questionDrag('D')})
.press()
.move({origin: questionDrag('B')})
.release()
);
await gu.waitForServer();
assert.deepEqual(await readLabels(), ['A', 'D', 'B', 'C']);
// Make sure that it is not inside the section anymore.
assert.equal(await element('Section').element('label').isPresent(), false);
await gu.undo();
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
assert.equal(await element('Section').element('label').getText(), 'D');
await revert();
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
});
it('basic columns work', async function() {
const revert = await gu.begin();
await drop().click();
await clickMenu('Columns');
await gu.waitForServer();
// We have two placeholders for free.
assert.equal(await elementCount('Placeholder', element('Columns')), 2);
// We can add another placeholder
await element('add').click();
await gu.waitForServer();
// Now we have 3 placeholders.
assert.equal(await elementCount('Placeholder', element('Columns')), 3);
// We can click the middle one, and add a question.
await element('Columns').find(`.test-forms-editor:nth-child(2) .test-forms-Placeholder`).click();
await clickMenu('Text');
await gu.waitForServer();
// Now we have 2 placeholders
assert.equal(await elementCount('Placeholder', element('Columns')), 2);
// And 4 questions.
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
// The question D is in the columns.
assert.equal(await element('Columns').element('label').getText(), 'D');
// We can move it around.
await driver.withActions(a =>
a.move({origin: questionDrag('D')})
.press()
.move({origin: questionDrag('B')})
.release()
);
await gu.waitForServer();
assert.deepEqual(await readLabels(), ['A', 'D', 'B', 'C']);
// And move it back.
await driver.withActions(a =>
a.move({origin: questionDrag('D')})
.press()
.move({origin: element('Columns').find(`.test-forms-editor:nth-child(2) .test-forms-drag`)})
.release()
);
await gu.waitForServer();
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
let allColumns = await driver.findAll('.test-forms-column');
assert.lengthOf(allColumns, 3);
assert.isTrue(await allColumns[0].matches('.test-forms-Placeholder'));
assert.isTrue(await allColumns[1].matches('.test-forms-question'));
assert.equal(await allColumns[1].find('.test-forms-label').getText(), 'D');
assert.isTrue(await allColumns[2].matches('.test-forms-Placeholder'));
// Check that we can remove the question.
await question('D').rightClick();
await clickMenu('Hide');
await gu.waitForServer();
// Now we have 3 placeholders.
assert.equal(await elementCount('Placeholder', element('Columns')), 3);
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
// Undo and check it goes back at the right place.
await gu.undo();
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
allColumns = await driver.findAll('.test-forms-column');
assert.lengthOf(allColumns, 3);
assert.isTrue(await allColumns[0].matches('.test-forms-Placeholder'));
assert.isTrue(await allColumns[1].matches('.test-forms-question'));
assert.equal(await allColumns[1].find('.test-forms-label').getText(), 'D');
assert.isTrue(await allColumns[2].matches('.test-forms-Placeholder'));
await revert();
assert.lengthOf(await driver.findAll('.test-forms-column'), 0);
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
});
it('changes type of a question', async function() {
// Add text question as D column.
await drop().click();
await clickMenu('Text');
await gu.waitForServer();
assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']);
// Make sure it is a text question.
assert.equal(await questionType('D'), 'Text');
// Now change it to a choice, from the backend (as the UI is not clear here).
await gu.sendActions([
['ModifyColumn', 'Form', 'D', {type: 'Choice', widgetOptions: JSON.stringify({choices: ['A', 'B', 'C']})}],
]);
// Make sure it is a choice question.
await gu.waitToPass(async () => {
assert.equal(await questionType('D'), 'Choice');
});
// Now change it back to a text question.
await gu.undo();
await gu.waitToPass(async () => {
assert.equal(await questionType('D'), 'Text');
});
await gu.redo();
await gu.waitToPass(async () => {
assert.equal(await questionType('D'), 'Choice');
});
await gu.undo(2);
await gu.waitToPass(async () => {
assert.deepEqual(await readLabels(), ['A', 'B', 'C']);
});
});
});
function element(type: string, parent?: WebElement) {
return extra((parent ?? driver).find(`.test-forms-${type}`));
}
async function elementCount(type: string, parent?: WebElement) {
return await (parent ?? driver).findAll(`.test-forms-${type}`).then(els => els.length);
}
async function readLabels() {
return await driver.findAll('.test-forms-question .test-forms-label', el => el.getText());
}
function question(label: string) {
return extra(driver.findContent('.test-forms-question .test-forms-label', gu.exactMatch(label))
.findClosest('.test-forms-editor'));
}
function questionDrag(label: string) {
return question(label).find('.test-forms-drag');
}
function questionType(label: string) {
return question(label).find('.test-forms-type').value();
}
function drop() {
return element('dropzone');
}
function drops() {
return driver.findAll('.test-forms-dropzone');
}
async function clickMenu(label: string) {
// First try command as it will also contain the keyboard shortcut we need to discard.
if (await driver.findContent('.grist-floating-menu li .test-cmd-name', gu.exactMatch(label)).isPresent()) {
return driver.findContent('.grist-floating-menu li .test-cmd-name', gu.exactMatch(label)).click();
}
return driver.findContentWait('.grist-floating-menu li', gu.exactMatch(label), 100).click();
}
function isSelected() {
return driver.findAll('.test-forms-field-editor-selected').then(els => els.length > 0);
}
function selected() {
return driver.find('.test-forms-field-editor-selected');
}
function selectedLabel() {
return selected().find('.test-forms-label').getText();
}
function hiddenColumns() {
return driver.findAll('.test-vfc-hidden-field', e => e.getText());
}
function hiddenColumn(label: string) {
return driver.findContent('.test-vfc-hidden-field', gu.exactMatch(label));
}
type ExtraElement = WebElementPromise & {
rightClick: () => Promise<void>,
element: (type: string) => ExtraElement,
/**
* A draggable element inside. This is 2x2px div to help with drag and drop.
*/
drag: () => WebElementPromise,
};
function extra(el: WebElementPromise): ExtraElement {
const webElement: any = el;
webElement.rightClick = async function() {
await driver.withActions(a => a.contextClick(webElement));
};
webElement.element = function(type: string) {
return element(type, webElement);
};
webElement.drag = function() {
return webElement.find('.test-forms-drag');
};
return webElement;
}

@ -1154,7 +1154,7 @@ export async function addNewTable(name?: string) {
// Add a new page using the 'Add New' menu and wait for the new page to be shown.
export async function addNewPage(
typeRe: RegExp|'Table'|'Card'|'Card List'|'Chart'|'Custom',
typeRe: RegExp|'Table'|'Card'|'Card List'|'Chart'|'Custom'|'Form',
tableRe: RegExp|string,
options?: PageWidgetPickerOptions) {
const url = await driver.getCurrentUrl();
@ -2855,7 +2855,8 @@ export async function duplicateTab() {
export async function scrollActiveView(x: number, y: number) {
await driver.executeScript(function(x1: number, y1: number) {
const view = document.querySelector(".active_section .grid_view_data") ||
document.querySelector(".active_section .detailview_scroll_pane");
document.querySelector(".active_section .detailview_scroll_pane") ||
document.querySelector(".active_section .test-forms-editor");
view!.scrollBy(x1, y1);
}, x, y);
await driver.sleep(10); // wait a bit for the scroll to happen (this is async operation in Grist).
@ -2864,7 +2865,8 @@ export async function scrollActiveView(x: number, y: number) {
export async function scrollActiveViewTop() {
await driver.executeScript(function() {
const view = document.querySelector(".active_section .grid_view_data") ||
document.querySelector(".active_section .detailview_scroll_pane");
document.querySelector(".active_section .detailview_scroll_pane") ||
document.querySelector(".active_section .test-forms-editor");
view!.scrollTop = 0;
});
await driver.sleep(10); // wait a bit for the scroll to happen (this is async operation in Grist).
@ -3552,6 +3554,51 @@ export async function sendCommand(name: CommandName, argument: any = null) {
await waitForServer();
}
/**
* Helper controller for choices list editor.
*/
export const choicesEditor = {
async hasReset() {
return (await driver.find(".test-choice-list-entry-edit").getText()) === "Reset";
},
async reset() {
await driver.find(".test-choice-list-entry-edit").click();
},
async label() {
return await driver.find(".test-choice-list-entry-row").getText();
},
async add(label: string) {
await driver.find(".test-tokenfield-input").click();
await driver.find(".test-tokenfield-input").clear();
await sendKeys(label, Key.ENTER);
},
async rename(label: string, label2: string) {
const entry = await driver.findWait(`.test-choice-list-entry .test-token-label[value='${label}']`, 100);
await entry.click();
await sendKeys(label2);
await sendKeys(Key.ENTER);
},
async color(token: string, color: string) {
const label = await driver.findWait(`.test-choice-list-entry .test-token-label[value='${token}']`, 100);
await label.findClosest(".test-tokenfield-token").find(".test-color-button").click();
await setFillColor(color);
await sendKeys(Key.ENTER);
},
async read() {
return await driver.findAll(".test-choice-list-entry-label", e => e.getText());
},
async edit() {
await this.reset();
},
async save() {
await driver.find(".test-choice-list-entry-save").click();
await waitForServer();
},
async cancel() {
await driver.find(".test-choice-list-entry-cancel").click();
}
};
} // end of namespace gristUtils

@ -10,7 +10,7 @@
import { WebDriver, WebElement } from 'mocha-webdriver';
type SectionTypes = 'Table'|'Card'|'Card List'|'Chart'|'Custom';
type SectionTypes = 'Table'|'Card'|'Card List'|'Chart'|'Custom'|'Form';
export class GristWebDriverUtils {
public constructor(public driver: WebDriver) {

@ -636,7 +636,7 @@
resolved "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.32.tgz"
integrity sha512-bPYT5ECFiblzsVzyURaNhljBH2Gh1t9LowgUwciMrNAhFewLkHT2H0Mto07Y4/3KCOGZHRQll3CTtQZ0X11D/A==
"@types/dompurify@3.0.5", "@types/dompurify@^3.0.3":
"@types/dompurify@3.0.5":
version "3.0.5"
resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-3.0.5.tgz#02069a2fcb89a163bacf1a788f73cb415dd75cb7"
integrity sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==
@ -2939,7 +2939,7 @@ domain-browser@~1.1.0:
resolved "https://registry.npmjs.org/domain-browser/-/domain-browser-1.1.7.tgz"
integrity sha1-hnqksJP6oF8d4IwG9NeyH9+GmLw=
dompurify@3.0.6, dompurify@^3.0.6:
dompurify@3.0.6:
version "3.0.6"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.0.6.tgz#925ebd576d54a9531b5d76f0a5bef32548351dae"
integrity sha512-ilkD8YEnnGh1zJ240uJsW7AzE+2qpbOUYjacomn3AvJ6J4JhKGSZ2nh4wUIXPZrEPppaCLx5jFe8T89Rk8tQ7w==
@ -4774,15 +4774,6 @@ isobject@^3.0.1:
resolved "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz"
integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8=
isomorphic-dompurify@1.11.0:
version "1.11.0"
resolved "https://registry.yarnpkg.com/isomorphic-dompurify/-/isomorphic-dompurify-1.11.0.tgz#83d9060a14fb7e02624b25c118194baa435ce86e"
integrity sha512-1Z8C9oPnbNGajiL9zAdf265aDWr8/PY8wvHR435uLxH8mnfurM9YklzmOZm6gH5XQkmIxIfAONq35eASx2xmKQ==
dependencies:
"@types/dompurify" "^3.0.3"
dompurify "^3.0.6"
jsdom "^23.0.0"
isstream@0.1.x:
version "0.1.2"
resolved "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz"

Loading…
Cancel
Save