(core) updates from grist-core

alex/skip-fstrings-3.9
Paul Fitzpatrick 8 months ago
commit 18f7e255df

@ -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

@ -1,11 +1,22 @@
import { UrlTweaks } from 'app/common/gristUrls';
import { IAttrObj } from 'grainjs';
export interface IHooks {
iframeAttributes?: Record<string, any>,
fetch?: typeof fetch,
baseURI?: string,
urlTweaks?: UrlTweaks,
/**
* Modify the attributes of an <a> 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;
}
};

@ -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<UIRowId>;
// 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<FilterState>;
// filterColValues is a subset of the current filterState needed for filtering (subset of ClientQuery)
// {[colId]: colValues, [colId]: operations} mapping,
public readonly filterColValues?: ko.Computed<FilterColValues>;
// 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<LinkType>;
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<FilterColValues>();
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<FilterState>();
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<FilterState>|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<FilterColValues> {
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<FilterState> | 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<FilterColValues> | 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<FilterColValues> | undefined {
//"null" for column implies id column
private _srcCustomFilter(
column: ColumnRec|undefined, operation: QueryOperation): ko.Computed<FilterState> {
//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;
}
}

@ -209,7 +209,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
public createLeftPane(leftPanelOpen: Observable<boolean>) {
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.

@ -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.

@ -5,14 +5,27 @@ import {dom, DomElementArg, Observable, styled} from "grainjs";
const t = makeT(`AddNewButton`);
export function addNewButton(isOpen: Observable<boolean> | boolean = true, ...args: DomElementArg[]) {
export function addNewButton(
{
isOpen,
isDisabled = false,
}: {
isOpen: Observable<boolean> | 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};

@ -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() })),

@ -30,16 +30,17 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
const creating = observable<boolean>(false);
const renaming = observable<Workspace|null>(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'),
),

@ -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();
}),

@ -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<boolean>) {
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 <pre></pre> 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<Boolean> = 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;
`);

@ -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'))),
];

@ -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"),

@ -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<number>;
@ -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;

@ -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[];

@ -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);
}

@ -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<string> {
const activeDoc: ActiveDoc = await this.createNewEmptyDoc(docSession, docId);
await activeDoc.addInitialTable(docSession);

@ -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

@ -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() :

@ -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(),

@ -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<T extends any[]>(
make: (a: ActiveDocSource, testDates: boolean, output: Stream, ...args: T) => Promise<void>
make: (a: ActiveDocSource, testDates: boolean, output: Stream, ...args: T) => Promise<void|ExcelBuffer>
) {
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<void> {
): Promise<void|ExcelBuffer> {
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<void>,
end: () => Promise<void|ExcelBuffer>,
} {
// 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<void|ExcelBuffer> {
if (!stream) {
return wb.xlsx.writeBuffer();
}
ws.commit();
return maybeCommit(wb);
}
function end() { return wb.commit(); }
return {exportTable, end};
}

@ -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",

@ -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",

@ -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. Dont 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."
}
}

@ -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": "清除队列",

@ -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);
});
});
});

@ -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",

@ -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 () => {

Loading…
Cancel
Save