diff --git a/README.md b/README.md index af392236..8dead7d6 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,8 @@ GRIST_SERVERS | the types of server to setup. Comma separated values which may c GRIST_SESSION_COOKIE | if set, overrides the name of Grist's cookie GRIST_SESSION_DOMAIN | if set, associates the cookie with the given domain - otherwise defaults to GRIST_DOMAIN GRIST_SESSION_SECRET | a key used to encode sessions -GRIST_FORCE_LOGIN | when set to 'true' disables anonymous access +GRIST_ANON_PLAYGROUND | When set to 'false' deny anonymous users access to the home page +GRIST_FORCE_LOGIN | Much like GRIST_ANON_PLAYGROUND but don't support anonymous access at all (features like sharing docs publicly requires authentication) GRIST_SINGLE_ORG | set to an org "domain" to pin client to that org GRIST_TEMPLATE_ORG | set to an org "domain" to show public docs from that org GRIST_HELP_CENTER | set the help center link ref diff --git a/app/client/DefaultHooks.ts b/app/client/DefaultHooks.ts index 216d80d3..a35d7559 100644 --- a/app/client/DefaultHooks.ts +++ b/app/client/DefaultHooks.ts @@ -1,11 +1,22 @@ import { UrlTweaks } from 'app/common/gristUrls'; +import { IAttrObj } from 'grainjs'; export interface IHooks { iframeAttributes?: Record, fetch?: typeof fetch, baseURI?: string, urlTweaks?: UrlTweaks, + + /** + * Modify the attributes of an dom element. + * Convenient in grist-static to directly hook up a + * download link with the function that provides the data. + */ + maybeModifyLinkAttrs(attrs: IAttrObj): IAttrObj; } export const defaultHooks: IHooks = { + maybeModifyLinkAttrs(attrs: IAttrObj) { + return attrs; + } }; diff --git a/app/client/components/LinkingState.ts b/app/client/components/LinkingState.ts index 310cb2e3..7db6f134 100644 --- a/app/client/components/LinkingState.ts +++ b/app/client/components/LinkingState.ts @@ -9,137 +9,201 @@ import {FilterColValues, QueryOperation} from "app/common/ActiveDocAPI"; import {isList, isListType, isRefListType} from "app/common/gristTypes"; import * as gutil from "app/common/gutil"; import {UIRowId} from 'app/plugin/GristAPI'; +import {CellValue} from "app/plugin/GristData"; import {encodeObject} from 'app/plugin/objtypes'; -import {Disposable} from "grainjs"; +import {Disposable, Holder, MultiHolder} from "grainjs"; import * as ko from "knockout"; -import identity = require('lodash/identity'); +import merge = require('lodash/merge'); import mapValues = require('lodash/mapValues'); +import pick = require('lodash/pick'); import pickBy = require('lodash/pickBy'); -/** - * Returns if the first table is a summary of the second. If both are summary tables, returns true - * if the second table is a more detailed summary, i.e. has additional group-by columns. - * @param summary: TableRec for the table to check for being the summary table. - * @param detail: TableRec for the table to check for being the detailed version. - * @returns {Boolean} Whether the first argument is a summarized version of the second. - */ -function isSummaryOf(summary: TableRec, detail: TableRec): boolean { - const summarySource = summary.summarySourceTable(); - if (summarySource === detail.getRowId()) { return true; } - const detailSource = detail.summarySourceTable(); - return (Boolean(summarySource) && - detailSource === summarySource && - summary.getRowId() !== detail.getRowId() && - gutil.isSubset(summary.summarySourceColRefs(), detail.summarySourceColRefs())); -} +// Descriptive string enum for each case of linking +// Currently used for rendering user-facing link info +// TODO JV: Eventually, switching the main block of linking logic in LinkingState constructor to be a big +// switch(linkType){} would make things cleaner. +// TODO JV: also should add "Custom-widget-linked" to this, but holding off until Jarek's changes land +type LinkType = "Filter:Summary-Group" | + "Filter:Col->Col"| + "Filter:Row->Col"| + "Summary"| + "Show-Referenced-Records"| + "Cursor:Same-Table"| + "Cursor:Reference"| + "Error:Invalid"; + +// If this LinkingState represents a filter link, it will set its filterState to this object +// The filterColValues portion is just the data needed for filtering (same as manual filtering), and is passed +// to the backend in some cases (CSV export) +// The filterState includes extra info to display filter state to the user +type FilterState = FilterColValues & { + filterLabels: { [colId: string]: string[] }; //formatted and displayCol-ed values to show to user + colTypes: {[colId: string]: string;} +}; +function FilterStateToColValues(fs: FilterState) { return pick(fs, ['filters', 'operations']); } + +//Since we're not making full objects for these, need to define sensible "empty" values here +export const EmptyFilterState: FilterState = {filters: {}, filterLabels: {}, operations: {}, colTypes: {}}; +export const EmptyFilterColValues: FilterColValues = FilterStateToColValues(EmptyFilterState); + -/** - * Maintains state useful for linking sections, i.e. auto-filtering and auto-scrolling. - * Exposes .filterColValues, which is either null or a computed evaluating to a filtering object; - * and .cursorPos, which is either null or a computed that evaluates to a cursor position. - * LinkingState must be created with a valid srcSection and tgtSection. - * - * There are several modes of linking: - * (1) If tgtColId is set, tgtSection will be filtered to show rows whose values of target column - * are equal to the value of source column in srcSection at the cursor. With byAllShown set, all - * values in srcSection are used (rather than only the value in the cursor). - * (2) If srcSection is a summary of tgtSection, then tgtSection is filtered to show only those - * rows that match the row at the cursor of srcSection. - * (3) If tgtColId is null, tgtSection is scrolled to the rowId determined by the value of the - * source column at the cursor in srcSection. - * - * @param gristDoc: GristDoc instance, for getting the relevant TableData objects. - * @param srcSection: RowModel for the section that drives the target section. - * @param srcColId: Name of the column that drives the target section, or null to use rowId. - * @param tgtSection: RowModel for the section that's being driven. - * @param tgtColId: Name of the reference column to auto-filter by, or null to auto-scroll. - * @param byAllShown: For auto-filter, filter by all values in srcSection rather than only the - * value at the cursor. The user can use column filters on srcSection to control what's shown - * in the linked tgtSection. - */ export class LinkingState extends Disposable { // If linking affects target section's cursor, this will be a computed for the cursor rowId. + // Is undefined if not cursor-linked public readonly cursorPos?: ko.Computed; - // If linking affects filtering, this is a computed for the current filtering state, as a - // {[colId]: colValues} mapping, with a dependency on srcSection.activeRowId() + // If linking affects filtering, this is a computed for the current filtering state, including user-facing + // labels for filter values and types of the filtered columns + // with a dependency on srcSection.activeRowId() + // Is undefined if not link-filtered + public readonly filterState?: ko.Computed; + + // filterColValues is a subset of the current filterState needed for filtering (subset of ClientQuery) + // {[colId]: colValues, [colId]: operations} mapping, public readonly filterColValues?: ko.Computed; // Get default values for a new record so that it continues to satisfy the current linking filters public readonly getDefaultColValues: () => any; + // Which case of linking we've got, this is a descriptive string-enum. + public readonly linkTypeDescription: ko.Computed; + + private _docModel: DocModel; private _srcSection: ViewSectionRec; private _srcTableModel: DataTableModel; - private _srcCol: ColumnRec; private _srcColId: string | undefined; constructor(docModel: DocModel, linkConfig: LinkConfig) { super(); const {srcSection, srcCol, srcColId, tgtSection, tgtCol, tgtColId} = linkConfig; + this._docModel = docModel; this._srcSection = srcSection; - this._srcCol = srcCol; this._srcColId = srcColId; this._srcTableModel = docModel.dataTables[srcSection.table().tableId()]; const srcTableData = this._srcTableModel.tableData; - if (tgtColId) { - const operation = isRefListType(tgtCol.type()) ? 'intersects' : 'in'; - if (srcSection.selectedRowsActive()) { - this.filterColValues = this._srcCustomFilter(tgtColId, operation); - } else if (srcColId) { - this.filterColValues = this._srcCellFilter(tgtColId, operation); - } else { - this.filterColValues = this._simpleFilter(tgtColId, operation, (rowId => [rowId])); + // === IMPORTANT NOTE! (this applies throughout this file) + // srcCol and tgtCol can be the "empty column" + // - emptyCol.getRowId() === 0 + // - emptyCol.colId() === undefined + // The typical pattern to deal with this is to use `srcColId = col?.colId()`, and test for `if (srcColId) {...}` + + this.linkTypeDescription = this.autoDispose(ko.computed((): LinkType => { + if(srcSection.isDisposed()) { + //srcSection disposed can happen transiently. Can happen when deleting tables and then undoing? + //nbrowser tests: LinkingErrors and RawData seem to hit this case + console.warn("srcSection disposed in linkingState: linkTypeDescription"); + return "Error:Invalid"; } - } else if (srcColId && isRefListType(srcCol.type())) { - this.filterColValues = this._srcCellFilter('id', 'in'); - } else if (!srcColId && isSummaryOf(srcSection.table(), tgtSection.table())) { - // We filter summary tables when a summary section is linked to a more detailed one without - // specifying src or target column. The filtering is on the shared group-by column (i.e. all - // those in the srcSection). - // TODO: This approach doesn't help cursor-linking (the other direction). If we have the - // inverse of summary-table's 'group' column, we could implement both, and more efficiently. - const isDirectSummary = srcSection.table().summarySourceTable() === tgtSection.table().getRowId(); - const _filterColValues = ko.observable(); - this.filterColValues = this.autoDispose(ko.computed(() => _filterColValues())); - - // source data table could still be loading (this could happen after changing the group by - // columns of a linked summary table for instance), hence the below listener. - this.autoDispose(srcTableData.dataLoadedEmitter.addListener(_update)); - _update(); - function _update() { - const result: FilterColValues = {filters: {}, operations: {}}; - if (srcSection.isDisposed()) { - return result; + if (srcSection.table().summarySourceTable() && srcColId === "group") { + return "Filter:Summary-Group"; //implemented as col->col, but special-cased in select-by + } else if (srcColId && tgtColId) { + return "Filter:Col->Col"; + } else if (!srcColId && tgtColId) { + return "Filter:Row->Col"; + } else if (srcColId && !tgtColId) { // Col->Row, i.e. show a ref + if (isRefListType(srcCol.type())) // TODO: fix this once ref-links are unified, both could be show-ref-rec + { return "Show-Referenced-Records"; } + else + { return "Cursor:Reference"; } + } else if (!srcColId && !tgtColId) { //Either same-table cursor link OR summary link + if (isSummaryOf(srcSection.table(), tgtSection.table())) + { return "Summary"; } + else + { return "Cursor:Same-Table"; } + } else { // This case shouldn't happen, but just check to be safe + return "Error:Invalid"; + } + })); + + if (srcSection.selectedRowsActive()) { // old, special-cased custom filter + const operation = (tgtColId && isRefListType(tgtCol.type())) ? 'intersects' : 'in'; + this.filterState = this._srcCustomFilter(tgtCol, operation); // works whether tgtCol is the empty col or not + + } else if (tgtColId) { // Standard filter link + // If srcCol is the empty col, is a row->col filter (i.e. id -> tgtCol) + // else is a col->col filter (srcCol -> tgtCol) + // MakeFilterObs handles it either way + this.filterState = this._makeFilterObs(srcCol, tgtCol); + + } else if (srcColId && isRefListType(srcCol.type())) { // "Show Referenced Records" link + // tgtCol is the emptycol (i.e. the id col) + // srcCol must be a reference to the tgt table + // Link will filter tgt section to show exactly the set of rowIds referenced by the srcCol + // (NOTE: currently we only do this for reflists, single refs handled as cursor links for now) + this.filterState = this._makeFilterObs(srcCol, undefined); + + } else if (!srcColId && isSummaryOf(srcSection.table(), tgtSection.table())) { //Summary linking + // We do summary filtering if no cols specified and summary section is linked to a more detailed summary + // (or to the summarySource table) + // Implemented as multiple column filters, one for each groupByCol of the src table + + // temp vars for _update to use (can't set filterState directly since it's gotta be a computed) + const _filterState = ko.observable(); + this.filterState = this.autoDispose(ko.computed(() => _filterState())); + + // update may be called multiple times, so need a holder to handle disposal + // Note: grainjs MultiHolder can't actually be cleared. To be able to dispose of multiple things, we need + // to make a MultiHolder in a Holder, which feels ugly but works. + // TODO: Update this if we ever patch grainjs to allow multiHolder.clear() + const updateHolder = Holder.create(this); + + // source data table could still be loading (this could happen after changing the group-by + // columns of a linked summary table for instance). Define an _update function to be called when data loads + const _update = () => { + if (srcSection.isDisposed() || srcSection.table().groupByColumns().length === 0) { + // srcSection disposed can happen transiently. Can happen when deleting tables and then undoing? + // Tests nbrowser/LinkingErrors and RawData might hit this case + // groupByColumns === [] can happen if we make a summary tab [group by nothing]. (in which case: don't filter) + _filterState(EmptyFilterState); + return; } - const srcRowId = srcSection.activeRowId(); - for (const c of srcSection.table().groupByColumns()) { - const colId = c.colId(); - const srcValue = srcTableData.getValue(srcRowId as number, colId); - result.filters[colId] = [srcValue]; - result.operations[colId] = 'in'; - if (isDirectSummary && isListType(c.summarySource().type())) { - // If the source groupby column is a ChoiceList or RefList, then null or '' in the summary table - // should match against an empty list in the source table. - result.operations[colId] = srcValue ? 'intersects' : 'empty'; - } + + //Make a MultiHolder to own this invocation's objects (disposes of old one) + //TODO (MultiHolder in a Holder is a bit of a hack, but needed to hold multiple objects I think) + const updateMultiHolder = MultiHolder.create(updateHolder); + + //Make one filter for each groupBycolumn of srcSection + const resultFilters: (ko.Computed|undefined)[] = srcSection.table().groupByColumns().map(srcGCol => + this._makeFilterObs(srcGCol, summaryGetCorrespondingCol(srcGCol, tgtSection.table()), updateMultiHolder) + ); + + //If any are undef (i.e. error in makeFilterObs), error out + if(resultFilters.some((f) => f === undefined)) { + console.warn("LINKINGSTATE: some of filters are undefined", resultFilters); + _filterState(EmptyFilterState); + return; } - _filterColValues(result); - } - } else if (srcSection.selectedRowsActive()) { - this.filterColValues = this._srcCustomFilter('id', 'in'); - } else { - const srcValueFunc = srcColId ? this._makeSrcCellGetter() : identity; - if (srcValueFunc) { + + //Merge them together in a computed + const resultComputed = updateMultiHolder.autoDispose(ko.computed(() => { + return merge({}, ...resultFilters.map(filtObs => filtObs!())) as FilterState; + })); + _filterState(resultComputed()); + resultComputed.subscribe((val) => _filterState(val)); + }; // End of update function + + // Call update when data loads, also call now to be safe + this.autoDispose(srcTableData.dataLoadedEmitter.addListener(_update)); + _update(); + + // ================ CURSOR LINKS: ================= + } else { //!tgtCol && !summary-link && (!lookup-link || !reflist), + // either same-table cursor-link (!srcCol && !tgtCol, so do activeRowId -> cursorPos) + // or cursor-link by reference ( srcCol && !tgtCol, so do srcCol -> cursorPos) + + //colVal, or rowId if no srcCol + const srcValueFunc = this._makeValGetter(this._srcSection.table(), this._srcColId); + + if (srcValueFunc) { // if makeValGetter succeeded, set up cursorPos this.cursorPos = this.autoDispose(ko.computed(() => srcValueFunc(srcSection.activeRowId()) as UIRowId )); } - if (!srcColId) { - // This is a same-record link: copy getDefaultColValues from the source if possible + if (!srcColId) { // If same-table cursor-link, copy getDefaultColValues from the source if possible const getDefaultColValues = srcSection.linkingState()?.getDefaultColValues; if (getDefaultColValues) { this.getDefaultColValues = getDefaultColValues; @@ -147,12 +211,18 @@ export class LinkingState extends Disposable { } } + // Make filterColValues, which is just the filtering-relevant parts of filterState + // (it's used in places that don't need the user-facing labels, e.g. CSV export) + this.filterColValues = (this.filterState) ? + ko.computed(() => FilterStateToColValues(this.filterState!())) + : undefined; + if (!this.getDefaultColValues) { this.getDefaultColValues = () => { - if (!this.filterColValues) { + if (!this.filterState) { return {}; } - const {filters, operations} = this.filterColValues.peek(); + const {filters, operations} = this.filterState.peek(); return mapValues( pickBy(filters, (value: any[], key: string) => value.length > 0 && key !== "id"), (value, key) => operations[key] === "intersects" ? encodeObject(value) : value[0] @@ -165,71 +235,245 @@ export class LinkingState extends Disposable { * Returns a boolean indicating whether editing should be disabled in the destination section. */ public disableEditing(): boolean { - return Boolean(this.filterColValues) && this._srcSection.activeRowId() === 'new'; + return Boolean(this.filterState) && this._srcSection.activeRowId() === 'new'; } - // Value for this.filterColValues filtering based on a single column - private _simpleFilter( - colId: string, operation: QueryOperation, valuesFunc: (rowId: UIRowId|null) => any[] - ): ko.Computed { - return this.autoDispose(ko.computed(() => { + + /** + * Makes a standard filter link (summary tables and cursor links handled separately) + * treats (srcCol === undefined) as srcColId === "id", same for tgt + * + * if srcColId === "id", uses src activeRowId as the selector value (i.e. a ref to that row) + * else, gets the current value in selectedRow's SrcCol + * + * Returns a FilterColValues with a single filter {[tgtColId|"id":string] : (selectorVals:val[])} + * note: selectorVals is always a list of values: if reflist the leading "L" is trimmed, if single val then [val] + * + * If unable to initialize (sometimes happens when things are loading?), returns undefined + * + * NOTE: srcColId and tgtColId MUST NOT both be undefined, that implies either cursor linking or summary linking, + * which this doesn't handle + * + * @param srcCol srcCol for the filter, or undefined/the empty column to mean the entire record + * @param tgtCol tgtCol for the filter, or undefined/the empty column to mean the entire record + * @param [owner=this] Owner for all created disposables + * @private + */ + private _makeFilterObs( + srcCol: ColumnRec|undefined, + tgtCol: ColumnRec|undefined, + owner: MultiHolder = this): ko.Computed | undefined + { + const srcColId = srcCol?.colId(); + const tgtColId = tgtCol?.colId(); + + //Assert: if both are null then it's a summary filter or same-table cursor-link, neither of which should go here + if(!srcColId && !tgtColId) { + throw Error("ERROR in _makeFilterObs: srcCol and tgtCol can't both be empty"); + } + + //if (srcCol), selectorVal is the value in activeRowId[srcCol]. + //if (!srcCol), then selectorVal is the entire record, so func just returns the rowId, or null if the rowId is "new" + const selectorValGetter = this._makeValGetter(this._srcSection.table(), srcColId); + + // Figure out display val to show for the selector (if selector is a Ref) + // - if srcCol is a ref, we display its displayColModel(), which is what is shown in the cell + // - However, if srcColId === 'id', there is no srcCol.displayColModel. + // We also can't use tgtCol.displayColModel, since we're getting values from the source section. + // Therefore: The value we want to display is srcRow[tgtCol.visibleColModel.colId] + // + // Note: if we've gotten here, tgtCol is guaranteed to be a ref/reflist if srcColId === undefined + // (because we ruled out the undef/undef case above) + // Note: tgtCol.visibleCol.colId can be undefined, iff visibleCol is rowId. makeValGetter handles that implicitly + const displayColId = srcColId ? + srcCol!.displayColModel().colId() : + tgtCol!.visibleColModel().colId(); + const displayValGetter = this._makeValGetter(this._srcSection.table(), displayColId); + + //Note: if src is a reflist, its displayVal will be a list of the visibleCol vals, + // i.e ["L", visVal1, visVal2], but they won't be formatter()-ed + + //Grab the formatter (for numerics, dates, etc) + const displayValFormatter = srcColId ? srcCol!.visibleColFormatter() : tgtCol!.visibleColFormatter(); + + const isSrcRefList = srcColId && isRefListType(srcCol!.type()); + const isTgtRefList = tgtColId && isRefListType(tgtCol!.type()); + + if (!selectorValGetter || !displayValGetter) { + console.error("ERROR in _makeFilterObs: couldn't create valGetters for srcSection"); + return undefined; + } + + //Now, create the actual observable that updates with activeRowId + //(we autodispose/return it at the end of the function) is this right? TODO JV + return owner.autoDispose(ko.computed(() => { + + //Get selector-rowId const srcRowId = this._srcSection.activeRowId(); if (srcRowId === null) { - console.warn("_simpleFilter activeRowId is null"); - return { filters: {}, operations: {}}; + console.warn("_makeFilterObs activeRowId is null"); + return EmptyFilterState; } - const values = valuesFunc(srcRowId); - return {filters: {[colId]: values}, operations: {[colId]: operation}} as FilterColValues; - })); - } - // Value for this.filterColValues based on the value in srcCol at the selected row - private _srcCellFilter(colId: string, operation: QueryOperation): ko.Computed | undefined { - const srcCellGetter = this._makeSrcCellGetter(); - if (srcCellGetter) { - const isSrcRefList = isRefListType(this._srcCol.type()); - return this._simpleFilter(colId, operation, rowId => { - const value = srcCellGetter(rowId); - if (isSrcRefList) { - if (isList(value)) { - return value.slice(1); - } else { - // The cell value is invalid, so the filter should be empty - return []; - } + //Get values from selector row + const selectorCellVal = selectorValGetter(srcRowId); + const displayCellVal = displayValGetter(srcRowId); + + // Coerce values into lists (FilterColValues wants output as a list, even if only 1 val) + let filterValues: any[]; + let displayValues: any[]; + if(!isSrcRefList) { + filterValues = [selectorCellVal]; + displayValues = [displayCellVal]; + + } else if(isSrcRefList && isList(selectorCellVal)) { //Reflists are: ["L", ref1, ref2, ...], slice off the L + filterValues = selectorCellVal.slice(1); + + //selectorValue and displayValue might not match up? Shouldn't happen, but let's yell loudly if it does + if (isList(displayCellVal) && displayCellVal.length === selectorCellVal.length) { + displayValues = displayCellVal.slice(1); } else { - return [value]; + console.warn("Error in LinkingState: displayVal list doesn't match selectorVal list "); + displayValues = filterValues; //fallback to unformatted values } - }); - } + + } else { //isSrcRefList && !isList(val), probably null. Happens with blank reflists, or if cursor on the 'new' row + filterValues = []; + displayValues = []; + if(selectorCellVal !== null) { // should be null, but let's warn if it's not + console.warn("Error in LinkingState.makeFilterObs(), srcVal is reflist but has non-list non-null value"); + } + } + + //Need to use 'intersects' for ChoiceLists or RefLists + let operation = (tgtColId && isListType(tgtCol!.type())) ? 'intersects' : 'in'; + + // If selectorVal is a blank-cell value, need to change operation for correct behavior with lists + // Blank selector shouldn't mean "show no records", it should mean "show records where tgt column is also blank" + if(srcRowId !== 'new') { //(EXCEPTION: the add-row, which is when we ACTUALLY want to show no records) + + // If tgtCol is a list (RefList or Choicelist) and selectorVal is null/blank, operation must be 'empty' + if (tgtCol?.type() === "ChoiceList" && !isSrcRefList && selectorCellVal === "") { operation = 'empty'; } + else if (isTgtRefList && !isSrcRefList && selectorCellVal === 0) { operation = 'empty'; } + else if (isTgtRefList && isSrcRefList && filterValues.length === 0) { operation = 'empty'; } + // other types can have falsey values when non-blank (e.g. for numbers, 0 is a valid value; blank cell is null) + // However, we don't need to check for those here, since we only care about lists (Reflist or Choicelist) + + // If tgtCol is a single ref, nullness is represented by [0], not by [], so need to create that null explicitly + else if (!isTgtRefList && isSrcRefList && filterValues.length === 0) { + filterValues = [0]; + displayValues = ['']; + } + + // NOTES ON CHOICELISTS: they only show up in a few cases. + // - ChoiceList can only ever appear in links as the tgtcol + // (ChoiceLists can only be linked from summ. tables, and summary flattens lists, so srcCol would be 'Choice') + // - empty choicelist is [""]. + } + + // Run values through formatters (for dates, numerics, Refs with visCol = rowId) + const filterLabelVals: string[] = displayValues.map(v => displayValFormatter.formatAny(v)); + + return { + filters: {[tgtColId || "id"]: filterValues}, + filterLabels: {[tgtColId || "id"]: filterLabelVals}, + operations: {[tgtColId || "id"]: operation}, + colTypes: {[tgtColId || "id"]: (tgtCol || srcCol)!.type()} + //at least one of tgt/srcCol is guaranteed to be non-null, and they will have the same type + } as FilterState; + })); } // Value for this.filterColValues based on the values in srcSection.selectedRows - private _srcCustomFilter(colId: string, operation: QueryOperation): ko.Computed | undefined { + //"null" for column implies id column + private _srcCustomFilter( + column: ColumnRec|undefined, operation: QueryOperation): ko.Computed { + //Note: column may be the empty column, i.e. column != undef, but column.colId() is undefined + const colId = (!column || column.colId() === undefined) ? "id" : column.colId(); return this.autoDispose(ko.computed(() => { const values = this._srcSection.selectedRows(); - return {filters: {[colId]: values}, operations: {[colId]: operation}} as FilterColValues; + return { + filters: {[colId]: values}, + filterLabels: {[colId]: values?.map(v => String(v))}, //selectedRows should never be null if customFiltered + operations: {[colId]: operation}, + colTypes: {[colId]: column?.type() || `Ref:${column?.table().tableId}`} + } as FilterState; //TODO: fix this once we have cases of customwidget linking to test with })); } - // Returns a function which returns the value of the cell - // in srcCol in the selected record of srcSection. - // Uses a row model to create a dependency on the cell's value, - // so changes to the cell value will notify observers - private _makeSrcCellGetter() { - const srcRowModel = this.autoDispose(this._srcTableModel.createFloatingRowModel()) as DataRowModel; - const srcCellObs = srcRowModel.cells[this._srcColId!]; - // If no srcCellObs, linking is broken; do nothing. This shouldn't happen, but may happen + // Returns a ValGetter function, i.e. (rowId) => cellValue(rowId, colId), for the specified table and colId, + // Or null if there's an error in making the valgetter + // Note: + // - Uses a row model to create a dependency on the cell's value, so changes to the cell value will notify observers + // - ValGetter returns null for the 'new' row + // - An undefined colId means to use the 'id' column, i.e. Valgetter is (rowId)=>rowId + private _makeValGetter(table: TableRec, colId: string | undefined, owner: MultiHolder=this) + : ( null | ((r: UIRowId | null) => CellValue | null) ) // (null | ValGetter) + { + if(colId === undefined) { //passthrough for id cols + return (rowId: UIRowId | null) => { return rowId === 'new' ? null : rowId; }; + } + + const tableModel = this._docModel.dataTables[table.tableId()]; + const rowModel = (tableModel.createFloatingRowModel()) as DataRowModel; + owner.autoDispose(rowModel); + const cellObs = rowModel.cells[colId]; + // If no cellObs, can't make a val getter. This shouldn't happen, but may happen // transiently while the separate linking-related observables get updated. - if (!srcCellObs) { + if (!cellObs) { + console.warn(`Issue in LinkingState._makeValGetter(${table.tableId()},${colId}): cellObs is nullish`); return null; } - return (rowId: UIRowId | null) => { - srcRowModel.assign(rowId); - if (rowId === 'new') { - return 'new'; - } - return srcCellObs(); + + return (rowId: UIRowId | null) => { // returns cellValue | null + rowModel.assign(rowId); + if (rowId === 'new') { return null; } // used to return "new", hopefully the change doesn't come back to haunt us + return cellObs(); }; } } + +// === Helpers: + +/** + * Returns whether the first table is a summary of the second. If both are summary tables, returns true + * if the second table is a more detailed summary, i.e. has additional group-by columns. + * @param summary: TableRec for the table to check for being the summary table. + * @param detail: TableRec for the table to check for being the detailed version. + * @returns {Boolean} Whether the first argument is a summarized version of the second. + */ +function isSummaryOf(summary: TableRec, detail: TableRec): boolean { + const summarySource = summary.summarySourceTable(); + if (summarySource === detail.getRowId()) { return true; } + const detailSource = detail.summarySourceTable(); + return (Boolean(summarySource) && + detailSource === summarySource && + summary.getRowId() !== detail.getRowId() && + gutil.isSubset(summary.summarySourceColRefs(), detail.summarySourceColRefs())); +} + +/** + * When TableA is a summary of TableB, each of TableA.groupByCols corresponds to a specific col of TableB + * This function returns the column of B that corresponds to a particular groupByCol of A + * - If A is a direct summary of B, then the corresponding col for A.someCol is A.someCol.summarySource() + * - However if A and B are both summaries of C, then A.someCol.summarySource() would + * give us C.someCol, but what we actually want is B.someCol. + * - Since we know A is a summary of B, then B's groupByCols must include all of A's groupbycols, + * so we can get B.someCol by matching on colId. + * @param srcGBCol: ColumnRec, must be a groupByColumn, and srcGBCol.table() must be a summary of tgtTable + * @param tgtTable: TableRec to get corresponding column from + * @returns {ColumnRec} The corresponding column of tgtTable + */ +function summaryGetCorrespondingCol(srcGBCol: ColumnRec, tgtTable: TableRec): ColumnRec { + if(!isSummaryOf(srcGBCol.table(), tgtTable)) + { throw Error("ERROR in LinkingState summaryGetCorrespondingCol: srcTable must be summary of tgtTable"); } + + if(tgtTable.summarySourceTable() === 0) { //if direct summary + return srcGBCol.summarySource(); + } else { // else summary->summary, match by colId + const srcColId = srcGBCol.colId(); + const retVal = tgtTable.groupByColumns().find((tgtCol) => tgtCol.colId() === srcColId); //should always exist + if(!retVal) { throw Error("ERROR in LinkingState summaryGetCorrespondingCol: summary table lacks groupby col"); } + return retVal; + } +} diff --git a/app/client/models/DocPageModel.ts b/app/client/models/DocPageModel.ts index 8b45c58e..9b4cf3ac 100644 --- a/app/client/models/DocPageModel.ts +++ b/app/client/models/DocPageModel.ts @@ -209,7 +209,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel { public createLeftPane(leftPanelOpen: Observable) { return cssLeftPanel( dom.maybe(this.gristDoc, (activeDoc) => [ - addNewButton(leftPanelOpen, + addNewButton({ isOpen: leftPanelOpen }, menu(() => addMenu(this.importSources, activeDoc, this.isReadonly.get()), { placement: 'bottom-start', // "Add New" menu should have the same width as the "Add New" button that opens it. diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts index f02c0c6b..a30728b3 100644 --- a/app/client/models/entities/ViewSectionRec.ts +++ b/app/client/models/entities/ViewSectionRec.ts @@ -1,5 +1,5 @@ import BaseView from 'app/client/components/BaseView'; -import {LinkingState} from 'app/client/components/LinkingState'; +import {EmptyFilterColValues, LinkingState} from 'app/client/components/LinkingState'; import {KoArray} from 'app/client/lib/koArray'; import {ColumnToMapImpl} from 'app/client/models/ColumnToMap'; import { @@ -637,7 +637,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): })); this.linkingFilter = this.autoDispose(ko.pureComputed(() => { - return this.linkingState()?.filterColValues?.() || {filters: {}, operations: {}}; + return this.linkingState()?.filterColValues?.() || EmptyFilterColValues; })); // If the view instance for this section is instantiated, it will be accessible here. diff --git a/app/client/ui/AddNewButton.ts b/app/client/ui/AddNewButton.ts index 1cd45dc1..02fa1762 100644 --- a/app/client/ui/AddNewButton.ts +++ b/app/client/ui/AddNewButton.ts @@ -5,14 +5,27 @@ import {dom, DomElementArg, Observable, styled} from "grainjs"; const t = makeT(`AddNewButton`); -export function addNewButton(isOpen: Observable | boolean = true, ...args: DomElementArg[]) { +export function addNewButton( + { + isOpen, + isDisabled = false, + }: { + isOpen: Observable | boolean, + isDisabled?: boolean + }, + ...args: DomElementArg[] +) { return cssAddNewButton( cssAddNewButton.cls('-open', isOpen), + cssAddNewButton.cls('-disabled', isDisabled), // Setting spacing as flex items allows them to shrink faster when there isn't enough space. cssLeftMargin(), cssAddText(t("Add New")), dom('div', {style: 'flex: 1 1 16px'}), - cssPlusButton(cssPlusIcon('Plus')), + cssPlusButton( + cssPlusButton.cls('-disabled', isDisabled), + cssPlusIcon('Plus') + ), dom('div', {style: 'flex: 0 1 16px'}), ...args, ); @@ -47,6 +60,11 @@ export const cssAddNewButton = styled('div', ` background-color: ${theme.controlPrimaryHoverBg}; --circle-color: ${theme.addNewCircleHoverBg}; } + + &-disabled, &-disabled:hover { + color: ${theme.controlDisabledFg}; + background-color: ${theme.controlDisabledBg} + } `); const cssLeftMargin = styled('div', ` flex: 0 1 24px; @@ -72,6 +90,9 @@ const cssPlusButton = styled('div', ` border-radius: 14px; background-color: var(--circle-color); text-align: center; + &-disabled { + background-color: ${theme.controlDisabledBg}; + } `); const cssPlusIcon = styled(icon, ` background-color: ${theme.addNewCircleFg}; diff --git a/app/client/ui/HomeIntro.ts b/app/client/ui/HomeIntro.ts index a5d52d10..debe362f 100644 --- a/app/client/ui/HomeIntro.ts +++ b/app/client/ui/HomeIntro.ts @@ -1,5 +1,5 @@ import {makeT} from 'app/client/lib/localization'; -import {getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState'; +import {getLoginOrSignupUrl, getLoginUrl, getSignupUrl, urlState} from 'app/client/models/gristUrlState'; import {HomeModel} from 'app/client/models/HomeModel'; import {productPill} from 'app/client/ui/AppHeader'; import * as css from 'app/client/ui/DocMenuCss'; @@ -12,6 +12,7 @@ import {cssLink} from 'app/client/ui2018/links'; import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls'; import {FullUser} from 'app/common/LoginSessionAPI'; import * as roles from 'app/common/roles'; +import {getGristConfig} from 'app/common/urlUtils'; import {Computed, dom, DomContents, styled} from 'grainjs'; const t = makeT('HomeIntro'); @@ -112,10 +113,36 @@ function makePersonalIntro(homeModel: HomeModel, user: FullUser) { ]; } +function makeAnonIntroWithoutPlayground(homeModel: HomeModel) { + return [ + (!isFeatureEnabled('helpCenter') ? null : cssIntroLine(t("Visit our {{link}} to learn more about Grist.", { + link: helpCenterLink() + }), testId('welcome-text-no-playground'))), + cssIntroLine(t("To use Grist, please either sign up or sign in.")), + cssBtnGroup( + cssBtn(t("Sign up"), cssButton.cls('-primary'), testId('intro-sign-up'), + dom.on('click', () => location.href = getSignupUrl()) + ), + cssBtn(t("Sign in"), testId('intro-sign-in'), + dom.on('click', () => location.href = getLoginUrl()) + ) + ) + ]; +} + function makeAnonIntro(homeModel: HomeModel) { + const welcomeToGrist = css.docListHeader(t("Welcome to Grist!"), testId('welcome-title')); + + if (!getGristConfig().enableAnonPlayground) { + return [ + welcomeToGrist, + ...makeAnonIntroWithoutPlayground(homeModel) + ]; + } + const signUp = cssLink({href: getLoginOrSignupUrl()}, t("Sign up")); return [ - css.docListHeader(t("Welcome to Grist!"), testId('welcome-title')), + welcomeToGrist, cssIntroLine(t("Get started by exploring templates, or creating your first Grist document.")), cssIntroLine(t("{{signUp}} to save your work. ", {signUp}), (!isFeatureEnabled('helpCenter') ? null : t("Visit our {{link}} to learn more.", { link: helpCenterLink() })), diff --git a/app/client/ui/HomeLeftPane.ts b/app/client/ui/HomeLeftPane.ts index 2d02bfc0..c2858d77 100644 --- a/app/client/ui/HomeLeftPane.ts +++ b/app/client/ui/HomeLeftPane.ts @@ -30,16 +30,17 @@ export function createHomeLeftPane(leftPanelOpen: Observable, home: Hom const creating = observable(false); const renaming = observable(null); const isAnonymous = !home.app.currentValidUser; + const canCreate = !isAnonymous || getGristConfig().enableAnonPlayground; return cssContent( dom.autoDispose(creating), dom.autoDispose(renaming), - addNewButton(leftPanelOpen, - menu(() => addMenu(home, creating), { + addNewButton({ isOpen: leftPanelOpen, isDisabled: !canCreate }, + canCreate ? menu(() => addMenu(home, creating), { placement: 'bottom-start', // "Add New" menu should have the same width as the "Add New" button that opens it. stretchToSelector: `.${cssAddNewButton.className}` - }), + }) : null, dom.cls('behavioral-prompt-add-new'), testId('dm-add-new'), ), diff --git a/app/client/ui/MakeCopyMenu.ts b/app/client/ui/MakeCopyMenu.ts index 645fe72b..54415a31 100644 --- a/app/client/ui/MakeCopyMenu.ts +++ b/app/client/ui/MakeCopyMenu.ts @@ -3,6 +3,7 @@ * the sample documents (those in the Support user's Examples & Templates workspace). */ +import {hooks} from 'app/client/Hooks'; import {makeT} from 'app/client/lib/localization'; import {AppModel, reportError} from 'app/client/models/AppModel'; import {DocPageModel} from 'app/client/models/DocPageModel'; @@ -310,14 +311,14 @@ export function downloadDocModal(doc: Document, pageModel: DocPageModel) { ), cssModalButtons( dom.domComputed(use => - bigPrimaryButtonLink(`Download`, { + bigPrimaryButtonLink(`Download`, hooks.maybeModifyLinkAttrs({ href: pageModel.appModel.api.getDocAPI(doc.id).getDownloadUrl({ template: use(selected) === "template", removeHistory: use(selected) === "nohistory" || use(selected) === "template", }), target: '_blank', download: '' - }, + }), dom.on('click', () => { ctl.close(); }), diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index a9e21037..3c490974 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -16,14 +16,15 @@ import * as commands from 'app/client/components/commands'; import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc'; +import {EmptyFilterState} from "app/client/components/LinkingState"; import {RefSelect} from 'app/client/components/RefSelect'; import ViewConfigTab from 'app/client/components/ViewConfigTab'; import {domAsync} from 'app/client/lib/domAsync'; import * as imports from 'app/client/lib/imports'; import {makeT} from 'app/client/lib/localization'; -import {createSessionObs} from 'app/client/lib/sessionObs'; +import {createSessionObs, isBoolean, SessionObs} from 'app/client/lib/sessionObs'; import {reportError} from 'app/client/models/AppModel'; -import {ViewSectionRec} from 'app/client/models/DocModel'; +import {ColumnRec, ViewSectionRec} from 'app/client/models/DocModel'; import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig'; import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig'; import {BuildEditorOptions} from 'app/client/ui/FieldConfig'; @@ -41,6 +42,8 @@ import {IconName} from 'app/client/ui2018/IconList'; import {icon} from 'app/client/ui2018/icons'; import {select} from 'app/client/ui2018/menus'; import {FieldBuilder} from 'app/client/widgets/FieldBuilder'; +import {isFullReferencingType} from "app/common/gristTypes"; +import {not} from 'app/common/gutil'; import {StringUnion} from 'app/common/StringUnion'; import {IWidgetType} from 'app/common/widgetTypes'; import { @@ -60,6 +63,10 @@ import { } from 'grainjs'; import * as ko from 'knockout'; +// some unicode characters +const BLACK_CIRCLE = '\u2022'; +const ELEMENTOF = '\u2208'; //220A for small elementof + const t = makeT('RightPanel'); // Represents a top tab of the right side-pane. @@ -109,6 +116,10 @@ export class RightPanel extends Disposable { return sec.getRowId() ? sec : null; }); + // Which subtab is open for configuring page widget. + private _advLinkInfoCollapsed = createSessionObs(this, "rightPageAdvancedLinkInfoCollapsed", + true, isBoolean); + constructor(private _gristDoc: GristDoc, private _isOpen: Observable) { super(); this._extraTool = _gristDoc.rightPanelTool; @@ -484,6 +495,189 @@ export class RightPanel extends Disposable { return dom.maybe(viewConfigTab, (vct) => vct.buildSortFilterDom()); } + private _buildLinkInfo(activeSection: ViewSectionRec, ...domArgs: DomElementArg[]) { + //NOTE!: linkingState.filterState might transiently be EmptyFilterState while things load + //Each case (filters-table, id cols, etc) needs to be able to handle having lfilter.filterLabels = {} + const tgtSec = activeSection; + return dom.domComputed((use) => { + + const srcSec = use(tgtSec.linkSrcSection); //might be the empty section + const srcCol = use(tgtSec.linkSrcCol); + const srcColId = use(use(tgtSec.linkSrcCol).colId); // if srcCol is the empty col, colId will be undefined + //const tgtColId = use(use(tgtSec.linkTargetCol).colId); + const srcTable = use(srcSec.table); + const tgtTable = use(tgtSec.table); + + const lstate = use(tgtSec.linkingState); + if(lstate == null) { return null; } + + // if not filter-linking, this will be incorrect, but we don't use it then + const lfilter = lstate.filterState ? use(lstate.filterState): EmptyFilterState; + + //If it's null then no cursor-link is set, but in that case we won't show the string anyway. + const cursorPos = lstate.cursorPos ? use(lstate.cursorPos) : 0; + const linkedCursorStr = cursorPos ? `${use(tgtTable.tableId)}[${cursorPos}]` : ''; + + // Make descriptor for the link's source like: "TableName . ColName" or "${SIGMA} TableName", etc + const fromTableDom = [ + dom.maybe((use2) => use2(srcTable.summarySourceTable), () => cssLinkInfoIcon("Pivot")), + use(srcSec.titleDef) + (srcColId ? ` ${BLACK_CIRCLE} ${use(srcCol.label)}` : ''), + dom.style("white-space", "normal"), //Allow table name to wrap, reduces how often scrollbar needed + ]; + + //Count filters for proper pluralization + const hasId = lfilter.filterLabels?.hasOwnProperty("id"); + const numFilters = Object.keys(lfilter.filterLabels).length - (hasId ? 1 : 0); + + // ================== Link-info Helpers + + //For each col-filter in lfilters, makes a row showing "${icon} colName = [filterVals]" + //FilterVals is in a box to look like a grid cell + const makeFiltersTable = (): DomContents => { + return cssLinkInfoBody( + dom.style("width", "100%"), //width 100 keeps table from growing outside bounds of flex parent if overfull + dom("table", + dom.style("margin-left", "8px"), + Object.keys(lfilter.filterLabels).map( (colId) => { + const vals = lfilter.filterLabels[colId]; + let operationSymbol = "="; + //if [filter (reflist) <- ref], op="intersects", need to convey "list has value". symbol =":" + //if [filter (ref) <- reflist], op="in", vals.length>1, need to convey "ref in list" + //Sometimes operation will be 'empty', but in that case "=" still works fine, i.e. "list = []" + if (lfilter.operations[colId] == "intersects") { operationSymbol = ":"; } + else if (vals.length > 1) { operationSymbol = ELEMENTOF; } + + if (colId == "id") { + return dom("div", `ERROR: ID FILTER: ${colId}[${vals}]`); + } else { + return dom("tr", + dom("td", cssLinkInfoIcon("Filter"), + `${colId}`), + dom("td", operationSymbol, dom.style('padding', '0 2px 0 2px')), + dom("td", cssLinkInfoValuesBox( + isFullReferencingType(lfilter.colTypes[colId]) ? + cssLinkInfoIcon("FieldReference"): null, + `${vals.join(', ')}`)), + ); + } }), //end of keys(filterLabels).map + )); + }; + + //Given a list of filterLabels, show them all in a box, as if a grid cell + //Shows a "Reference" icon in the left side, since this should only be used for reflinks and cursor links + const makeValuesBox = (valueLabels: string[]): DomContents => { + return cssLinkInfoBody(( + cssLinkInfoValuesBox( + cssLinkInfoIcon("FieldReference"), + valueLabels.join(', '), ) //TODO: join labels like "Entries[1], Entries[2]" to "Entries[[1,2]]" + )); + }; + + const linkType = lstate.linkTypeDescription(); + + return cssLinkInfoPanel(() => { switch (linkType) { + case "Filter:Summary-Group": + case "Filter:Col->Col": + case "Filter:Row->Col": + case "Summary": + return [ + dom("div", `Link applies filter${numFilters > 1 ? "s" : ""}:`), + makeFiltersTable(), + dom("div", `Linked from `, fromTableDom), + ]; + case "Show-Referenced-Records": { + //filterLabels might be {} if EmptyFilterState, so filterLabels["id"] might be undefined + const displayValues = lfilter.filterLabels["id"] ?? []; + return [ + dom("div", `Link shows record${displayValues.length > 1 ? "s" : ""}:`), + makeValuesBox(displayValues), + dom("div", `from `, fromTableDom), + ]; + } + case "Cursor:Same-Table": + case "Cursor:Reference": + return [ + dom("div", `Link sets cursor to:`), + makeValuesBox([linkedCursorStr]), + dom("div", `from `, fromTableDom), + ]; + case "Error:Invalid": + default: + return dom("div", `Error: Couldn't identify link state`); + } }, + ...domArgs + ); // End of cssLinkInfoPanel + }); +} + + private _buildLinkInfoAdvanced(activeSection: ViewSectionRec) { + return dom.domComputed((use): DomContents => { + //TODO: if this just outputs a string, this could really be in LinkingState as a toDebugStr function + // but the fact that it's all observables makes that trickier to do correctly, so let's leave it here + const srcSec = use(activeSection.linkSrcSection); //might be the empty section + const tgtSec = activeSection; + const srcCol = use(activeSection.linkSrcCol); // might be the empty column + const tgtCol = use(activeSection.linkTargetCol); + // columns might be the empty column + // to check nullness, use `.getRowId() == 0` or `use(srcCol.colId) == undefined` + + const secToStr = (sec: ViewSectionRec) => (!sec || !sec.getRowId()) ? + 'null' : + `#${use(sec.id)} "${use(sec.titleDef)}", (table "${use(use(sec.table).tableId)}")`; + const colToStr = (col: ColumnRec) => (!col || !col.getRowId()) ? + 'null' : + `#${use(col.id)} "${use(col.colId)}", type "${use(col.type)}")`; + + // linkingState can be null if the constructor throws, so for debugging we want to show link info + // if either the viewSection or the linkingState claim there's a link + const hasLink = use(srcSec.id) != undefined || use(tgtSec.linkingState) != null; + const lstate = use(tgtSec.linkingState); + const lfilter = lstate?.filterState ? use(lstate.filterState) : undefined; + + const cursorPosStr = lstate?.cursorPos ? `${tgtSec.tableId()}[${use(lstate.cursorPos)}]` : "N/A"; + + //Main link info as a big string, will be in a
 block
