mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +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:
@@ -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());
|
||||
|
||||
|
||||
@@ -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<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) {
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
`);
|
||||
|
||||
@@ -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<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 {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<TabBarRec> = this._metaTableModel("_grist_TabBar", createTabBarRec);
|
||||
public validations: MTM<ValidationRec> = this._metaTableModel("_grist_Validations", createValidationRec);
|
||||
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 filters: MTM<FilterRec> = this._metaTableModel("_grist_Filters", createFilterRec);
|
||||
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 allPages: ko.Computed<PageRec[]>;
|
||||
/** Pages that are shown in the menu. These can include censored pages if they have children. */
|
||||
public menuPages: ko.Computed<PageRec[]>;
|
||||
// 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'));
|
||||
|
||||
|
||||
@@ -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<boolean>;
|
||||
isCensored: ko.Computed<boolean>;
|
||||
isSpecial: ko.Computed<boolean>;
|
||||
share: ko.Computed<ShareRec>;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 {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<boolean>;
|
||||
|
||||
page: ko.Computed<PageRec|null>;
|
||||
}
|
||||
|
||||
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'] {
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
|
||||
columns: ko.Computed<ColumnRec[]>;
|
||||
|
||||
optionsObj: modelUtil.SaveableObjObservable<any>;
|
||||
shareOptionsObj: modelUtil.SaveableObjObservable<any>;
|
||||
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user