import BaseView from 'app/client/components/BaseView'; import * as commands from 'app/client/components/commands'; import {Cursor} from 'app/client/components/Cursor'; import {FormLayoutNode, FormLayoutNodeType, patchLayoutSpec} from 'app/client/components/FormRenderer'; import * as components from 'app/client/components/Forms/elements'; import {NewBox} from 'app/client/components/Forms/Menu'; import {BoxModel, 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 {copyToClipboard} from 'app/client/lib/clipboardUtils'; import {Disposable} from 'app/client/lib/dispose'; import {AsyncComputed, makeTestId, stopEvent} from 'app/client/lib/domUtils'; import {makeT} from 'app/client/lib/localization'; import {localStorageBoolObs} from 'app/client/lib/localStorageObs'; import {logTelemetryEvent} from 'app/client/lib/telemetry'; import DataTableModel from 'app/client/models/DataTableModel'; import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel'; import {reportError} from 'app/client/models/errors'; import {jsonObservable, SaveableObjObservable} from 'app/client/models/modelUtil'; import {ShareRec} from 'app/client/models/entities/ShareRec'; import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec'; import {docUrl, urlState} from 'app/client/models/gristUrlState'; import {SortedRowSet} from 'app/client/models/rowset'; import {hoverTooltip, showTransientTooltip} from 'app/client/ui/tooltips'; import {cssButton} from 'app/client/ui2018/buttons'; import {icon} from 'app/client/ui2018/icons'; import {loadingSpinner} from 'app/client/ui2018/loaders'; import {menuCssClass} from 'app/client/ui2018/menus'; import {confirmModal} from 'app/client/ui2018/modals'; import {INITIAL_FIELDS_COUNT} from 'app/common/Forms'; import {isOwner} from 'app/common/roles'; import {Events as BackboneEvents} from 'backbone'; import {Computed, dom, Holder, IDomArgs, MultiHolder, Observable} from 'grainjs'; import defaults from 'lodash/defaults'; import isEqual from 'lodash/isEqual'; import {v4 as uuidv4} from 'uuid'; import * as ko from 'knockout'; import {defaultMenuOptions, IOpenController, setPopupToCreateDom} from 'popweasel'; const t = makeT('FormView'); const testId = makeTestId('test-forms-'); export class FormView extends Disposable { public viewPane: HTMLElement; public gristDoc: GristDoc; public viewSection: ViewSectionRec; public selectedBox: Computed; public selectedColumns: ko.Computed|null; public disableDeleteSection: Computed; protected sortedRows: SortedRowSet; protected tableModel: DataTableModel; protected cursor: Cursor; protected menuHolder: Holder; protected bundle: (clb: () => Promise) => Promise; private _formFields: Computed; private _layoutSpec: SaveableObjObservable; private _layout: Computed; private _root: BoxModel; private _savedLayout: any; private _saving: boolean = false; private _previewUrl: Computed; private _pageShare: Computed; private _remoteShare: AsyncComputed<{key: string}|null>; private _isFork: Computed; private _published: Computed; private _showPublishedMessage: Observable; private _isOwner: boolean; private _openingForm: Observable; private _formEditorBodyElement: HTMLElement; public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) { BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false}); this.menuHolder = Holder.create(this); // We will store selected box here. const selectedBox = Observable.create(this, null as BoxModel|null); // But we will guard it with a computed, so that if box is disposed we will clear it. this.selectedBox = Computed.create(this, use => use(selectedBox)); // Prepare scope for the method calls. const holder = Holder.create(this); this.selectedBox.onWrite((box) => { // Create new scope and dispose the previous one (using holder). const scope = MultiHolder.create(holder); if (!box) { selectedBox.set(null); return; } if (box.isDisposed()) { throw new Error('Box is disposed'); } selectedBox.set(box); // Now subscribe to the new box, if it is disposed, remove it from the selected box. // Note that the dispose listener itself is disposed when the box is switched as we don't // care anymore for this event if the box is switched. scope.autoDispose(box.onDispose(() => { if (selectedBox.get() === box) { selectedBox.set(null); } })); }); this.bundle = (clb) => this.gristDoc.docData.bundleActions('Saving form layout', clb, {nestInActiveBundle: true}); 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.selectedColumns = this.autoDispose(ko.pureComputed(() => { const result = this.viewSection.viewFields().all().filter((field, index) => { // During column removal or restoring (with undo), some columns fields // might be disposed. if (field.isDisposed() || field.column().isDisposed()) { return false; } return this.cursor.currentPosition().fieldIndex === index; }); return result; })); // Wire up selected fields to the cursor. this.autoDispose(this.selectedColumns.subscribe((columns) => { this.viewSection.selectedFields(columns); })); this.viewSection.selectedFields(this.selectedColumns.peek()); this._formFields = Computed.create(this, use => { const fields = use(use(this.viewSection.viewFields).getObservable()); return fields.filter(f => { const column = use(f.column); return ( use(column.pureType) !== 'Attachments' && !(use(column.isRealFormula) && !use(column.colId).startsWith('gristHelper_Transform')) ); }); }); this._layoutSpec = jsonObservable(this.viewSection.layoutSpec, (layoutSpec: FormLayoutNode|null) => { return layoutSpec ?? buildDefaultFormLayout(this._formFields.get()); }); this._layout = Computed.create(this, use => { const fields = use(this._formFields); const layoutSpec = use(this._layoutSpec); const patchedLayout = patchLayoutSpec(layoutSpec, new Set(fields.map(f => f.id()))); if (!patchedLayout) { throw new Error('Invalid form layout spec'); } return patchedLayout; }); this._root = this.autoDispose(new LayoutModel(this._layout.get(), null, async (clb?: () => Promise) => { await this.bundle(async () => { if (clb) { await clb(); } await this.save(); }); }, this)); this._layout.addListener((v) => { if (this._saving) { console.warn('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; } selected.copySelf().catch(reportError); }, cut: () => { const selected = this.selectedBox.get(); if (!selected) { return; } selected.cutSelf().catch(reportError); }, paste: () => { const doPaste = 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)); } const maybeCutBox = this._root.find(boxInClipboard.id); if (maybeCutBox?.cut.get()) { maybeCutBox.removeSelf(); } await this._root.save(); await navigator.clipboard.writeText(''); }; doPaste().catch(reportError); }, nextField: () => { const current = this.selectedBox.get(); const all = [...this._root.traverse()]; 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.traverse()]; 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.traverse()]; if (!all.length) { return; } this.selectedBox.set(all[all.length - 1]); }, firstField: () => { const all = [...this._root.traverse()]; 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 || selected.canRemove?.() === false) { return; } keyboardActions.nextField(); this.bundle(async () => { await selected.deleteSelf(); }).catch(reportError); }, hideFields: (colId: [string]) => { // Get the ref from colId. const existing: Array<[number, string]> = this.viewSection.viewFields().all().map(f => [f.id(), f.column().colId()]); const ref = existing.filter(([_, c]) => colId.includes(c)).map(([r, _]) => r); if (!ref.length) { return; } const box = Array.from(this._root.filter(b => ref.includes(b.prop('leaf')?.get()))); box.forEach(b => b.removeSelf()); this._root.save(async () => { await this.viewSection.removeField(ref); }).catch(reportError); }, insertFieldBefore: (what: NewBox) => { const selected = this.selectedBox.get(); if (!selected) { return; } if ('add' in what || 'show' in what) { this.addNewQuestion(selected.placeBeforeMe(), what).catch(reportError); } else { selected.insertBefore(components.defaultElement(what.structure)); this.save().catch(reportError); } }, insertField: (what: NewBox) => { const selected = this.selectedBox.get(); if (!selected) { return; } const place = selected.placeAfterListChild(); if ('add' in what || 'show' in what) { this.addNewQuestion(place, what).catch(reportError); } else { place(components.defaultElement(what.structure)); this.save().catch(reportError); } }, insertFieldAfter: (what: NewBox) => { const selected = this.selectedBox.get(); if (!selected) { return; } if ('add' in what || 'show' in what) { this.addNewQuestion(selected.placeAfterMe(), what).catch(reportError); } else { selected.insertAfter(components.defaultElement(what.structure)); this.save().catch(reportError); } }, showColumns: (colIds: string[]) => { // Sanity check that type is correct. if (!colIds.every(c => typeof c === 'string')) { throw new Error('Invalid column id'); } this._root.save(async () => { const boxes: FormLayoutNode[] = []; 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 = { id: uuidv4(), leaf: fieldRef, type: 'Field' as FormLayoutNodeType, }; boxes.push(box); } // Add to selected or last section, or root. const selected = this.selectedBox.get(); if (selected instanceof components.SectionModel) { boxes.forEach(b => selected.append(b)); } else { const topLevel = this._root.kids().reverse().find(b => b instanceof components.SectionModel); if (topLevel) { boxes.forEach(b => topLevel.append(b)); } else { boxes.forEach(b => this._root.append(b)); } } }).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, hideFields: keyboardActions.hideFields, }, this, this.viewSection.hasFocus)); this._previewUrl = Computed.create(this, use => { const doc = use(this.gristDoc.docPageModel.currentDoc); if (!doc) { return ''; } const url = urlState().makeUrl({ ...docUrl(doc), form: { vsId: use(this.viewSection.id), }, }); return url; }); this._pageShare = Computed.create(this, use => { const page = use(use(this.viewSection.view).page); if (!page) { return null; } return use(page.share); }); this._remoteShare = AsyncComputed.create(this, async (use) => { const share = use(this._pageShare); if (!share) { return null; } try { const remoteShare = await this.gristDoc.docComm.getShare(use(share.linkId)); return remoteShare ?? null; } catch(ex) { // TODO: for now ignore the error, but the UI should be updated to not show editor // for non owners. if (ex.code === 'AUTH_NO_OWNER') { return null; } throw ex; } }); this._isFork = Computed.create(this, use => { const {docPageModel} = this.gristDoc; return use(docPageModel.isFork) || use(docPageModel.isPrefork); }); this._published = Computed.create(this, use => { const isFork = use(this._isFork); if (isFork) { return false; } const pageShare = use(this._pageShare); const remoteShare = use(this._remoteShare) || use(this._remoteShare.dirty); const validShare = pageShare && remoteShare; if (!validShare) { return false; } return use(pageShare.optionsObj.prop('publish')) && use(this.viewSection.shareOptionsObj.prop('publish')); }); const userId = this.gristDoc.app.topAppModel.appObs.get()?.currentUser?.id || 0; this._showPublishedMessage = this.autoDispose(localStorageBoolObs( `u:${userId};d:${this.gristDoc.docId()};vs:${this.viewSection.id()};formShowPublishedMessage`, true )); this._isOwner = isOwner(this.gristDoc.docPageModel.currentDoc.get()); this._openingForm = Observable.create(this, false); // 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 style.cssFormView( testId('editor'), this._formEditorBodyElement = style.cssFormEditBody( style.cssFormContainer( dom('div', dom.forEach(this._root.children, (child) => { if (!child) { return dom('div', 'Empty node'); } const element = child.render(); if (!(element instanceof Node)) { throw new Error('Element is not an HTMLElement'); } return element; })), ), ), this._buildPublisher(), dom.on('click', () => this.selectedBox.set(null)), dom.maybe(this.gristDoc.docPageModel.isReadonly, () => style.cssFormDisabledOverlay()), ); } public buildOverlay(...args: IDomArgs) { return style.cssSelectedOverlay( ...args, ); } public async addNewQuestion(insert: Place, action: {add: string}|{show: string}) { await this.gristDoc.docData.bundleActions(`Saving form layout`, async () => { // First save the layout, so that we don't have autogenerated layout. await this.save(); // Now that the layout is saved, we won't be bothered with autogenerated layout, // and we can safely insert to column. let fieldRef = 0; if ('show' in action) { fieldRef = await this.showColumn(action.show); } else { const result = await this.insertColumn(null, { colInfo: { type: action.add, } }); fieldRef = result.fieldRef; } // And add it into the layout. this.selectedBox.set(insert({ id: uuidv4(), leaf: fieldRef, type: 'Field' })); await this._root.save(); }, {nestInActiveBundle: true}); } public async save() { 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._layoutSpec.setAndSave(newVersion); } finally { this._saving = false; } } private async _handleClickPublish() { if (this.gristDoc.appModel.dismissedPopups.get().includes('publishForm')) { await this._publishForm(); } else { confirmModal(t('Publish your form?'), t('Publish'), async (dontShowAgain) => { await this._publishForm(); if (dontShowAgain) { this.gristDoc.appModel.dismissPopup('publishForm', true); } }, { explanation: ( dom('div', style.cssParagraph( t( 'Publishing your form will generate a share link. Anyone with the link can ' + 'see the empty form and submit a response.' ), ), style.cssParagraph( t( 'Users are limited to submitting ' + 'entries (records in your table) and reading pre-set values in designated ' + 'fields, such as reference and choice columns.' ), ), ) ), hideDontShowAgain: false, }, ); } } private async _publishForm() { const page = this.viewSection.view().page(); if (!page) { throw new Error('Unable to publish form: undefined page'); } let validShare = page.shareRef() !== 0; // If page is shared, make sure home server is aware of it. if (validShare) { try { const pageShare = page.share(); const serverShare = await this.gristDoc.docComm.getShare(pageShare.linkId()); validShare = !!serverShare; } catch(ex) { // TODO: for now ignore the error, but the UI should be updated to not show editor if (ex.code === 'AUTH_NO_OWNER') { return; } throw ex; } } logTelemetryEvent('publishedForm', { full: { docIdDigest: this.gristDoc.docId(), }, }); await this.gristDoc.docModel.docData.bundleActions('Publish form', async () => { if (!validShare) { const shareRef = await this.gristDoc.docModel.docData.sendAction([ 'AddRecord', '_grist_Shares', null, { linkId: uuidv4(), options: JSON.stringify({ publish: true, }), } ]); await this.gristDoc.docModel.docData.sendAction(['UpdateRecord', '_grist_Pages', page.id(), {shareRef}]); } else { const share = page.share(); share.optionsObj.update({publish: true}); await share.optionsObj.save(); } await this.save(); this.viewSection.shareOptionsObj.update({ form: true, publish: true, }); await this.viewSection.shareOptionsObj.save(); }); } private async _handleClickUnpublish() { if (this.gristDoc.appModel.dismissedPopups.get().includes('unpublishForm')) { await this._unpublishForm(); } else { confirmModal(t('Unpublish your form?'), t('Unpublish'), async (dontShowAgain) => { await this._unpublishForm(); if (dontShowAgain) { this.gristDoc.appModel.dismissPopup('unpublishForm', true); } }, { explanation: ( dom('div', style.cssParagraph( t( 'Unpublishing the form will disable the share link so that users accessing ' + 'your form via that link will see an error.' ), ), ) ), hideDontShowAgain: false, }, ); } } private async _unpublishForm() { logTelemetryEvent('unpublishedForm', { full: { docIdDigest: this.gristDoc.docId(), }, }); await this.gristDoc.docModel.docData.bundleActions('Unpublish form', async () => { this.viewSection.shareOptionsObj.update({ publish: false, }); await this.viewSection.shareOptionsObj.save(); const view = this.viewSection.view(); if (view.viewSections().peek().every(vs => !vs.shareOptionsObj.prop('publish')())) { const share = this._pageShare.get(); if (!share) { return; } share.optionsObj.update({ publish: false, }); await share.optionsObj.save(); } }); } private _buildPublisher() { return style.cssSwitcher( this._buildNotifications(), style.cssButtonGroup( style.cssSmallButton( style.cssSmallButton.cls('-frameless'), icon('Revert'), testId('reset'), dom('div', t('Reset form')), dom.style('visibility', use => use(this._published) ? 'hidden' : 'visible'), dom.style('margin-right', 'auto'), // move it to the left dom.on('click', () => { return confirmModal(t('Are you sure you want to reset your form?'), t('Reset'), () => this._resetForm(), ); }) ), dom.domComputed(this._published, published => { if (published) { return style.cssSmallButton( testId('view'), icon('EyeShow'), t('View'), dom.boolAttr('disabled', this._openingForm), dom.on('click', async (ev) => { // If this form is not yet saved, we will save it first. if (!this._savedLayout) { await this.save(); } try { this._openingForm.set(true); window.open(await this._getFormUrl()); } finally { this._openingForm.set(false); } }) ); } else { return style.cssSmallLinkButton( testId('preview'), icon('EyeShow'), t('Preview'), dom.attr('href', this._previewUrl), dom.prop('target', '_blank'), dom.on('click', async (ev) => { // If this form is not yet saved, we will save it first. if (!this._savedLayout) { stopEvent(ev); await this.save(); window.open(this._previewUrl.get()); } }) ); } }), style.cssSmallButton( icon('Share'), testId('share'), dom('div', t('Share')), dom.show(use => this._isOwner && use(this._published)), elem => { setPopupToCreateDom(elem, ctl => this._buildShareMenu(ctl), { ...defaultMenuOptions, placement: 'top-end', }); }, ), dom.domComputed(use => { const isFork = use(this._isFork); const published = use(this._published); return published ? style.cssSmallButton( dom('div', t('Unpublish')), dom.show(this._isOwner), style.cssSmallButton.cls('-warning'), dom.on('click', () => this._handleClickUnpublish()), testId('unpublish'), ) : style.cssSmallButton( dom('div', t('Publish')), dom.boolAttr('disabled', isFork), !isFork ? null : hoverTooltip(t('Save your document to publish this form.'), { placement: 'top', }), dom.show(this._isOwner), cssButton.cls('-primary'), dom.on('click', () => this._handleClickPublish()), testId('publish'), ); }), ), ); } private async _getFormUrl() { const share = this._pageShare.get(); if (!share) { throw new Error('Unable to get form link: form is not published'); } const remoteShare = await this.gristDoc.docComm.getShare(share.linkId()); if (!remoteShare) { throw new Error('Unable to get form link: form is not published'); } return urlState().makeUrl({ doc: undefined, form: { shareKey: remoteShare.key, vsId: this.viewSection.id(), }, }); } private _buildShareMenu(ctl: IOpenController) { const formUrl = Observable.create(ctl, null); const showEmbedCode = Observable.create(this, false); const embedCode = Computed.create(ctl, formUrl, (_use, url) => { if (!url) { return null; } return '`; }); // Reposition the popup when its height changes. ctl.autoDispose(formUrl.addListener(() => ctl.update())); ctl.autoDispose(showEmbedCode.addListener(() => ctl.update())); this._getFormUrl() .then((url) => { if (ctl.isDisposed()) { return; } formUrl.set(url); }) .catch((e) => { ctl.close(); reportError(e); }); return style.cssShareMenu( dom.cls(menuCssClass), style.cssShareMenuHeader( style.cssShareMenuCloseButton( icon('CrossBig'), dom.on('click', () => ctl.close()), ), ), style.cssShareMenuBody( dom.domComputed(use => { const url = use(formUrl); const code = use(embedCode); if (!url || !code) { return style.cssShareMenuSpinner(loadingSpinner()); } return [ dom('div', style.cssShareMenuSectionHeading( t('Share this form'), ), dom('div', style.cssShareMenuHintText( t('Anyone with the link below can see the empty form and submit a response.'), ), style.cssShareMenuUrlBlock( style.cssShareMenuUrl( {readonly: true, value: url}, dom.on('click', (_ev, el) => { setTimeout(() => el.select(), 0); }), ), style.cssShareMenuCopyButton( testId('link'), t('Copy link'), dom.on('click', async (_ev, el) => { await copyToClipboard(url); showTransientTooltip( el, t('Link copied to clipboard'), {key: 'share-form-menu'} ); }) ), ), ), ), dom.domComputed(showEmbedCode, (showCode) => { if (!showCode) { return dom('div', style.cssShareMenuEmbedFormButton( t('Embed this form'), dom.on('click', () => showEmbedCode.set(true)), ) ); } else { return dom('div', style.cssShareMenuSectionHeading(t('Embed this form')), dom.maybe(showEmbedCode, () => style.cssShareMenuCodeBlock( style.cssShareMenuCode( code, {readonly: true, rows: '3'}, dom.on('click', (_ev, el) => { setTimeout(() => el.select(), 0); }), ), style.cssShareMenuCodeBlockButtons( style.cssShareMenuCopyButton( testId('code'), t('Copy code'), dom.on('click', async (_ev, el) => { await copyToClipboard(code); showTransientTooltip( el, t('Code copied to clipboard'), {key: 'share-form-menu'} ); }), ), ), )), ); } }), ]; }), ), ); } private _getSectionCount() { return [...this._root.filter(box => box.type === 'Section')].length; } private _getEstimatedFormHeightPx() { return ( // Form height. this._formEditorBodyElement.scrollHeight + // Minus "+" button height in each section. (-32 * this._getSectionCount()) + // Plus form footer height (visible only in the preview and published form). 64 ); } private _buildNotifications() { return [ this._buildFormPublishedNotification(), ]; } private _buildFormPublishedNotification() { return dom.maybe(use => use(this._published) && use(this._showPublishedMessage), () => { return style.cssSwitcherMessage( style.cssSwitcherMessageBody( t( 'Your form is published. Every change is live and visible to users ' + 'with access to the form. If you want to make changes in draft, unpublish the form.' ), ), style.cssSwitcherMessageDismissButton( icon('CrossSmall'), dom.on('click', () => { this._showPublishedMessage.set(false); }), ), dom.show(this._isOwner), ); }); } private async _resetForm() { this.selectedBox.set(null); await this.gristDoc.docData.bundleActions('Reset form', async () => { // First we will remove all fields from this section, and add top 9 back. const toDelete = this.viewSection.viewFields().all().map(f => f.getRowId()); const toAdd = this.viewSection.table().columns().peek() .filter(c => c.isFormCol()) .sort((a, b) => a.parentPos() - b.parentPos()); const colRef = toAdd.slice(0, INITIAL_FIELDS_COUNT).map(c => c.id()); const parentId = colRef.map(() => this.viewSection.id()); const parentPos = colRef.map((_, i) => i + 1); const ids = colRef.map(() => null); await this.gristDoc.docData.sendActions([ ['BulkRemoveRecord', '_grist_Views_section_field', toDelete], ['BulkAddRecord', '_grist_Views_section_field', ids, { colRef, parentId, parentPos, }], ]); const fields = this.viewSection.viewFields().all().slice(0, 9); await this._layoutSpec.setAndSave(buildDefaultFormLayout(fields)); }); } } /** * Generates a default form layout based on the fields in the view section. */ export function buildDefaultFormLayout(fields: ViewFieldRec[]): FormLayoutNode { const boxes: FormLayoutNode[] = fields.map(f => { return { id: uuidv4(), type: 'Field', leaf: f.id(), }; }); const section = components.Section(...boxes); return { id: uuidv4(), type: 'Layout', children: [ {id: uuidv4(), type: 'Paragraph', text: FORM_TITLE, alignment: 'center', }, {id: uuidv4(), type: 'Paragraph', text: FORM_DESC, alignment: 'center', }, section, {id: uuidv4(), type: 'Submit'}, ], }; } // 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); // Default values when form is reset. const FORM_TITLE = "## **Form Title**"; const FORM_DESC = "Your form description goes here.";