From a424450cbe531c1a36d5b65e878b0cc5a1518147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Tue, 12 Dec 2023 10:58:20 +0100 Subject: [PATCH] (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 --- app/client/components/Forms/Columns.ts | 176 ++++ app/client/components/Forms/Field.ts | 210 +++++ app/client/components/Forms/FormView.ts | 679 ++++++++++++++ .../components/Forms/HiddenQuestionConfig.ts | 140 +++ app/client/components/Forms/Model.ts | 378 ++++++++ app/client/components/Forms/Paragraph.ts | 94 ++ app/client/components/Forms/Section.ts | 25 + app/client/components/Forms/Submit.ts | 10 + app/client/components/Forms/Text.ts | 26 + app/client/components/Forms/elements.ts | 20 + app/client/components/Forms/styles.ts | 350 ++++++++ app/client/components/GristDoc.ts | 14 +- app/client/components/ViewLayout.ts | 2 + app/client/components/commandList.ts | 12 +- app/client/lib/domUtils.ts | 11 +- app/client/models/MetaRowModel.js | 10 + app/client/models/entities/ViewFieldRec.ts | 18 +- app/client/models/entities/ViewSectionRec.ts | 32 +- app/client/models/features.ts | 4 + app/client/ui/DescriptionConfig.ts | 118 ++- app/client/ui/GridViewMenus.ts | 50 +- app/client/ui/PageWidgetPicker.ts | 28 +- app/client/ui/RightPanel.ts | 43 +- app/client/ui/VisibleFieldsConfig.ts | 9 +- app/client/ui/inputs.ts | 6 +- app/client/ui/widgetTypesMap.ts | 1 + app/client/ui2018/cssVars.ts | 2 +- app/client/ui2018/menus.ts | 7 +- app/common/Forms.ts | 226 +++++ app/common/UserAPI.ts | 8 + app/common/gutil.ts | 56 +- app/common/widgetTypes.ts | 2 +- app/gen-server/lib/DocApiForwarder.ts | 1 + app/server/lib/DocApi.ts | 142 ++- app/server/lib/FlexServer.ts | 2 +- package.json | 1 - static/forms/form.html | 156 ++++ static/forms/grist-form-submit.js | 169 ++++ static/forms/purify.min.js | 3 + test/nbrowser/FormView.ts | 847 ++++++++++++++++++ test/nbrowser/gristUtils.ts | 53 +- test/nbrowser/gristWebDriverUtils.ts | 2 +- yarn.lock | 13 +- 43 files changed, 4023 insertions(+), 133 deletions(-) create mode 100644 app/client/components/Forms/Columns.ts create mode 100644 app/client/components/Forms/Field.ts create mode 100644 app/client/components/Forms/FormView.ts create mode 100644 app/client/components/Forms/HiddenQuestionConfig.ts create mode 100644 app/client/components/Forms/Model.ts create mode 100644 app/client/components/Forms/Paragraph.ts create mode 100644 app/client/components/Forms/Section.ts create mode 100644 app/client/components/Forms/Submit.ts create mode 100644 app/client/components/Forms/Text.ts create mode 100644 app/client/components/Forms/elements.ts create mode 100644 app/client/components/Forms/styles.ts create mode 100644 app/common/Forms.ts create mode 100644 static/forms/form.html create mode 100644 static/forms/grist-form-submit.js create mode 100644 static/forms/purify.min.js create mode 100644 test/nbrowser/FormView.ts diff --git a/app/client/components/Forms/Columns.ts b/app/client/components/Forms/Columns.ts new file mode 100644 index 00000000..b5379ff8 --- /dev/null +++ b/app/client/components/Forms/Columns.ts @@ -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; +`); diff --git a/app/client/components/Forms/Field.ts b/app/client/components/Forms/Field.ts new file mode 100644 index 00000000..1c325c72 --- /dev/null +++ b/app/client/components/Forms/Field.ts @@ -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; + } + + 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 = 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 = 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 { + 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) { + return dom('input', {type: 'hidden'}, dom.prop('value', value), testId('type')); +} diff --git a/app/client/components/Forms/FormView.ts b/app/client/components/Forms/FormView.ts new file mode 100644 index 00000000..aa2fbb08 --- /dev/null +++ b/app/client/components/Forms/FormView.ts @@ -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; + public selectedBox: Observable; + + protected sortedRows: SortedRowSet; + protected tableModel: DataTableModel; + protected cursor: Cursor; + protected menuHolder: Holder; + protected bundle: (clb: () => Promise) => Promise; + + private _autoLayout: Computed; + private _root: BoxModel; + private _savedLayout: any; + private _saving: boolean = false; + private _url: Computed; + + 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 { + 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 + } + , + ...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); diff --git a/app/client/components/Forms/HiddenQuestionConfig.ts b/app/client/components/Forms/HiddenQuestionConfig.ts new file mode 100644 index 00000000..24a4aec3 --- /dev/null +++ b/app/client/components/Forms/HiddenQuestionConfig.ts @@ -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; +`); diff --git a/app/client/components/Forms/Model.ts b/app/client/components/Forms/Model.ts new file mode 100644 index 00000000..884f97f8 --- /dev/null +++ b/app/client/components/Forms/Model.ts @@ -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 { + type: BoxType, + children?: Array, +} + +/** + * 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; + /** + * List of children boxes. + */ + public children: MutableObsArray; + /** + * Any other dynamically added properties (that are not concrete fields in the derived classes) + */ + public props: Record> = {}; + /** + * 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 { + 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 { + 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, 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, +} + +export type Builder = (owner: MultiHolder, options: { + box: BoxModel, + view: FormView, + overlay: Observable, +}) => DomContents; + +export const ignoreClick = dom.on('click', (ev) => { + ev.stopPropagation(); + ev.preventDefault(); +}); + +export function unwrap(val: T | Computed): 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; + } +} diff --git a/app/client/components/Forms/Paragraph.ts b/app/client/components/Forms/Paragraph.ts new file mode 100644 index 00000000..868146ab --- /dev/null +++ b/app/client/components/Forms/Paragraph.ts @@ -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; + 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; +`); diff --git a/app/client/components/Forms/Section.ts b/app/client/components/Forms/Section.ts new file mode 100644 index 00000000..d96c5665 --- /dev/null +++ b/app/client/components/Forms/Section.ts @@ -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; + } +} diff --git a/app/client/components/Forms/Submit.ts b/app/client/components/Forms/Submit.ts new file mode 100644 index 00000000..c2c8115e --- /dev/null +++ b/app/client/components/Forms/Submit.ts @@ -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')); + } +} diff --git a/app/client/components/Forms/Text.ts b/app/client/components/Forms/Text.ts new file mode 100644 index 00000000..6bb92543 --- /dev/null +++ b/app/client/components/Forms/Text.ts @@ -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')), + ]), + ); +}; diff --git a/app/client/components/Forms/elements.ts b/app/client/components/Forms/elements.ts new file mode 100644 index 00000000..16539e64 --- /dev/null +++ b/app/client/components/Forms/elements.ts @@ -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}; + } +} diff --git a/app/client/components/Forms/styles.ts b/app/client/components/Forms/styles.ts new file mode 100644 index 00000000..f38c194e --- /dev/null +++ b/app/client/components/Forms/styles.ts @@ -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, ...args: IDomArgs) { + 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; +`); diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index d652eb35..8a030929 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -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 () => { diff --git a/app/client/components/ViewLayout.ts b/app/client/components/ViewLayout.ts index 244efd24..c8e54b37 100644 --- a/app/client/components/ViewLayout.ts +++ b/app/client/components/ViewLayout.ts @@ -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, }; diff --git a/app/client/components/commandList.ts b/app/client/components/commandList.ts index 9b402171..f81de383 100644 --- a/app/client/components/commandList.ts +++ b/app/client/components/commandList.ts @@ -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', diff --git a/app/client/lib/domUtils.ts b/app/client/lib/domUtils.ts index 578ebfe2..9bddfe53 100644 --- a/app/client/lib/domUtils.ts +++ b/app/client/lib/domUtils.ts @@ -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) => dom.cls(prefix + id, obs ?? true); + return (id: BindableValue, obs?: BindableValue) => { + return dom.cls(use => { + if (obs !== undefined && !useBindable(use, obs)) { + return ''; + } + return `${useBindable(use, prefix)}${useBindable(use, id)}`; + }); + }; } diff --git a/app/client/models/MetaRowModel.js b/app/client/models/MetaRowModel.js index 0d7ba991..edbe5c4c 100644 --- a/app/client/models/MetaRowModel.js +++ b/app/client/models/MetaRowModel.js @@ -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() { diff --git a/app/client/models/entities/ViewFieldRec.ts b/app/client/models/entities/ViewFieldRec.ts index 3d5ed04e..5f0c045a 100644 --- a/app/client/models/entities/ViewFieldRec.ts +++ b/app/client/models/entities/ViewFieldRec.ts @@ -17,7 +17,9 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field">, R widthPx: ko.Computed; column: ko.Computed; + origLabel: ko.Computed; origCol: ko.Computed; + pureType: ko.Computed; colId: ko.Computed; label: ko.Computed; description: modelUtil.KoSaveableObservable; @@ -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; + /** Label in FormView. By default FormView uses label, use this to override it. */ + question: modelUtil.KoSaveableObservable; + 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({ diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts index bc170587..d94bcefa 100644 --- a/app/client/models/entities/ViewSectionRec.ts +++ b/app/client/models/entities/ViewSectionRec.ts @@ -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; - layoutSpecObj: modelUtil.ObjObservable; + layoutSpecObj: modelUtil.SaveableObjObservable; _savedFilters: ko.Computed>; @@ -268,9 +273,11 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO // Saves custom definition (bundles change) saveCustomDef(): Promise; - insertColumn(colId?: string|null, options?: InsertColOptions): Promise; + insertColumn(colId?: string|null, options?: InsertColOptions): Promise; + + showColumn(col: number|string, index?: number): Promise - showColumn(colRef: number, index?: number): Promise + removeField(colRef: number): Promise; } 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); }; } diff --git a/app/client/models/features.ts b/app/client/models/features.ts index 353fe0a3..d5053724 100644 --- a/app/client/models/features.ts +++ b/app/client/models/features.ts @@ -33,3 +33,7 @@ export function PERMITTED_CUSTOM_WIDGETS(): Observable { } return G.window.PERMITTED_CUSTOM_WIDGETS; } + +export function GRIST_FORMS_FEATURE() { + return Boolean(getGristConfig().experimentalPlugins); +} diff --git a/app/client/ui/DescriptionConfig.ts b/app/client/ui/DescriptionConfig.ts index 7e592769..fdae4195 100644 --- a/app/client/ui/DescriptionConfig.ts +++ b/app/client/ui/DescriptionConfig.ts @@ -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, - options: { - cursor: ko.Computed, - testPrefix: string, - }, - ) { + owner: MultiHolder, + description: KoSaveableObservable, + options: { + cursor: ko.Computed, + 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, + cursor: ko.Computed, + label: string, + placeholder?: ko.Computed, + }, + ...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}; diff --git a/app/client/ui/GridViewMenus.ts b/app/client/ui/GridViewMenus.ts index ade42c04..6753a6e7 100644 --- a/app/client/ui/GridViewMenus.ts +++ b/app/client/ui/GridViewMenus.ts @@ -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) => [ diff --git a/app/client/ui/PageWidgetPicker.ts b/app/client/ui/PageWidgetPicker.ts index 0bb35d3d..f8e6d0cd 100644 --- a/app/client/ui/PageWidgetPicker.ts +++ b/app/client/ui/PageWidgetPicker.ts @@ -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; } diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index da91ffb4..b832a6ff 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -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), + ]), ]); } diff --git a/app/client/ui/VisibleFieldsConfig.ts b/app/client/ui/VisibleFieldsConfig.ts index 01cae124..56e4c61d 100644 --- a/app/client/ui/VisibleFieldsConfig.ts +++ b/app/client/ui/VisibleFieldsConfig.ts @@ -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) { diff --git a/app/client/ui/inputs.ts b/app/client/ui/inputs.ts index ec664a8a..1ac18863 100644 --- a/app/client/ui/inputs.ts +++ b/app/client/ui/inputs.ts @@ -39,9 +39,9 @@ export const cssInput = styled('input', ` /** * Builds a text input that updates `obs` as you type. */ -export function textInput(obs: Observable, ...args: DomElementArg[]): HTMLInputElement { +export function textInput(obs: Observable, ...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)), ); -} \ No newline at end of file +} diff --git a/app/client/ui/widgetTypesMap.ts b/app/client/ui/widgetTypesMap.ts index 4a2c33d6..51c9dd6b 100644 --- a/app/client/ui/widgetTypesMap.ts +++ b/app/client/ui/widgetTypesMap.ts @@ -7,6 +7,7 @@ export const widgetTypesMap = new Map([ ['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'}], ]); diff --git a/app/client/ui2018/cssVars.ts b/app/client/ui2018/cssVars.ts index f7c8329f..247561c4 100644 --- a/app/client/ui2018/cssVars.ts +++ b/app/client/ui2018/cssVars.ts @@ -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'), diff --git a/app/client/ui2018/menus.ts b/app/client/ui2018/menus.ts index ba2e508c..61f5839d 100644 --- a/app/client/ui2018/menus.ts +++ b/app/client/ui2018/menus.ts @@ -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}; diff --git a/app/common/Forms.ts b/app/common/Forms.ts new file mode 100644 index 00000000..0ca0e596 --- /dev/null +++ b/app/common/Forms.ts @@ -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 { + type: BoxType, + children?: Array, +} + +/** + * 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; +} + +/** + * 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 ` +
${html}
+ `; + } +} + +class Section extends RenderBox { + /** Nothing, default is enough */ +} + +class Columns extends RenderBox { + public override toHTML(): string { + const kids = this.box.children || []; + return ` +
+ ${kids.map((child) => child.toHTML()).join('\n')} +
+ `; + } +} + +class Submit extends RenderBox { + public override toHTML() { + return ` +
+ +
+ `; + } +} + +class Placeholder extends RenderBox { + public override toHTML() { + return ` +
+
+ `; + } +} + +class Layout extends RenderBox { + /** Nothing, default is enough */ +} + +/** + * Field is a special kind of box, as it renders a Grist field (a Question). It provides a default frame, like label and + * description, and then renders the field itself in same way as the main Boxes where rendered. + */ +class Field extends RenderBox { + + public 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 `
Field not found
`; + } + const label = field.question ? field.question : field.colId; + const name = field.colId; + let description = field.description || ''; + if (description) { + description = `
${description}
`; + } + const html = `
${Field.render(field, this.ctx)}
`; + return ` +
+ + ${html} + ${description} +
+ `; + } +} + +interface Question { + toHTML(field: FieldModel, context: RenderContext): string; +} + + +class Text implements Question { + public toHTML(field: FieldModel, context: RenderContext): string { + return ` + + `; + } +} + +class Date implements Question { + public toHTML(field: FieldModel, context: RenderContext): string { + return ` + + `; + } +} + +class DateTime implements Question { + public toHTML(field: FieldModel, context: RenderContext): string { + return ` + + `; + } +} + +class Choice implements Question { + public toHTML(field: FieldModel, context: RenderContext): string { + const choices: string[] = field.options.choices || []; + return ` + + `; + } +} + +class Bool implements Question { + public toHTML(field: FieldModel, context: RenderContext): string { + return ` + + `; + } +} + +class ChoiceList implements Question { + public toHTML(field: FieldModel, context: RenderContext): string { + const choices: string[] = field.options.choices || []; + return ` +
+ ${choices.map((choice) => ` + + `).join('')} +
+ `; + } +} + +/** + * List of all available questions we will render of the form. + * TODO: add other renderers. + */ +const questions: Partial Question>> = { + 'Text': Text, + 'Choice': Choice, + 'Bool': Bool, + 'ChoiceList': ChoiceList, + 'Date': Date, + 'DateTime': DateTime, +}; + +/** + * 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, +}; diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 057361b7..9bdaf6be 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -419,6 +419,10 @@ export interface UserAPI { * is specific to Grist installation, and might not be supported. */ closeOrg(): Promise; + /** + * 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([['showRemoved', '1']]); return new UserAPIImpl(this._homeUrl, {...this._options, extraParameters}); diff --git a/app/common/gutil.ts b/app/common/gutil.ts index 93fea1d1..a6e89654 100644 --- a/app/common/gutil.ts +++ b/app/common/gutil.ts @@ -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(observable: Observable, // `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): DomElementMethod { +export function inlineStyle(property: string, valueObs: BindableValue): 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(use: UseCBOwner, obs: BindableValue): 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( + owner: IDisposableOwner, + func: (owner: IDisposableOwner, use: UseCBOwner) => T +): Computed { + const holder = Holder.create(owner); + return Computed.create(owner, use => { + const computedOwner = MultiHolder.create(holder); + return func(computedOwner, use); + }); +} + +export type Constructor = new (...args: any[]) => T; diff --git a/app/common/widgetTypes.ts b/app/common/widgetTypes.ts index 3b20ffbe..e8b90c9d 100644 --- a/app/common/widgetTypes.ts +++ b/app/common/widgetTypes.ts @@ -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; diff --git a/app/gen-server/lib/DocApiForwarder.ts b/app/gen-server/lib/DocApiForwarder.ts index b29a5f9e..36d58b9d 100644 --- a/app/gen-server/lib/DocApiForwarder.ts +++ b/app/gen-server/lib/DocApiForwarder.ts @@ -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); } diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index dce4a3ef..c0327e5c 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -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 { - 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 { + 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(``)) + // 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('', escaped || '') + .replace("", ``) + .replace('', docUrl) + .replace('', 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(); } diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 081c82bc..f4bb8260 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -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); } } diff --git a/package.json b/package.json index 9c66e49d..056eb49d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/static/forms/form.html b/static/forms/form.html new file mode 100644 index 00000000..5880ba91 --- /dev/null +++ b/static/forms/form.html @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + +
+
+ +
+ +
+ + + diff --git a/static/forms/grist-form-submit.js b/static/forms/grist-form-submit.js new file mode 100644 index 00000000..429b4627 --- /dev/null +++ b/static/forms/grist-form-submit.js @@ -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); + + })(); +} diff --git a/static/forms/purify.min.js b/static/forms/purify.min.js new file mode 100644 index 00000000..7a4da768 --- /dev/null +++ b/static/forms/purify.min.js @@ -0,0 +1,3 @@ +/*! @license DOMPurify 3.0.6 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.0.6/LICENSE */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).DOMPurify=t()}(this,(function(){"use strict";const{entries:e,setPrototypeOf:t,isFrozen:n,getPrototypeOf:o,getOwnPropertyDescriptor:r}=Object;let{freeze:i,seal:a,create:l}=Object,{apply:c,construct:s}="undefined"!=typeof Reflect&&Reflect;i||(i=function(e){return e}),a||(a=function(e){return e}),c||(c=function(e,t,n){return e.apply(t,n)}),s||(s=function(e,t){return new e(...t)});const u=N(Array.prototype.forEach),m=N(Array.prototype.pop),f=N(Array.prototype.push),p=N(String.prototype.toLowerCase),d=N(String.prototype.toString),h=N(String.prototype.match),g=N(String.prototype.replace),T=N(String.prototype.indexOf),y=N(String.prototype.trim),E=N(RegExp.prototype.test),A=(_=TypeError,function(){for(var e=arguments.length,t=new Array(e),n=0;n1?n-1:0),r=1;r2&&void 0!==arguments[2]?arguments[2]:p;t&&t(e,null);let i=o.length;for(;i--;){let t=o[i];if("string"==typeof t){const e=r(t);e!==t&&(n(o)||(o[i]=e),t=e)}e[t]=!0}return e}function S(t){const n=l(null);for(const[o,i]of e(t))void 0!==r(t,o)&&(n[o]=i);return n}function R(e,t){for(;null!==e;){const n=r(e,t);if(n){if(n.get)return N(n.get);if("function"==typeof n.value)return N(n.value)}e=o(e)}return function(e){return console.warn("fallback value for",e),null}}const w=i(["a","abbr","acronym","address","area","article","aside","audio","b","bdi","bdo","big","blink","blockquote","body","br","button","canvas","caption","center","cite","code","col","colgroup","content","data","datalist","dd","decorator","del","details","dfn","dialog","dir","div","dl","dt","element","em","fieldset","figcaption","figure","font","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","img","input","ins","kbd","label","legend","li","main","map","mark","marquee","menu","menuitem","meter","nav","nobr","ol","optgroup","option","output","p","picture","pre","progress","q","rp","rt","ruby","s","samp","section","select","shadow","small","source","spacer","span","strike","strong","style","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","track","tt","u","ul","var","video","wbr"]),D=i(["svg","a","altglyph","altglyphdef","altglyphitem","animatecolor","animatemotion","animatetransform","circle","clippath","defs","desc","ellipse","filter","font","g","glyph","glyphref","hkern","image","line","lineargradient","marker","mask","metadata","mpath","path","pattern","polygon","polyline","radialgradient","rect","stop","style","switch","symbol","text","textpath","title","tref","tspan","view","vkern"]),L=i(["feBlend","feColorMatrix","feComponentTransfer","feComposite","feConvolveMatrix","feDiffuseLighting","feDisplacementMap","feDistantLight","feDropShadow","feFlood","feFuncA","feFuncB","feFuncG","feFuncR","feGaussianBlur","feImage","feMerge","feMergeNode","feMorphology","feOffset","fePointLight","feSpecularLighting","feSpotLight","feTile","feTurbulence"]),v=i(["animate","color-profile","cursor","discard","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","foreignobject","hatch","hatchpath","mesh","meshgradient","meshpatch","meshrow","missing-glyph","script","set","solidcolor","unknown","use"]),x=i(["math","menclose","merror","mfenced","mfrac","mglyph","mi","mlabeledtr","mmultiscripts","mn","mo","mover","mpadded","mphantom","mroot","mrow","ms","mspace","msqrt","mstyle","msub","msup","msubsup","mtable","mtd","mtext","mtr","munder","munderover","mprescripts"]),k=i(["maction","maligngroup","malignmark","mlongdiv","mscarries","mscarry","msgroup","mstack","msline","msrow","semantics","annotation","annotation-xml","mprescripts","none"]),C=i(["#text"]),O=i(["accept","action","align","alt","autocapitalize","autocomplete","autopictureinpicture","autoplay","background","bgcolor","border","capture","cellpadding","cellspacing","checked","cite","class","clear","color","cols","colspan","controls","controlslist","coords","crossorigin","datetime","decoding","default","dir","disabled","disablepictureinpicture","disableremoteplayback","download","draggable","enctype","enterkeyhint","face","for","headers","height","hidden","high","href","hreflang","id","inputmode","integrity","ismap","kind","label","lang","list","loading","loop","low","max","maxlength","media","method","min","minlength","multiple","muted","name","nonce","noshade","novalidate","nowrap","open","optimum","pattern","placeholder","playsinline","poster","preload","pubdate","radiogroup","readonly","rel","required","rev","reversed","role","rows","rowspan","spellcheck","scope","selected","shape","size","sizes","span","srclang","start","src","srcset","step","style","summary","tabindex","title","translate","type","usemap","valign","value","width","xmlns","slot"]),I=i(["accent-height","accumulate","additive","alignment-baseline","ascent","attributename","attributetype","azimuth","basefrequency","baseline-shift","begin","bias","by","class","clip","clippathunits","clip-path","clip-rule","color","color-interpolation","color-interpolation-filters","color-profile","color-rendering","cx","cy","d","dx","dy","diffuseconstant","direction","display","divisor","dur","edgemode","elevation","end","fill","fill-opacity","fill-rule","filter","filterunits","flood-color","flood-opacity","font-family","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-weight","fx","fy","g1","g2","glyph-name","glyphref","gradientunits","gradienttransform","height","href","id","image-rendering","in","in2","k","k1","k2","k3","k4","kerning","keypoints","keysplines","keytimes","lang","lengthadjust","letter-spacing","kernelmatrix","kernelunitlength","lighting-color","local","marker-end","marker-mid","marker-start","markerheight","markerunits","markerwidth","maskcontentunits","maskunits","max","mask","media","method","mode","min","name","numoctaves","offset","operator","opacity","order","orient","orientation","origin","overflow","paint-order","path","pathlength","patterncontentunits","patterntransform","patternunits","points","preservealpha","preserveaspectratio","primitiveunits","r","rx","ry","radius","refx","refy","repeatcount","repeatdur","restart","result","rotate","scale","seed","shape-rendering","specularconstant","specularexponent","spreadmethod","startoffset","stddeviation","stitchtiles","stop-color","stop-opacity","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke","stroke-width","style","surfacescale","systemlanguage","tabindex","targetx","targety","transform","transform-origin","text-anchor","text-decoration","text-rendering","textlength","type","u1","u2","unicode","values","viewbox","visibility","version","vert-adv-y","vert-origin-x","vert-origin-y","width","word-spacing","wrap","writing-mode","xchannelselector","ychannelselector","x","x1","x2","xmlns","y","y1","y2","z","zoomandpan"]),M=i(["accent","accentunder","align","bevelled","close","columnsalign","columnlines","columnspan","denomalign","depth","dir","display","displaystyle","encoding","fence","frame","height","href","id","largeop","length","linethickness","lspace","lquote","mathbackground","mathcolor","mathsize","mathvariant","maxsize","minsize","movablelimits","notation","numalign","open","rowalign","rowlines","rowspacing","rowspan","rspace","rquote","scriptlevel","scriptminsize","scriptsizemultiplier","selection","separator","separators","stretchy","subscriptshift","supscriptshift","symmetric","voffset","width","xmlns"]),U=i(["xlink:href","xml:id","xlink:title","xml:space","xmlns:xlink"]),P=a(/\{\{[\w\W]*|[\w\W]*\}\}/gm),F=a(/<%[\w\W]*|[\w\W]*%>/gm),H=a(/\${[\w\W]*}/gm),z=a(/^data-[\-\w.\u00B7-\uFFFF]/),B=a(/^aria-[\-\w]+$/),W=a(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),G=a(/^(?:\w+script|data):/i),Y=a(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),j=a(/^html$/i);var q=Object.freeze({__proto__:null,MUSTACHE_EXPR:P,ERB_EXPR:F,TMPLIT_EXPR:H,DATA_ATTR:z,ARIA_ATTR:B,IS_ALLOWED_URI:W,IS_SCRIPT_OR_DATA:G,ATTR_WHITESPACE:Y,DOCTYPE_NAME:j});const X=function(){return"undefined"==typeof window?null:window},K=function(e,t){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let n=null;const o="data-tt-policy-suffix";t&&t.hasAttribute(o)&&(n=t.getAttribute(o));const r="dompurify"+(n?"#"+n:"");try{return e.createPolicy(r,{createHTML:e=>e,createScriptURL:e=>e})}catch(e){return console.warn("TrustedTypes policy "+r+" could not be created."),null}};var V=function t(){let n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:X();const o=e=>t(e);if(o.version="3.0.6",o.removed=[],!n||!n.document||9!==n.document.nodeType)return o.isSupported=!1,o;let{document:r}=n;const a=r,c=a.currentScript,{DocumentFragment:s,HTMLTemplateElement:_,Node:N,Element:P,NodeFilter:F,NamedNodeMap:H=n.NamedNodeMap||n.MozNamedAttrMap,HTMLFormElement:z,DOMParser:B,trustedTypes:G}=n,Y=P.prototype,V=R(Y,"cloneNode"),$=R(Y,"nextSibling"),Z=R(Y,"childNodes"),J=R(Y,"parentNode");if("function"==typeof _){const e=r.createElement("template");e.content&&e.content.ownerDocument&&(r=e.content.ownerDocument)}let Q,ee="";const{implementation:te,createNodeIterator:ne,createDocumentFragment:oe,getElementsByTagName:re}=r,{importNode:ie}=a;let ae={};o.isSupported="function"==typeof e&&"function"==typeof J&&te&&void 0!==te.createHTMLDocument;const{MUSTACHE_EXPR:le,ERB_EXPR:ce,TMPLIT_EXPR:se,DATA_ATTR:ue,ARIA_ATTR:me,IS_SCRIPT_OR_DATA:fe,ATTR_WHITESPACE:pe}=q;let{IS_ALLOWED_URI:de}=q,he=null;const ge=b({},[...w,...D,...L,...x,...C]);let Te=null;const ye=b({},[...O,...I,...M,...U]);let Ee=Object.seal(l(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),Ae=null,_e=null,Ne=!0,be=!0,Se=!1,Re=!0,we=!1,De=!1,Le=!1,ve=!1,xe=!1,ke=!1,Ce=!1,Oe=!0,Ie=!1;const Me="user-content-";let Ue=!0,Pe=!1,Fe={},He=null;const ze=b({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let Be=null;const We=b({},["audio","video","img","source","image","track"]);let Ge=null;const Ye=b({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),je="http://www.w3.org/1998/Math/MathML",qe="http://www.w3.org/2000/svg",Xe="http://www.w3.org/1999/xhtml";let Ke=Xe,Ve=!1,$e=null;const Ze=b({},[je,qe,Xe],d);let Je=null;const Qe=["application/xhtml+xml","text/html"],et="text/html";let tt=null,nt=null;const ot=r.createElement("form"),rt=function(e){return e instanceof RegExp||e instanceof Function},it=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!nt||nt!==e){if(e&&"object"==typeof e||(e={}),e=S(e),Je=Je=-1===Qe.indexOf(e.PARSER_MEDIA_TYPE)?et:e.PARSER_MEDIA_TYPE,tt="application/xhtml+xml"===Je?d:p,he="ALLOWED_TAGS"in e?b({},e.ALLOWED_TAGS,tt):ge,Te="ALLOWED_ATTR"in e?b({},e.ALLOWED_ATTR,tt):ye,$e="ALLOWED_NAMESPACES"in e?b({},e.ALLOWED_NAMESPACES,d):Ze,Ge="ADD_URI_SAFE_ATTR"in e?b(S(Ye),e.ADD_URI_SAFE_ATTR,tt):Ye,Be="ADD_DATA_URI_TAGS"in e?b(S(We),e.ADD_DATA_URI_TAGS,tt):We,He="FORBID_CONTENTS"in e?b({},e.FORBID_CONTENTS,tt):ze,Ae="FORBID_TAGS"in e?b({},e.FORBID_TAGS,tt):{},_e="FORBID_ATTR"in e?b({},e.FORBID_ATTR,tt):{},Fe="USE_PROFILES"in e&&e.USE_PROFILES,Ne=!1!==e.ALLOW_ARIA_ATTR,be=!1!==e.ALLOW_DATA_ATTR,Se=e.ALLOW_UNKNOWN_PROTOCOLS||!1,Re=!1!==e.ALLOW_SELF_CLOSE_IN_ATTR,we=e.SAFE_FOR_TEMPLATES||!1,De=e.WHOLE_DOCUMENT||!1,xe=e.RETURN_DOM||!1,ke=e.RETURN_DOM_FRAGMENT||!1,Ce=e.RETURN_TRUSTED_TYPE||!1,ve=e.FORCE_BODY||!1,Oe=!1!==e.SANITIZE_DOM,Ie=e.SANITIZE_NAMED_PROPS||!1,Ue=!1!==e.KEEP_CONTENT,Pe=e.IN_PLACE||!1,de=e.ALLOWED_URI_REGEXP||W,Ke=e.NAMESPACE||Xe,Ee=e.CUSTOM_ELEMENT_HANDLING||{},e.CUSTOM_ELEMENT_HANDLING&&rt(e.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(Ee.tagNameCheck=e.CUSTOM_ELEMENT_HANDLING.tagNameCheck),e.CUSTOM_ELEMENT_HANDLING&&rt(e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(Ee.attributeNameCheck=e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),e.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(Ee.allowCustomizedBuiltInElements=e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),we&&(be=!1),ke&&(xe=!0),Fe&&(he=b({},[...C]),Te=[],!0===Fe.html&&(b(he,w),b(Te,O)),!0===Fe.svg&&(b(he,D),b(Te,I),b(Te,U)),!0===Fe.svgFilters&&(b(he,L),b(Te,I),b(Te,U)),!0===Fe.mathMl&&(b(he,x),b(Te,M),b(Te,U))),e.ADD_TAGS&&(he===ge&&(he=S(he)),b(he,e.ADD_TAGS,tt)),e.ADD_ATTR&&(Te===ye&&(Te=S(Te)),b(Te,e.ADD_ATTR,tt)),e.ADD_URI_SAFE_ATTR&&b(Ge,e.ADD_URI_SAFE_ATTR,tt),e.FORBID_CONTENTS&&(He===ze&&(He=S(He)),b(He,e.FORBID_CONTENTS,tt)),Ue&&(he["#text"]=!0),De&&b(he,["html","head","body"]),he.table&&(b(he,["tbody"]),delete Ae.tbody),e.TRUSTED_TYPES_POLICY){if("function"!=typeof e.TRUSTED_TYPES_POLICY.createHTML)throw A('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof e.TRUSTED_TYPES_POLICY.createScriptURL)throw A('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');Q=e.TRUSTED_TYPES_POLICY,ee=Q.createHTML("")}else void 0===Q&&(Q=K(G,c)),null!==Q&&"string"==typeof ee&&(ee=Q.createHTML(""));i&&i(e),nt=e}},at=b({},["mi","mo","mn","ms","mtext"]),lt=b({},["foreignobject","desc","title","annotation-xml"]),ct=b({},["title","style","font","a","script"]),st=b({},D);b(st,L),b(st,v);const ut=b({},x);b(ut,k);const mt=function(e){let t=J(e);t&&t.tagName||(t={namespaceURI:Ke,tagName:"template"});const n=p(e.tagName),o=p(t.tagName);return!!$e[e.namespaceURI]&&(e.namespaceURI===qe?t.namespaceURI===Xe?"svg"===n:t.namespaceURI===je?"svg"===n&&("annotation-xml"===o||at[o]):Boolean(st[n]):e.namespaceURI===je?t.namespaceURI===Xe?"math"===n:t.namespaceURI===qe?"math"===n&<[o]:Boolean(ut[n]):e.namespaceURI===Xe?!(t.namespaceURI===qe&&!lt[o])&&(!(t.namespaceURI===je&&!at[o])&&(!ut[n]&&(ct[n]||!st[n]))):!("application/xhtml+xml"!==Je||!$e[e.namespaceURI]))},ft=function(e){f(o.removed,{element:e});try{e.parentNode.removeChild(e)}catch(t){e.remove()}},pt=function(e,t){try{f(o.removed,{attribute:t.getAttributeNode(e),from:t})}catch(e){f(o.removed,{attribute:null,from:t})}if(t.removeAttribute(e),"is"===e&&!Te[e])if(xe||ke)try{ft(t)}catch(e){}else try{t.setAttribute(e,"")}catch(e){}},dt=function(e){let t=null,n=null;if(ve)e=""+e;else{const t=h(e,/^[\r\n\t ]+/);n=t&&t[0]}"application/xhtml+xml"===Je&&Ke===Xe&&(e=''+e+"");const o=Q?Q.createHTML(e):e;if(Ke===Xe)try{t=(new B).parseFromString(o,Je)}catch(e){}if(!t||!t.documentElement){t=te.createDocument(Ke,"template",null);try{t.documentElement.innerHTML=Ve?ee:o}catch(e){}}const i=t.body||t.documentElement;return e&&n&&i.insertBefore(r.createTextNode(n),i.childNodes[0]||null),Ke===Xe?re.call(t,De?"html":"body")[0]:De?t.documentElement:i},ht=function(e){return ne.call(e.ownerDocument||e,e,F.SHOW_ELEMENT|F.SHOW_COMMENT|F.SHOW_TEXT,null)},gt=function(e){return e instanceof z&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||!(e.attributes instanceof H)||"function"!=typeof e.removeAttribute||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore||"function"!=typeof e.hasChildNodes)},Tt=function(e){return"function"==typeof N&&e instanceof N},yt=function(e,t,n){ae[e]&&u(ae[e],(e=>{e.call(o,t,n,nt)}))},Et=function(e){let t=null;if(yt("beforeSanitizeElements",e,null),gt(e))return ft(e),!0;const n=tt(e.nodeName);if(yt("uponSanitizeElement",e,{tagName:n,allowedTags:he}),e.hasChildNodes()&&!Tt(e.firstElementChild)&&E(/<[/\w]/g,e.innerHTML)&&E(/<[/\w]/g,e.textContent))return ft(e),!0;if(!he[n]||Ae[n]){if(!Ae[n]&&_t(n)){if(Ee.tagNameCheck instanceof RegExp&&E(Ee.tagNameCheck,n))return!1;if(Ee.tagNameCheck instanceof Function&&Ee.tagNameCheck(n))return!1}if(Ue&&!He[n]){const t=J(e)||e.parentNode,n=Z(e)||e.childNodes;if(n&&t){for(let o=n.length-1;o>=0;--o)t.insertBefore(V(n[o],!0),$(e))}}return ft(e),!0}return e instanceof P&&!mt(e)?(ft(e),!0):"noscript"!==n&&"noembed"!==n&&"noframes"!==n||!E(/<\/no(script|embed|frames)/i,e.innerHTML)?(we&&3===e.nodeType&&(t=e.textContent,u([le,ce,se],(e=>{t=g(t,e," ")})),e.textContent!==t&&(f(o.removed,{element:e.cloneNode()}),e.textContent=t)),yt("afterSanitizeElements",e,null),!1):(ft(e),!0)},At=function(e,t,n){if(Oe&&("id"===t||"name"===t)&&(n in r||n in ot))return!1;if(be&&!_e[t]&&E(ue,t));else if(Ne&&E(me,t));else if(!Te[t]||_e[t]){if(!(_t(e)&&(Ee.tagNameCheck instanceof RegExp&&E(Ee.tagNameCheck,e)||Ee.tagNameCheck instanceof Function&&Ee.tagNameCheck(e))&&(Ee.attributeNameCheck instanceof RegExp&&E(Ee.attributeNameCheck,t)||Ee.attributeNameCheck instanceof Function&&Ee.attributeNameCheck(t))||"is"===t&&Ee.allowCustomizedBuiltInElements&&(Ee.tagNameCheck instanceof RegExp&&E(Ee.tagNameCheck,n)||Ee.tagNameCheck instanceof Function&&Ee.tagNameCheck(n))))return!1}else if(Ge[t]);else if(E(de,g(n,pe,"")));else if("src"!==t&&"xlink:href"!==t&&"href"!==t||"script"===e||0!==T(n,"data:")||!Be[e]){if(Se&&!E(fe,g(n,pe,"")));else if(n)return!1}else;return!0},_t=function(e){return e.indexOf("-")>0},Nt=function(e){yt("beforeSanitizeAttributes",e,null);const{attributes:t}=e;if(!t)return;const n={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:Te};let r=t.length;for(;r--;){const i=t[r],{name:a,namespaceURI:l,value:c}=i,s=tt(a);let f="value"===a?c:y(c);if(n.attrName=s,n.attrValue=f,n.keepAttr=!0,n.forceKeepAttr=void 0,yt("uponSanitizeAttribute",e,n),f=n.attrValue,n.forceKeepAttr)continue;if(pt(a,e),!n.keepAttr)continue;if(!Re&&E(/\/>/i,f)){pt(a,e);continue}we&&u([le,ce,se],(e=>{f=g(f,e," ")}));const p=tt(e.nodeName);if(At(p,s,f)){if(!Ie||"id"!==s&&"name"!==s||(pt(a,e),f=Me+f),Q&&"object"==typeof G&&"function"==typeof G.getAttributeType)if(l);else switch(G.getAttributeType(p,s)){case"TrustedHTML":f=Q.createHTML(f);break;case"TrustedScriptURL":f=Q.createScriptURL(f)}try{l?e.setAttributeNS(l,a,f):e.setAttribute(a,f),m(o.removed)}catch(e){}}}yt("afterSanitizeAttributes",e,null)},bt=function e(t){let n=null;const o=ht(t);for(yt("beforeSanitizeShadowDOM",t,null);n=o.nextNode();)yt("uponSanitizeShadowNode",n,null),Et(n)||(n.content instanceof s&&e(n.content),Nt(n));yt("afterSanitizeShadowDOM",t,null)};return o.sanitize=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null,r=null,i=null,l=null;if(Ve=!e,Ve&&(e="\x3c!--\x3e"),"string"!=typeof e&&!Tt(e)){if("function"!=typeof e.toString)throw A("toString is not a function");if("string"!=typeof(e=e.toString()))throw A("dirty is not a string, aborting")}if(!o.isSupported)return e;if(Le||it(t),o.removed=[],"string"==typeof e&&(Pe=!1),Pe){if(e.nodeName){const t=tt(e.nodeName);if(!he[t]||Ae[t])throw A("root node is forbidden and cannot be sanitized in-place")}}else if(e instanceof N)n=dt("\x3c!----\x3e"),r=n.ownerDocument.importNode(e,!0),1===r.nodeType&&"BODY"===r.nodeName||"HTML"===r.nodeName?n=r:n.appendChild(r);else{if(!xe&&!we&&!De&&-1===e.indexOf("<"))return Q&&Ce?Q.createHTML(e):e;if(n=dt(e),!n)return xe?null:Ce?ee:""}n&&ve&&ft(n.firstChild);const c=ht(Pe?e:n);for(;i=c.nextNode();)Et(i)||(i.content instanceof s&&bt(i.content),Nt(i));if(Pe)return e;if(xe){if(ke)for(l=oe.call(n.ownerDocument);n.firstChild;)l.appendChild(n.firstChild);else l=n;return(Te.shadowroot||Te.shadowrootmode)&&(l=ie.call(a,l,!0)),l}let m=De?n.outerHTML:n.innerHTML;return De&&he["!doctype"]&&n.ownerDocument&&n.ownerDocument.doctype&&n.ownerDocument.doctype.name&&E(j,n.ownerDocument.doctype.name)&&(m="\n"+m),we&&u([le,ce,se],(e=>{m=g(m,e," ")})),Q&&Ce?Q.createHTML(m):m},o.setConfig=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};it(e),Le=!0},o.clearConfig=function(){nt=null,Le=!1},o.isValidAttribute=function(e,t,n){nt||it({});const o=tt(e),r=tt(t);return At(o,r,n)},o.addHook=function(e,t){"function"==typeof t&&(ae[e]=ae[e]||[],f(ae[e],t))},o.removeHook=function(e){if(ae[e])return m(ae[e])},o.removeHooks=function(e){ae[e]&&(ae[e]=[])},o.removeAllHooks=function(){ae={}},o}();return V})); +//# sourceMappingURL=purify.min.js.map diff --git a/test/nbrowser/FormView.ts b/test/nbrowser/FormView.ts new file mode 100644 index 00000000..c61288fd --- /dev/null +++ b/test/nbrowser/FormView.ts @@ -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, + 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; +} diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 831749d9..9082c42c 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -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 diff --git a/test/nbrowser/gristWebDriverUtils.ts b/test/nbrowser/gristWebDriverUtils.ts index 2be86a6e..5130dc6d 100644 --- a/test/nbrowser/gristWebDriverUtils.ts +++ b/test/nbrowser/gristWebDriverUtils.ts @@ -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) { diff --git a/yarn.lock b/yarn.lock index 79b5916c..13e4dd09 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"