mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) updates from grist-core
This commit is contained in:
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_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_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_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_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_TEMPLATE_ORG | set to an org "domain" to show public docs from that org
|
||||||
GRIST_HELP_CENTER | set the help center link ref
|
GRIST_HELP_CENTER | set the help center link ref
|
||||||
|
@ -1,11 +1,22 @@
|
|||||||
import { UrlTweaks } from 'app/common/gristUrls';
|
import { UrlTweaks } from 'app/common/gristUrls';
|
||||||
|
import { IAttrObj } from 'grainjs';
|
||||||
|
|
||||||
export interface IHooks {
|
export interface IHooks {
|
||||||
iframeAttributes?: Record<string, any>,
|
iframeAttributes?: Record<string, any>,
|
||||||
fetch?: typeof fetch,
|
fetch?: typeof fetch,
|
||||||
baseURI?: string,
|
baseURI?: string,
|
||||||
urlTweaks?: UrlTweaks,
|
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 = {
|
export const defaultHooks: IHooks = {
|
||||||
|
maybeModifyLinkAttrs(attrs: IAttrObj) {
|
||||||
|
return attrs;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
@ -9,16 +9,434 @@ import {FilterColValues, QueryOperation} from "app/common/ActiveDocAPI";
|
|||||||
import {isList, isListType, isRefListType} from "app/common/gristTypes";
|
import {isList, isListType, isRefListType} from "app/common/gristTypes";
|
||||||
import * as gutil from "app/common/gutil";
|
import * as gutil from "app/common/gutil";
|
||||||
import {UIRowId} from 'app/plugin/GristAPI';
|
import {UIRowId} from 'app/plugin/GristAPI';
|
||||||
|
import {CellValue} from "app/plugin/GristData";
|
||||||
import {encodeObject} from 'app/plugin/objtypes';
|
import {encodeObject} from 'app/plugin/objtypes';
|
||||||
import {Disposable} from "grainjs";
|
import {Disposable, Holder, MultiHolder} from "grainjs";
|
||||||
import * as ko from "knockout";
|
import * as ko from "knockout";
|
||||||
import identity = require('lodash/identity');
|
import merge = require('lodash/merge');
|
||||||
import mapValues = require('lodash/mapValues');
|
import mapValues = require('lodash/mapValues');
|
||||||
|
import pick = require('lodash/pick');
|
||||||
import pickBy = require('lodash/pickBy');
|
import pickBy = require('lodash/pickBy');
|
||||||
|
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
|
||||||
|
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, 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 _srcColId: string | undefined;
|
||||||
|
|
||||||
|
constructor(docModel: DocModel, linkConfig: LinkConfig) {
|
||||||
|
super();
|
||||||
|
const {srcSection, srcCol, srcColId, tgtSection, tgtCol, tgtColId} = linkConfig;
|
||||||
|
this._docModel = docModel;
|
||||||
|
this._srcSection = srcSection;
|
||||||
|
this._srcColId = srcColId;
|
||||||
|
this._srcTableModel = docModel.dataTables[srcSection.table().tableId()];
|
||||||
|
const srcTableData = this._srcTableModel.tableData;
|
||||||
|
|
||||||
|
// === 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";
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
//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;
|
||||||
|
}
|
||||||
|
|
||||||
|
//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) { // If same-table cursor-link, copy getDefaultColValues from the source if possible
|
||||||
|
const getDefaultColValues = srcSection.linkingState()?.getDefaultColValues;
|
||||||
|
if (getDefaultColValues) {
|
||||||
|
this.getDefaultColValues = getDefaultColValues;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.filterState) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a boolean indicating whether editing should be disabled in the destination section.
|
||||||
|
*/
|
||||||
|
public disableEditing(): boolean {
|
||||||
|
return Boolean(this.filterState) && this._srcSection.activeRowId() === 'new';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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("_makeFilterObs activeRowId is null");
|
||||||
|
return EmptyFilterState;
|
||||||
|
}
|
||||||
|
|
||||||
|
//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 {
|
||||||
|
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
|
||||||
|
//"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},
|
||||||
|
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 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 (!cellObs) {
|
||||||
|
console.warn(`Issue in LinkingState._makeValGetter(${table.tableId()},${colId}): cellObs is nullish`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 if the first table is a summary of the second. If both are summary tables, returns true
|
* 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.
|
* 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 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.
|
* @param detail: TableRec for the table to check for being the detailed version.
|
||||||
@ -35,201 +453,27 @@ function isSummaryOf(summary: TableRec, detail: TableRec): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maintains state useful for linking sections, i.e. auto-filtering and auto-scrolling.
|
* When TableA is a summary of TableB, each of TableA.groupByCols corresponds to a specific col of TableB
|
||||||
* Exposes .filterColValues, which is either null or a computed evaluating to a filtering object;
|
* This function returns the column of B that corresponds to a particular groupByCol of A
|
||||||
* and .cursorPos, which is either null or a computed that evaluates to a cursor position.
|
* - If A is a direct summary of B, then the corresponding col for A.someCol is A.someCol.summarySource()
|
||||||
* LinkingState must be created with a valid srcSection and tgtSection.
|
* - 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.
|
||||||
* There are several modes of linking:
|
* - Since we know A is a summary of B, then B's groupByCols must include all of A's groupbycols,
|
||||||
* (1) If tgtColId is set, tgtSection will be filtered to show rows whose values of target column
|
* so we can get B.someCol by matching on colId.
|
||||||
* are equal to the value of source column in srcSection at the cursor. With byAllShown set, all
|
* @param srcGBCol: ColumnRec, must be a groupByColumn, and srcGBCol.table() must be a summary of tgtTable
|
||||||
* values in srcSection are used (rather than only the value in the cursor).
|
* @param tgtTable: TableRec to get corresponding column from
|
||||||
* (2) If srcSection is a summary of tgtSection, then tgtSection is filtered to show only those
|
* @returns {ColumnRec} The corresponding column of tgtTable
|
||||||
* 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 {
|
function summaryGetCorrespondingCol(srcGBCol: ColumnRec, tgtTable: TableRec): ColumnRec {
|
||||||
// If linking affects target section's cursor, this will be a computed for the cursor rowId.
|
if(!isSummaryOf(srcGBCol.table(), tgtTable))
|
||||||
public readonly cursorPos?: ko.Computed<UIRowId>;
|
{ throw Error("ERROR in LinkingState summaryGetCorrespondingCol: srcTable must be summary of tgtTable"); }
|
||||||
|
|
||||||
// If linking affects filtering, this is a computed for the current filtering state, as a
|
if(tgtTable.summarySourceTable() === 0) { //if direct summary
|
||||||
// {[colId]: colValues} mapping, with a dependency on srcSection.activeRowId()
|
return srcGBCol.summarySource();
|
||||||
public readonly filterColValues?: ko.Computed<FilterColValues>;
|
} else { // else summary->summary, match by colId
|
||||||
|
const srcColId = srcGBCol.colId();
|
||||||
// Get default values for a new record so that it continues to satisfy the current linking filters
|
const retVal = tgtTable.groupByColumns().find((tgtCol) => tgtCol.colId() === srcColId); //should always exist
|
||||||
public readonly getDefaultColValues: () => any;
|
if(!retVal) { throw Error("ERROR in LinkingState summaryGetCorrespondingCol: summary table lacks groupby col"); }
|
||||||
|
return retVal;
|
||||||
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._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]));
|
|
||||||
}
|
|
||||||
} 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;
|
|
||||||
}
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_filterColValues(result);
|
|
||||||
}
|
|
||||||
} else if (srcSection.selectedRowsActive()) {
|
|
||||||
this.filterColValues = this._srcCustomFilter('id', 'in');
|
|
||||||
} else {
|
|
||||||
const srcValueFunc = srcColId ? this._makeSrcCellGetter() : identity;
|
|
||||||
if (srcValueFunc) {
|
|
||||||
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
|
|
||||||
const getDefaultColValues = srcSection.linkingState()?.getDefaultColValues;
|
|
||||||
if (getDefaultColValues) {
|
|
||||||
this.getDefaultColValues = getDefaultColValues;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.getDefaultColValues) {
|
|
||||||
this.getDefaultColValues = () => {
|
|
||||||
if (!this.filterColValues) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
const {filters, operations} = this.filterColValues.peek();
|
|
||||||
return mapValues(
|
|
||||||
pickBy(filters, (value: any[], key: string) => value.length > 0 && key !== "id"),
|
|
||||||
(value, key) => operations[key] === "intersects" ? encodeObject(value) : value[0]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a boolean indicating whether editing should be disabled in the destination section.
|
|
||||||
*/
|
|
||||||
public disableEditing(): boolean {
|
|
||||||
return Boolean(this.filterColValues) && 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(() => {
|
|
||||||
const srcRowId = this._srcSection.activeRowId();
|
|
||||||
if (srcRowId === null) {
|
|
||||||
console.warn("_simpleFilter activeRowId is null");
|
|
||||||
return { filters: {}, operations: {}};
|
|
||||||
}
|
|
||||||
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 [];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return [value];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Value for this.filterColValues based on the values in srcSection.selectedRows
|
|
||||||
private _srcCustomFilter(colId: string, operation: QueryOperation): ko.Computed<FilterColValues> | undefined {
|
|
||||||
return this.autoDispose(ko.computed(() => {
|
|
||||||
const values = this._srcSection.selectedRows();
|
|
||||||
return {filters: {[colId]: values}, operations: {[colId]: operation}} as FilterColValues;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
// transiently while the separate linking-related observables get updated.
|
|
||||||
if (!srcCellObs) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (rowId: UIRowId | null) => {
|
|
||||||
srcRowModel.assign(rowId);
|
|
||||||
if (rowId === 'new') {
|
|
||||||
return 'new';
|
|
||||||
}
|
|
||||||
return srcCellObs();
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -209,7 +209,7 @@ export class DocPageModelImpl extends Disposable implements DocPageModel {
|
|||||||
public createLeftPane(leftPanelOpen: Observable<boolean>) {
|
public createLeftPane(leftPanelOpen: Observable<boolean>) {
|
||||||
return cssLeftPanel(
|
return cssLeftPanel(
|
||||||
dom.maybe(this.gristDoc, (activeDoc) => [
|
dom.maybe(this.gristDoc, (activeDoc) => [
|
||||||
addNewButton(leftPanelOpen,
|
addNewButton({ isOpen: leftPanelOpen },
|
||||||
menu(() => addMenu(this.importSources, activeDoc, this.isReadonly.get()), {
|
menu(() => addMenu(this.importSources, activeDoc, this.isReadonly.get()), {
|
||||||
placement: 'bottom-start',
|
placement: 'bottom-start',
|
||||||
// "Add New" menu should have the same width as the "Add New" button that opens it.
|
// "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 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 {KoArray} from 'app/client/lib/koArray';
|
||||||
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
|
import {ColumnToMapImpl} from 'app/client/models/ColumnToMap';
|
||||||
import {
|
import {
|
||||||
@ -637,7 +637,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
this.linkingFilter = this.autoDispose(ko.pureComputed(() => {
|
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.
|
// 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`);
|
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(
|
return cssAddNewButton(
|
||||||
cssAddNewButton.cls('-open', isOpen),
|
cssAddNewButton.cls('-open', isOpen),
|
||||||
|
cssAddNewButton.cls('-disabled', isDisabled),
|
||||||
// Setting spacing as flex items allows them to shrink faster when there isn't enough space.
|
// Setting spacing as flex items allows them to shrink faster when there isn't enough space.
|
||||||
cssLeftMargin(),
|
cssLeftMargin(),
|
||||||
cssAddText(t("Add New")),
|
cssAddText(t("Add New")),
|
||||||
dom('div', {style: 'flex: 1 1 16px'}),
|
dom('div', {style: 'flex: 1 1 16px'}),
|
||||||
cssPlusButton(cssPlusIcon('Plus')),
|
cssPlusButton(
|
||||||
|
cssPlusButton.cls('-disabled', isDisabled),
|
||||||
|
cssPlusIcon('Plus')
|
||||||
|
),
|
||||||
dom('div', {style: 'flex: 0 1 16px'}),
|
dom('div', {style: 'flex: 0 1 16px'}),
|
||||||
...args,
|
...args,
|
||||||
);
|
);
|
||||||
@ -47,6 +60,11 @@ export const cssAddNewButton = styled('div', `
|
|||||||
background-color: ${theme.controlPrimaryHoverBg};
|
background-color: ${theme.controlPrimaryHoverBg};
|
||||||
--circle-color: ${theme.addNewCircleHoverBg};
|
--circle-color: ${theme.addNewCircleHoverBg};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-disabled, &-disabled:hover {
|
||||||
|
color: ${theme.controlDisabledFg};
|
||||||
|
background-color: ${theme.controlDisabledBg}
|
||||||
|
}
|
||||||
`);
|
`);
|
||||||
const cssLeftMargin = styled('div', `
|
const cssLeftMargin = styled('div', `
|
||||||
flex: 0 1 24px;
|
flex: 0 1 24px;
|
||||||
@ -72,6 +90,9 @@ const cssPlusButton = styled('div', `
|
|||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background-color: var(--circle-color);
|
background-color: var(--circle-color);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
&-disabled {
|
||||||
|
background-color: ${theme.controlDisabledBg};
|
||||||
|
}
|
||||||
`);
|
`);
|
||||||
const cssPlusIcon = styled(icon, `
|
const cssPlusIcon = styled(icon, `
|
||||||
background-color: ${theme.addNewCircleFg};
|
background-color: ${theme.addNewCircleFg};
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import {makeT} from 'app/client/lib/localization';
|
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 {HomeModel} from 'app/client/models/HomeModel';
|
||||||
import {productPill} from 'app/client/ui/AppHeader';
|
import {productPill} from 'app/client/ui/AppHeader';
|
||||||
import * as css from 'app/client/ui/DocMenuCss';
|
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 {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
|
||||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||||
import * as roles from 'app/common/roles';
|
import * as roles from 'app/common/roles';
|
||||||
|
import {getGristConfig} from 'app/common/urlUtils';
|
||||||
import {Computed, dom, DomContents, styled} from 'grainjs';
|
import {Computed, dom, DomContents, styled} from 'grainjs';
|
||||||
|
|
||||||
const t = makeT('HomeIntro');
|
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) {
|
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"));
|
const signUp = cssLink({href: getLoginOrSignupUrl()}, t("Sign up"));
|
||||||
return [
|
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("Get started by exploring templates, or creating your first Grist document.")),
|
||||||
cssIntroLine(t("{{signUp}} to save your work. ", {signUp}),
|
cssIntroLine(t("{{signUp}} to save your work. ", {signUp}),
|
||||||
(!isFeatureEnabled('helpCenter') ? null : t("Visit our {{link}} to learn more.", { link: helpCenterLink() })),
|
(!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 creating = observable<boolean>(false);
|
||||||
const renaming = observable<Workspace|null>(null);
|
const renaming = observable<Workspace|null>(null);
|
||||||
const isAnonymous = !home.app.currentValidUser;
|
const isAnonymous = !home.app.currentValidUser;
|
||||||
|
const canCreate = !isAnonymous || getGristConfig().enableAnonPlayground;
|
||||||
|
|
||||||
return cssContent(
|
return cssContent(
|
||||||
dom.autoDispose(creating),
|
dom.autoDispose(creating),
|
||||||
dom.autoDispose(renaming),
|
dom.autoDispose(renaming),
|
||||||
addNewButton(leftPanelOpen,
|
addNewButton({ isOpen: leftPanelOpen, isDisabled: !canCreate },
|
||||||
menu(() => addMenu(home, creating), {
|
canCreate ? menu(() => addMenu(home, creating), {
|
||||||
placement: 'bottom-start',
|
placement: 'bottom-start',
|
||||||
// "Add New" menu should have the same width as the "Add New" button that opens it.
|
// "Add New" menu should have the same width as the "Add New" button that opens it.
|
||||||
stretchToSelector: `.${cssAddNewButton.className}`
|
stretchToSelector: `.${cssAddNewButton.className}`
|
||||||
}),
|
}) : null,
|
||||||
dom.cls('behavioral-prompt-add-new'),
|
dom.cls('behavioral-prompt-add-new'),
|
||||||
testId('dm-add-new'),
|
testId('dm-add-new'),
|
||||||
),
|
),
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
* the sample documents (those in the Support user's Examples & Templates workspace).
|
* 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 {makeT} from 'app/client/lib/localization';
|
||||||
import {AppModel, reportError} from 'app/client/models/AppModel';
|
import {AppModel, reportError} from 'app/client/models/AppModel';
|
||||||
import {DocPageModel} from 'app/client/models/DocPageModel';
|
import {DocPageModel} from 'app/client/models/DocPageModel';
|
||||||
@ -310,14 +311,14 @@ export function downloadDocModal(doc: Document, pageModel: DocPageModel) {
|
|||||||
),
|
),
|
||||||
cssModalButtons(
|
cssModalButtons(
|
||||||
dom.domComputed(use =>
|
dom.domComputed(use =>
|
||||||
bigPrimaryButtonLink(`Download`, {
|
bigPrimaryButtonLink(`Download`, hooks.maybeModifyLinkAttrs({
|
||||||
href: pageModel.appModel.api.getDocAPI(doc.id).getDownloadUrl({
|
href: pageModel.appModel.api.getDocAPI(doc.id).getDownloadUrl({
|
||||||
template: use(selected) === "template",
|
template: use(selected) === "template",
|
||||||
removeHistory: use(selected) === "nohistory" || use(selected) === "template",
|
removeHistory: use(selected) === "nohistory" || use(selected) === "template",
|
||||||
}),
|
}),
|
||||||
target: '_blank',
|
target: '_blank',
|
||||||
download: ''
|
download: ''
|
||||||
},
|
}),
|
||||||
dom.on('click', () => {
|
dom.on('click', () => {
|
||||||
ctl.close();
|
ctl.close();
|
||||||
}),
|
}),
|
||||||
|
@ -16,14 +16,15 @@
|
|||||||
|
|
||||||
import * as commands from 'app/client/components/commands';
|
import * as commands from 'app/client/components/commands';
|
||||||
import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc';
|
import {GristDoc, IExtraTool, TabContent} from 'app/client/components/GristDoc';
|
||||||
|
import {EmptyFilterState} from "app/client/components/LinkingState";
|
||||||
import {RefSelect} from 'app/client/components/RefSelect';
|
import {RefSelect} from 'app/client/components/RefSelect';
|
||||||
import ViewConfigTab from 'app/client/components/ViewConfigTab';
|
import ViewConfigTab from 'app/client/components/ViewConfigTab';
|
||||||
import {domAsync} from 'app/client/lib/domAsync';
|
import {domAsync} from 'app/client/lib/domAsync';
|
||||||
import * as imports from 'app/client/lib/imports';
|
import * as imports from 'app/client/lib/imports';
|
||||||
import {makeT} from 'app/client/lib/localization';
|
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 {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 {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
|
||||||
import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig';
|
import {buildDescriptionConfig} from 'app/client/ui/DescriptionConfig';
|
||||||
import {BuildEditorOptions} from 'app/client/ui/FieldConfig';
|
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 {icon} from 'app/client/ui2018/icons';
|
||||||
import {select} from 'app/client/ui2018/menus';
|
import {select} from 'app/client/ui2018/menus';
|
||||||
import {FieldBuilder} from 'app/client/widgets/FieldBuilder';
|
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 {StringUnion} from 'app/common/StringUnion';
|
||||||
import {IWidgetType} from 'app/common/widgetTypes';
|
import {IWidgetType} from 'app/common/widgetTypes';
|
||||||
import {
|
import {
|
||||||
@ -60,6 +63,10 @@ import {
|
|||||||
} from 'grainjs';
|
} from 'grainjs';
|
||||||
import * as ko from 'knockout';
|
import * as ko from 'knockout';
|
||||||
|
|
||||||
|
// some unicode characters
|
||||||
|
const BLACK_CIRCLE = '\u2022';
|
||||||
|
const ELEMENTOF = '\u2208'; //220A for small elementof
|
||||||
|
|
||||||
const t = makeT('RightPanel');
|
const t = makeT('RightPanel');
|
||||||
|
|
||||||
// Represents a top tab of the right side-pane.
|
// Represents a top tab of the right side-pane.
|
||||||
@ -109,6 +116,10 @@ export class RightPanel extends Disposable {
|
|||||||
return sec.getRowId() ? sec : null;
|
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>) {
|
constructor(private _gristDoc: GristDoc, private _isOpen: Observable<boolean>) {
|
||||||
super();
|
super();
|
||||||
this._extraTool = _gristDoc.rightPanelTool;
|
this._extraTool = _gristDoc.rightPanelTool;
|
||||||
@ -484,6 +495,189 @@ export class RightPanel extends Disposable {
|
|||||||
return dom.maybe(viewConfigTab, (vct) => vct.buildSortFilterDom());
|
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) {
|
private _buildPageDataConfig(owner: MultiHolder, activeSection: ViewSectionRec) {
|
||||||
const viewConfigTab = this._createViewConfigTab(owner);
|
const viewConfigTab = this._createViewConfigTab(owner);
|
||||||
const viewModel = this._gristDoc.viewModel;
|
const viewModel = this._gristDoc.viewModel;
|
||||||
@ -570,15 +764,22 @@ export class RightPanel extends Disposable {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
dom.maybe(activeSection.linkingState, () => cssRow(this._buildLinkInfo(activeSection))),
|
||||||
|
|
||||||
domComputed((use) => {
|
domComputed((use) => {
|
||||||
const selectorFor = use(use(activeSection.linkedSections).getObservable());
|
const selectorFor = use(use(activeSection.linkedSections).getObservable());
|
||||||
// TODO: sections should be listed following the order of appearance in the view layout (ie:
|
// TODO: sections should be listed following the order of appearance in the view layout (ie:
|
||||||
// left/right - top/bottom);
|
// left/right - top/bottom);
|
||||||
return selectorFor.length ? [
|
return selectorFor.length ? [
|
||||||
cssLabel(t("SELECTOR FOR"), testId('selector-for')),
|
cssLabel(t("SELECTOR FOR"), testId('selector-for')),
|
||||||
cssRow(cssList(selectorFor.map((sec) => this._buildSectionItem(sec))))
|
cssRow(cssList(selectorFor.map((sec) => [
|
||||||
|
this._buildSectionItem(sec)
|
||||||
|
]))),
|
||||||
] : null;
|
] : 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) {
|
private _buildSectionItem(sec: ViewSectionRec) {
|
||||||
return cssListItem(
|
return cssListItem(
|
||||||
dom.text(sec.titleDef),
|
dom.text(sec.titleDef),
|
||||||
|
this._buildLinkInfo(sec, dom.style("border", "none")),
|
||||||
testId('selector-for-entry')
|
testId('selector-for-entry')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -865,3 +1067,65 @@ const cssTextInput = styled(textInput, `
|
|||||||
const cssSection = styled('div', `
|
const cssSection = styled('div', `
|
||||||
position: relative;
|
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 {loadUserManager} from 'app/client/lib/imports';
|
||||||
import {AppModel, reportError} from 'app/client/models/AppModel';
|
import {AppModel, reportError} from 'app/client/models/AppModel';
|
||||||
import {DocInfo, DocPageModel} from 'app/client/models/DocPageModel';
|
import {DocInfo, DocPageModel} from 'app/client/models/DocPageModel';
|
||||||
@ -278,12 +279,12 @@ function menuExports(doc: Document, pageModel: DocPageModel) {
|
|||||||
menuItem(() => downloadDocModal(doc, pageModel),
|
menuItem(() => downloadDocModal(doc, pageModel),
|
||||||
menuIcon('Download'), t("Download..."), testId('tb-share-option'))
|
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')),
|
menuIcon('Download'), t("Export CSV"), testId('tb-share-option')),
|
||||||
menuItemLink({
|
menuItemLink(hooks.maybeModifyLinkAttrs({
|
||||||
href: pageModel.appModel.api.getDocAPI(doc.id).getDownloadXlsxUrl(),
|
href: pageModel.appModel.api.getDocAPI(doc.id).getDownloadXlsxUrl(),
|
||||||
target: '_blank', download: ''
|
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),
|
(!isFeatureEnabled("sendToDrive") ? null : menuItem(() => sendToDrive(doc, pageModel),
|
||||||
menuIcon('Download'), t("Send to Google Drive"), testId('tb-share-option'))),
|
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 {makeT} from 'app/client/lib/localization';
|
||||||
import {allCommands} from 'app/client/components/commands';
|
import {allCommands} from 'app/client/components/commands';
|
||||||
import {ViewSectionRec} from 'app/client/models/DocModel';
|
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')),
|
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')),
|
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')),
|
t("Download as XLSX"), testId('download-section')),
|
||||||
dom.maybe((use) => ['detail', 'single'].includes(use(viewSection.parentKey)), () =>
|
dom.maybe((use) => ['detail', 'single'].includes(use(viewSection.parentKey)), () =>
|
||||||
menuItemCmd(allCommands.editLayout, t("Edit Card Layout"),
|
menuItemCmd(allCommands.editLayout, t("Edit Card Layout"),
|
||||||
|
@ -42,6 +42,9 @@ interface LinkNode {
|
|||||||
// is the table a summary table
|
// is the table a summary table
|
||||||
isSummary: boolean;
|
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
|
// For a summary table, the set of col refs of the groupby columns of the underlying table
|
||||||
groupbyColumns?: Set<number>;
|
groupbyColumns?: Set<number>;
|
||||||
|
|
||||||
@ -114,6 +117,12 @@ function isValidLink(source: LinkNode, target: LinkNode) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//cannot select from attachments, even though they're implemented as reflists
|
||||||
|
if (source.isAttachments || target.isAttachments) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// cannot select from chart
|
// cannot select from chart
|
||||||
if (source.widgetType === 'chart') {
|
if (source.widgetType === 'chart') {
|
||||||
return false;
|
return false;
|
||||||
@ -230,6 +239,7 @@ function fromViewSectionRec(section: ViewSectionRec): LinkNode[] {
|
|||||||
const mainNode: LinkNode = {
|
const mainNode: LinkNode = {
|
||||||
tableId: table.primaryTableId.peek(),
|
tableId: table.primaryTableId.peek(),
|
||||||
isSummary,
|
isSummary,
|
||||||
|
isAttachments: isSummary && table.groupByColumns.peek().some(col => col.type.peek() == "Attachments"),
|
||||||
groupbyColumns: isSummary ? table.summarySourceColRefs.peek() : undefined,
|
groupbyColumns: isSummary ? table.summarySourceColRefs.peek() : undefined,
|
||||||
widgetType: section.parentKey.peek(),
|
widgetType: section.parentKey.peek(),
|
||||||
ancestors,
|
ancestors,
|
||||||
@ -266,6 +276,8 @@ function fromPageWidget(docModel: DocModel, pageWidget: IPageWidget): LinkNode[]
|
|||||||
const mainNode: LinkNode = {
|
const mainNode: LinkNode = {
|
||||||
tableId: table.primaryTableId.peek(),
|
tableId: table.primaryTableId.peek(),
|
||||||
isSummary,
|
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,
|
groupbyColumns,
|
||||||
widgetType: pageWidget.type,
|
widgetType: pageWidget.type,
|
||||||
ancestors: new Set(),
|
ancestors: new Set(),
|
||||||
@ -284,7 +296,7 @@ function fromColumns(table: TableRec, mainNode: LinkNode, tableExists: boolean =
|
|||||||
}
|
}
|
||||||
const tableId = getReferencedTableId(column.type.peek());
|
const tableId = getReferencedTableId(column.type.peek());
|
||||||
if (tableId) {
|
if (tableId) {
|
||||||
nodes.push({...mainNode, tableId, column});
|
nodes.push({...mainNode, tableId, column, isAttachments: column.type.peek() == "Attachments"});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nodes;
|
return nodes;
|
||||||
|
@ -607,6 +607,9 @@ export interface GristLoadConfig {
|
|||||||
// If set, enable anonymous sharing UI elements.
|
// If set, enable anonymous sharing UI elements.
|
||||||
supportAnon?: boolean;
|
supportAnon?: boolean;
|
||||||
|
|
||||||
|
// If set, enable anonymous playground.
|
||||||
|
enableAnonPlayground?: boolean;
|
||||||
|
|
||||||
// If set, allow selection of the specified engines.
|
// If set, allow selection of the specified engines.
|
||||||
// TODO: move this list to a separate endpoint.
|
// TODO: move this list to a separate endpoint.
|
||||||
supportEngines?: EngineCode[];
|
supportEngines?: EngineCode[];
|
||||||
|
@ -50,7 +50,7 @@ import {IDocWorkerMap} from "app/server/lib/DocWorkerMap";
|
|||||||
import {DownloadOptions, parseExportParameters} from "app/server/lib/Export";
|
import {DownloadOptions, parseExportParameters} from "app/server/lib/Export";
|
||||||
import {downloadCSV} from "app/server/lib/ExportCSV";
|
import {downloadCSV} from "app/server/lib/ExportCSV";
|
||||||
import {collectTableSchemaInFrictionlessFormat} from "app/server/lib/ExportTableSchema";
|
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 {expressWrap} from 'app/server/lib/expressWrap';
|
||||||
import {filterDocumentInPlace} from "app/server/lib/filterUtils";
|
import {filterDocumentInPlace} from "app/server/lib/filterUtils";
|
||||||
import {googleAuthTokenMiddleware} from "app/server/lib/GoogleAuth";
|
import {googleAuthTokenMiddleware} from "app/server/lib/GoogleAuth";
|
||||||
@ -173,6 +173,7 @@ export class DocWorkerApi {
|
|||||||
const canView = expressWrap(this._assertAccess.bind(this, 'viewers', false));
|
const canView = expressWrap(this._assertAccess.bind(this, 'viewers', false));
|
||||||
// check document exists (not soft deleted) and user can edit it
|
// check document exists (not soft deleted) and user can edit it
|
||||||
const canEdit = expressWrap(this._assertAccess.bind(this, 'editors', false));
|
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));
|
const isOwner = expressWrap(this._assertAccess.bind(this, 'owners', false));
|
||||||
// check user can edit document, with soft-deleted documents being acceptable
|
// check user can edit document, with soft-deleted documents being acceptable
|
||||||
const canEditMaybeRemoved = expressWrap(this._assertAccess.bind(this, 'editors', true));
|
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.
|
* 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);
|
const userId = getUserId(req);
|
||||||
|
|
||||||
let uploadId: number|undefined;
|
let uploadId: number|undefined;
|
||||||
@ -1522,6 +1523,17 @@ export class DocWorkerApi {
|
|||||||
return await this._dbManager.increaseUsage(getDocScope(req), limit, {delta: 1});
|
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,
|
private async _assertAccess(role: 'viewers'|'editors'|'owners'|null, allowRemoved: boolean,
|
||||||
req: Request, res: Response, next: NextFunction) {
|
req: Request, res: Response, next: NextFunction) {
|
||||||
const scope = getDocScope(req);
|
const scope = getDocScope(req);
|
||||||
@ -1969,3 +1981,14 @@ export interface WebhookSubscription {
|
|||||||
unsubscribeKey: string;
|
unsubscribeKey: string;
|
||||||
webhookId: 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');
|
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> {
|
public async createNamedDoc(docSession: OptDocSession, docId: string): Promise<string> {
|
||||||
const activeDoc: ActiveDoc = await this.createNewEmptyDoc(docSession, docId);
|
const activeDoc: ActiveDoc = await this.createNewEmptyDoc(docSession, docId);
|
||||||
await activeDoc.addInitialTable(docSession);
|
await activeDoc.addInitialTable(docSession);
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Overview of Excel exports, which now use worker-threads.
|
* 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
|
* 1. The flow starts with the streamXLSX() method called in the main thread.
|
||||||
* Google Drive export).
|
|
||||||
* 2. It uses the 'piscina' library to call a makeXLSX* method in a worker thread, registered in
|
* 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.
|
* 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()
|
* 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.
|
* 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 {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 log from 'app/server/lib/log';
|
||||||
import {addAbortHandler} from 'app/server/lib/requestUtils';
|
import {addAbortHandler} from 'app/server/lib/requestUtils';
|
||||||
import * as express from 'express';
|
import * as express from 'express';
|
||||||
import contentDisposition from 'content-disposition';
|
|
||||||
import {Rpc} from 'grain-rpc';
|
import {Rpc} from 'grain-rpc';
|
||||||
import {AbortController} from 'node-abort-controller';
|
import {AbortController} from 'node-abort-controller';
|
||||||
import {Writable} from 'stream';
|
import {Writable} from 'stream';
|
||||||
@ -38,24 +36,12 @@ const exportPool = new Piscina({
|
|||||||
idleTimeout: 10_000, // Drop unused threads after 10s of inactivity.
|
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.
|
* Converts `activeDoc` to XLSX and sends to the given outputStream.
|
||||||
*/
|
*/
|
||||||
export async function streamXLSX(activeDoc: ActiveDoc, req: express.Request,
|
export async function streamXLSX(activeDoc: ActiveDoc, req: express.Request,
|
||||||
outputStream: Writable, options: ExportParameters) {
|
outputStream: Writable, options: ExportParameters) {
|
||||||
log.debug(`Generating .xlsx file`);
|
log.debug(`Generating .xlsx file`);
|
||||||
const {tableId, viewSectionId, filters, sortOrder, linkingFilter} = options;
|
|
||||||
const testDates = (req.hostname === 'localhost');
|
const testDates = (req.hostname === 'localhost');
|
||||||
|
|
||||||
const { port1, port2 } = new MessageChannel();
|
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
|
// hanlding 3 cases : full XLSX export (full file), view xlsx export, table xlsx export
|
||||||
try {
|
try {
|
||||||
if (viewSectionId) {
|
await run('makeXLSXFromOptions', options);
|
||||||
await run('makeXLSXFromViewSection', viewSectionId, sortOrder, filters, linkingFilter);
|
|
||||||
} else if (tableId) {
|
|
||||||
await run('makeXLSXFromTable', tableId);
|
|
||||||
} else {
|
|
||||||
await run('makeXLSX');
|
|
||||||
}
|
|
||||||
log.debug('XLSX file generated');
|
log.debug('XLSX file generated');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// We fiddle with errors in workerExporter to preserve extra properties like 'status'. Make
|
// 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,
|
baseDomain: this._defaultBaseDomain,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isForced = appSettings.section('login').flag('forced').readBool({
|
const forceLogin = appSettings.section('login').flag('forced').readBool({
|
||||||
envVar: 'GRIST_FORCE_LOGIN',
|
envVar: 'GRIST_FORCE_LOGIN',
|
||||||
});
|
});
|
||||||
const forcedLoginMiddleware = isForced ? this._redirectToLoginWithoutExceptionsMiddleware : noop;
|
|
||||||
|
const forcedLoginMiddleware = forceLogin ? this._redirectToLoginWithoutExceptionsMiddleware : noop;
|
||||||
|
|
||||||
const welcomeNewUser: express.RequestHandler = isSingleUserMode() ?
|
const welcomeNewUser: express.RequestHandler = isSingleUserMode() ?
|
||||||
(req, res, next) => next() :
|
(req, res, next) => next() :
|
||||||
|
@ -56,6 +56,7 @@ export function makeGristConfig(options: MakeGristConfigOptons): GristLoadConfig
|
|||||||
helpCenterUrl: process.env.GRIST_HELP_CENTER || "https://support.getgrist.com",
|
helpCenterUrl: process.env.GRIST_HELP_CENTER || "https://support.getgrist.com",
|
||||||
pathOnly,
|
pathOnly,
|
||||||
supportAnon: shouldSupportAnon(),
|
supportAnon: shouldSupportAnon(),
|
||||||
|
enableAnonPlayground: isAffirmative(process.env.GRIST_ANON_PLAYGROUND ?? true),
|
||||||
supportEngines: getSupportedEngineChoices(),
|
supportEngines: getSupportedEngineChoices(),
|
||||||
features: getFeatures(),
|
features: getFeatures(),
|
||||||
pageTitleSuffix: configuredPageTitleSuffix(),
|
pageTitleSuffix: configuredPageTitleSuffix(),
|
||||||
|
@ -1,19 +1,19 @@
|
|||||||
import {PassThrough} from 'stream';
|
import {PassThrough} from 'stream';
|
||||||
import {FilterColValues} from "app/common/ActiveDocAPI";
|
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 {createExcelFormatter} from 'app/server/lib/ExcelFormatter';
|
||||||
import * as log from 'app/server/lib/log';
|
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 {Rpc} from 'grain-rpc';
|
||||||
import {Stream} from 'stream';
|
import {Stream} from 'stream';
|
||||||
import {MessagePort, threadId} from 'worker_threads';
|
import {MessagePort, threadId} from 'worker_threads';
|
||||||
|
|
||||||
export const makeXLSX = handleExport(doMakeXLSX);
|
export const makeXLSXFromOptions = handleExport(doMakeXLSXFromOptions);
|
||||||
export const makeXLSXFromTable = handleExport(doMakeXLSXFromTable);
|
|
||||||
export const makeXLSXFromViewSection = handleExport(doMakeXLSXFromViewSection);
|
|
||||||
|
|
||||||
function handleExport<T extends any[]>(
|
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}) {
|
return async function({port, testDates, args}: {port: MessagePort, testDates: boolean, args: T}) {
|
||||||
try {
|
try {
|
||||||
@ -73,6 +73,23 @@ function bufferedPipe(stream: Stream, callback: (chunk: Buffer) => void, thresho
|
|||||||
stream.on('end', flush);
|
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.
|
* Returns a XLSX stream of a view section that can be transformed or parsed.
|
||||||
*
|
*
|
||||||
@ -86,14 +103,14 @@ async function doMakeXLSXFromViewSection(
|
|||||||
testDates: boolean,
|
testDates: boolean,
|
||||||
stream: Stream,
|
stream: Stream,
|
||||||
viewSectionId: number,
|
viewSectionId: number,
|
||||||
sortOrder: number[],
|
sortOrder: number[] | null,
|
||||||
filters: Filter[],
|
filters: Filter[] | null,
|
||||||
linkingFilter: FilterColValues,
|
linkingFilter: FilterColValues | null,
|
||||||
) {
|
) {
|
||||||
const data = await doExportSection(activeDocSource, viewSectionId, sortOrder, filters, linkingFilter);
|
const data = await doExportSection(activeDocSource, viewSectionId, sortOrder, filters, linkingFilter);
|
||||||
const {exportTable, end} = convertToExcel(stream, testDates);
|
const {exportTable, end} = convertToExcel(stream, testDates);
|
||||||
exportTable(data);
|
exportTable(data);
|
||||||
await end();
|
return end();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -111,7 +128,7 @@ async function doMakeXLSXFromTable(
|
|||||||
const data = await doExportTable(activeDocSource, {tableId});
|
const data = await doExportTable(activeDocSource, {tableId});
|
||||||
const {exportTable, end} = convertToExcel(stream, testDates);
|
const {exportTable, end} = convertToExcel(stream, testDates);
|
||||||
exportTable(data);
|
exportTable(data);
|
||||||
await end();
|
return end();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -121,24 +138,33 @@ async function doMakeXLSX(
|
|||||||
activeDocSource: ActiveDocSource,
|
activeDocSource: ActiveDocSource,
|
||||||
testDates: boolean,
|
testDates: boolean,
|
||||||
stream: Stream,
|
stream: Stream,
|
||||||
): Promise<void> {
|
): Promise<void|ExcelBuffer> {
|
||||||
const {exportTable, end} = convertToExcel(stream, testDates);
|
const {exportTable, end} = convertToExcel(stream, testDates);
|
||||||
await doExportDoc(activeDocSource, async (table: ExportData) => exportTable(table));
|
await doExportDoc(activeDocSource, async (table: ExportData) => exportTable(table));
|
||||||
await end();
|
return end();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts export data to an excel file.
|
* 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,
|
exportTable: (table: ExportData) => void,
|
||||||
end: () => Promise<void>,
|
end: () => Promise<void|ExcelBuffer>,
|
||||||
} {
|
} {
|
||||||
// Create workbook and add single sheet to it. Using the WorkbookWriter interface avoids
|
// 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
|
// 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
|
// https://github.com/exceljs/exceljs#streaming-xlsx-writercontents. (The options useStyles and
|
||||||
// useSharedStrings replicate more closely what was used previously.)
|
// 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) {
|
if (testDates) {
|
||||||
// HACK: for testing, we will keep static dates
|
// HACK: for testing, we will keep static dates
|
||||||
const date = new Date(Date.UTC(2018, 11, 1, 0, 0, 0));
|
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
|
// Populate excel file with data
|
||||||
for (const row of rowIds) {
|
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)))));
|
||||||
}
|
}
|
||||||
ws.commit();
|
maybeCommit(ws);
|
||||||
|
}
|
||||||
|
async function end(): Promise<void|ExcelBuffer> {
|
||||||
|
if (!stream) {
|
||||||
|
return wb.xlsx.writeBuffer();
|
||||||
|
}
|
||||||
|
return maybeCommit(wb);
|
||||||
}
|
}
|
||||||
function end() { return wb.commit(); }
|
|
||||||
return {exportTable, end};
|
return {exportTable, end};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "grist-core",
|
"name": "grist-core",
|
||||||
"version": "1.1.3",
|
"version": "1.1.4",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"description": "Grist is the evolution of spreadsheets",
|
"description": "Grist is the evolution of spreadsheets",
|
||||||
"homepage": "https://github.com/gristlabs/grist-core",
|
"homepage": "https://github.com/gristlabs/grist-core",
|
||||||
|
@ -426,7 +426,10 @@
|
|||||||
"personal site": "personal site",
|
"personal site": "personal site",
|
||||||
"{{signUp}} to save your work. ": "{{signUp}} to save your work. ",
|
"{{signUp}} to save your work. ": "{{signUp}} to save your work. ",
|
||||||
"Welcome to Grist, {{- name}}!": "Welcome to Grist, {{- name}}!",
|
"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": {
|
"HomeLeftPane": {
|
||||||
"Access Details": "Access Details",
|
"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 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.",
|
"Allow everyone to view Access Rules.": "Vsakomur omogočite ogled pravil za dostop.",
|
||||||
"Attribute name": "Ime atributa",
|
"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": {
|
"ACUserManager": {
|
||||||
"We'll email an invite to {{email}}": "Vabilo bomo poslali po e-pošti {{email}}",
|
"We'll email an invite to {{email}}": "Vabilo bomo poslali po e-pošti {{email}}",
|
||||||
@ -80,12 +82,14 @@
|
|||||||
},
|
},
|
||||||
"ViewAsDropdown": {
|
"ViewAsDropdown": {
|
||||||
"View As": "Poglej kot",
|
"View As": "Poglej kot",
|
||||||
"Users from table": "Uporabniki iz tabele"
|
"Users from table": "Uporabniki iz tabele",
|
||||||
|
"Example Users": "Primer Uporabniki"
|
||||||
},
|
},
|
||||||
"ActionLog": {
|
"ActionLog": {
|
||||||
"Column {{colId}} was subsequently removed in action #{{action.actionNum}}": "Stolpec {{colId}} je bil pozneje odstranjen v akciji #{{action.actionNum}}",
|
"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",
|
"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": {
|
"ApiKey": {
|
||||||
"Remove": "Odstrani",
|
"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?",
|
"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",
|
"Click to show": "Kliknite za prikaz",
|
||||||
"Remove API Key": "Odstranite API ključ",
|
"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": {
|
"App": {
|
||||||
"Description": "Opis",
|
"Description": "Opis",
|
||||||
"Key": "Ključ",
|
"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": {
|
"CellContextMenu": {
|
||||||
"Delete {{count}} columns_one": "Brisanje stolpca",
|
"Delete {{count}} columns_one": "Brisanje stolpca",
|
||||||
@ -121,7 +128,9 @@
|
|||||||
"Comment": "Komentar:",
|
"Comment": "Komentar:",
|
||||||
"Copy": "Kopiraj",
|
"Copy": "Kopiraj",
|
||||||
"Cut": "Izreži",
|
"Cut": "Izreži",
|
||||||
"Paste": "Prilepi"
|
"Paste": "Prilepi",
|
||||||
|
"Clear values": "Izbriši vrednosti",
|
||||||
|
"Clear cell": "Čista celica"
|
||||||
},
|
},
|
||||||
"DocMenu": {
|
"DocMenu": {
|
||||||
"Document will be moved to Trash.": "Dokument se bo premaknil v koš.",
|
"Document will be moved to Trash.": "Dokument se bo premaknil v koš.",
|
||||||
@ -147,12 +156,46 @@
|
|||||||
"Examples and Templates": "Primeri in predloge",
|
"Examples and Templates": "Primeri in predloge",
|
||||||
"Featured": "Priporočeni",
|
"Featured": "Priporočeni",
|
||||||
"Manage Users": "Upravljanje uporabnikov",
|
"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": {
|
"GridViewMenus": {
|
||||||
"Rename column": "Preimenovanje stolpca",
|
"Rename column": "Preimenovanje stolpca",
|
||||||
"Delete {{count}} columns_one": "Brisanje 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": {
|
"HomeLeftPane": {
|
||||||
"Trash": "Koš",
|
"Trash": "Koš",
|
||||||
@ -189,7 +232,8 @@
|
|||||||
"Click to copy": "Kliknite za kopiranje",
|
"Click to copy": "Kliknite za kopiranje",
|
||||||
"Duplicate Table": "Podvojena tabela",
|
"Duplicate Table": "Podvojena tabela",
|
||||||
"Table ID copied to clipboard": "ID tabele kopiran v odložišče",
|
"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": {
|
"ViewLayoutMenu": {
|
||||||
"Delete record": "Brisanje zapisa",
|
"Delete record": "Brisanje zapisa",
|
||||||
@ -205,7 +249,10 @@
|
|||||||
"Grist Templates": "Grist predloge"
|
"Grist Templates": "Grist predloge"
|
||||||
},
|
},
|
||||||
"ChartView": {
|
"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": {
|
"ColumnFilterMenu": {
|
||||||
"All": "Vse",
|
"All": "Vse",
|
||||||
@ -223,7 +270,8 @@
|
|||||||
"Other Values": "Druge vrednosti",
|
"Other Values": "Druge vrednosti",
|
||||||
"Others": "Drugo",
|
"Others": "Drugo",
|
||||||
"Search": "Iskanje",
|
"Search": "Iskanje",
|
||||||
"Search values": "Iskanje vrednosti"
|
"Search values": "Iskanje vrednosti",
|
||||||
|
"Filter by Range": "Filtriranje po obsegu"
|
||||||
},
|
},
|
||||||
"CustomSectionConfig": {
|
"CustomSectionConfig": {
|
||||||
" (optional)": " (neobvezno)",
|
" (optional)": " (neobvezno)",
|
||||||
@ -234,7 +282,10 @@
|
|||||||
"Pick a column": "Izberite stolpec",
|
"Pick a column": "Izberite stolpec",
|
||||||
"Pick a {{columnType}} column": "Izberite stolpec {{columnType}}",
|
"Pick a {{columnType}} column": "Izberite stolpec {{columnType}}",
|
||||||
"Read selected table": "Preberite izbrano tabelo",
|
"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": {
|
"DocHistory": {
|
||||||
"Activity": "Dejavnost",
|
"Activity": "Dejavnost",
|
||||||
@ -242,10 +293,19 @@
|
|||||||
"Compare to Current": "Primerjava s trenutnim",
|
"Compare to Current": "Primerjava s trenutnim",
|
||||||
"Compare to Previous": "Primerjava s prejšnjimi",
|
"Compare to Previous": "Primerjava s prejšnjimi",
|
||||||
"Snapshots": "Posnetki",
|
"Snapshots": "Posnetki",
|
||||||
"Snapshots are unavailable.": "Posnetki niso na voljo."
|
"Snapshots are unavailable.": "Posnetki niso na voljo.",
|
||||||
|
"Open Snapshot": "Odpri posnetek stanja"
|
||||||
},
|
},
|
||||||
"ExampleInfo": {
|
"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": {
|
"CodeEditorPanel": {
|
||||||
"Access denied": "Dostop zavrnjen",
|
"Access denied": "Dostop zavrnjen",
|
||||||
@ -255,5 +315,85 @@
|
|||||||
"Apply": "Uporabi",
|
"Apply": "Uporabi",
|
||||||
"Cancel": "Prekliči",
|
"Cancel": "Prekliči",
|
||||||
"Default cell style": "Privzet slog celic"
|
"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": "更新原件",
|
"Update Original": "更新原件",
|
||||||
"Workspace": "工作区",
|
"Workspace": "工作区",
|
||||||
"You do not have write access to the selected 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": {
|
"NotifyUI": {
|
||||||
"Go to your free personal site": "转到您的免费个人网站",
|
"Go to your free personal site": "转到您的免费个人网站",
|
||||||
@ -583,7 +586,7 @@
|
|||||||
"Document ID copied to clipboard": "文档 ID 已复制到剪贴板",
|
"Document ID copied to clipboard": "文档 ID 已复制到剪贴板",
|
||||||
"Ok": "好的",
|
"Ok": "好的",
|
||||||
"Manage Webhooks": "管理 Webhooks",
|
"Manage Webhooks": "管理 Webhooks",
|
||||||
"Webhooks": "Webhooks"
|
"Webhooks": "网络钩子"
|
||||||
},
|
},
|
||||||
"DocumentUsage": {
|
"DocumentUsage": {
|
||||||
"Attachments Size": "附件大小",
|
"Attachments Size": "附件大小",
|
||||||
@ -706,7 +709,18 @@
|
|||||||
"{{count}} unmatched field_one": "{{count}} 个不匹配字段",
|
"{{count}} unmatched field_one": "{{count}} 个不匹配字段",
|
||||||
"{{count}} unmatched field in import_one": "导入中 {{count}} 个字段不匹配",
|
"{{count}} unmatched field in import_one": "导入中 {{count}} 个字段不匹配",
|
||||||
"{{count}} unmatched field_other": "{{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": {
|
"LeftPanelCommon": {
|
||||||
"Help Center": "帮助中心"
|
"Help Center": "帮助中心"
|
||||||
@ -770,7 +784,8 @@
|
|||||||
"Show in folder": "展现在文件夹中",
|
"Show in folder": "展现在文件夹中",
|
||||||
"Unsaved": "未保存",
|
"Unsaved": "未保存",
|
||||||
"Work on a Copy": "在副本上工作",
|
"Work on a Copy": "在副本上工作",
|
||||||
"Share": "分享"
|
"Share": "分享",
|
||||||
|
"Download...": "下载..."
|
||||||
},
|
},
|
||||||
"SiteSwitcher": {
|
"SiteSwitcher": {
|
||||||
"Create new team site": "创建新的团队网站",
|
"Create new team site": "创建新的团队网站",
|
||||||
@ -923,7 +938,10 @@
|
|||||||
"Cell Style": "单元样式",
|
"Cell Style": "单元样式",
|
||||||
"Default cell style": "默认单元格样式",
|
"Default cell style": "默认单元格样式",
|
||||||
"Mixed style": "混合风格",
|
"Mixed style": "混合风格",
|
||||||
"Open row styles": "打开行样式"
|
"Open row styles": "打开行样式",
|
||||||
|
"HEADER STYLE": "标题样式",
|
||||||
|
"Header Style": "标题样式",
|
||||||
|
"Default header style": "默认标题样式"
|
||||||
},
|
},
|
||||||
"ChoiceTextBox": {
|
"ChoiceTextBox": {
|
||||||
"CHOICES": "选择"
|
"CHOICES": "选择"
|
||||||
@ -1055,7 +1073,8 @@
|
|||||||
"Function List": "函数列表",
|
"Function List": "函数列表",
|
||||||
"Grist's AI Assistance": "Grist 人工智能助手",
|
"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 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": {
|
"WebhookPage": {
|
||||||
"Clear Queue": "清除队列",
|
"Clear Queue": "清除队列",
|
||||||
|
52
test/nbrowser/HomeIntroWithoutPlaygound.ts
Normal file
52
test/nbrowser/HomeIntroWithoutPlaygound.ts
Normal file
@ -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
|
// check that selector-of is present and that all selected section are listed
|
||||||
assert.equal(await driver.find('.test-selector-for').isPresent(), true);
|
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",
|
"CITY",
|
||||||
"COUNTRYLANGUAGE",
|
"COUNTRYLANGUAGE",
|
||||||
"COUNTRY Card List",
|
"COUNTRY Card List",
|
||||||
|
@ -120,6 +120,24 @@ describe('DocApi', function () {
|
|||||||
testDocApi();
|
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.
|
// the way these tests are written, non-merged server requires redis.
|
||||||
if (process.env.TEST_REDIS_URL) {
|
if (process.env.TEST_REDIS_URL) {
|
||||||
describe("should work with a home server and a docworker", async () => {
|
describe("should work with a home server and a docworker", async () => {
|
||||||
|
Loading…
Reference in New Issue
Block a user