From e12471347bb323908dbdf6e5908ee6feee0f9551 Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Fri, 12 Jan 2024 09:35:24 -0800 Subject: [PATCH] (core) Form Publishing Summary: Adds initial implementation of form publishing, built upon WYSIWYS shares. A simple UI for publishing and unpublishing forms is included. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Subscribers: paulfitz, jarek Differential Revision: https://phab.getgrist.com/D4154 --- app/client/components/DocComm.ts | 1 + app/client/components/Forms/FormView.ts | 260 +++++++++++++++++-- app/client/components/Forms/styles.ts | 80 ++++-- app/client/lib/domUtils.ts | 52 +++- app/client/models/DocModel.ts | 8 +- app/client/models/entities/PageRec.ts | 3 + app/client/models/entities/ShareRec.ts | 10 + app/client/models/entities/ViewRec.ts | 9 +- app/client/models/entities/ViewSectionRec.ts | 2 + app/common/ActiveDocAPI.ts | 19 +- app/common/UserAPI.ts | 38 ++- app/gen-server/lib/HomeDBManager.ts | 24 +- app/server/lib/ActiveDoc.ts | 27 +- app/server/lib/AppEndpoint.ts | 14 + app/server/lib/DocApi.ts | 97 ++++++- app/server/lib/DocWorker.ts | 3 +- test/nbrowser/FormView.ts | 72 ++++- 17 files changed, 634 insertions(+), 85 deletions(-) create mode 100644 app/client/models/entities/ShareRec.ts diff --git a/app/client/components/DocComm.ts b/app/client/components/DocComm.ts index fc211c24..444773b9 100644 --- a/app/client/components/DocComm.ts +++ b/app/client/components/DocComm.ts @@ -47,6 +47,7 @@ export class DocComm extends Disposable implements ActiveDocAPI { public waitForInitialization = this._wrapMethod("waitForInitialization"); public getUsersForViewAs = this._wrapMethod("getUsersForViewAs"); public getAccessToken = this._wrapMethod("getAccessToken"); + public getShare = this._wrapMethod("getShare"); public changeUrlIdEmitter = this.autoDispose(new Emitter()); diff --git a/app/client/components/Forms/FormView.ts b/app/client/components/Forms/FormView.ts index aa2fbb08..79d14e56 100644 --- a/app/client/components/Forms/FormView.ts +++ b/app/client/components/Forms/FormView.ts @@ -6,17 +6,23 @@ 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 {copyToClipboard} from 'app/client/lib/clipboardUtils'; import {Disposable} from 'app/client/lib/dispose'; -import {makeTestId} from 'app/client/lib/domUtils'; +import {AsyncComputed, makeTestId} from 'app/client/lib/domUtils'; import {FocusLayer} from 'app/client/lib/FocusLayer'; +import {makeT} from 'app/client/lib/localization'; +import {localStorageBoolObs} from 'app/client/lib/localStorageObs'; import DataTableModel from 'app/client/models/DataTableModel'; import {ViewSectionRec} from 'app/client/models/DocModel'; +import {ShareRec} from 'app/client/models/entities/ShareRec'; 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 {showTransientTooltip} from 'app/client/ui/tooltips'; import {cssButton} from 'app/client/ui2018/buttons'; import {icon} from 'app/client/ui2018/icons'; import * as menus from 'app/client/ui2018/menus'; +import {confirmModal} from 'app/client/ui2018/modals'; import {not} from 'app/common/gutil'; import {Events as BackboneEvents} from 'backbone'; import {Computed, dom, Holder, IDisposableOwner, IDomArgs, MultiHolder, Observable} from 'grainjs'; @@ -24,6 +30,8 @@ import defaults from 'lodash/defaults'; import isEqual from 'lodash/isEqual'; import {v4 as uuidv4} from 'uuid'; +const t = makeT('FormView'); + const testId = makeTestId('test-forms-'); export class FormView extends Disposable { @@ -44,6 +52,11 @@ export class FormView extends Disposable { private _savedLayout: any; private _saving: boolean = false; private _url: Computed; + private _copyingLink: Observable; + private _pageShare: Computed; + private _remoteShare: AsyncComputed<{key: string}|null>; + private _published: Computed; + private _showPublishedMessage: Observable; public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) { BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false}); @@ -242,10 +255,44 @@ export class FormView extends Disposable { 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)); + const url = this.gristDoc.app.topAppModel.api.formUrl({ + urlId: doc.id, + vsId: use(this.viewSection.id), + }); return url; }); + this._copyingLink = Observable.create(this, false); + + 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; } + const remoteShare = await this.gristDoc.docComm.getShare(use(share.linkId)); + return remoteShare ?? null; + }); + + this._published = Computed.create(this, use => { + 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 + )); + // Last line, build the dom. this.viewPane = this.autoDispose(this.buildDom()); } @@ -260,13 +307,12 @@ export class FormView extends Disposable { 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, () => [ + dom.maybe(this.isEdit, () => style.cssFormEditBody( style.cssFormContainer( dom.forEach(this._root.children, (child) => { if (!child) { @@ -285,12 +331,13 @@ export class FormView extends Disposable { }), this.buildDropzone(this, this._root.placeAfterListChild()), ), - ]), + )), dom.maybe(not(this.isEdit), () => [ style.cssPreview( dom.prop('src', this._url), ) ]), + this._buildSwitcher(), dom.on('click', () => this.selectedBox.set(null)) ); } @@ -639,6 +686,100 @@ export class FormView extends Disposable { } } + private async _publish() { + confirmModal(t('Publish your form?'), + t('Publish'), + async () => { + await this.gristDoc.docModel.docData.bundleActions('Publish form', async () => { + const page = this.viewSection.view().page(); + if (!page) { + throw new Error('Unable to publish form: undefined page'); + } + + if (page.shareRef() === 0) { + 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(); + } + + this.viewSection.shareOptionsObj.update({ + form: true, + publish: true, + }); + await this.viewSection.shareOptionsObj.save(); + }); + }, + { + 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.' + ), + ), + ) + ), + }, + ); + } + + private async _unpublish() { + confirmModal(t('Unpublish your form?'), + t('Unpublish'), + async () => { + 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(); + } + }); + }, + { + 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.' + ), + ), + ) + ), + }, + ); + } private _buildSwitcher() { const toggle = (val: boolean) => () => { @@ -646,32 +787,95 @@ export class FormView extends Disposable { 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'} + return style.cssSwitcher( + this._buildSwitcherMessage(), + 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.cssIconButton( + icon('FieldAttachment'), + testId('link'), + dom('div', 'Copy Link'), + dom.prop('disabled', this._copyingLink), + dom.show(use => this.gristDoc.appModel.isOwner() && use(this._published)), + dom.on('click', async (_event, element) => { + try { + this._copyingLink.set(true); + const share = this._pageShare.get(); + if (!share) { + throw new Error('Unable to copy link: form is not published'); + } + + const remoteShare = await this.gristDoc.docComm.getShare(share.linkId()); + if (!remoteShare) { + throw new Error('Unable to copy link: form is not published'); + } + + const url = this.gristDoc.app.topAppModel.api.formUrl({ + shareKey:remoteShare.key, + vsId: this.viewSection.id(), + }); + await copyToClipboard(url); + showTransientTooltip(element, 'Link copied to clipboard', {key: 'copy-form-link'}); + } finally { + this._copyingLink.set(false); + } + }), + ), + dom.domComputed(this._published, published => { + return published + ? style.cssIconButton( + dom('div', 'Unpublish'), + dom.show(this.gristDoc.appModel.isOwner()), + style.cssIconButton.cls('-warning'), + dom.on('click', () => this._unpublish()), + testId('unpublish'), + ) + : style.cssIconButton( + dom('div', 'Publish'), + dom.show(this.gristDoc.appModel.isOwner()), + cssButton.cls('-primary'), + dom.on('click', () => this._publish()), + testId('publish'), + ); + }), ), ); } + + private _buildSwitcherMessage() { + 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.gristDoc.appModel.isOwner()), + ); + }); + } } // Getting an ES6 class to work with old-style multiple base classes takes a little hacking. Credits: ./ChartView.ts diff --git a/app/client/components/Forms/styles.ts b/app/client/components/Forms/styles.ts index f38c194e..9274d2dc 100644 --- a/app/client/components/Forms/styles.ts +++ b/app/client/components/Forms/styles.ts @@ -1,5 +1,5 @@ import {sanitizeHTML} from 'app/client/ui/sanitizeHTML'; -import {basicButton, basicButtonLink} from 'app/client/ui2018/buttons'; +import {basicButton} 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'; @@ -16,10 +16,16 @@ export { cssAddText, cssFormContainer, cssFormEdit, + cssFormEditBody, cssSection, cssStaticText, }; +const cssFormEditBody = styled('div', ` + width: 100%; + overflow: auto; + padding-top: 52px; +`); const cssFormEdit = styled('div', ` color: ${theme.text}; @@ -28,12 +34,12 @@ const cssFormEdit = styled('div', ` flex-direction: column; flex-basis: 0px; align-items: center; - padding-top: 52px; + justify-content: space-between; position: relative; - padding-bottom: 32px; --section-background: #739bc31f; /* On white background this is almost #f1f5f9 (slate-100 on tailwind palette) */ &, &-preview { + background-color: ${theme.leftPanelBg}; overflow: auto; min-height: 100%; width: 100%; @@ -42,7 +48,6 @@ const cssFormEdit = styled('div', ` } `); - const cssLabel = styled('label', ` font-size: 15px; font-weight: normal; @@ -288,17 +293,17 @@ const cssFormContainer = styled('div', ` border-radius: 8px; display: flex; flex-direction: column; + flex-grow: 1; 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; + justify-content: flex-end; + flex-wrap: wrap; + padding: 0px 24px 0px 24px; + margin-bottom: 16px; gap: 8px; `); @@ -308,19 +313,21 @@ export const cssIconButton = styled(basicButton, ` display: flex; align-items: center; gap: 4px; + min-height: 24px; &-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}; + &-warning { + color: ${theme.controlPrimaryFg}; + background-color: ${theme.toastWarningBg}; + border: none; + } + &-warning:hover { + color: ${theme.controlPrimaryFg}; + background-color: #B8791B; + border: none; + } `); const cssStaticText = styled('div', ` @@ -348,3 +355,40 @@ export const cssPreview = styled('iframe', ` height: 100%; border: 0px; `); + +export const cssSwitcher = styled('div', ` + flex-shrink: 0; + margin-top: 24px; + width: 100%; +`); + +export const cssSwitcherMessage = styled('div', ` + display: flex; + padding: 0px 16px 0px 16px; + margin-bottom: 16px; +`); + +export const cssSwitcherMessageBody = styled('div', ` + flex-grow: 1; + display: flex; + justify-content: center; + align-items: center; + padding: 0px 32px 0px 32px; +`); + +export const cssSwitcherMessageDismissButton = styled('div', ` + align-self: flex-start; + flex-shrink: 0; + padding: 0px; + border-radius: 4px; + cursor: pointer; + --icon-color: ${theme.controlSecondaryFg}; + + &:hover { + background-color: ${theme.hover}; + } +`); + +export const cssParagraph = styled('div', ` + margin-bottom: 16px; +`); diff --git a/app/client/lib/domUtils.ts b/app/client/lib/domUtils.ts index 9bddfe53..2d46e324 100644 --- a/app/client/lib/domUtils.ts +++ b/app/client/lib/domUtils.ts @@ -1,5 +1,5 @@ import {useBindable} from 'app/common/gutil'; -import {BindableValue, dom} from 'grainjs'; +import {BindableValue, Computed, dom, IDisposableOwner, Observable, UseCB} from 'grainjs'; /** * Version of makeTestId that can be appended conditionally. @@ -14,3 +14,53 @@ export function makeTestId(prefix: string) { }); }; } + +export function autoFocus() { + return (el: HTMLElement) => void setTimeout(() => el.focus(), 10); +} + +export function autoSelect() { + return (el: HTMLElement) => void setTimeout(() => (el as any).select?.(), 10); +} + +/** + * Async computed version of Computed. + */ +export const AsyncComputed = { + create(owner: IDisposableOwner, cb: (use: UseCB) => Promise): AsyncComputed { + const backend: Observable = Observable.create(owner, undefined); + const dirty = Observable.create(owner, true); + const computed: Computed> = Computed.create(owner, cb as any); + let ticket = 0; + const listener = (prom: Promise): void => { + dirty.set(true); + const myTicket = ++ticket; + prom.then(v => { + if (ticket !== myTicket) { return; } + if (backend.isDisposed()) { return; } + dirty.set(false); + backend.set(v); + }).catch(reportError); + }; + owner?.autoDispose(computed.addListener(listener)); + listener(computed.get()); + return Object.assign(backend, { + dirty + }); + } +}; +export interface AsyncComputed extends Observable { + /** + * Whether computed wasn't updated yet. + */ + dirty: Observable; +} + +/** + * Stops propagation of the event, and prevents default action. + */ +export function stopEvent(ev: Event) { + ev.stopPropagation(); + ev.preventDefault(); + ev.stopImmediatePropagation(); +} diff --git a/app/client/models/DocModel.ts b/app/client/models/DocModel.ts index e391ab91..ad6ad06d 100644 --- a/app/client/models/DocModel.ts +++ b/app/client/models/DocModel.ts @@ -34,6 +34,7 @@ import {ColumnRec, createColumnRec} from 'app/client/models/entities/ColumnRec'; import {createDocInfoRec, DocInfoRec} from 'app/client/models/entities/DocInfoRec'; import {createFilterRec, FilterRec} from 'app/client/models/entities/FilterRec'; import {createPageRec, PageRec} from 'app/client/models/entities/PageRec'; +import {createShareRec, ShareRec} from 'app/client/models/entities/ShareRec'; import {createTabBarRec, TabBarRec} from 'app/client/models/entities/TabBarRec'; import {createTableRec, TableRec} from 'app/client/models/entities/TableRec'; import {createValidationRec, ValidationRec} from 'app/client/models/entities/ValidationRec'; @@ -127,6 +128,7 @@ export class DocModel { public tabBar: MTM = this._metaTableModel("_grist_TabBar", createTabBarRec); public validations: MTM = this._metaTableModel("_grist_Validations", createValidationRec); public pages: MTM = this._metaTableModel("_grist_Pages", createPageRec); + public shares: MTM = this._metaTableModel("_grist_Shares", createShareRec); public rules: MTM = this._metaTableModel("_grist_ACLRules", createACLRuleRec); public filters: MTM = this._metaTableModel("_grist_Filters", createFilterRec); public cells: MTM = this._metaTableModel("_grist_Cells", createCellRec); @@ -149,6 +151,7 @@ export class DocModel { public allTabs: KoArray = this.tabBar.createAllRowsModel('tabPos'); + public allPages: ko.Computed; /** Pages that are shown in the menu. These can include censored pages if they have children. */ public menuPages: ko.Computed; // Excludes pages hidden by ACL rules or other reasons (e.g. doc-tour) @@ -217,8 +220,9 @@ export class DocModel { // Get a list of only the visible pages. const allPages = this.pages.createAllRowsModel('pagePos'); + this.allPages = ko.computed(() => allPages.all()); this.menuPages = ko.computed(() => { - const pagesToShow = allPages.all().filter(p => !p.isSpecial()).sort((a, b) => a.pagePos() - b.pagePos()); + const pagesToShow = this.allPages().filter(p => !p.isSpecial()).sort((a, b) => a.pagePos() - b.pagePos()); // Helper to find all children of a page. const children = memoize((page: PageRec) => { const following = pagesToShow.slice(pagesToShow.indexOf(page) + 1); @@ -230,7 +234,7 @@ export class DocModel { const hide = memoize((page: PageRec): boolean => page.isCensored() && children(page).every(p => hide(p))); return pagesToShow.filter(p => !hide(p)); }); - this.visibleDocPages = ko.computed(() => allPages.all().filter(p => !p.isHidden())); + this.visibleDocPages = ko.computed(() => this.allPages().filter(p => !p.isHidden())); this.hasDocTour = ko.computed(() => this.visibleTableIds.all().includes('GristDocTour')); diff --git a/app/client/models/entities/PageRec.ts b/app/client/models/entities/PageRec.ts index 2aa87cbd..fff80f80 100644 --- a/app/client/models/entities/PageRec.ts +++ b/app/client/models/entities/PageRec.ts @@ -1,4 +1,5 @@ import {DocModel, IRowModel, refRecord, ViewRec} from 'app/client/models/DocModel'; +import {ShareRec} from 'app/client/models/entities/ShareRec'; import * as ko from 'knockout'; // Represents a page entry in the tree of pages. @@ -7,6 +8,7 @@ export interface PageRec extends IRowModel<"_grist_Pages"> { isHidden: ko.Computed; isCensored: ko.Computed; isSpecial: ko.Computed; + share: ko.Computed; } export function createPageRec(this: PageRec, docModel: DocModel): void { @@ -36,4 +38,5 @@ export function createPageRec(this: PageRec, docModel: DocModel): void { this.isHidden = ko.pureComputed(() => { return this.isCensored() || this.isSpecial(); }); + this.share = refRecord(docModel.shares, this.shareRef); } diff --git a/app/client/models/entities/ShareRec.ts b/app/client/models/entities/ShareRec.ts new file mode 100644 index 00000000..3af5be58 --- /dev/null +++ b/app/client/models/entities/ShareRec.ts @@ -0,0 +1,10 @@ +import {IRowModel} from 'app/client/models/DocModel'; +import * as modelUtil from 'app/client/models/modelUtil'; + +export interface ShareRec extends IRowModel<"_grist_Shares"> { + optionsObj: modelUtil.SaveableObjObservable; +} + +export function createShareRec(this: ShareRec): void { + this.optionsObj = modelUtil.jsonObservable(this.options); +} diff --git a/app/client/models/entities/ViewRec.ts b/app/client/models/entities/ViewRec.ts index a1fcf808..efcaf1c0 100644 --- a/app/client/models/entities/ViewRec.ts +++ b/app/client/models/entities/ViewRec.ts @@ -1,7 +1,7 @@ import {BoxSpec} from 'app/client/components/Layout'; import {KoArray} from 'app/client/lib/koArray'; import * as koUtil from 'app/client/lib/koUtil'; -import {DocModel, IRowModel, recordSet, refRecord} from 'app/client/models/DocModel'; +import {DocModel, IRowModel, PageRec, recordSet, refRecord} from 'app/client/models/DocModel'; import {TabBarRec, ViewSectionRec} from 'app/client/models/DocModel'; import * as modelUtil from 'app/client/models/modelUtil'; import * as ko from 'knockout'; @@ -30,6 +30,8 @@ export interface ViewRec extends IRowModel<"_grist_Views"> { // If the active section is removed, set the next active section to be the default. _isActiveSectionGone: ko.Computed; + + page: ko.Computed; } export function createViewRec(this: ViewRec, docModel: DocModel): void { @@ -76,6 +78,11 @@ export function createViewRec(this: ViewRec, docModel: DocModel): void { this.activeSectionId(0); } })); + + this.page = this.autoDispose(ko.pureComputed(() => { + const viewRef = this.id(); + return docModel.allPages().find(p => p.viewRef() === viewRef) ?? null; + })); } function getFirstLeaf(layoutSpec: BoxSpec|undefined): BoxSpec['leaf'] { diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts index d94bcefa..1d227569 100644 --- a/app/client/models/entities/ViewSectionRec.ts +++ b/app/client/models/entities/ViewSectionRec.ts @@ -71,6 +71,7 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO columns: ko.Computed; optionsObj: modelUtil.SaveableObjObservable; + shareOptionsObj: modelUtil.SaveableObjObservable; customDef: CustomViewSectionDef; @@ -380,6 +381,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): }; this.optionsObj = modelUtil.jsonObservable(this.options, (obj: any) => defaults(obj || {}, defaultOptions)); + this.shareOptionsObj = modelUtil.jsonObservable(this.shareOptions); const customViewDefaults = { mode: 'url', diff --git a/app/common/ActiveDocAPI.ts b/app/common/ActiveDocAPI.ts index d428e45e..5eeafddd 100644 --- a/app/common/ActiveDocAPI.ts +++ b/app/common/ActiveDocAPI.ts @@ -268,6 +268,21 @@ type ISuggestion = string | [string, string, boolean]; // Suggestion paired with an optional example value to show on the right export type ISuggestionWithValue = [ISuggestion, string | null]; +/** + * Share information from a Grist document. + */ +export interface ShareInfo { + linkId: string; + options: string; +} + +/** + * Share information from the Grist home database. + */ +export interface RemoteShareInfo { + key: string; +} + export interface ActiveDocAPI { /** * Closes a document, and unsubscribes from its userAction events. @@ -330,7 +345,7 @@ export interface ActiveDocAPI { * Returns a diff of changes that will be applied to the destination table from `transformRule` * if the data from `hiddenTableId` is imported with the specified `mergeOptions`. */ - generateImportDiff(hiddenTableId: string, transformRule: TransformRule, + generateImportDiff(hiddenTableId: string, transformRule: TransformRule, mergeOptions: MergeOptions): Promise; /** @@ -428,4 +443,6 @@ export interface ActiveDocAPI { * Get users that are worth proposing to "View As" for access control purposes. */ getUsersForViewAs(): Promise; + + getShare(linkId: string): Promise; } diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 9bdaf6be..29befe82 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -422,7 +422,30 @@ export interface UserAPI { /** * Creates publicly shared URL for a rendered form. */ - formUrl(docId: string, vsId: number): string; + formUrl(options: FormUrlOptions): string; +} + +interface FormUrlOptions { + vsId: number; + /** + * The canonical URL or document ID. + * + * If set, the returned form URL will only be accessible by users with access to the + * document. This is currently only used for the preview functionality in the widget, + * where document access is a pre-requisite. + * + * Only one of `urlId` or `shareKey` should be set. + */ + urlId?: string; + /** + * The key of the Share granting access to the form. + * + * If set, the returned form URL will be accessible by anyone, so long as the form + * is published. + * + * Only one of `urlId` or `shareKey` should be set. + */ + shareKey?: string; } /** @@ -514,8 +537,17 @@ 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 formUrl(options: FormUrlOptions): string { + const {urlId, shareKey, vsId} = options; + if (!urlId && !shareKey) { + throw new Error('Invalid form URL: missing urlId or shareKey'); + } + + if (urlId) { + return `${this._url}/api/docs/${urlId}/forms/${vsId}`; + } else { + return `${this._url}/forms/${shareKey}/${vsId}`; + } } public forRemoved(): UserAPI { diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index a5f4c091..1e82318f 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -1,3 +1,4 @@ +import {ShareInfo} from 'app/common/ActiveDocAPI'; import {ApiError, LimitType} from 'app/common/ApiError'; import {mapGetOrSet, mapSetOrClear, MapWithTTL} from 'app/common/AsyncCreate'; import {getDataLimitStatus} from 'app/common/DocLimits'; @@ -1221,7 +1222,7 @@ export class HomeDBManager extends EventEmitter { // to the regular path through this method. workspace: this.unwrapQueryResult( await this.getWorkspace({userId: this.getSupportUserId()}, - this._exampleWorkspaceId)), + this._exampleWorkspaceId)), aliases: [], access: 'editors', // a share may have view/edit access, // need to check at granular level @@ -3089,6 +3090,22 @@ export class HomeDBManager extends EventEmitter { }); } + public async getShareByKey(key: string) { + return this._connection.createQueryBuilder() + .select('shares') + .from(Share, 'shares') + .where('shares.key = :key', {key}) + .getOne(); + } + + public async getShareByLinkId(docId: string, linkId: string) { + return this._connection.createQueryBuilder() + .select('shares') + .from(Share, 'shares') + .where('shares.doc_id = :docId and shares.link_id = :linkId', {docId, linkId}) + .getOne(); + } + private async _getOrCreateLimit(accountId: number, limitType: LimitType, force: boolean): Promise { if (accountId === 0) { throw new Error(`getLimit: called for not existing account`); @@ -4933,8 +4950,3 @@ export function getDocAuthKeyFromScope(scope: Scope): DocAuthKey { if (!urlId) { throw new Error('document required'); } return {urlId, userId, org}; } - -interface ShareInfo { - linkId: string; - options: string; -} diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index 5a30dd5f..b24357c7 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -80,6 +80,7 @@ import {convertFromColumn} from 'app/common/ValueConverter'; import {guessColInfo} from 'app/common/ValueGuesser'; import {parseUserAction} from 'app/common/ValueParser'; import {Document} from 'app/gen-server/entity/Document'; +import {Share} from 'app/gen-server/entity/Share'; import {ParseFileResult, ParseOptions} from 'app/plugin/FileParserAPI'; import {AccessTokenOptions, AccessTokenResult, GristDocAPI, UIRowId} from 'app/plugin/GristAPI'; import {compileAclFormula} from 'app/server/lib/ACLFormula'; @@ -88,6 +89,7 @@ import {AssistanceContext} from 'app/common/AssistancePrompts'; import {Authorizer, RequestWithLogin} from 'app/server/lib/Authorizer'; import {checksumFile} from 'app/server/lib/checksumFile'; import {Client} from 'app/server/lib/Client'; +import {getMetaTables} from 'app/server/lib/DocApi'; import {DEFAULT_CACHE_TTL, DocManager} from 'app/server/lib/DocManager'; import {ICreateActiveDocOptions} from 'app/server/lib/ICreate'; import {makeForkIds} from 'app/server/lib/idUtils'; @@ -140,7 +142,6 @@ import remove = require('lodash/remove'); import sum = require('lodash/sum'); import without = require('lodash/without'); import zipObject = require('lodash/zipObject'); -import { getMetaTables } from './DocApi'; bluebird.promisifyAll(tmp); @@ -1367,11 +1368,7 @@ export class ActiveDoc extends EventEmitter { * TODO: reconcile the two ways there are now of preparing a fork. */ public async fork(docSession: OptDocSession): Promise { - const dbManager = this.getHomeDbManager(); - if (!dbManager) { - throw new Error('HomeDbManager not available'); - } - + const dbManager = this._getHomeDbManagerOrFail(); const user = getDocSessionUser(docSession); // For now, fork only if user can read everything (or is owner). // TODO: allow forks with partial content. @@ -1386,7 +1383,7 @@ export class ActiveDoc extends EventEmitter { if (docSession.authorizer) { doc = await docSession.authorizer.getDoc(); } else if (docSession.req) { - doc = await this.getHomeDbManager()?.getDoc(docSession.req); + doc = await dbManager.getDoc(docSession.req); } if (!doc) { throw new Error('Document not found'); } @@ -1844,10 +1841,14 @@ export class ActiveDoc extends EventEmitter { options: String(vals['options'][idx]), }; }); - await this.getHomeDbManager()?.syncShares(this.docName, goodShares); + await this._getHomeDbManagerOrFail().syncShares(this.docName, goodShares); return goodShares; } + public async getShare(_docSession: OptDocSession, linkId: string): Promise { + return await this._getHomeDbManagerOrFail().getShareByLinkId(this.docName, linkId); + } + /** * Loads an open document from DocStorage. Returns a list of the tables it contains. */ @@ -2787,6 +2788,16 @@ export class ActiveDoc extends EventEmitter { await this.shutdown(); } } + + private _getHomeDbManagerOrFail() { + const dbManager = this.getHomeDbManager(); + if (!dbManager) { + throw new Error('HomeDbManager not available'); + } + + return dbManager; + } + } // Helper to initialize a sandbox action bundle with no values. diff --git a/app/server/lib/AppEndpoint.ts b/app/server/lib/AppEndpoint.ts index f9df04c5..b2676dff 100644 --- a/app/server/lib/AppEndpoint.ts +++ b/app/server/lib/AppEndpoint.ts @@ -226,4 +226,18 @@ export function attachAppEndpoint(options: AttachOptions): void { ...docMiddleware, docHandler); app.get('/:urlId([^-/]{12,})(/:slug([^/]+):remainder(*))?', ...docMiddleware, docHandler); + app.get('/forms/:urlId([^/]+)/:sectionId', ...middleware, expressWrap(async (req, res) => { + const formUrl = gristServer.getHomeUrl(req, + `/api/s/${req.params.urlId}/forms/${req.params.sectionId}`); + const response = await fetch(formUrl, { + headers: getTransitiveHeaders(req), + }); + if (response.status === 200) { + const html = await response.text(); + res.send(html); + } else { + const error = await response.json(); + throw new ApiError(error?.error ?? 'Failed to fetch form', response.status); + } + })); } diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index 3ea0a042..e150f2e2 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -11,6 +11,7 @@ import { TableRecordValue, UserAction } from 'app/common/DocActions'; +import {DocData} from 'app/common/DocData'; import {isRaisedException} from "app/common/gristTypes"; import {Box, RenderBox, RenderContext} from "app/common/Forms"; import {buildUrlId, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls"; @@ -47,7 +48,8 @@ import { RequestWithLogin } from 'app/server/lib/Authorizer'; import {DocManager} from "app/server/lib/DocManager"; -import {docSessionFromRequest, makeExceptionalDocSession, OptDocSession} from "app/server/lib/DocSession"; +import {docSessionFromRequest, getDocSessionShare, makeExceptionalDocSession, + OptDocSession} from "app/server/lib/DocSession"; import {DocWorker} from "app/server/lib/DocWorker"; import {IDocWorkerMap} from "app/server/lib/DocWorkerMap"; import {DownloadOptions, parseExportParameters} from "app/server/lib/Export"; @@ -1373,29 +1375,49 @@ export class DocWorkerApi { return res.status(200).json(docId); })); - // Get the specified table in record-oriented format + /** + * Get the specified section's form as HTML. + * + * Forms are typically accessed via shares, with URLs like: https://docs.getgrist.com/forms/${shareKey}/${id}. + * + * AppEndpoint.ts handles forwarding of such URLs to this endpoint. + */ this._app.get('/api/docs/:docId/forms/:id', canView, withDoc(async (activeDoc, req, res) => { + const docSession = docSessionFromRequest(req); + const linkId = getDocSessionShare(docSession); + const sectionId = integerParam(req.params.id, 'id'); + if (linkId) { + /* If accessed via a share, the share's `linkId` will be present and + * we'll need to check that the form is in fact published, and that the + * share key is associated with the form, before granting access to the + * form. */ + this._assertFormIsPublished({ + docData: activeDoc.docData, + linkId, + sectionId, + }); + } + // 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] }, { } + req, activeDoc, '_grist_Views_section', { id: [sectionId] }, {} )); - const vs = records.find(r => r.id === id); - if (!vs) { - throw new ApiError(`ViewSection ${id} not found`, 404); + const section = records.find(r => r.id === sectionId); + if (!section) { + throw new ApiError('Form 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] }, { } + req, activeDoc, '_grist_Views_section_field', { parentId: [sectionId] }, { } )); const cols = asRecords(await readTable( - req, activeDoc, '_grist_Tables_column', { parentId: [vs.fields.tableRef] }, { } + req, activeDoc, '_grist_Tables_column', { parentId: [section.fields.tableRef] }, { } )); // Read the box specs - const spec = vs.fields.layoutSpec; + const spec = section.fields.layoutSpec; let box: Box = safeJsonParse(spec ? String(spec) : '', null); if (!box) { const editable = fields.filter(f => { @@ -1453,20 +1475,67 @@ export class DocWorkerApi { // 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}); + const tableId = await getRealTableId(String(section.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); }) ); } + /** + * Throws if the specified section is not of a published form. + */ + private _assertFormIsPublished(params: { + docData: DocData | null, + linkId: string, + sectionId: number, + }) { + const {docData, linkId, sectionId} = params; + if (!docData) { + throw new ApiError('DocData not available', 500); + } + + // Check that the request is for a valid section in the document. + const sections = docData.getMetaTable('_grist_Views_section'); + const section = sections.getRecords().find(s => s.id === sectionId); + if (!section) { + throw new ApiError('Form not found', 404); + } + + // Check that the section is for a form. + const sectionShareOptions = safeJsonParse(section.shareOptions, {}); + if (!sectionShareOptions.form) { + throw new ApiError('Form not found', 400); + } + + // Check that the form is associated with a share. + const viewId = section.parentId; + const pages = docData.getMetaTable('_grist_Pages'); + const page = pages.getRecords().find(p => p.viewRef === viewId); + if (!page) { + throw new ApiError('Form not found', 404); + } + const shares = docData.getMetaTable('_grist_Shares'); + const share = shares.getRecord(page.shareRef); + if (!share) { + throw new ApiError('Form not found', 404); + } + + // Check that the share's link id matches the expected link id. + if (share.linkId !== linkId) { + throw new ApiError('Form not found', 404); + } + + // Finally, check that both the section and share are published. + if (!sectionShareOptions.publish || !safeJsonParse(share.options, {})?.publish) { + throw new ApiError('Form not published', 400); + } + } + private async _copyDocToWorkspace(req: Request, options: { userId: number, sourceDocumentId: string, diff --git a/app/server/lib/DocWorker.ts b/app/server/lib/DocWorker.ts index 48444d00..f417abf0 100644 --- a/app/server/lib/DocWorker.ts +++ b/app/server/lib/DocWorker.ts @@ -135,6 +135,7 @@ export class DocWorker { waitForInitialization: activeDocMethod.bind(null, 'viewers', 'waitForInitialization'), getUsersForViewAs: activeDocMethod.bind(null, 'viewers', 'getUsersForViewAs'), getAccessToken: activeDocMethod.bind(null, 'viewers', 'getAccessToken'), + getShare: activeDocMethod.bind(null, 'owners', 'getShare'), }); } @@ -193,7 +194,7 @@ export class DocWorker { * Translates calls from the browser client into calls of the form * `activeDoc.method(docSession, ...args)`. */ -async function activeDocMethod(role: 'viewers'|'editors'|null, methodName: string, client: Client, +async function activeDocMethod(role: 'viewers'|'editors'|'owners'|null, methodName: string, client: Client, docFD: number, ...args: any[]): Promise { const docSession = client.getDocSession(docFD); const activeDoc = docSession.activeDoc; diff --git a/test/nbrowser/FormView.ts b/test/nbrowser/FormView.ts index c61288fd..be2e661f 100644 --- a/test/nbrowser/FormView.ts +++ b/test/nbrowser/FormView.ts @@ -26,8 +26,36 @@ describe('FormView', function() { const session = await gu.session().login(); docId = await session.tempNewDoc(cleanup); api = session.createHomeApi(); + await driver.executeScript(createClipboardTextArea); }); + after(async function() { + await driver.executeScript(removeClipboardTextArea); + }); + + /** + * Adds a temporary textarea to the document for pasting the contents of + * the clipboard. + * + * Used to test copying of form URLs to the clipboard. + */ + function createClipboardTextArea() { + const textArea = document.createElement('textarea'); + textArea.style.position = 'absolute'; + textArea.style.top = '0'; + textArea.style.height = '2rem'; + textArea.style.width = '16rem'; + textArea.id = 'clipboardText'; + window.document.body.appendChild(textArea); + } + + function removeClipboardTextArea() { + const textArea = document.getElementById('clipboardText'); + if (textArea) { + window.document.body.removeChild(textArea); + } + } + async function createFormWith(type: string, more = false) { await gu.addNewSection('Form', 'Table1'); @@ -44,9 +72,22 @@ describe('FormView', function() { // Make sure we see this new question (D). assert.deepEqual(await readLabels(), ['A', 'B', 'C', 'D']); + await driver.find('.test-forms-publish').click(); + await driver.find('.test-modal-confirm').click(); + await gu.waitForServer(); + // Now open the form in external window. - const formUrl = await driver.find(`.test-forms-link`).getAttribute('href'); - return formUrl; + await clipboard.lockAndPerform(async (cb) => { + await driver.find(`.test-forms-link`).click(); + await gu.waitForServer(); + await gu.waitToPass(async () => assert.match( + await driver.find('.test-tooltip').getText(), /Link copied to clipboard/), 1000); + await driver.find('#clipboardText').click(); + await gu.selectAll(); + await cb.paste(); + }); + + return await driver.find('#clipboardText').value(); } async function removeForm() { @@ -220,6 +261,33 @@ describe('FormView', function() { await removeForm(); }); + it('can unpublish forms', async function() { + const formUrl = await createFormWith('Text'); + await driver.find('.test-forms-unpublish').click(); + await driver.find('.test-modal-confirm').click(); + await gu.waitForServer(); + await gu.onNewTab(async () => { + await driver.get(formUrl); + assert.match( + await driver.findWait('.test-error-header', 2000).getText(), + /Something went wrong/ + ); + assert.match( + await driver.findWait('.test-error-content', 2000).getText(), + /There was an error: Form not published\./ + ); + }); + + // Republish the form and check that the same URL works again. + await driver.find('.test-forms-publish').click(); + await driver.find('.test-modal-confirm').click(); + await gu.waitForServer(); + await gu.onNewTab(async () => { + await driver.get(formUrl); + await driver.findWait('input[name="D"]', 1000); + }); + }); + it('can create a form for a blank table', async function() { // Add new page and select form.