+      let preString = "No Incoming Link";
+      if (hasLink) {
+        preString = [
+          `From Sec: ${secToStr(srcSec)}`,
+          `To   Sec: ${secToStr(tgtSec)}`,
+          '',
+          `From Col: ${colToStr(srcCol)}`,
+          `To   Col: ${colToStr(tgtCol)}`,
+          '===========================',
+          // Show linkstate
+          lstate == null ? "LinkState: null" : [
+              `Link Type: ${use(lstate.linkTypeDescription)}`,
+              ``,
+
+              "Cursor Pos: " + cursorPosStr,
+              !lfilter ? "Filter State: null" :
+                ["Filter State:", ...(Object.keys(lfilter).map(key =>
+                  `- ${key}: ${JSON.stringify((lfilter as any)[key])}`))].join('\n'),
+            ].join('\n')
+        ].join('\n');
+      }
+
+      const collapsed: SessionObs = this._advLinkInfoCollapsed;
+      return hasLink ? [
+          cssRow(
+            icon('Dropdown', dom.style('transform', (use2) => use2(collapsed) ? 'rotate(-90deg)' : '')),
+            "Advanced Link info",
+            dom.style('font-size', `${vars.smallFontSize}`),
+            dom.style('text-transform', 'uppercase'),
+            dom.style('cursor', 'pointer'),
+            dom.on('click', () => collapsed.set(!collapsed.get())),
+          ),
+          dom.maybe(not(collapsed), () => cssRow(cssLinkInfoPre(preString)))
+      ] : null;
+    });
+  }
+
+
+
+
+
   private _buildPageDataConfig(owner: MultiHolder, activeSection: ViewSectionRec) {
     const viewConfigTab = this._createViewConfigTab(owner);
     const viewModel = this._gristDoc.viewModel;
@@ -570,15 +764,22 @@ export class RightPanel extends Disposable {
         ),
       ]),
 
