mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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
This commit is contained in:
parent
8ddcff4310
commit
e12471347b
@ -47,6 +47,7 @@ export class DocComm extends Disposable implements ActiveDocAPI {
|
|||||||
public waitForInitialization = this._wrapMethod("waitForInitialization");
|
public waitForInitialization = this._wrapMethod("waitForInitialization");
|
||||||
public getUsersForViewAs = this._wrapMethod("getUsersForViewAs");
|
public getUsersForViewAs = this._wrapMethod("getUsersForViewAs");
|
||||||
public getAccessToken = this._wrapMethod("getAccessToken");
|
public getAccessToken = this._wrapMethod("getAccessToken");
|
||||||
|
public getShare = this._wrapMethod("getShare");
|
||||||
|
|
||||||
public changeUrlIdEmitter = this.autoDispose(new Emitter());
|
public changeUrlIdEmitter = this.autoDispose(new Emitter());
|
||||||
|
|
||||||
|
@ -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 {Box, BoxModel, BoxType, LayoutModel, parseBox, Place} from 'app/client/components/Forms/Model';
|
||||||
import * as style from 'app/client/components/Forms/styles';
|
import * as style from 'app/client/components/Forms/styles';
|
||||||
import {GristDoc} from 'app/client/components/GristDoc';
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
|
import {copyToClipboard} from 'app/client/lib/clipboardUtils';
|
||||||
import {Disposable} from 'app/client/lib/dispose';
|
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 {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 DataTableModel from 'app/client/models/DataTableModel';
|
||||||
import {ViewSectionRec} from 'app/client/models/DocModel';
|
import {ViewSectionRec} from 'app/client/models/DocModel';
|
||||||
|
import {ShareRec} from 'app/client/models/entities/ShareRec';
|
||||||
import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec';
|
import {InsertColOptions} from 'app/client/models/entities/ViewSectionRec';
|
||||||
import {SortedRowSet} from 'app/client/models/rowset';
|
import {SortedRowSet} from 'app/client/models/rowset';
|
||||||
import {getColumnTypes as getNewColumnTypes} from 'app/client/ui/GridViewMenus';
|
import {getColumnTypes as getNewColumnTypes} from 'app/client/ui/GridViewMenus';
|
||||||
|
import {showTransientTooltip} from 'app/client/ui/tooltips';
|
||||||
import {cssButton} from 'app/client/ui2018/buttons';
|
import {cssButton} from 'app/client/ui2018/buttons';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import * as menus from 'app/client/ui2018/menus';
|
import * as menus from 'app/client/ui2018/menus';
|
||||||
|
import {confirmModal} from 'app/client/ui2018/modals';
|
||||||
import {not} from 'app/common/gutil';
|
import {not} from 'app/common/gutil';
|
||||||
import {Events as BackboneEvents} from 'backbone';
|
import {Events as BackboneEvents} from 'backbone';
|
||||||
import {Computed, dom, Holder, IDisposableOwner, IDomArgs, MultiHolder, Observable} from 'grainjs';
|
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 isEqual from 'lodash/isEqual';
|
||||||
import {v4 as uuidv4} from 'uuid';
|
import {v4 as uuidv4} from 'uuid';
|
||||||
|
|
||||||
|
const t = makeT('FormView');
|
||||||
|
|
||||||
const testId = makeTestId('test-forms-');
|
const testId = makeTestId('test-forms-');
|
||||||
|
|
||||||
export class FormView extends Disposable {
|
export class FormView extends Disposable {
|
||||||
@ -44,6 +52,11 @@ export class FormView extends Disposable {
|
|||||||
private _savedLayout: any;
|
private _savedLayout: any;
|
||||||
private _saving: boolean = false;
|
private _saving: boolean = false;
|
||||||
private _url: Computed<string>;
|
private _url: Computed<string>;
|
||||||
|
private _copyingLink: Observable<boolean>;
|
||||||
|
private _pageShare: Computed<ShareRec | null>;
|
||||||
|
private _remoteShare: AsyncComputed<{key: string}|null>;
|
||||||
|
private _published: Computed<boolean>;
|
||||||
|
private _showPublishedMessage: Observable<boolean>;
|
||||||
|
|
||||||
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
|
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
|
||||||
BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false});
|
BaseView.call(this as any, gristDoc, viewSectionModel, {'addNewRow': false});
|
||||||
@ -242,10 +255,44 @@ export class FormView extends Disposable {
|
|||||||
this._url = Computed.create(this, use => {
|
this._url = Computed.create(this, use => {
|
||||||
const doc = use(this.gristDoc.docPageModel.currentDoc);
|
const doc = use(this.gristDoc.docPageModel.currentDoc);
|
||||||
if (!doc) { return ''; }
|
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;
|
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.
|
// Last line, build the dom.
|
||||||
this.viewPane = this.autoDispose(this.buildDom());
|
this.viewPane = this.autoDispose(this.buildDom());
|
||||||
}
|
}
|
||||||
@ -260,13 +307,12 @@ export class FormView extends Disposable {
|
|||||||
|
|
||||||
public buildDom() {
|
public buildDom() {
|
||||||
return dom('div.flexauto.flexvbox',
|
return dom('div.flexauto.flexvbox',
|
||||||
this._buildSwitcher(),
|
|
||||||
style.cssFormEdit.cls('-preview', not(this.isEdit)),
|
style.cssFormEdit.cls('-preview', not(this.isEdit)),
|
||||||
style.cssFormEdit.cls('', this.isEdit),
|
style.cssFormEdit.cls('', this.isEdit),
|
||||||
testId('preview', not(this.isEdit)),
|
testId('preview', not(this.isEdit)),
|
||||||
testId('editor', this.isEdit),
|
testId('editor', this.isEdit),
|
||||||
|
|
||||||
dom.maybe(this.isEdit, () => [
|
dom.maybe(this.isEdit, () => style.cssFormEditBody(
|
||||||
style.cssFormContainer(
|
style.cssFormContainer(
|
||||||
dom.forEach(this._root.children, (child) => {
|
dom.forEach(this._root.children, (child) => {
|
||||||
if (!child) {
|
if (!child) {
|
||||||
@ -285,12 +331,13 @@ export class FormView extends Disposable {
|
|||||||
}),
|
}),
|
||||||
this.buildDropzone(this, this._root.placeAfterListChild()),
|
this.buildDropzone(this, this._root.placeAfterListChild()),
|
||||||
),
|
),
|
||||||
]),
|
)),
|
||||||
dom.maybe(not(this.isEdit), () => [
|
dom.maybe(not(this.isEdit), () => [
|
||||||
style.cssPreview(
|
style.cssPreview(
|
||||||
dom.prop('src', this._url),
|
dom.prop('src', this._url),
|
||||||
)
|
)
|
||||||
]),
|
]),
|
||||||
|
this._buildSwitcher(),
|
||||||
dom.on('click', () => this.selectedBox.set(null))
|
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() {
|
private _buildSwitcher() {
|
||||||
|
|
||||||
const toggle = (val: boolean) => () => {
|
const toggle = (val: boolean) => () => {
|
||||||
@ -646,32 +787,95 @@ export class FormView extends Disposable {
|
|||||||
this._saveNow().catch(reportError);
|
this._saveNow().catch(reportError);
|
||||||
};
|
};
|
||||||
|
|
||||||
return style.cssButtonGroup(
|
return style.cssSwitcher(
|
||||||
style.cssIconButton(
|
this._buildSwitcherMessage(),
|
||||||
icon('Pencil'),
|
style.cssButtonGroup(
|
||||||
testId('edit'),
|
style.cssIconButton(
|
||||||
dom('div', 'Editor'),
|
icon('Pencil'),
|
||||||
cssButton.cls('-primary', this.isEdit),
|
testId('edit'),
|
||||||
style.cssIconButton.cls('-standard', not(this.isEdit)),
|
dom('div', 'Editor'),
|
||||||
dom.on('click', toggle(true))
|
cssButton.cls('-primary', this.isEdit),
|
||||||
),
|
style.cssIconButton.cls('-standard', not(this.isEdit)),
|
||||||
style.cssIconButton(
|
dom.on('click', toggle(true))
|
||||||
icon('EyeShow'),
|
),
|
||||||
dom('div', 'Preview'),
|
style.cssIconButton(
|
||||||
testId('preview'),
|
icon('EyeShow'),
|
||||||
cssButton.cls('-primary', not(this.isEdit)),
|
dom('div', 'Preview'),
|
||||||
style.cssIconButton.cls('-standard', (this.isEdit)),
|
testId('preview'),
|
||||||
dom.on('click', toggle(false))
|
cssButton.cls('-primary', not(this.isEdit)),
|
||||||
),
|
style.cssIconButton.cls('-standard', (this.isEdit)),
|
||||||
style.cssIconLink(
|
dom.on('click', toggle(false))
|
||||||
icon('FieldAttachment'),
|
),
|
||||||
testId('link'),
|
style.cssIconButton(
|
||||||
dom('div', 'Link'),
|
icon('FieldAttachment'),
|
||||||
dom.prop('href', this._url),
|
testId('link'),
|
||||||
{target: '_blank'}
|
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
|
// Getting an ES6 class to work with old-style multiple base classes takes a little hacking. Credits: ./ChartView.ts
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
|
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 {colors, theme, vars} from 'app/client/ui2018/cssVars';
|
||||||
import {BindableValue, dom, IDomArgs, styled, subscribeBindable} from 'grainjs';
|
import {BindableValue, dom, IDomArgs, styled, subscribeBindable} from 'grainjs';
|
||||||
import {marked} from 'marked';
|
import {marked} from 'marked';
|
||||||
@ -16,10 +16,16 @@ export {
|
|||||||
cssAddText,
|
cssAddText,
|
||||||
cssFormContainer,
|
cssFormContainer,
|
||||||
cssFormEdit,
|
cssFormEdit,
|
||||||
|
cssFormEditBody,
|
||||||
cssSection,
|
cssSection,
|
||||||
cssStaticText,
|
cssStaticText,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cssFormEditBody = styled('div', `
|
||||||
|
width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
padding-top: 52px;
|
||||||
|
`);
|
||||||
|
|
||||||
const cssFormEdit = styled('div', `
|
const cssFormEdit = styled('div', `
|
||||||
color: ${theme.text};
|
color: ${theme.text};
|
||||||
@ -28,12 +34,12 @@ const cssFormEdit = styled('div', `
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-basis: 0px;
|
flex-basis: 0px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-top: 52px;
|
justify-content: space-between;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-bottom: 32px;
|
|
||||||
|
|
||||||
--section-background: #739bc31f; /* On white background this is almost #f1f5f9 (slate-100 on tailwind palette) */
|
--section-background: #739bc31f; /* On white background this is almost #f1f5f9 (slate-100 on tailwind palette) */
|
||||||
&, &-preview {
|
&, &-preview {
|
||||||
|
background-color: ${theme.leftPanelBg};
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -42,7 +48,6 @@ const cssFormEdit = styled('div', `
|
|||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
||||||
const cssLabel = styled('label', `
|
const cssLabel = styled('label', `
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
@ -288,17 +293,17 @@ const cssFormContainer = styled('div', `
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
max-width: calc(100% - 32px);
|
max-width: calc(100% - 32px);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
export const cssButtonGroup = styled('div', `
|
export const cssButtonGroup = styled('div', `
|
||||||
position: absolute;
|
|
||||||
top: 18px;
|
|
||||||
left: 24px;
|
|
||||||
right: 24px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0px 24px 0px 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@ -308,19 +313,21 @@ export const cssIconButton = styled(basicButton, `
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
min-height: 24px;
|
||||||
|
|
||||||
&-standard {
|
&-standard {
|
||||||
background-color: ${theme.leftPanelBg};
|
background-color: ${theme.leftPanelBg};
|
||||||
}
|
}
|
||||||
`);
|
&-warning {
|
||||||
|
color: ${theme.controlPrimaryFg};
|
||||||
export const cssIconLink = styled(basicButtonLink, `
|
background-color: ${theme.toastWarningBg};
|
||||||
padding: 3px 8px;
|
border: none;
|
||||||
font-size: ${vars.smallFontSize};
|
}
|
||||||
display: flex;
|
&-warning:hover {
|
||||||
align-items: center;
|
color: ${theme.controlPrimaryFg};
|
||||||
gap: 4px;
|
background-color: #B8791B;
|
||||||
background-color: ${theme.leftPanelBg};
|
border: none;
|
||||||
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssStaticText = styled('div', `
|
const cssStaticText = styled('div', `
|
||||||
@ -348,3 +355,40 @@ export const cssPreview = styled('iframe', `
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
border: 0px;
|
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;
|
||||||
|
`);
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import {useBindable} from 'app/common/gutil';
|
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.
|
* 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<T>(owner: IDisposableOwner, cb: (use: UseCB) => Promise<T>): AsyncComputed<T> {
|
||||||
|
const backend: Observable<T|undefined> = Observable.create(owner, undefined);
|
||||||
|
const dirty = Observable.create(owner, true);
|
||||||
|
const computed: Computed<Promise<T>> = Computed.create(owner, cb as any);
|
||||||
|
let ticket = 0;
|
||||||
|
const listener = (prom: Promise<T>): 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<T> extends Observable<T|undefined> {
|
||||||
|
/**
|
||||||
|
* Whether computed wasn't updated yet.
|
||||||
|
*/
|
||||||
|
dirty: Observable<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops propagation of the event, and prevents default action.
|
||||||
|
*/
|
||||||
|
export function stopEvent(ev: Event) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
@ -34,6 +34,7 @@ import {ColumnRec, createColumnRec} from 'app/client/models/entities/ColumnRec';
|
|||||||
import {createDocInfoRec, DocInfoRec} from 'app/client/models/entities/DocInfoRec';
|
import {createDocInfoRec, DocInfoRec} from 'app/client/models/entities/DocInfoRec';
|
||||||
import {createFilterRec, FilterRec} from 'app/client/models/entities/FilterRec';
|
import {createFilterRec, FilterRec} from 'app/client/models/entities/FilterRec';
|
||||||
import {createPageRec, PageRec} from 'app/client/models/entities/PageRec';
|
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 {createTabBarRec, TabBarRec} from 'app/client/models/entities/TabBarRec';
|
||||||
import {createTableRec, TableRec} from 'app/client/models/entities/TableRec';
|
import {createTableRec, TableRec} from 'app/client/models/entities/TableRec';
|
||||||
import {createValidationRec, ValidationRec} from 'app/client/models/entities/ValidationRec';
|
import {createValidationRec, ValidationRec} from 'app/client/models/entities/ValidationRec';
|
||||||
@ -127,6 +128,7 @@ export class DocModel {
|
|||||||
public tabBar: MTM<TabBarRec> = this._metaTableModel("_grist_TabBar", createTabBarRec);
|
public tabBar: MTM<TabBarRec> = this._metaTableModel("_grist_TabBar", createTabBarRec);
|
||||||
public validations: MTM<ValidationRec> = this._metaTableModel("_grist_Validations", createValidationRec);
|
public validations: MTM<ValidationRec> = this._metaTableModel("_grist_Validations", createValidationRec);
|
||||||
public pages: MTM<PageRec> = this._metaTableModel("_grist_Pages", createPageRec);
|
public pages: MTM<PageRec> = this._metaTableModel("_grist_Pages", createPageRec);
|
||||||
|
public shares: MTM<ShareRec> = this._metaTableModel("_grist_Shares", createShareRec);
|
||||||
public rules: MTM<ACLRuleRec> = this._metaTableModel("_grist_ACLRules", createACLRuleRec);
|
public rules: MTM<ACLRuleRec> = this._metaTableModel("_grist_ACLRules", createACLRuleRec);
|
||||||
public filters: MTM<FilterRec> = this._metaTableModel("_grist_Filters", createFilterRec);
|
public filters: MTM<FilterRec> = this._metaTableModel("_grist_Filters", createFilterRec);
|
||||||
public cells: MTM<CellRec> = this._metaTableModel("_grist_Cells", createCellRec);
|
public cells: MTM<CellRec> = this._metaTableModel("_grist_Cells", createCellRec);
|
||||||
@ -149,6 +151,7 @@ export class DocModel {
|
|||||||
|
|
||||||
public allTabs: KoArray<TabBarRec> = this.tabBar.createAllRowsModel('tabPos');
|
public allTabs: KoArray<TabBarRec> = this.tabBar.createAllRowsModel('tabPos');
|
||||||
|
|
||||||
|
public allPages: ko.Computed<PageRec[]>;
|
||||||
/** Pages that are shown in the menu. These can include censored pages if they have children. */
|
/** Pages that are shown in the menu. These can include censored pages if they have children. */
|
||||||
public menuPages: ko.Computed<PageRec[]>;
|
public menuPages: ko.Computed<PageRec[]>;
|
||||||
// Excludes pages hidden by ACL rules or other reasons (e.g. doc-tour)
|
// 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.
|
// Get a list of only the visible pages.
|
||||||
const allPages = this.pages.createAllRowsModel('pagePos');
|
const allPages = this.pages.createAllRowsModel('pagePos');
|
||||||
|
this.allPages = ko.computed(() => allPages.all());
|
||||||
this.menuPages = ko.computed(() => {
|
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.
|
// Helper to find all children of a page.
|
||||||
const children = memoize((page: PageRec) => {
|
const children = memoize((page: PageRec) => {
|
||||||
const following = pagesToShow.slice(pagesToShow.indexOf(page) + 1);
|
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)));
|
const hide = memoize((page: PageRec): boolean => page.isCensored() && children(page).every(p => hide(p)));
|
||||||
return pagesToShow.filter(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'));
|
this.hasDocTour = ko.computed(() => this.visibleTableIds.all().includes('GristDocTour'));
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {DocModel, IRowModel, refRecord, ViewRec} from 'app/client/models/DocModel';
|
import {DocModel, IRowModel, refRecord, ViewRec} from 'app/client/models/DocModel';
|
||||||
|
import {ShareRec} from 'app/client/models/entities/ShareRec';
|
||||||
import * as ko from 'knockout';
|
import * as ko from 'knockout';
|
||||||
|
|
||||||
// Represents a page entry in the tree of pages.
|
// Represents a page entry in the tree of pages.
|
||||||
@ -7,6 +8,7 @@ export interface PageRec extends IRowModel<"_grist_Pages"> {
|
|||||||
isHidden: ko.Computed<boolean>;
|
isHidden: ko.Computed<boolean>;
|
||||||
isCensored: ko.Computed<boolean>;
|
isCensored: ko.Computed<boolean>;
|
||||||
isSpecial: ko.Computed<boolean>;
|
isSpecial: ko.Computed<boolean>;
|
||||||
|
share: ko.Computed<ShareRec>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPageRec(this: PageRec, docModel: DocModel): void {
|
export function createPageRec(this: PageRec, docModel: DocModel): void {
|
||||||
@ -36,4 +38,5 @@ export function createPageRec(this: PageRec, docModel: DocModel): void {
|
|||||||
this.isHidden = ko.pureComputed(() => {
|
this.isHidden = ko.pureComputed(() => {
|
||||||
return this.isCensored() || this.isSpecial();
|
return this.isCensored() || this.isSpecial();
|
||||||
});
|
});
|
||||||
|
this.share = refRecord(docModel.shares, this.shareRef);
|
||||||
}
|
}
|
||||||
|
10
app/client/models/entities/ShareRec.ts
Normal file
10
app/client/models/entities/ShareRec.ts
Normal file
@ -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<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createShareRec(this: ShareRec): void {
|
||||||
|
this.optionsObj = modelUtil.jsonObservable(this.options);
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import {BoxSpec} from 'app/client/components/Layout';
|
import {BoxSpec} from 'app/client/components/Layout';
|
||||||
import {KoArray} from 'app/client/lib/koArray';
|
import {KoArray} from 'app/client/lib/koArray';
|
||||||
import * as koUtil from 'app/client/lib/koUtil';
|
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 {TabBarRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||||
import * as modelUtil from 'app/client/models/modelUtil';
|
import * as modelUtil from 'app/client/models/modelUtil';
|
||||||
import * as ko from 'knockout';
|
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.
|
// If the active section is removed, set the next active section to be the default.
|
||||||
_isActiveSectionGone: ko.Computed<boolean>;
|
_isActiveSectionGone: ko.Computed<boolean>;
|
||||||
|
|
||||||
|
page: ko.Computed<PageRec|null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createViewRec(this: ViewRec, docModel: DocModel): void {
|
export function createViewRec(this: ViewRec, docModel: DocModel): void {
|
||||||
@ -76,6 +78,11 @@ export function createViewRec(this: ViewRec, docModel: DocModel): void {
|
|||||||
this.activeSectionId(0);
|
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'] {
|
function getFirstLeaf(layoutSpec: BoxSpec|undefined): BoxSpec['leaf'] {
|
||||||
|
@ -71,6 +71,7 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
|
|||||||
columns: ko.Computed<ColumnRec[]>;
|
columns: ko.Computed<ColumnRec[]>;
|
||||||
|
|
||||||
optionsObj: modelUtil.SaveableObjObservable<any>;
|
optionsObj: modelUtil.SaveableObjObservable<any>;
|
||||||
|
shareOptionsObj: modelUtil.SaveableObjObservable<any>;
|
||||||
|
|
||||||
customDef: CustomViewSectionDef;
|
customDef: CustomViewSectionDef;
|
||||||
|
|
||||||
@ -380,6 +381,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
|||||||
};
|
};
|
||||||
this.optionsObj = modelUtil.jsonObservable(this.options,
|
this.optionsObj = modelUtil.jsonObservable(this.options,
|
||||||
(obj: any) => defaults(obj || {}, defaultOptions));
|
(obj: any) => defaults(obj || {}, defaultOptions));
|
||||||
|
this.shareOptionsObj = modelUtil.jsonObservable(this.shareOptions);
|
||||||
|
|
||||||
const customViewDefaults = {
|
const customViewDefaults = {
|
||||||
mode: 'url',
|
mode: 'url',
|
||||||
|
@ -268,6 +268,21 @@ type ISuggestion = string | [string, string, boolean];
|
|||||||
// Suggestion paired with an optional example value to show on the right
|
// Suggestion paired with an optional example value to show on the right
|
||||||
export type ISuggestionWithValue = [ISuggestion, string | null];
|
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 {
|
export interface ActiveDocAPI {
|
||||||
/**
|
/**
|
||||||
* Closes a document, and unsubscribes from its userAction events.
|
* 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`
|
* 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`.
|
* if the data from `hiddenTableId` is imported with the specified `mergeOptions`.
|
||||||
*/
|
*/
|
||||||
generateImportDiff(hiddenTableId: string, transformRule: TransformRule,
|
generateImportDiff(hiddenTableId: string, transformRule: TransformRule,
|
||||||
mergeOptions: MergeOptions): Promise<DocStateComparison>;
|
mergeOptions: MergeOptions): Promise<DocStateComparison>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -428,4 +443,6 @@ export interface ActiveDocAPI {
|
|||||||
* Get users that are worth proposing to "View As" for access control purposes.
|
* Get users that are worth proposing to "View As" for access control purposes.
|
||||||
*/
|
*/
|
||||||
getUsersForViewAs(): Promise<PermissionDataWithExtraUsers>;
|
getUsersForViewAs(): Promise<PermissionDataWithExtraUsers>;
|
||||||
|
|
||||||
|
getShare(linkId: string): Promise<RemoteShareInfo>;
|
||||||
}
|
}
|
||||||
|
@ -422,7 +422,30 @@ export interface UserAPI {
|
|||||||
/**
|
/**
|
||||||
* Creates publicly shared URL for a rendered form.
|
* 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);
|
super(_options);
|
||||||
}
|
}
|
||||||
|
|
||||||
public formUrl(docId: string, vsId: number): string {
|
public formUrl(options: FormUrlOptions): string {
|
||||||
return `${this._url}/api/docs/${docId}/forms/${vsId}`;
|
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 {
|
public forRemoved(): UserAPI {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import {ShareInfo} from 'app/common/ActiveDocAPI';
|
||||||
import {ApiError, LimitType} from 'app/common/ApiError';
|
import {ApiError, LimitType} from 'app/common/ApiError';
|
||||||
import {mapGetOrSet, mapSetOrClear, MapWithTTL} from 'app/common/AsyncCreate';
|
import {mapGetOrSet, mapSetOrClear, MapWithTTL} from 'app/common/AsyncCreate';
|
||||||
import {getDataLimitStatus} from 'app/common/DocLimits';
|
import {getDataLimitStatus} from 'app/common/DocLimits';
|
||||||
@ -1221,7 +1222,7 @@ export class HomeDBManager extends EventEmitter {
|
|||||||
// to the regular path through this method.
|
// to the regular path through this method.
|
||||||
workspace: this.unwrapQueryResult<Workspace>(
|
workspace: this.unwrapQueryResult<Workspace>(
|
||||||
await this.getWorkspace({userId: this.getSupportUserId()},
|
await this.getWorkspace({userId: this.getSupportUserId()},
|
||||||
this._exampleWorkspaceId)),
|
this._exampleWorkspaceId)),
|
||||||
aliases: [],
|
aliases: [],
|
||||||
access: 'editors', // a share may have view/edit access,
|
access: 'editors', // a share may have view/edit access,
|
||||||
// need to check at granular level
|
// 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<Limit|null> {
|
private async _getOrCreateLimit(accountId: number, limitType: LimitType, force: boolean): Promise<Limit|null> {
|
||||||
if (accountId === 0) {
|
if (accountId === 0) {
|
||||||
throw new Error(`getLimit: called for not existing account`);
|
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'); }
|
if (!urlId) { throw new Error('document required'); }
|
||||||
return {urlId, userId, org};
|
return {urlId, userId, org};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ShareInfo {
|
|
||||||
linkId: string;
|
|
||||||
options: string;
|
|
||||||
}
|
|
||||||
|
@ -80,6 +80,7 @@ import {convertFromColumn} from 'app/common/ValueConverter';
|
|||||||
import {guessColInfo} from 'app/common/ValueGuesser';
|
import {guessColInfo} from 'app/common/ValueGuesser';
|
||||||
import {parseUserAction} from 'app/common/ValueParser';
|
import {parseUserAction} from 'app/common/ValueParser';
|
||||||
import {Document} from 'app/gen-server/entity/Document';
|
import {Document} from 'app/gen-server/entity/Document';
|
||||||
|
import {Share} from 'app/gen-server/entity/Share';
|
||||||
import {ParseFileResult, ParseOptions} from 'app/plugin/FileParserAPI';
|
import {ParseFileResult, ParseOptions} from 'app/plugin/FileParserAPI';
|
||||||
import {AccessTokenOptions, AccessTokenResult, GristDocAPI, UIRowId} from 'app/plugin/GristAPI';
|
import {AccessTokenOptions, AccessTokenResult, GristDocAPI, UIRowId} from 'app/plugin/GristAPI';
|
||||||
import {compileAclFormula} from 'app/server/lib/ACLFormula';
|
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 {Authorizer, RequestWithLogin} from 'app/server/lib/Authorizer';
|
||||||
import {checksumFile} from 'app/server/lib/checksumFile';
|
import {checksumFile} from 'app/server/lib/checksumFile';
|
||||||
import {Client} from 'app/server/lib/Client';
|
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 {DEFAULT_CACHE_TTL, DocManager} from 'app/server/lib/DocManager';
|
||||||
import {ICreateActiveDocOptions} from 'app/server/lib/ICreate';
|
import {ICreateActiveDocOptions} from 'app/server/lib/ICreate';
|
||||||
import {makeForkIds} from 'app/server/lib/idUtils';
|
import {makeForkIds} from 'app/server/lib/idUtils';
|
||||||
@ -140,7 +142,6 @@ import remove = require('lodash/remove');
|
|||||||
import sum = require('lodash/sum');
|
import sum = require('lodash/sum');
|
||||||
import without = require('lodash/without');
|
import without = require('lodash/without');
|
||||||
import zipObject = require('lodash/zipObject');
|
import zipObject = require('lodash/zipObject');
|
||||||
import { getMetaTables } from './DocApi';
|
|
||||||
|
|
||||||
bluebird.promisifyAll(tmp);
|
bluebird.promisifyAll(tmp);
|
||||||
|
|
||||||
@ -1367,11 +1368,7 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
* TODO: reconcile the two ways there are now of preparing a fork.
|
* TODO: reconcile the two ways there are now of preparing a fork.
|
||||||
*/
|
*/
|
||||||
public async fork(docSession: OptDocSession): Promise<ForkResult> {
|
public async fork(docSession: OptDocSession): Promise<ForkResult> {
|
||||||
const dbManager = this.getHomeDbManager();
|
const dbManager = this._getHomeDbManagerOrFail();
|
||||||
if (!dbManager) {
|
|
||||||
throw new Error('HomeDbManager not available');
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = getDocSessionUser(docSession);
|
const user = getDocSessionUser(docSession);
|
||||||
// For now, fork only if user can read everything (or is owner).
|
// For now, fork only if user can read everything (or is owner).
|
||||||
// TODO: allow forks with partial content.
|
// TODO: allow forks with partial content.
|
||||||
@ -1386,7 +1383,7 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
if (docSession.authorizer) {
|
if (docSession.authorizer) {
|
||||||
doc = await docSession.authorizer.getDoc();
|
doc = await docSession.authorizer.getDoc();
|
||||||
} else if (docSession.req) {
|
} 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'); }
|
if (!doc) { throw new Error('Document not found'); }
|
||||||
|
|
||||||
@ -1844,10 +1841,14 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
options: String(vals['options'][idx]),
|
options: String(vals['options'][idx]),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
await this.getHomeDbManager()?.syncShares(this.docName, goodShares);
|
await this._getHomeDbManagerOrFail().syncShares(this.docName, goodShares);
|
||||||
return goodShares;
|
return goodShares;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getShare(_docSession: OptDocSession, linkId: string): Promise<Share|null> {
|
||||||
|
return await this._getHomeDbManagerOrFail().getShareByLinkId(this.docName, linkId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads an open document from DocStorage. Returns a list of the tables it contains.
|
* 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();
|
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.
|
// Helper to initialize a sandbox action bundle with no values.
|
||||||
|
@ -226,4 +226,18 @@ export function attachAppEndpoint(options: AttachOptions): void {
|
|||||||
...docMiddleware, docHandler);
|
...docMiddleware, docHandler);
|
||||||
app.get('/:urlId([^-/]{12,})(/:slug([^/]+):remainder(*))?',
|
app.get('/:urlId([^-/]{12,})(/:slug([^/]+):remainder(*))?',
|
||||||
...docMiddleware, docHandler);
|
...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);
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,7 @@ import {
|
|||||||
TableRecordValue,
|
TableRecordValue,
|
||||||
UserAction
|
UserAction
|
||||||
} from 'app/common/DocActions';
|
} from 'app/common/DocActions';
|
||||||
|
import {DocData} from 'app/common/DocData';
|
||||||
import {isRaisedException} from "app/common/gristTypes";
|
import {isRaisedException} from "app/common/gristTypes";
|
||||||
import {Box, RenderBox, RenderContext} from "app/common/Forms";
|
import {Box, RenderBox, RenderContext} from "app/common/Forms";
|
||||||
import {buildUrlId, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls";
|
import {buildUrlId, parseUrlId, SHARE_KEY_PREFIX} from "app/common/gristUrls";
|
||||||
@ -47,7 +48,8 @@ import {
|
|||||||
RequestWithLogin
|
RequestWithLogin
|
||||||
} from 'app/server/lib/Authorizer';
|
} from 'app/server/lib/Authorizer';
|
||||||
import {DocManager} from "app/server/lib/DocManager";
|
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 {DocWorker} from "app/server/lib/DocWorker";
|
||||||
import {IDocWorkerMap} from "app/server/lib/DocWorkerMap";
|
import {IDocWorkerMap} from "app/server/lib/DocWorkerMap";
|
||||||
import {DownloadOptions, parseExportParameters} from "app/server/lib/Export";
|
import {DownloadOptions, parseExportParameters} from "app/server/lib/Export";
|
||||||
@ -1373,29 +1375,49 @@ export class DocWorkerApi {
|
|||||||
return res.status(200).json(docId);
|
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,
|
this._app.get('/api/docs/:docId/forms/:id', canView,
|
||||||
withDoc(async (activeDoc, req, res) => {
|
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.
|
// Get the viewSection record for the specified id.
|
||||||
const id = integerParam(req.params.id, 'id');
|
|
||||||
const records = asRecords(await readTable(
|
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);
|
const section = records.find(r => r.id === sectionId);
|
||||||
if (!vs) {
|
if (!section) {
|
||||||
throw new ApiError(`ViewSection ${id} not found`, 404);
|
throw new ApiError('Form not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare the context that will be needed for rendering this form.
|
// Prepare the context that will be needed for rendering this form.
|
||||||
const fields = asRecords(await readTable(
|
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(
|
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
|
// Read the box specs
|
||||||
const spec = vs.fields.layoutSpec;
|
const spec = section.fields.layoutSpec;
|
||||||
let box: Box = safeJsonParse(spec ? String(spec) : '', null);
|
let box: Box = safeJsonParse(spec ? String(spec) : '', null);
|
||||||
if (!box) {
|
if (!box) {
|
||||||
const editable = fields.filter(f => {
|
const editable = fields.filter(f => {
|
||||||
@ -1453,20 +1475,67 @@ export class DocWorkerApi {
|
|||||||
// Fill out the blanks and send the result.
|
// Fill out the blanks and send the result.
|
||||||
const doc = await this._dbManager.getDoc(req);
|
const doc = await this._dbManager.getDoc(req);
|
||||||
const docUrl = await this._grist.getResourceUrl(doc, 'html');
|
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
|
res.status(200).send(form
|
||||||
.replace('<!-- INSERT CONTENT -->', escaped || '')
|
.replace('<!-- INSERT CONTENT -->', escaped || '')
|
||||||
.replace("<!-- INSERT BASE -->", `<base href="${staticBaseUrl}">`)
|
.replace("<!-- INSERT BASE -->", `<base href="${staticBaseUrl}">`)
|
||||||
.replace('<!-- INSERT DOC URL -->', docUrl)
|
.replace('<!-- INSERT DOC URL -->', docUrl)
|
||||||
.replace('<!-- INSERT TABLE ID -->', tableId)
|
.replace('<!-- INSERT TABLE ID -->', 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: {
|
private async _copyDocToWorkspace(req: Request, options: {
|
||||||
userId: number,
|
userId: number,
|
||||||
sourceDocumentId: string,
|
sourceDocumentId: string,
|
||||||
|
@ -135,6 +135,7 @@ export class DocWorker {
|
|||||||
waitForInitialization: activeDocMethod.bind(null, 'viewers', 'waitForInitialization'),
|
waitForInitialization: activeDocMethod.bind(null, 'viewers', 'waitForInitialization'),
|
||||||
getUsersForViewAs: activeDocMethod.bind(null, 'viewers', 'getUsersForViewAs'),
|
getUsersForViewAs: activeDocMethod.bind(null, 'viewers', 'getUsersForViewAs'),
|
||||||
getAccessToken: activeDocMethod.bind(null, 'viewers', 'getAccessToken'),
|
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
|
* Translates calls from the browser client into calls of the form
|
||||||
* `activeDoc.method(docSession, ...args)`.
|
* `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<any> {
|
docFD: number, ...args: any[]): Promise<any> {
|
||||||
const docSession = client.getDocSession(docFD);
|
const docSession = client.getDocSession(docFD);
|
||||||
const activeDoc = docSession.activeDoc;
|
const activeDoc = docSession.activeDoc;
|
||||||
|
@ -26,8 +26,36 @@ describe('FormView', function() {
|
|||||||
const session = await gu.session().login();
|
const session = await gu.session().login();
|
||||||
docId = await session.tempNewDoc(cleanup);
|
docId = await session.tempNewDoc(cleanup);
|
||||||
api = session.createHomeApi();
|
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) {
|
async function createFormWith(type: string, more = false) {
|
||||||
await gu.addNewSection('Form', 'Table1');
|
await gu.addNewSection('Form', 'Table1');
|
||||||
|
|
||||||
@ -44,9 +72,22 @@ describe('FormView', function() {
|
|||||||
// Make sure we see this new question (D).
|
// Make sure we see this new question (D).
|
||||||
assert.deepEqual(await readLabels(), ['A', 'B', 'C', '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.
|
// Now open the form in external window.
|
||||||
const formUrl = await driver.find(`.test-forms-link`).getAttribute('href');
|
await clipboard.lockAndPerform(async (cb) => {
|
||||||
return formUrl;
|
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() {
|
async function removeForm() {
|
||||||
@ -220,6 +261,33 @@ describe('FormView', function() {
|
|||||||
await removeForm();
|
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() {
|
it('can create a form for a blank table', async function() {
|
||||||
|
|
||||||
// Add new page and select form.
|
// Add new page and select form.
|
||||||
|
Loading…
Reference in New Issue
Block a user