+      dom.maybe(activeSection.linkingState, () => cssRow(this._buildLinkInfo(activeSection))),
+
       domComputed((use) => {
         const selectorFor = use(use(activeSection.linkedSections).getObservable());
         // TODO: sections should be listed following the order of appearance in the view layout (ie:
         // left/right - top/bottom);
         return selectorFor.length ? [
           cssLabel(t("SELECTOR FOR"), testId('selector-for')),
-          cssRow(cssList(selectorFor.map((sec) => this._buildSectionItem(sec))))
+          cssRow(cssList(selectorFor.map((sec) => [
+            this._buildSectionItem(sec)
+          ]))),
         ] : null;
       }),
+
+      //Advanced link info is a little too JSON-ish for general use. But it's very useful for debugging
+      this._buildLinkInfoAdvanced(activeSection),
     ];
   }
 
@@ -597,6 +798,7 @@ export class RightPanel extends Disposable {
   private _buildSectionItem(sec: ViewSectionRec) {
     return cssListItem(
       dom.text(sec.titleDef),
+      this._buildLinkInfo(sec, dom.style("border", "none")),
       testId('selector-for-entry')
     );
   }
@@ -865,3 +1067,65 @@ const cssTextInput = styled(textInput, `
 const cssSection = styled('div', `
   position: relative;
 `);
+
+
+//============ LinkInfo CSS ============
+
+//LinkInfoPanel is a flex-column
+//`LinkInfoPanel > table` is the table where we show linked filters, if there are any
+const cssLinkInfoPanel = styled('div', `
+  width: 100%;
+
+  display: flex;
+  flex-flow: column;
+  align-items: start;
+  text-align: left;
+
+  font-family: ${vars.fontFamily};
+
+  border: 1px solid ${theme.pagePanelsBorder};
+  border-radius: 4px;
+
+  padding: 6px;
+
+  white-space: nowrap;
+  overflow-x: auto;
+
+  & table {
+      border-spacing: 2px;
+      border-collapse: separate;
+  }
+`);
+
+// Center table / values box inside LinkInfoPanel
+const cssLinkInfoBody= styled('div', `
+  margin: 2px 0 2px 0;
+  align-self: center;
+`);
+
+// Intended to imitate style of a grid cell
+// white-space: normal allows multiple values to wrap
+// min-height: 22px matches real field size, +2 for the borders
+const cssLinkInfoValuesBox = styled('div', `
+  border: 1px solid ${'#CCC'};
+  padding: 3px 3px 0px 3px;
+  min-width: 60px;
+  min-height: 24px;
+
+  white-space: normal;
+`);
+
+//If inline with text, icons look better shifted up slightly
+//since icons are position:relative, bottom:1 should shift it without affecting layout
+const cssLinkInfoIcon = styled(icon, `
+  bottom: 1px;
+  margin-right: 3px;
+  background-color: ${theme.controlSecondaryFg};
+`);
+
+// ============== styles for _buildLinkInfoAdvanced
+const cssLinkInfoPre = styled("pre", `
+  padding: 6px;
+  font-size: ${vars.smallFontSize};
+  line-height: 1.2;
+`);
diff --git a/app/client/ui/ShareMenu.ts b/app/client/ui/ShareMenu.ts
index 63e282b0..ddeae9a2 100644
--- a/app/client/ui/ShareMenu.ts
+++ b/app/client/ui/ShareMenu.ts
@@ -1,3 +1,4 @@
+import {hooks} from 'app/client/Hooks';
 import {loadUserManager} from 'app/client/lib/imports';
 import {AppModel, reportError} from 'app/client/models/AppModel';
 import {DocInfo, DocPageModel} from 'app/client/models/DocPageModel';
@@ -278,12 +279,12 @@ function menuExports(doc: Document, pageModel: DocPageModel) {
         menuItem(() => downloadDocModal(doc, pageModel),
         menuIcon('Download'), t("Download..."), testId('tb-share-option'))
     ),
-    menuItemLink({ href: gristDoc.getCsvLink(), target: '_blank', download: ''},
+    menuItemLink(hooks.maybeModifyLinkAttrs({ href: gristDoc.getCsvLink(), target: '_blank', download: ''}),
       menuIcon('Download'), t("Export CSV"), testId('tb-share-option')),
-    menuItemLink({
+    menuItemLink(hooks.maybeModifyLinkAttrs({
       href: pageModel.appModel.api.getDocAPI(doc.id).getDownloadXlsxUrl(),
       target: '_blank', download: ''
-    }, menuIcon('Download'), t("Export XLSX"), testId('tb-share-option')),
+    }), menuIcon('Download'), t("Export XLSX"), testId('tb-share-option')),
     (!isFeatureEnabled("sendToDrive") ? null : menuItem(() => sendToDrive(doc, pageModel),
       menuIcon('Download'), t("Send to Google Drive"), testId('tb-share-option'))),
   ];
diff --git a/app/client/ui/ViewLayoutMenu.ts b/app/client/ui/ViewLayoutMenu.ts
index 4e60490b..8a39ad7f 100644
--- a/app/client/ui/ViewLayoutMenu.ts
+++ b/app/client/ui/ViewLayoutMenu.ts
@@ -1,3 +1,4 @@
+import {hooks} from 'app/client/Hooks';
 import {makeT} from 'app/client/lib/localization';
 import {allCommands} from 'app/client/components/commands';
 import {ViewSectionRec} from 'app/client/models/DocModel';
@@ -76,9 +77,9 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
       )
     ),
     menuItemCmd(allCommands.printSection, t("Print widget"), testId('print-section')),
-    menuItemLink({ href: gristDoc.getCsvLink(), target: '_blank', download: ''},
+    menuItemLink(hooks.maybeModifyLinkAttrs({ href: gristDoc.getCsvLink(), target: '_blank', download: ''}),
       t("Download as CSV"), testId('download-section')),
-    menuItemLink({ href: gristDoc.getXlsxActiveViewLink(), target: '_blank', download: ''},
+    menuItemLink(hooks.maybeModifyLinkAttrs({ href: gristDoc.getXlsxActiveViewLink(), target: '_blank', download: ''}),
       t("Download as XLSX"), testId('download-section')),
     dom.maybe((use) => ['detail', 'single'].includes(use(viewSection.parentKey)), () =>
       menuItemCmd(allCommands.editLayout, t("Edit Card Layout"),
diff --git a/app/client/ui/selectBy.ts b/app/client/ui/selectBy.ts
index ced09836..a6664781 100644
--- a/app/client/ui/selectBy.ts
+++ b/app/client/ui/selectBy.ts
@@ -42,6 +42,9 @@ interface LinkNode {
   // is the table a summary table
   isSummary: boolean;
 
+  // does this node involve an "Attachments" column. Can be tricky if Attachments is one of groupby cols
+  isAttachments: boolean;
+
   // For a summary table, the set of col refs of the groupby columns of the underlying table
   groupbyColumns?: Set;
 
@@ -114,6 +117,12 @@ function isValidLink(source: LinkNode, target: LinkNode) {
     return false;
   }
 
+  //cannot select from attachments, even though they're implemented as reflists
+  if (source.isAttachments || target.isAttachments) {
+    return false;
+  }
+
+
   // cannot select from chart
   if (source.widgetType === 'chart') {
     return false;
@@ -230,6 +239,7 @@ function fromViewSectionRec(section: ViewSectionRec): LinkNode[] {
   const mainNode: LinkNode = {
     tableId: table.primaryTableId.peek(),
     isSummary,
+    isAttachments: isSummary && table.groupByColumns.peek().some(col => col.type.peek() == "Attachments"),
     groupbyColumns: isSummary ? table.summarySourceColRefs.peek() : undefined,
     widgetType: section.parentKey.peek(),
     ancestors,
@@ -266,6 +276,8 @@ function fromPageWidget(docModel: DocModel, pageWidget: IPageWidget): LinkNode[]
   const mainNode: LinkNode = {
     tableId: table.primaryTableId.peek(),
     isSummary,
+    isAttachments: false, // hmm, we should need a check here in case attachments col is on the main-node link
+    // (e.g.: link from summary table with Attachments in group-by) but it seems to work fine as is
     groupbyColumns,
     widgetType: pageWidget.type,
     ancestors: new Set(),
@@ -284,7 +296,7 @@ function fromColumns(table: TableRec, mainNode: LinkNode, tableExists: boolean =
     }
     const tableId = getReferencedTableId(column.type.peek());
     if (tableId) {
-      nodes.push({...mainNode, tableId, column});
+      nodes.push({...mainNode, tableId, column, isAttachments: column.type.peek() == "Attachments"});
     }
   }
   return nodes;
diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts
index 91897d04..472232cb 100644
--- a/app/common/gristUrls.ts
+++ b/app/common/gristUrls.ts
@@ -607,6 +607,9 @@ export interface GristLoadConfig {
   // If set, enable anonymous sharing UI elements.
   supportAnon?: boolean;
 
+  // If set, enable anonymous playground.
+  enableAnonPlayground?: boolean;
+
   // If set, allow selection of the specified engines.
   // TODO: move this list to a separate endpoint.
   supportEngines?: EngineCode[];
diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts
index b883c5b0..2d4026b8 100644
--- a/app/server/lib/DocApi.ts
+++ b/app/server/lib/DocApi.ts
@@ -50,7 +50,7 @@ import {IDocWorkerMap} from "app/server/lib/DocWorkerMap";
 import {DownloadOptions, parseExportParameters} from "app/server/lib/Export";
 import {downloadCSV} from "app/server/lib/ExportCSV";
 import {collectTableSchemaInFrictionlessFormat} from "app/server/lib/ExportTableSchema";
-import {downloadXLSX} from "app/server/lib/ExportXLSX";
+import {streamXLSX} from "app/server/lib/ExportXLSX";
 import {expressWrap} from 'app/server/lib/expressWrap';
 import {filterDocumentInPlace} from "app/server/lib/filterUtils";
 import {googleAuthTokenMiddleware} from "app/server/lib/GoogleAuth";
@@ -173,6 +173,7 @@ export class DocWorkerApi {
     const canView = expressWrap(this._assertAccess.bind(this, 'viewers', false));
     // check document exists (not soft deleted) and user can edit it
     const canEdit = expressWrap(this._assertAccess.bind(this, 'editors', false));
+    const checkAnonymousCreation = expressWrap(this._checkAnonymousCreation.bind(this));
     const isOwner = expressWrap(this._assertAccess.bind(this, 'owners', false));
     // check user can edit document, with soft-deleted documents being acceptable
     const canEditMaybeRemoved = expressWrap(this._assertAccess.bind(this, 'editors', true));
@@ -1241,7 +1242,7 @@ export class DocWorkerApi {
      *
      * TODO: unify this with the other document creation and import endpoints.
      */
-    this._app.post('/api/docs', expressWrap(async (req, res) => {
+    this._app.post('/api/docs', checkAnonymousCreation, expressWrap(async (req, res) => {
       const userId = getUserId(req);
 
       let uploadId: number|undefined;
@@ -1522,6 +1523,17 @@ export class DocWorkerApi {
     return await this._dbManager.increaseUsage(getDocScope(req), limit, {delta: 1});
   }
 
+  /**
+   * Disallow document creation for anonymous users if GRIST_ANONYMOUS_CREATION is set to false.
+   */
+  private async _checkAnonymousCreation(req: Request, res: Response, next: NextFunction) {
+    const isAnonPlayground = isAffirmative(process.env.GRIST_ANON_PLAYGROUND ?? true);
+    if (isAnonymousUser(req) && !isAnonPlayground) {
+      throw new ApiError('Anonymous document creation is disabled', 403);
+    }
+    next();
+  }
+
   private async _assertAccess(role: 'viewers'|'editors'|'owners'|null, allowRemoved: boolean,
                               req: Request, res: Response, next: NextFunction) {
     const scope = getDocScope(req);
@@ -1969,3 +1981,14 @@ export interface WebhookSubscription {
   unsubscribeKey: string;
   webhookId: string;
 }
+
+/**
+ * Converts `activeDoc` to XLSX and sends the converted data through `res`.
+ */
+export async function downloadXLSX(activeDoc: ActiveDoc, req: Request,
+                                   res: Response, options: DownloadOptions) {
+  const {filename} = options;
+  res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
+  res.setHeader('Content-Disposition', contentDisposition(filename + '.xlsx'));
+  return streamXLSX(activeDoc, req, res, options);
+}
diff --git a/app/server/lib/DocManager.ts b/app/server/lib/DocManager.ts
index c2b5768f..5eaf2169 100644
--- a/app/server/lib/DocManager.ts
+++ b/app/server/lib/DocManager.ts
@@ -133,6 +133,14 @@ export class DocManager extends EventEmitter {
     return this.createNamedDoc(docSession, 'Untitled');
   }
 
+  /**
+   * Add an ActiveDoc created externally. This is a hook used by
+   * grist-static.
+   */
+  public addActiveDoc(docId: string, activeDoc: ActiveDoc) {
+    this._activeDocs.set(docId, Promise.resolve(activeDoc));
+  }
+
   public async createNamedDoc(docSession: OptDocSession, docId: string): Promise {
     const activeDoc: ActiveDoc = await this.createNewEmptyDoc(docSession, docId);
     await activeDoc.addInitialTable(docSession);
diff --git a/app/server/lib/ExportXLSX.ts b/app/server/lib/ExportXLSX.ts
index b18d828c..99d9b5af 100644
--- a/app/server/lib/ExportXLSX.ts
+++ b/app/server/lib/ExportXLSX.ts
@@ -1,8 +1,7 @@
 /**
  * Overview of Excel exports, which now use worker-threads.
  *
- * 1. The flow starts with downloadXLSX() method called in the main thread (or streamXLSX() used for
- *    Google Drive export).
+ * 1. The flow starts with the streamXLSX() method called in the main thread.
  * 2. It uses the 'piscina' library to call a makeXLSX* method in a worker thread, registered in
  *    workerExporter.ts, to export full doc, a table, or a section.
  * 3. Each of those methods calls a doMakeXLSX* method defined in that file. I.e. downloadXLSX()
@@ -12,11 +11,10 @@
  * 5. The resulting stream of Excel data is streamed back to the main thread using Rpc too.
  */
 import {ActiveDoc} from 'app/server/lib/ActiveDoc';
-import {ActiveDocSource, ActiveDocSourceDirect, DownloadOptions, ExportParameters} from 'app/server/lib/Export';
+import {ActiveDocSource, ActiveDocSourceDirect, ExportParameters} from 'app/server/lib/Export';
 import log from 'app/server/lib/log';
 import {addAbortHandler} from 'app/server/lib/requestUtils';
 import * as express from 'express';
-import contentDisposition from 'content-disposition';
 import {Rpc} from 'grain-rpc';
 import {AbortController} from 'node-abort-controller';
 import {Writable} from 'stream';
@@ -38,24 +36,12 @@ const exportPool = new Piscina({
   idleTimeout: 10_000,    // Drop unused threads after 10s of inactivity.
 });
 
-/**
- * Converts `activeDoc` to XLSX and sends the converted data through `res`.
- */
-export async function downloadXLSX(activeDoc: ActiveDoc, req: express.Request,
-                                   res: express.Response, options: DownloadOptions) {
-  const {filename} = options;
-  res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
-  res.setHeader('Content-Disposition', contentDisposition(filename + '.xlsx'));
-  return streamXLSX(activeDoc, req, res, options);
-}
-
 /**
  * Converts `activeDoc` to XLSX and sends to the given outputStream.
  */
 export async function streamXLSX(activeDoc: ActiveDoc, req: express.Request,
                                  outputStream: Writable, options: ExportParameters) {
   log.debug(`Generating .xlsx file`);
-  const {tableId, viewSectionId, filters, sortOrder, linkingFilter} = options;
   const testDates = (req.hostname === 'localhost');
 
   const { port1, port2 } = new MessageChannel();
@@ -89,13 +75,7 @@ export async function streamXLSX(activeDoc: ActiveDoc, req: express.Request,
 
     // hanlding 3 cases : full XLSX export (full file), view xlsx export, table xlsx export
     try {
-      if (viewSectionId) {
-        await run('makeXLSXFromViewSection', viewSectionId, sortOrder, filters, linkingFilter);
-      } else if (tableId) {
-        await run('makeXLSXFromTable', tableId);
-      } else {
-        await run('makeXLSX');
-      }
+      await run('makeXLSXFromOptions', options);
       log.debug('XLSX file generated');
     } catch (e) {
       // We fiddle with errors in workerExporter to preserve extra properties like 'status'. Make
diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts
index feadfa3f..6fa23079 100644
--- a/app/server/lib/FlexServer.ts
+++ b/app/server/lib/FlexServer.ts
@@ -852,10 +852,11 @@ export class FlexServer implements GristServer {
       baseDomain: this._defaultBaseDomain,
     });
 
-    const isForced = appSettings.section('login').flag('forced').readBool({
+    const forceLogin = appSettings.section('login').flag('forced').readBool({
       envVar: 'GRIST_FORCE_LOGIN',
     });
-    const forcedLoginMiddleware = isForced ? this._redirectToLoginWithoutExceptionsMiddleware : noop;
+
+    const forcedLoginMiddleware = forceLogin ? this._redirectToLoginWithoutExceptionsMiddleware : noop;
 
     const welcomeNewUser: express.RequestHandler = isSingleUserMode() ?
       (req, res, next) => next() :
diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts
index d80bc0d3..304c0422 100644
--- a/app/server/lib/sendAppPage.ts
+++ b/app/server/lib/sendAppPage.ts
@@ -56,6 +56,7 @@ export function makeGristConfig(options: MakeGristConfigOptons): GristLoadConfig
     helpCenterUrl: process.env.GRIST_HELP_CENTER || "https://support.getgrist.com",
     pathOnly,
     supportAnon: shouldSupportAnon(),
+    enableAnonPlayground: isAffirmative(process.env.GRIST_ANON_PLAYGROUND ?? true),
     supportEngines: getSupportedEngineChoices(),
     features: getFeatures(),
     pageTitleSuffix: configuredPageTitleSuffix(),
diff --git a/app/server/lib/workerExporter.ts b/app/server/lib/workerExporter.ts
index bb8f6c12..c6992c20 100644
--- a/app/server/lib/workerExporter.ts
+++ b/app/server/lib/workerExporter.ts
@@ -1,19 +1,19 @@
 import {PassThrough} from 'stream';
 import {FilterColValues} from "app/common/ActiveDocAPI";
-import {ActiveDocSource, doExportDoc, doExportSection, doExportTable, ExportData, Filter} from 'app/server/lib/Export';
+import {ActiveDocSource, doExportDoc, doExportSection, doExportTable,
+        ExportData, ExportParameters, Filter} from 'app/server/lib/Export';
 import {createExcelFormatter} from 'app/server/lib/ExcelFormatter';
 import * as log from 'app/server/lib/log';
-import {Alignment, Border, stream as ExcelWriteStream, Fill} from 'exceljs';
+import {Alignment, Border, Buffer as ExcelBuffer, stream as ExcelWriteStream,
+        Fill, Workbook} from 'exceljs';
 import {Rpc} from 'grain-rpc';
 import {Stream} from 'stream';
 import {MessagePort, threadId} from 'worker_threads';
 
-export const makeXLSX = handleExport(doMakeXLSX);
-export const makeXLSXFromTable = handleExport(doMakeXLSXFromTable);
-export const makeXLSXFromViewSection = handleExport(doMakeXLSXFromViewSection);
+export const makeXLSXFromOptions = handleExport(doMakeXLSXFromOptions);
 
 function handleExport(
-  make: (a: ActiveDocSource, testDates: boolean, output: Stream, ...args: T) => Promise
+  make: (a: ActiveDocSource, testDates: boolean, output: Stream, ...args: T) => Promise
 ) {
   return async function({port, testDates, args}: {port: MessagePort, testDates: boolean, args: T}) {
     try {
@@ -73,6 +73,23 @@ function bufferedPipe(stream: Stream, callback: (chunk: Buffer) => void, thresho
   stream.on('end', flush);
 }
 
+export async function doMakeXLSXFromOptions(
+  activeDocSource: ActiveDocSource,
+  testDates: boolean,
+  stream: Stream,
+  options: ExportParameters
+) {
+  const {tableId, viewSectionId, filters, sortOrder, linkingFilter} = options;
+  if (viewSectionId) {
+    return doMakeXLSXFromViewSection(activeDocSource, testDates, stream, viewSectionId,
+      sortOrder || null, filters || null, linkingFilter || null);
+  } else if (tableId) {
+    return doMakeXLSXFromTable(activeDocSource, testDates, stream, tableId);
+  } else {
+    return doMakeXLSX(activeDocSource, testDates, stream);
+  }
+}
+
 /**
  * Returns a XLSX stream of a view section that can be transformed or parsed.
  *
@@ -86,14 +103,14 @@ async function doMakeXLSXFromViewSection(
   testDates: boolean,
   stream: Stream,
   viewSectionId: number,
-  sortOrder: number[],
-  filters: Filter[],
-  linkingFilter: FilterColValues,
+  sortOrder: number[] | null,
+  filters: Filter[] | null,
+  linkingFilter: FilterColValues | null,
 ) {
   const data = await doExportSection(activeDocSource, viewSectionId, sortOrder, filters, linkingFilter);
   const {exportTable, end} = convertToExcel(stream, testDates);
   exportTable(data);
-  await end();
+  return end();
 }
 
 /**
@@ -111,7 +128,7 @@ async function doMakeXLSXFromTable(
   const data = await doExportTable(activeDocSource, {tableId});
   const {exportTable, end} = convertToExcel(stream, testDates);
   exportTable(data);
-  await end();
+  return end();
 }
 
 /**
@@ -121,24 +138,33 @@ async function doMakeXLSX(
   activeDocSource: ActiveDocSource,
   testDates: boolean,
   stream: Stream,
-): Promise {
+): Promise {
   const {exportTable, end} = convertToExcel(stream, testDates);
   await doExportDoc(activeDocSource, async (table: ExportData) => exportTable(table));
-  await end();
+  return end();
 }
 
 /**
  * Converts export data to an excel file.
+ * If a stream is provided, use it via the more memory-efficient
+ * WorkbookWriter, otherwise fall back on using a Workbook directly,
+ * and return a buffer.
+ * (The second option is for grist-static; at the time of writing
+ * WorkbookWriter doesn't appear to be available in a browser context).
  */
-function convertToExcel(stream: Stream, testDates: boolean): {
+function convertToExcel(stream: Stream|undefined, testDates: boolean): {
   exportTable: (table: ExportData) => void,
-  end: () => Promise,
+  end: () => Promise,
 } {
   // Create workbook and add single sheet to it. Using the WorkbookWriter interface avoids
   // creating the entire Excel file in memory, which can be very memory-heavy. See
   // https://github.com/exceljs/exceljs#streaming-xlsx-writercontents. (The options useStyles and
   // useSharedStrings replicate more closely what was used previously.)
-  const wb = new ExcelWriteStream.xlsx.WorkbookWriter({useStyles: true, useSharedStrings: true, stream});
+  // If there is no stream, write with a Workbook.
+  const wb: Workbook | ExcelWriteStream.xlsx.WorkbookWriter = stream ?
+      new ExcelWriteStream.xlsx.WorkbookWriter({ useStyles: true, useSharedStrings: true, stream }) :
+      new Workbook();
+  const maybeCommit = stream ? (t: any) => t.commit() : (t: any) => {};
   if (testDates) {
     // HACK: for testing, we will keep static dates
     const date = new Date(Date.UTC(2018, 11, 1, 0, 0, 0));
@@ -201,11 +227,16 @@ function convertToExcel(stream: Stream, testDates: boolean): {
     });
     // Populate excel file with data
     for (const row of rowIds) {
-      ws.addRow(access.map((getter, c) => formatters[c].formatAny(getter(row)))).commit();
+      maybeCommit(ws.addRow(access.map((getter, c) => formatters[c].formatAny(getter(row)))));
+    }
+    maybeCommit(ws);
+  }
+  async function end(): Promise {
+    if (!stream) {
+      return wb.xlsx.writeBuffer();
     }
-    ws.commit();
+    return maybeCommit(wb);
   }
-  function end() { return wb.commit(); }
   return {exportTable, end};
 }
 
diff --git a/package.json b/package.json
index 4a67584b..3a6c451d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "grist-core",
-  "version": "1.1.3",
+  "version": "1.1.4",
   "license": "Apache-2.0",
   "description": "Grist is the evolution of spreadsheets",
   "homepage": "https://github.com/gristlabs/grist-core",
diff --git a/static/locales/en.client.json b/static/locales/en.client.json
index 63fdb168..8be0f270 100644
--- a/static/locales/en.client.json
+++ b/static/locales/en.client.json
@@ -426,7 +426,10 @@
         "personal site": "personal site",
         "{{signUp}} to save your work. ": "{{signUp}} to save your work. ",
         "Welcome to Grist, {{- name}}!": "Welcome to Grist, {{- name}}!",
-        "Welcome to {{- orgName}}": "Welcome to {{- orgName}}"
+        "Welcome to {{- orgName}}": "Welcome to {{- orgName}}",
+        "Sign in": "Sign in",
+        "To use Grist, please either sign up or sign in.": "To use Grist, please either sign up or sign in.",
+        "Visit our {{link}} to learn more about Grist.": "Visit our {{link}} to learn more about Grist."
     },
     "HomeLeftPane": {
         "Access Details": "Access Details",
diff --git a/static/locales/sl.client.json b/static/locales/sl.client.json
index f8e5bf64..4a39c40d 100644
--- a/static/locales/sl.client.json
+++ b/static/locales/sl.client.json
@@ -33,7 +33,9 @@
         "Allow everyone to copy the entire document, or view it in full in fiddle mode.\nUseful for examples and templates, but not for sensitive data.": "Vsakomur omogočite kopiranje celotnega dokumenta ali pa si ga oglejte v celoti v načinu fiddle.\nUporabno za primere in predloge, ne pa za občutljive podatke.",
         "Allow everyone to view Access Rules.": "Vsakomur omogočite ogled pravil za dostop.",
         "Attribute name": "Ime atributa",
-        "Attribute to Look Up": "Atribut za iskanje"
+        "Attribute to Look Up": "Atribut za iskanje",
+        "Lookup Table": "Preglednica za iskanje",
+        "This default should be changed if editors' access is to be limited. ": "To privzeto nastavitev je treba spremeniti, če je treba omejiti dostop urednikov. "
     },
     "ACUserManager": {
         "We'll email an invite to {{email}}": "Vabilo bomo poslali po e-pošti {{email}}",
@@ -80,12 +82,14 @@
     },
     "ViewAsDropdown": {
         "View As": "Poglej kot",
-        "Users from table": "Uporabniki iz tabele"
+        "Users from table": "Uporabniki iz tabele",
+        "Example Users": "Primer Uporabniki"
     },
     "ActionLog": {
         "Column {{colId}} was subsequently removed in action #{{action.actionNum}}": "Stolpec {{colId}} je bil pozneje odstranjen v akciji #{{action.actionNum}}",
         "Action Log failed to load": "Dnevnik ukrepov se ni uspel naložiti",
-        "This row was subsequently removed in action {{action.actionNum}}": "Ta vrstica je bila pozneje odstranjena z akcijo {{action.actionNum}}"
+        "This row was subsequently removed in action {{action.actionNum}}": "Ta vrstica je bila pozneje odstranjena z akcijo {{action.actionNum}}",
+        "Table {{tableId}} was subsequently removed in action #{{actionNum}}": "Tabela {{tableId}} je bila pozneje odstranjena v akciji #{{actionNum}}"
     },
     "ApiKey": {
         "Remove": "Odstrani",
@@ -93,12 +97,15 @@
         "You're about to delete an API key. This will cause all future requests using this API key to be rejected. Do you still want to delete?": "Želite izbrisati ključ API. To bo povzročilo zavrnitev vseh prihodnjih zahtevkov, ki bodo uporabljali ta ključ API. Ali še vedno želite izbrisati?",
         "Click to show": "Kliknite za prikaz",
         "Remove API Key": "Odstranite API ključ",
-        "This API key can be used to access this account anonymously via the API.": "Ta API ključ  lahko uporabite za anonimen dostop do tega računa prek vmesnika API."
+        "This API key can be used to access this account anonymously via the API.": "Ta API ključ  lahko uporabite za anonimen dostop do tega računa prek vmesnika API.",
+        "This API key can be used to access your account via the API. Don’t share your API key with anyone.": "Ta ključ API lahko uporabite za dostop do svojega računa prek vmesnika API. Svojega ključa API ne delite z nikomer.",
+        "By generating an API key, you will be able to make API calls for your own account.": "Z ustvarjanjem API ključa  boste lahko  uporabljali klice API funkcij  za svoj račun."
     },
     "App": {
         "Description": "Opis",
         "Key": "Ključ",
-        "Memory Error": "Napaka pomnilnika"
+        "Memory Error": "Napaka pomnilnika",
+        "Translators: please translate this only when your language is ready to be offered to users": "Prevajalci: prosimo, prevedite to šele, ko bo vaš jezik pripravljen, da se ponudi uporabnikom"
     },
     "CellContextMenu": {
         "Delete {{count}} columns_one": "Brisanje stolpca",
@@ -121,7 +128,9 @@
         "Comment": "Komentar:",
         "Copy": "Kopiraj",
         "Cut": "Izreži",
-        "Paste": "Prilepi"
+        "Paste": "Prilepi",
+        "Clear values": "Izbriši vrednosti",
+        "Clear cell": "Čista celica"
     },
     "DocMenu": {
         "Document will be moved to Trash.": "Dokument se bo premaknil v koš.",
@@ -147,12 +156,46 @@
         "Examples and Templates": "Primeri in predloge",
         "Featured": "Priporočeni",
         "Manage Users": "Upravljanje uporabnikov",
-        "More Examples and Templates": "Več primerov in predlog"
+        "More Examples and Templates": "Več primerov in predlog",
+        "This service is not available right now": "Ta storitev trenutno ni na voljo",
+        "Workspace not found": "Ne najdem delovnega prostora",
+        "Pin Document": "Pripni dokument",
+        "Remove": "Odstrani",
+        "Move": "Premakni",
+        "Unpin Document": "Odpni dokument",
+        "Requires edit permissions": "Zahteva dovoljenja za urejanje",
+        "Other Sites": "Druga spletna mesta",
+        "Pinned Documents": "Pripeti dokumenti",
+        "To restore this document, restore the workspace first.": "Če želite obnoviti ta dokument, najprej obnovite delovni prostor.",
+        "You are on your personal site. You also have access to the following sites:": "Nahajate se na svojem osebnem spletnem mestu. Prav tako imate dostop do naslednjih spletnih mest:",
+        "Restore": "Obnovi",
+        "Move {{name}} to workspace": "Premakni {{name}} v delovni prostor"
     },
     "GridViewMenus": {
         "Rename column": "Preimenovanje stolpca",
         "Delete {{count}} columns_one": "Brisanje stolpca",
-        "Delete {{count}} columns_other": "Brisanje stolpcev {{count}}"
+        "Delete {{count}} columns_other": "Brisanje stolpcev {{count}}",
+        "Unfreeze {{count}} columns_one": "Odmrzni ta stolpec",
+        "Sorted (#{{count}})_one": "Razvrščeno (#{{count}})",
+        "Unfreeze all columns": "Odmrznitev vseh stolpcev",
+        "Freeze {{count}} columns_other": "Zamrznite {{count}}  stolpcev",
+        "Show column {{- label}}": "Prikaži stolpec {{- label}}",
+        "Sort": "Razvrsti",
+        "Column Options": "Možnosti stolpcev",
+        "Filter Data": "Filtriranje podatkov",
+        "Hide {{count}} columns_other": "Skrij {{count}}  stolpcev",
+        "Add Column": "Dodaj stolpec",
+        "Reset {{count}} columns_one": "Ponastavitev stolpca",
+        "Freeze {{count}} columns_one": "Zamrznite ta stolpec",
+        "More sort options ...": "Več možnosti razvrščanja…",
+        "Freeze {{count}} more columns_one": "Zamrznite še en stolpec",
+        "Reset {{count}} columns_other": "Ponastavitev  {{count}} stolpcev",
+        "Clear values": "Izbriši vrednosti",
+        "Add to sort": "Dodaj v razvrščanje",
+        "Convert formula to data": "Pretvarjanje formule v podatke",
+        "Freeze {{count}} more columns_other": "Zamrznite še {{count}}  stolpcev",
+        "Hide {{count}} columns_one": "Skrij stolpec",
+        "Sorted (#{{count}})_other": "Razvrščeno (#{{count}})"
     },
     "HomeLeftPane": {
         "Trash": "Koš",
@@ -189,7 +232,8 @@
         "Click to copy": "Kliknite za kopiranje",
         "Duplicate Table": "Podvojena tabela",
         "Table ID copied to clipboard": "ID tabele kopiran v odložišče",
-        "You do not have edit access to this document": "Nimate dostopa za urejanje tega dokumenta"
+        "You do not have edit access to this document": "Nimate dostopa za urejanje tega dokumenta",
+        "Raw Data Tables": "Neobdelana tabela"
     },
     "ViewLayoutMenu": {
         "Delete record": "Brisanje zapisa",
@@ -205,7 +249,10 @@
         "Grist Templates": "Grist predloge"
     },
     "ChartView": {
-        "Pick a column": "Izberite stolpec"
+        "Pick a column": "Izberite stolpec",
+        "Toggle chart aggregation": "Preklopite združevanje grafikonov",
+        "Create separate series for each value of the selected column.": "Ustvarite ločene serije za vsako vrednost izbranega stolpca.",
+        "selected new group data columns": "izbrani novi stolpci podatkovnih skupin"
     },
     "ColumnFilterMenu": {
         "All": "Vse",
@@ -223,7 +270,8 @@
         "Other Values": "Druge vrednosti",
         "Others": "Drugo",
         "Search": "Iskanje",
-        "Search values": "Iskanje vrednosti"
+        "Search values": "Iskanje vrednosti",
+        "Filter by Range": "Filtriranje po obsegu"
     },
     "CustomSectionConfig": {
         " (optional)": " (neobvezno)",
@@ -234,7 +282,10 @@
         "Pick a column": "Izberite stolpec",
         "Pick a {{columnType}} column": "Izberite stolpec {{columnType}}",
         "Read selected table": "Preberite izbrano tabelo",
-        "Learn more about custom widgets": "Preberite več o gradnikih po meri"
+        "Learn more about custom widgets": "Preberite več o gradnikih po meri",
+        "Widget needs {{fullAccess}} to this document.": "Widget  potrebuje {{fullAccess}} tega dokumenta.",
+        "No document access": "Brez dostopa do dokumentov",
+        "Widget does not require any permissions.": "Widget ne zahteva nobenih dovoljenj."
     },
     "DocHistory": {
         "Activity": "Dejavnost",
@@ -242,10 +293,19 @@
         "Compare to Current": "Primerjava s trenutnim",
         "Compare to Previous": "Primerjava s prejšnjimi",
         "Snapshots": "Posnetki",
-        "Snapshots are unavailable.": "Posnetki niso na voljo."
+        "Snapshots are unavailable.": "Posnetki niso na voljo.",
+        "Open Snapshot": "Odpri posnetek stanja"
     },
     "ExampleInfo": {
-        "Check out our related tutorial for how to link data, and create high-productivity layouts.": "Oglejte si sorodno navodilo za povezovanje podatkov in ustvarjanje visokoproduktivnih postavitev."
+        "Check out our related tutorial for how to link data, and create high-productivity layouts.": "Oglejte si sorodno navodilo za povezovanje podatkov in ustvarjanje visokoproduktivnih postavitev.",
+        "Afterschool Program": "Program za izvenšolsko vzgojo",
+        "Welcome to the Investment Research template": "Dobrodošli v predlogi za investicijske raziskave",
+        "Welcome to the Afterschool Program template": "Dobrodošli v predlogi programa za popoldansko izobraževanje",
+        "Check out our related tutorial to learn how to create summary tables and charts, and to link charts dynamically.": "Oglejte si sorodno navodilo, v katerem boste izvedeli, kako ustvariti zbirne tabele in grafe ter dinamično povezati grafe.",
+        "Investment Research": "Investicijske raziskave",
+        "Tutorial: Create a CRM": "Učni pripomoček: Ustvarite CRM",
+        "Tutorial: Manage Business Data": "Učni pripomoček: Upravljanje poslovnih podatkov",
+        "Tutorial: Analyze & Visualize": "Učni pripomoček: Analizirajte in vizualizirajte"
     },
     "CodeEditorPanel": {
         "Access denied": "Dostop zavrnjen",
@@ -255,5 +315,85 @@
         "Apply": "Uporabi",
         "Cancel": "Prekliči",
         "Default cell style": "Privzet slog celic"
+    },
+    "Drafts": {
+        "Undo discard": "Preklic zavrženja",
+        "Restore last edit": "Obnovitev zadnjega urejanja"
+    },
+    "FieldConfig": {
+        "Column options are limited in summary tables.": "Možnosti stolpcev so v zbirnih tabelah omejene.",
+        "Set formula": "Nastavite formulo",
+        "Data Columns_other": "Stolpci podatkov",
+        "DESCRIPTION": "OPIS",
+        "Clear and reset": "Briši in ponastavi",
+        "Convert column to data": "Pretvori stolpec v podatke",
+        "Empty Columns_other": "Prazni stolpci",
+        "COLUMN LABEL AND ID": "OZNAKA IN ID STOLPCA",
+        "Empty Columns_one": "Prazen stolpec",
+        "Formula Columns_other": "Stolpci formule",
+        "Formula Columns_one": "Stolpec formule",
+        "Enter formula": "Vnesite formulo",
+        "Clear and make into formula": "Brišite in pretvorite v formulo",
+        "Mixed Behavior": "Mešano vedenje",
+        "Convert to trigger formula": "Pretvori v sprožitveno formulo",
+        "Data Columns_one": "Stolpec podatkov",
+        "TRIGGER FORMULA": "SPROŽILNA FORMULA",
+        "Set trigger formula": "Nastavite sprožitveno formulo"
+    },
+    "DuplicateTable": {
+        "Only the document default access rules will apply to the copy.": "Za kopijo bodo veljala samo privzeta pravila dostopa do dokumenta.",
+        "Copy all data in addition to the table structure.": "Poleg strukture tabele kopirajte tudi vse podatke.",
+        "Name for new table": "Ime za novo tabelo"
+    },
+    "DocPageModel": {
+        "Sorry, access to this document has been denied. [{{error}}]": "Žal je bil dostop do tega dokumenta zavrnjen. [{{error}}]",
+        "Add Empty Table": "Dodajte prazno tabelo",
+        "You do not have edit access to this document": "Nimate dostopa za urejanje tega dokumenta",
+        "Add Widget to Page": "Dodaj widget na stran",
+        "Add Page": "Dodaj stran",
+        "Document owners can attempt to recover the document. [{{error}}]": "Lastniki dokumentov lahko poskušajo obnoviti dokument. [{{error}}]",
+        "Error accessing document": "Napaka pri dostopu do dokumenta",
+        "Enter recovery mode": "Vstopite v način obnovitve"
+    },
+    "DocumentSettings": {
+        "Ok": "V REDU",
+        "API": "API",
+        "Save": "Shrani",
+        "Document ID copied to clipboard": "ID dokumenta kopiran v odložišče",
+        "Local currency ({{currency}})": "Lokalna valuta ({{currency}})",
+        "Save and Reload": "Shranjevanje in ponovno nalaganje",
+        "Time Zone:": "Časovni pas:",
+        "Currency:": "Valuta:",
+        "Document Settings": "Nastavitve dokumentov",
+        "Locale:": "Lokalizacija:",
+        "This document's ID (for API use):": "ID tega dokumenta (za uporabo API):"
+    },
+    "GridOptions": {
+        "Horizontal Gridlines": "Vodoravne linije",
+        "Vertical Gridlines": "Navpične linije",
+        "Grid Options": "Možnosti mreže",
+        "Zebra Stripes": "Zebraste vrstice"
+    },
+    "DocumentUsage": {
+        "Data Size": "Velikost podatkov",
+        "Usage statistics are only available to users with full access to the document data.": "Statistični podatki o uporabi so na voljo le uporabnikom s polnim dostopom do podatkov o dokumentu.",
+        "Usage": "Uporaba",
+        "Attachments Size": "Velikost prilog",
+        "For higher limits, ": "Za višje omejitve, ",
+        "Contact the site owner to upgrade the plan to raise limits.": "Obrnite se na lastnika spletnega mesta in nadgradite načrt za povečanje omejitev.",
+        "start your 30-day free trial of the Pro plan.": "začnite 30-dnevni brezplačni preizkus Pro različice.",
+        "Rows": "Vrstice"
+    },
+    "FieldMenus": {
+        "Use separate settings": "Uporaba ločenih nastavitev",
+        "Revert to common settings": "Vrnitev na običajne nastavitve",
+        "Using common settings": "Uporaba skupnih nastavitev",
+        "Using separate settings": "Uporaba ločenih nastavitev"
+    },
+    "FilterConfig": {
+        "Add Column": "Dodaj stolpec"
+    },
+    "AppModel": {
+        "This team site is suspended. Documents can be read, but not modified.": "To spletno mesto ekipe je začasno zaprto. Dokumente lahko berete, vendar jih ne morete  spreminjati."
     }
 }
diff --git a/static/locales/zh_Hans.client.json b/static/locales/zh_Hans.client.json
index 62f2475b..8d5e595e 100644
--- a/static/locales/zh_Hans.client.json
+++ b/static/locales/zh_Hans.client.json
@@ -366,7 +366,10 @@
         "Update Original": "更新原件",
         "Workspace": "工作区",
         "You do not have write access to the selected workspace": "您没有对所选工作区的写入权限",
-        "You do not have write access to this site": "您没有对此网站的写入权限"
+        "You do not have write access to this site": "您没有对此网站的写入权限",
+        "Remove all data but keep the structure to use as a template": "删除所有数据,但保留结构以用作模板",
+        "Remove document history (can significantly reduce file size)": "删除文件历史记录(可大幅减少文件大小)",
+        "Download full document and history": "下载完整文档和历史记录"
     },
     "NotifyUI": {
         "Go to your free personal site": "转到您的免费个人网站",
@@ -583,7 +586,7 @@
         "Document ID copied to clipboard": "文档 ID 已复制到剪贴板",
         "Ok": "好的",
         "Manage Webhooks": "管理 Webhooks",
-        "Webhooks": "Webhooks"
+        "Webhooks": "网络钩子"
     },
     "DocumentUsage": {
         "Attachments Size": "附件大小",
@@ -706,7 +709,18 @@
         "{{count}} unmatched field_one": "{{count}} 个不匹配字段",
         "{{count}} unmatched field in import_one": "导入中 {{count}} 个字段不匹配",
         "{{count}} unmatched field_other": "{{count}} 个不匹配字段",
-        "{{count}} unmatched field in import_other": "导入中 {{count}} 个字段不匹配"
+        "{{count}} unmatched field in import_other": "导入中 {{count}} 个字段不匹配",
+        "Column mapping": "列映射",
+        "Grist column": "Grist 列",
+        "Revert": "恢复",
+        "Skip Import": "跳过导入",
+        "New Table": "新表",
+        "Skip": "跳过",
+        "Column Mapping": "列映射",
+        "Destination table": "目的表",
+        "Skip Table on Import": "导入时跳过表",
+        "Import from file": "从文件导入",
+        "Source column": "来源列"
     },
     "LeftPanelCommon": {
         "Help Center": "帮助中心"
@@ -770,7 +784,8 @@
         "Show in folder": "展现在文件夹中",
         "Unsaved": "未保存",
         "Work on a Copy": "在副本上工作",
-        "Share": "分享"
+        "Share": "分享",
+        "Download...": "下载..."
     },
     "SiteSwitcher": {
         "Create new team site": "创建新的团队网站",
@@ -923,7 +938,10 @@
         "Cell Style": "单元样式",
         "Default cell style": "默认单元格样式",
         "Mixed style": "混合风格",
-        "Open row styles": "打开行样式"
+        "Open row styles": "打开行样式",
+        "HEADER STYLE": "标题样式",
+        "Header Style": "标题样式",
+        "Default header style": "默认标题样式"
     },
     "ChoiceTextBox": {
         "CHOICES": "选择"
@@ -1055,7 +1073,8 @@
         "Function List": "函数列表",
         "Grist's AI Assistance": "Grist 人工智能助手",
         "Sign up for a free Grist account to start using the Formula AI Assistant.": "注册一个免费的Grist帐户,开始使用Formula AI助手。",
-        "Sign Up for Free": "免费注册"
+        "Sign Up for Free": "免费注册",
+        "Formula AI Assistant is only available for logged in users.": "公式 AI 助手仅适用于登录用户。"
     },
     "WebhookPage": {
         "Clear Queue": "清除队列",
diff --git a/test/nbrowser/HomeIntroWithoutPlaygound.ts b/test/nbrowser/HomeIntroWithoutPlaygound.ts
new file mode 100644
index 00000000..aafc5353
--- /dev/null
+++ b/test/nbrowser/HomeIntroWithoutPlaygound.ts
@@ -0,0 +1,52 @@
+import {assert, driver} from 'mocha-webdriver';
+import * as gu from 'test/nbrowser/gristUtils';
+import {setupTestSuite} from 'test/nbrowser/testUtils';
+
+describe('HomeIntroWithoutPlayground', function() {
+  this.timeout(40000);
+  setupTestSuite({samples: true});
+  gu.withEnvironmentSnapshot({'GRIST_ANON_PLAYGROUND': false});
+
+  describe("Anonymous on merged-org", function() {
+    it('should show welcome page with signin and signup buttons and "add new" button disabled', async function () {
+      // Sign out
+      const session = await gu.session().personalSite.anon.login();
+
+      // Open doc-menu
+      await session.loadDocMenu('/');
+
+      assert.equal(await driver.find('.test-welcome-title').getText(), 'Welcome to Grist!');
+      assert.match(
+        await driver.find('.test-welcome-text-no-playground').getText(),
+        /Visit our Help Center.*about Grist./
+      );
+
+      // Check the sign-up and sign-in buttons.
+      const getSignUp = async () => await driver.findContent('.test-intro-sign-up', 'Sign up');
+      const getSignIn = async () => await driver.findContent('.test-intro-sign-in', 'Sign in');
+      // Check that these buttons take us to a Grist login page.
+      for (const getButton of [getSignUp, getSignIn]) {
+        const button = await getButton();
+        await button.click();
+        await gu.checkLoginPage();
+        await driver.navigate().back();
+        await gu.waitForDocMenuToLoad();
+      }
+    });
+
+    it('should not allow creating new documents', async function () {
+      // Sign out
+      const session = await gu.session().personalSite.anon.login();
+
+      // Open doc-menu
+      await session.loadDocMenu('/');
+
+      // Check that add-new button is disabled
+      assert.equal(await driver.find('.test-dm-add-new').matches('[class*=-disabled]'), true);
+
+      // Check that add-new menu is not displayed
+      await driver.find('.test-dm-add-new').doClick();
+      assert.equal(await driver.find('.test-dm-new-doc').isPresent(), false);
+    });
+  });
+});
diff --git a/test/nbrowser/RightPanel.ts b/test/nbrowser/RightPanel.ts
index f7d16459..a2f4af4b 100644
--- a/test/nbrowser/RightPanel.ts
+++ b/test/nbrowser/RightPanel.ts
@@ -245,7 +245,7 @@ describe('RightPanel', function() {
 
     // check that selector-of is present and that all selected section are listed
     assert.equal(await driver.find('.test-selector-for').isPresent(), true);
-    assert.deepEqual(await driver.findAll('.test-selector-for-entry', (e) => e.getText()), [
+    assert.deepEqual(await driver.findAll('.test-selector-for-entry', (e) => e.getText().then(s => s.split('\n')[0])), [
       "CITY",
       "COUNTRYLANGUAGE",
       "COUNTRY Card List",
diff --git a/test/server/lib/DocApi.ts b/test/server/lib/DocApi.ts
index a0b9376b..8c7750d2 100644
--- a/test/server/lib/DocApi.ts
+++ b/test/server/lib/DocApi.ts
@@ -120,6 +120,24 @@ describe('DocApi', function () {
     testDocApi();
   });
 
+  describe('With GRIST_ANON_PLAYGROUND disabled', async () => {
+    setup('anon-playground', async () => {
+      const additionalEnvConfiguration = {
+        ALLOWED_WEBHOOK_DOMAINS: `example.com,localhost:${webhooksTestPort}`,
+        GRIST_DATA_DIR: dataDir,
+        GRIST_ANON_PLAYGROUND: 'false'
+      };
+      home = docs = await TestServer.startServer('home,docs', tmpDir, suitename, additionalEnvConfiguration);
+      homeUrl = serverUrl = home.serverUrl;
+      hasHomeApi = true;
+    });
+
+    it('should not allow anonymous users to create new docs', async () => {
+      const resp = await axios.post(`${serverUrl}/api/docs`, null, nobody);
+      assert.equal(resp.status, 403);
+    });
+  });
+
   // the way these tests are written, non-merged server requires redis.
   if (process.env.TEST_REDIS_URL) {
     describe("should work with a home server and a docworker", async () => {