(core) Comments

Summary:
First iteration for comments system for Grist.
- Comments are stored in a generic metatable `_grist_Cells`
- Each comment is connected to a particular cell (hence the generic name of the table)
- Access level works naturally for records stored in this table
-- User can add/read comments for cells he can see
-- User can't update/remove comments that he doesn't own, but he can delete them by removing cells (rows/columns)
-- Anonymous users can't see comments at all.
- Each comment can have replies (but replies can't have more replies)

Comments are hidden by default, they can be enabled by COMMENTS=true env variable.
Some things for follow-up
- Avatars, currently the user's profile image is not shown or retrieved from the server
- Virtual rendering for comments list in creator panel. Currently, there is a limit of 200 comments.

Test Plan: New and existing tests

Reviewers: georgegevoian, paulfitz

Reviewed By: georgegevoian

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D3509
pull/322/head
Jarosław Sadziński 2 years ago
parent 8be920dd25
commit bfd7243fe2

@ -25,6 +25,8 @@ const {setTestState} = require('app/client/lib/testState');
const {ExtraRows} = require('app/client/models/DataTableModelWithDiff');
const {createFilterMenu} = require('app/client/ui/ColumnFilterMenu');
const {closeRegisteredMenu} = require('app/client/ui2018/menus');
const {COMMENTS} = require('app/client/models/features');
/**
* BaseView forms the basis for ViewSection classes.
@ -85,6 +87,8 @@ function BaseView(gristDoc, viewSectionModel, options) {
this._filteredRowSource.updateFilter(filterFunc);
}));
this.rowSource = this._filteredRowSource;
// Sorted collection of all rows to show in this view.
this.sortedRows = rowset.SortedRowSet.create(this, null, this.tableModel.tableData);
@ -238,7 +242,8 @@ BaseView.commonCommands = {
showRawData: function() { this.showRawData().catch(reportError); },
filterByThisCellValue: function() { this.filterByThisCellValue(); },
duplicateRows: function() { this._duplicateRows().catch(reportError); }
duplicateRows: function() { this._duplicateRows().catch(reportError); },
openDiscussion: function() { this.openDiscussionAtCursor(); },
};
/**
@ -288,6 +293,30 @@ BaseView.prototype.activateEditorAtCursor = function(options) {
builder.buildEditorDom(this.editRowModel, lazyRow, options || {});
};
/**
* Opens discussion panel at the cursor position. Returns true if discussion panel was opened.
*/
BaseView.prototype.openDiscussionAtCursor = function(id) {
if (!COMMENTS().get()) { return false; }
var builder = this.activeFieldBuilder();
if (builder.isEditorActive()) {
return false;
}
var rowId = this.viewData.getRowId(this.cursor.rowIndex());
// LazyArrayModel row model which is also used to build the cell dom. Needed since
// it may be used as a key to retrieve the cell dom, which is useful for editor placement.
var lazyRow = this.getRenderedRowModel(rowId);
if (!lazyRow) {
// TODO scroll into view. For now, just don't start discussion.
return false;
}
this.editRowModel.assign(rowId);
builder.buildDiscussionPopup(this.editRowModel, lazyRow, id);
return true;
};
/**
* Move the floating RowModel for editing to the current cursor position, and return it.
*

@ -48,6 +48,7 @@ import {isNarrowScreen, mediaSmall, testId} from 'app/client/ui2018/cssVars';
import {IconName} from 'app/client/ui2018/IconList';
import {invokePrompt} from 'app/client/ui2018/modals';
import {FieldEditor} from "app/client/widgets/FieldEditor";
import {DiscussionPanel} from 'app/client/widgets/DiscussionEditor';
import {MinimalActionGroup} from 'app/common/ActionGroup';
import {ClientQuery} from "app/common/ActiveDocAPI";
import {CommDocUsage, CommDocUserAction} from 'app/common/CommTypes';
@ -66,6 +67,7 @@ import {
bundleChanges,
Computed,
dom,
DomContents,
Emitter,
fromKo,
Holder,
@ -101,11 +103,11 @@ export interface TabOptions {
category?: any;
}
const RightPanelTool = StringUnion("none", "docHistory", "validations");
const RightPanelTool = StringUnion("none", "docHistory", "validations", "discussion");
export interface IExtraTool {
icon: IconName;
label: string;
label: DomContents;
content: TabContent[]|IDomComponent;
}
@ -162,6 +164,7 @@ export class GristDoc extends DisposableWithEvents {
private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null;
private _rightPanelTabs = new Map<string, TabContent[]>();
private _docHistory: DocHistory;
private _discussionPanel: DiscussionPanel;
private _rightPanelTool = createSessionObs(this, "rightPanelTool", "none", RightPanelTool.guard);
private _viewLayout: ViewLayout|null = null;
private _showGristTour = getUserOrgPrefObs(this.userOrgPrefs, 'showGristTour');
@ -317,6 +320,7 @@ export class GristDoc extends DisposableWithEvents {
this._actionLog = this.autoDispose(ActionLog.create({ gristDoc: this }));
this._undoStack = this.autoDispose(UndoStack.create(openDocResponse.log, { gristDoc: this }));
this._docHistory = DocHistory.create(this, this.docPageModel, this._actionLog);
this._discussionPanel = DiscussionPanel.create(this, this);
// Tap into docData's sendActions method to save the cursor position with every action, so that
// undo/redo can jump to the right place.
@ -404,6 +408,7 @@ export class GristDoc extends DisposableWithEvents {
return this.docPageModel.currentDocId.get()!;
}
// DEPRECATED This is used only for validation, which is not used anymore.
public addOptionsTab(label: string, iconElem: any, contentObj: TabContent[], options: TabOptions): IDisposable {
this._rightPanelTabs.set(label, contentObj);
// Return a do-nothing disposable, to satisfy the previous interface.
@ -857,7 +862,7 @@ export class GristDoc extends DisposableWithEvents {
public async recursiveMoveToCursorPos(
cursorPos: CursorPos,
setAsActiveSection: boolean,
silent: boolean = false): Promise<void> {
silent: boolean = false): Promise<boolean> {
try {
if (!cursorPos.sectionId) { throw new Error('sectionId required'); }
if (!cursorPos.rowId) { throw new Error('rowId required'); }
@ -931,11 +936,13 @@ export class GristDoc extends DisposableWithEvents {
// even though the cursor is at right place, the scroll could not have yet happened
// wait for a bit (scroll is done in a setTimeout 0)
await delay(0);
return true;
} catch (e) {
console.debug(`_recursiveMoveToCursorPos(${JSON.stringify(cursorPos)}): ${e}`);
if (!silent) {
throw new UserError('There was a problem finding the desired cell.');
}
return false;
}
}
@ -1051,6 +1058,9 @@ export class GristDoc extends DisposableWithEvents {
const content = this._rightPanelTabs.get("Validate Data");
return content ? {icon: 'Validation', label: 'Validation Rules', content} : null;
}
case 'discussion': {
return {icon: 'Chat', label: this._discussionPanel.buildMenu(), content: this._discussionPanel};
}
case 'none':
default: {
return null;

@ -318,6 +318,10 @@ exports.groups = [{
name: 'datepickerFocus',
keys: ['Up', 'Down'],
desc: null, // While editing a date cell, switch keyboard focus to the datepicker
}, {
name: 'openDiscussion',
keys: ['Mod+Alt+M'],
desc: 'Comment',
}
],
}, {

@ -56,6 +56,7 @@ declare module "app/client/components/BaseView" {
public gristDoc: GristDoc;
public cursor: Cursor;
public sortedRows: SortedRowSet;
public rowSource: RowSource;
public activeFieldBuilder: ko.Computed<FieldBuilder>;
public selectedColumns: ko.Computed<ViewFieldRec[]>|null;
public disableEditing: ko.Computed<boolean>;
@ -69,6 +70,7 @@ declare module "app/client/components/BaseView" {
public buildTitleControls(): DomArg;
public getLoadingDonePromise(): Promise<void>;
public activateEditorAtCursor(options?: Options): void;
public openDiscussionAtCursor(discussionId?: number): boolean;
public onResize(): void;
public prepareToPrint(onOff: boolean): void;
public moveEditRowToCursor(): DataRowModel;
@ -140,10 +142,19 @@ declare module "app/client/models/BaseRowModel" {
declare module "app/client/models/MetaRowModel" {
import BaseRowModel from "app/client/models/BaseRowModel";
import {ColValues} from 'app/common/DocActions';
import {SchemaTypes} from 'app/common/schema';
type NPartial<T> = {
[P in keyof T]?: T[P]|null;
};
type Values<T> = T extends keyof SchemaTypes ? NPartial<SchemaTypes[T]> : ColValues;
namespace MetaRowModel {}
class MetaRowModel extends BaseRowModel {
class MetaRowModel<TName extends (keyof SchemaTypes)|undefined = undefined> extends BaseRowModel {
public _isDeleted: ko.Observable<boolean>;
public events: { trigger: (key: string) => void };
public updateColValues(colValues: Values<TName>): Promise<void>;
}
export = MetaRowModel;
}

@ -37,21 +37,21 @@ import {createValidationRec, ValidationRec} from 'app/client/models/entities/Val
import {createViewFieldRec, ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {createViewRec, ViewRec} from 'app/client/models/entities/ViewRec';
import {createViewSectionRec, ViewSectionRec} from 'app/client/models/entities/ViewSectionRec';
import {CellRec, createCellRec} from 'app/client/models/entities/CellRec';
import {RefListValue} from 'app/common/gristTypes';
import {decodeObject} from 'app/plugin/objtypes';
// Re-export all the entity types available. The recommended usage is like this:
// import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel';
export type {ColumnRec, DocInfoRec, FilterRec, PageRec, TabBarRec, TableRec, ValidationRec,
ViewFieldRec, ViewRec, ViewSectionRec};
ViewFieldRec, ViewRec, ViewSectionRec, CellRec};
/**
* Creates the type for a MetaRowModel containing a KoSaveableObservable for each field listed in
* the auto-generated app/common/schema.ts. It represents the metadata record in the database.
* Particular DocModel entities derive from this, and add other helpful computed values.
*/
export type IRowModel<TName extends keyof SchemaTypes> = MetaRowModel & {
export type IRowModel<TName extends keyof SchemaTypes> = MetaRowModel<TName> & {
[ColId in keyof SchemaTypes[TName]]: KoSaveableObservable<SchemaTypes[TName][ColId]>;
};
@ -124,6 +124,7 @@ export class DocModel {
public pages: MTM<PageRec> = this._metaTableModel("_grist_Pages", createPageRec);
public rules: MTM<ACLRuleRec> = this._metaTableModel("_grist_ACLRules", createACLRuleRec);
public filters: MTM<FilterRec> = this._metaTableModel("_grist_Filters", createFilterRec);
public cells: MTM<CellRec> = this._metaTableModel("_grist_Cells", createCellRec);
public docInfoRow: DocInfoRec;

@ -0,0 +1,43 @@
import {isCensored} from 'app/common/gristTypes';
import * as ko from 'knockout';
import {KoArray} from 'app/client/lib/koArray';
import {jsonObservable} from 'app/client/models/modelUtil';
import * as modelUtil from 'app/client/models/modelUtil';
import {ColumnRec, DocModel, IRowModel, recordSet, refRecord, TableRec} from 'app/client/models/DocModel';
export interface CellRec extends IRowModel<"_grist_Cells"> {
column: ko.Computed<ColumnRec>;
table: ko.Computed<TableRec>;
children: ko.Computed<KoArray<CellRec>>;
hidden: ko.Computed<boolean>;
parent: ko.Computed<CellRec>;
text: modelUtil.KoSaveableObservable<string|undefined>;
userName: modelUtil.KoSaveableObservable<string|undefined>;
timeCreated: modelUtil.KoSaveableObservable<number|undefined>;
timeUpdated: modelUtil.KoSaveableObservable<number|undefined>;
resolved: modelUtil.KoSaveableObservable<boolean|undefined>;
resolvedBy: modelUtil.KoSaveableObservable<string|undefined>;
}
export function createCellRec(this: CellRec, docModel: DocModel): void {
this.hidden = ko.pureComputed(() => isCensored(this.content()));
this.column = refRecord(docModel.columns, this.colRef);
this.table = refRecord(docModel.tables, this.tableRef);
this.parent = refRecord(docModel.cells, this.parentId);
this.children = recordSet(this, docModel.cells, 'parentId');
const properContent = modelUtil.savingComputed({
read: () => this.hidden() ? '{}' : this.content(),
write: (setter, val) => setter(this.content, val)
});
const optionJson = jsonObservable(properContent);
// Comments:
this.text = optionJson.prop('text');
this.userName = optionJson.prop('userName');
this.timeCreated = optionJson.prop('timeCreated');
this.timeUpdated = optionJson.prop('timeUpdated');
this.resolved = optionJson.prop('resolved');
this.resolvedBy = optionJson.prop('resolvedBy');
}

@ -1,5 +1,6 @@
import {KoArray} from 'app/client/lib/koArray';
import {DocModel, IRowModel, recordSet, refRecord, TableRec, ViewFieldRec} from 'app/client/models/DocModel';
import {CellRec, DocModel, IRowModel, recordSet,
refRecord, TableRec, ViewFieldRec} from 'app/client/models/DocModel';
import {jsonObservable, ObjObservable} from 'app/client/models/modelUtil';
import * as gristTypes from 'app/common/gristTypes';
import {getReferencedTableId} from 'app/common/gristTypes';
@ -55,7 +56,7 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> {
visibleColModel: ko.Computed<ColumnRec>;
disableModifyBase: ko.Computed<boolean>; // True if column config can't be modified (name, type, etc.)
disableModify: ko.Computed<boolean>; // True if column can't be modified or is being transformed.
disableModify: ko.Computed<boolean>; // True if column can't be modified (is summary) or is being transformed.
disableEditData: ko.Computed<boolean>; // True to disable editing of the data in this column.
isHiddenCol: ko.Computed<boolean>;
@ -73,6 +74,7 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> {
// (i.e. they aren't actually referenced but they exist in the visible column and are relevant to e.g. autocomplete)
// `formatter` formats actual cell values, e.g. a whole list from the display column.
formatter: ko.Computed<BaseFormatter>;
cells: ko.Computed<KoArray<CellRec>>;
// Helper which adds/removes/updates column's displayCol to match the formula.
saveDisplayFormula(formula: string): Promise<void>|undefined;
@ -83,6 +85,7 @@ export function createColumnRec(this: ColumnRec, docModel: DocModel): void {
this.widgetOptionsJson = jsonObservable(this.widgetOptions);
this.viewFields = recordSet(this, docModel.viewFields, 'colRef');
this.summarySource = refRecord(docModel.columns, this.summarySourceCol);
this.cells = recordSet(this, docModel.cells, 'colRef');
// Is this an empty column (undecided if formula or data); denoted by an empty formula.
this.isEmpty = ko.pureComputed(() => this.isFormula() && this.formula() === '');

@ -0,0 +1,12 @@
import {getGristConfig} from 'app/common/urlUtils';
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
import {Observable} from 'grainjs';
export function COMMENTS(): Observable<boolean> {
const G = getBrowserGlobals('document', 'window');
if (!G.window.COMMENTS) {
G.window.COMMENTS = localStorageBoolObs('feature-comments', Boolean(getGristConfig().featureComments));
}
return G.window.COMMENTS;
}

@ -26,6 +26,7 @@ import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {CompareFunc, sortedIndex} from 'app/common/gutil';
import {SkippableRows} from 'app/common/TableData';
import {RowFilterFunc} from "app/common/RowFilterFunc";
import {Observable} from 'grainjs';
/**
* Special constant value that can be used for the `rows` array for the 'rowNotify'
@ -390,7 +391,10 @@ class RowGroupHelper<Value> extends RowSource {
}
// ----------------------------------------------------------------------
/**
* Helper function that does map.get(key).push(r), creating an Array for the given key if
* necessary.
*/
function _addToMapOfArrays<K, V>(map: Map<K, V[]>, key: K, r: V): void {
let arr = map.get(key);
if (!arr) { map.set(key, arr = []); }
@ -437,11 +441,6 @@ export class RowGrouping<Value> extends RowListener {
// Implementation of the RowListener interface.
/**
* Helper function that does map.get(key).push(r), creating an Array for the given key if
* necessary.
*/
public onAddRows(rows: RowList) {
const groupedRows = new Map();
for (const r of rows) {
@ -707,6 +706,41 @@ export class SortedRowSet extends RowListener {
}
}
type RowTester = (rowId: RowId) => boolean;
/**
* RowWatcher is a RowListener that maintains an observable function that checks whether a row
* is in the connected RowSource.
*/
export class RowWatcher extends RowListener {
/**
* Observable function that returns true if the row is in the connected RowSource.
*/
public rowFilter: Observable<RowTester> = Observable.create(this, () => false);
// We count the number of times the row is added or removed from the source.
// In most cases row is added and removed only once.
private _rowCounter: Map<RowId, number> = new Map();
public clear() {
this._rowCounter.clear();
this.rowFilter.set(() => false);
this.stopListening();
}
protected onAddRows(rows: RowList) {
for (const r of rows) {
this._rowCounter.set(r, (this._rowCounter.get(r) || 0) + 1);
}
this.rowFilter.set((row) => (this._rowCounter.get(row) ?? 0) > 0);
}
protected onRemoveRows(rows: RowList) {
for (const r of rows) {
this._rowCounter.set(r, (this._rowCounter.get(r) || 0) - 1);
}
this.rowFilter.set((row) => (this._rowCounter.get(row) ?? 0) > 0);
}
}
function isSmallChange(rows: RowList) {
return Array.isArray(rows) && rows.length <= 2;
}

@ -147,7 +147,7 @@ const cssAccountWidget = styled('div', `
white-space: nowrap;
`);
const cssUserIcon = styled('div', `
export const cssUserIcon = styled('div', `
height: 48px;
width: 48px;
padding: 8px;

@ -2,6 +2,7 @@ import { allCommands } from 'app/client/components/commands';
import { menuDivider, menuItemCmd } from 'app/client/ui2018/menus';
import { IMultiColumnContextMenu } from 'app/client/ui/GridViewMenus';
import { IRowContextMenu } from 'app/client/ui/RowContextMenu';
import { COMMENTS } from 'app/client/models/features';
import { dom } from 'grainjs';
export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiColumnContextMenu) {
@ -9,6 +10,8 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC
const { disableInsert, disableDelete, isViewSorted } = rowOptions;
const { disableModify, isReadonly } = colOptions;
// disableModify is true if the column is a summary column or is being transformed.
// isReadonly is true for readonly mode.
const disableForReadonlyColumn = dom.cls('disabled', Boolean(disableModify) || isReadonly);
const disableForReadonlyView = dom.cls('disabled', isReadonly);
@ -32,7 +35,7 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC
colOptions.isFormula ?
null :
menuItemCmd(allCommands.clearValues, nameClearCells, disableForReadonlyColumn),
menuItemCmd(allCommands.clearColumns, nameClearColumns, disableForReadonlyColumn),
menuItemCmd(allCommands.clearColumns, nameClearColumns, disableForReadonlyColumn),
...(
(numCols > 1 || numRows > 1) ? [] : [
@ -40,6 +43,9 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC
menuItemCmd(allCommands.copyLink, 'Copy anchor link'),
menuDivider(),
menuItemCmd(allCommands.filterByThisCellValue, `Filter by this value`),
menuItemCmd(allCommands.openDiscussion, 'Comment', dom.cls('disabled', (
isReadonly || numRows === 0 || numCols === 0
)), dom.hide(use => !use(COMMENTS())))
]
),
@ -70,8 +76,7 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC
menuDivider(),
// deletes
menuItemCmd(allCommands.deleteRecords, nameDeleteRows,
dom.cls('disabled', disableDelete)),
menuItemCmd(allCommands.deleteRecords, nameDeleteRows, dom.cls('disabled', disableDelete)),
menuItemCmd(allCommands.deleteFields, nameDeleteColumns, disableForReadonlyColumn),

@ -37,7 +37,7 @@ export interface IMultiColumnContextMenu {
// true for some columns, but not all.
numColumns: number;
numFrozen: number;
disableModify: boolean|'mixed'; // If the columns are read-only.
disableModify: boolean|'mixed'; // If the columns are read-only. Mixed for multiple columns where some are read-only.
isReadonly: boolean;
isRaw: boolean;
isFiltered: boolean; // If this view shows a proper subset of all rows in the table.

@ -12,6 +12,8 @@ import {docBreadcrumbs} from 'app/client/ui2018/breadcrumbs';
import {basicButton} from 'app/client/ui2018/buttons';
import {cssHideForNarrowScreen, testId, theme} from 'app/client/ui2018/cssVars';
import {IconName} from 'app/client/ui2018/IconList';
import {menuAnnotate} from 'app/client/ui2018/menus';
import {COMMENTS} from 'app/client/models/features';
import {waitGrainObs} from 'app/common/gutil';
import * as roles from 'app/common/roles';
import {Computed, dom, DomElementArg, makeTestId, MultiHolder, Observable, styled} from 'grainjs';
@ -92,6 +94,14 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode
buildShareMenuButton(pageModel),
dom.maybe(use =>
(
use(pageModel.gristDoc)
&& !use(use(pageModel.gristDoc)!.isReadonly)
&& use(COMMENTS())
),
() => buildShowDiscussionButton(pageModel)),
dom.update(
buildNotifyMenuButton(appModel.notifier, appModel),
cssHideForNarrowScreen.cls(''),
@ -101,6 +111,22 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode
];
}
function buildShowDiscussionButton(pageModel: DocPageModel) {
return cssHoverCircle({ style: `margin: 5px; position: relative;` },
cssTopBarBtn('Chat', dom.cls('tour-share-icon')),
cssBeta('Beta'),
testId('open-discussion'),
dom.on('click', () => pageModel.gristDoc.get()!.showTool('discussion'))
);
}
const cssBeta = styled(menuAnnotate, `
position: absolute;
top: 4px;
right: -9px;
font-weight: bold;
`);
// Given the GristDoc instance, returns a rename function for the current active page.
// If the current page is not able to be renamed or the new name is invalid, the function is a noop.
function getRenamePageFn(gristDoc: GristDoc): (val: string) => Promise<void> {

@ -39,6 +39,7 @@ export type IconName = "ChartArea" |
"BarcodeQR" |
"BarcodeQR2" |
"CenterAlign" |
"Chat" |
"Code" |
"Collapse" |
"Convert" |
@ -75,6 +76,7 @@ export type IconName = "ChartArea" |
"Lock" |
"Log" |
"Mail" |
"Message" |
"Minus" |
"MobileChat" |
"MobileChat2" |
@ -90,6 +92,7 @@ export type IconName = "ChartArea" |
"Pivot" |
"PivotLight" |
"Plus" |
"Popup" |
"Public" |
"PublicColor" |
"PublicFilled" |
@ -165,6 +168,7 @@ export const IconList: IconName[] = ["ChartArea",
"BarcodeQR",
"BarcodeQR2",
"CenterAlign",
"Chat",
"Code",
"Collapse",
"Convert",
@ -201,6 +205,7 @@ export const IconList: IconName[] = ["ChartArea",
"Lock",
"Log",
"Mail",
"Message",
"Minus",
"MobileChat",
"MobileChat2",
@ -216,6 +221,7 @@ export const IconList: IconName[] = ["ChartArea",
"Pivot",
"PivotLight",
"Plus",
"Popup",
"Public",
"PublicColor",
"PublicFilled",

@ -50,6 +50,7 @@ export const colors = {
light: new CustomProp('color-light', '#FFFFFF'),
dark: new CustomProp('color-dark', '#262633'),
darkText: new CustomProp('color-dark-text', '#494949'),
darkBg: new CustomProp('color-dark-bg', '#262633'),
slate: new CustomProp('color-slate', '#929299'),

File diff suppressed because it is too large Load Diff

@ -30,3 +30,18 @@
border-radius: 5px;
background-color: var(--grist-theme-right-panel-field-settings-button-bg, lightgrey);
}
.field-comment-indicator {
display: none;
}
.field-with-comments .field-comment-indicator {
display: block;
position: absolute;
top: 0;
right: 0;
width: 0;
height: 0;
border-top: 11px solid var(--grist-color-orange);
border-left: 11px solid transparent;
}

@ -14,6 +14,7 @@ import { DataRowModel } from 'app/client/models/DataRowModel';
import { ColumnRec, DocModel, ViewFieldRec } from 'app/client/models/DocModel';
import { SaveableObjObservable, setSaveValue } from 'app/client/models/modelUtil';
import { CombinedStyle, Style } from 'app/client/models/Styles';
import { COMMENTS } from 'app/client/models/features';
import { FieldSettingsMenu } from 'app/client/ui/FieldMenus';
import { cssBlockedCursor, cssLabel, cssRow } from 'app/client/ui/RightPanelStyles';
import { buttonSelect, cssButtonSelect } from 'app/client/ui2018/buttonSelect';
@ -22,6 +23,7 @@ import { IOptionFull, menu, select } from 'app/client/ui2018/menus';
import { DiffBox } from 'app/client/widgets/DiffBox';
import { buildErrorDom } from 'app/client/widgets/ErrorDom';
import { FieldEditor, saveWithoutEditor, setupEditorCleanup } from 'app/client/widgets/FieldEditor';
import { CellDiscussionPopup, EmptyCell } from 'app/client/widgets/DiscussionEditor';
import { openFormulaEditor } from 'app/client/widgets/FormulaEditor';
import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget';
import { NewBaseEditor } from "app/client/widgets/NewBaseEditor";
@ -37,6 +39,8 @@ import * as _ from 'underscore';
const testId = makeTestId('test-fbuilder-');
// Creates a FieldBuilder object for each field in viewFields
export function createAllFieldWidgets(gristDoc: GristDoc, viewFields: ko.Computed<KoArray<ViewFieldRec>>,
cursor: Cursor, options: { isPreview?: boolean } = {}) {
@ -99,6 +103,7 @@ export class FieldBuilder extends Disposable {
private readonly _widgetCons: ko.Computed<{create: (...args: any[]) => NewAbstractWidget}>;
private readonly _docModel: DocModel;
private readonly _readonly: Computed<boolean>;
private readonly _comments: ko.Computed<boolean>;
public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec,
private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) {
@ -107,6 +112,7 @@ export class FieldBuilder extends Disposable {
this._docModel = gristDoc.docModel;
this.origColumn = field.column();
this.options = field.widgetOptionsJson;
this._comments = ko.pureComputed(() => toKo(ko, COMMENTS())());
this._readOnlyPureType = ko.pureComputed(() => this.field.column().pureType());
@ -566,27 +572,47 @@ export class FieldBuilder extends Disposable {
const errorInStyle = ko.pureComputed(() => Boolean(computedRule()?.error));
const cellText = ko.pureComputed(() => this.field.textColor() || '');
const cllFill = ko.pureComputed(() => this.field.fillColor() || '');
const cellFill = ko.pureComputed(() => this.field.fillColor() || '');
const hasComment = koUtil.withKoUtils(ko.computed(() => {
if (this.isDisposed()) { return false; } // Work around JS errors during field removal.
if (!this._comments()) { return false; }
if (this.gristDoc.isReadonlyKo()) { return false; }
const rowId = row.id();
const discussion = this.field.column().cells().all()
.find(d =>
d.rowId() === rowId
&& !d.resolved()
&& d.type() === gristTypes.CellInfoType.COMMENT
&& !d.hidden()
&& d.root());
return Boolean(discussion);
}).extend({ deferred: true })).onlyNotifyUnequal();
const domHolder = new MultiHolder();
domHolder.autoDispose(hasComment);
domHolder.autoDispose(widgetObs);
domHolder.autoDispose(computedFlags);
domHolder.autoDispose(errorInStyle);
domHolder.autoDispose(cellText);
domHolder.autoDispose(cellFill);
domHolder.autoDispose(computedRule);
domHolder.autoDispose(fontBold);
domHolder.autoDispose(fontItalic);
domHolder.autoDispose(fontUnderline);
domHolder.autoDispose(fontStrikethrough);
return (elem: Element) => {
this._rowMap.set(row, elem);
dom(elem,
dom.autoDispose(widgetObs),
dom.autoDispose(computedFlags),
dom.autoDispose(errorInStyle),
dom.autoDispose(ruleText),
dom.autoDispose(computedRule),
dom.autoDispose(ruleFill),
dom.autoDispose(fontBold),
dom.autoDispose(fontItalic),
dom.autoDispose(fontUnderline),
dom.autoDispose(fontStrikethrough),
dom.autoDispose(domHolder),
kd.style('--grist-cell-color', cellText),
kd.style('--grist-cell-background-color', cllFill),
kd.style('--grist-cell-background-color', cellFill),
kd.style('--grist-rule-color', ruleText),
kd.style('--grist-column-rule-background-color', ruleFill),
this._options.isPreview ? null : kd.cssClass(this.field.formulaCssClass),
kd.toggleClass('field-with-comments', hasComment),
kd.maybe(hasComment, () => dom('div.field-comment-indicator')),
kd.toggleClass("readonly", toKo(ko, this._readonly)),
kd.maybe(isSelected, () => dom('div.selected_cursor',
kd.toggleClass('active_cursor', isActive)
@ -671,6 +697,43 @@ export class FieldBuilder extends Disposable {
this.gristDoc.activeEditor.set(fieldEditor);
}
public buildDiscussionPopup(editRow: DataRowModel, mainRowModel: DataRowModel, discussionId?: number) {
const owner = this.gristDoc.fieldEditorHolder;
const cellElem: Element = this._rowMap.get(mainRowModel)!;
if (this.columnTransform) {
this.columnTransform.finalize().catch(reportError);
return;
}
if (editRow._isAddRow.peek() || this._readonly.get()) {
return;
}
const cell = editRow.cells[this.field.colId()];
const value = cell && cell();
if (gristTypes.isCensored(value)) {
this._fieldEditorHolder.clear();
return;
}
const tableRef = this.field.viewSection.peek()!.tableRef.peek()!;
// Reuse fieldEditor holder to make sure only one popup/editor is attached to the cell.
const discussionHolder = MultiHolder.create(owner);
const discussions = EmptyCell.create(discussionHolder, {
gristDoc: this.gristDoc,
tableRef,
column: this.field.column.peek(),
rowId: editRow.id.peek(),
});
CellDiscussionPopup.create(discussionHolder, {
domEl: cellElem,
topic: discussions,
discussionId,
gristDoc: this.gristDoc,
closeClicked: () => owner.clear()
});
}
public isEditorActive() {
return !this._fieldEditorHolder.isEmpty();
}

@ -334,7 +334,7 @@ export class FieldEditor extends Disposable {
let waitPromise: Promise<unknown>|null = null;
if (isFormula) {
const formula = editor.getCellValue();
const formula = String(editor.getCellValue() ?? '');
// Bundle multiple changes so that we can undo them in one step.
if (isFormula !== col.isFormula.peek() || formula !== col.formula.peek()) {
waitPromise = this._gristDoc.docData.bundleActions(null, () => Promise.all([

@ -298,10 +298,10 @@ export function openFormulaEditor(options: {
// AsyncOnce ensures it's called once even if triggered multiple times.
const saveEdit = asyncOnce(async () => {
const formula = editor.getCellValue();
const formula = String(editor.getCellValue());
if (formula !== column.formula.peek()) {
if (options.onSave) {
await options.onSave(column, formula as string);
await options.onSave(column, formula);
} else {
await column.updateColValues({formula});
}

@ -79,7 +79,6 @@ export function isRenameTable(act: DocAction): act is RenameTable { return act[0
const SCHEMA_ACTIONS = new Set(['AddTable', 'RemoveTable', 'RenameTable', 'AddColumn',
'RemoveColumn', 'RenameColumn', 'ModifyColumn']);
// Maps each data action to whether it's a bulk action.
const DATA_ACTIONS = new Set(['AddRecord', 'RemoveRecord', 'UpdateRecord', 'BulkAddRecord',
'BulkRemoveRecord', 'BulkUpdateRecord', 'ReplaceTableData', 'TableData']);

@ -45,6 +45,7 @@ export interface UserInfo {
Origin: string | null;
LinkKey: Record<string, string | undefined>;
UserID: number | null;
UserRef: string | null;
[attributes: string]: unknown;
toJSON(): {[key: string]: any};
}

@ -175,6 +175,7 @@ export interface UserAccessData {
id: number;
name: string;
email: string;
ref?: string|null;
picture?: string|null; // When present, a url to a public image of unspecified dimensions.
// Represents the user's direct access to the resource of interest. Lack of access to a resource
// is represented by a null value.

@ -344,3 +344,10 @@ export function isValidRuleValue(value: CellValue|undefined) {
}
export type RefListValue = [GristObjCode.List, ...number[]]|null;
/**
* Type of cell metadata information.
*/
export enum CellInfoType {
COMMENT = 1,
}

@ -568,6 +568,9 @@ export interface GristLoadConfig {
// Loaded namespaces for translations.
namespaces?: readonly string[];
// TODO: remove when comments will be released.
featureComments?: boolean;
}
export const HideableUiElements = StringUnion("helpCenter", "billing", "templates", "multiSite", "multiAccounts");

@ -4,7 +4,7 @@ import { GristObjCode } from "app/plugin/GristData";
// tslint:disable:object-literal-key-quotes
export const SCHEMA_VERSION = 32;
export const SCHEMA_VERSION = 33;
export const schema = {
@ -196,6 +196,17 @@ export const schema = {
filter : "Text",
},
"_grist_Cells": {
tableRef : "Ref:_grist_Tables",
colRef : "Ref:_grist_Tables_column",
rowId : "Int",
root : "Bool",
parentId : "Ref:_grist_Cells",
type : "Int",
content : "Text",
userRef : "Text",
},
};
export interface SchemaTypes {
@ -388,4 +399,15 @@ export interface SchemaTypes {
filter: string;
};
"_grist_Cells": {
tableRef: number;
colRef: number;
rowId: number;
root: boolean;
parentId: number;
type: number;
content: string;
userRef: string;
};
}

@ -483,6 +483,7 @@ export class HomeDBManager extends EventEmitter {
email: user.logins[0].displayEmail,
name: user.name,
picture: user.picture,
ref: user.ref,
};
if (this.getAnonymousUserId() === user.id) {
result.anonymous = true;

@ -10,6 +10,11 @@ import {
BulkColValues,
BulkRemoveRecord,
BulkUpdateRecord,
getColValues,
isBulkAddRecord,
isBulkRemoveRecord,
isBulkUpdateRecord,
isUpdateRecord,
} from 'app/common/DocActions';
import { RemoveRecord, ReplaceTableData, UpdateRecord } from 'app/common/DocActions';
import { CellValue, ColValues, DocAction, getTableId, isSchemaAction } from 'app/common/DocActions';
@ -23,7 +28,7 @@ import { AclMatchInput, InfoEditor, InfoView } from 'app/common/GranularAccessCl
import { UserInfo } from 'app/common/GranularAccessClause';
import * as gristTypes from 'app/common/gristTypes';
import { getSetMapValue, isNonNullish, pruneArray } from 'app/common/gutil';
import { SingleCell } from 'app/common/TableData';
import { MetaRowRecord, SingleCell } from 'app/common/TableData';
import { canEdit, canView, isValidRole, Role } from 'app/common/roles';
import { FullUser, UserAccessData } from 'app/common/UserAPI';
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
@ -40,6 +45,7 @@ import { integerParam } from 'app/server/lib/requestUtils';
import { getRelatedRows, getRowIdsFromDocAction } from 'app/server/lib/RowAccess';
import cloneDeep = require('lodash/cloneDeep');
import fromPairs = require('lodash/fromPairs');
import memoize = require('lodash/memoize');
import get = require('lodash/get');
// tslint:disable:no-bitwise
@ -208,7 +214,7 @@ export interface GranularAccessForBundle {
* will be abandoned.
* - appliedBundle(), called when DocActions have been applied to the DB, but before
* those changes have been sent to clients.
* - sendDocUpdateforBundle() is called once a bundle has been applied, to notify
* - sendDocUpdateForBundle() is called once a bundle has been applied, to notify
* client of changes.
* - finishedBundle(), called when completely done with modification and any needed
* client notifications, whether successful or failed.
@ -303,21 +309,44 @@ export class GranularAccess implements GranularAccessForBundle {
}
/**
* Get content of a given cell, if user has read access.
* Checks if user has read access to a cell. Optionally takes docData that will be used
* to retrieve the cell value instead of the current docData.
*/
public async hasCellAccess(docSession: OptDocSession, cell: SingleCell, docData?: DocData): Promise<boolean> {
try {
await this.getCellValue(docSession, cell, docData);
return true;
} catch(err) {
if (err instanceof ErrorWithCode) { return false; }
throw err;
}
}
/**
* Get content of a given cell, if user has read access. Optionally takes docData that will be used
* to retrieve the cell value instead of the current docData.
* Throws if not.
*/
public async getCellValue(docSession: OptDocSession, cell: SingleCell): Promise<CellValue> {
public async getCellValue(docSession: OptDocSession, cell: SingleCell, docData?: DocData): Promise<CellValue> {
function fail(): never {
throw new ErrorWithCode('ACL_DENY', 'Cannot access cell');
}
const pset = await this.getTableAccess(docSession, cell.tableId);
const tableAccess = this.getReadPermission(pset);
if (tableAccess === 'deny') { fail(); }
const rows = await this._fetchQueryFromDB({
tableId: cell.tableId,
filters: { id: [cell.rowId] }
});
if (!rows || rows[2].length === 0) { fail(); }
if (!await this.hasTableAccess(docSession, cell.tableId)) { fail(); }
let rows: TableDataAction|null = null;
if (docData) {
const record = docData.getTable(cell.tableId)?.getRecord(cell.rowId);
if (record) {
rows = ['TableData', cell.tableId, [cell.rowId], getColValues([record])];
}
} else {
rows = await this._fetchQueryFromDB({
tableId: cell.tableId,
filters: { id: [cell.rowId] }
});
}
if (!rows || rows[2].length === 0) {
return fail();
}
const rec = new RecordView(rows, 0);
const input: AclMatchInput = {user: await this._getUser(docSession), rec, newRec: rec};
const rowPermInfo = new PermissionInfo(this._ruler.ruleCollection, input);
@ -361,7 +390,9 @@ export class GranularAccess implements GranularAccessForBundle {
public async canApplyBundle() {
if (!this._activeBundle) { throw new Error('no active bundle'); }
const {docActions, docSession, isDirect} = this._activeBundle;
if (this._activeBundle.hasDeliberateRuleChange && !await this.isOwner(docSession)) {
const currentUser = await this._getUser(docSession);
const userIsOwner = await this.isOwner(docSession);
if (this._activeBundle.hasDeliberateRuleChange && !userIsOwner) {
throw new ErrorWithCode('ACL_DENY', 'Only owners can modify access rules');
}
// Normally, viewer requests would never reach this point, but they can happen
@ -383,6 +414,8 @@ export class GranularAccess implements GranularAccessForBundle {
}));
}
await this._canApplyCellActions(currentUser, userIsOwner);
if (this._recoveryMode) {
// Don't do any further checking in recovery mode.
return;
@ -483,9 +516,12 @@ export class GranularAccess implements GranularAccessForBundle {
const actions = await Promise.all(
docActions.map((action, actionIdx) => this._filterOutgoingDocAction({docSession, action, actionIdx})));
return ([] as DocAction[]).concat(...actions);
const result = ([] as DocAction[]).concat(...actions);
return await this._filterOutgoingCellInfo(docSession, docActions, result);
}
/**
* Filter an ActionGroup to be sent to a client.
*/
@ -762,9 +798,21 @@ export class GranularAccess implements GranularAccessForBundle {
// If we are going to modify metadata, make a copy.
tables = cloneDeep(tables);
// Prepare cell censorship information.
const cells = new CellData(this._docData).convertToCells(tables['_grist_Cells']);
let cellCensor: CellAccessHelper|undefined;
if (cells.length > 0) {
cellCensor = this._createCellAccess(docSession);
await cellCensor.calculate(cells);
}
const permInfo = await this._getAccess(docSession);
const censor = new CensorshipInfo(permInfo, this._ruler.ruleCollection, tables,
await this.hasAccessRulesPermission(docSession));
await this.hasAccessRulesPermission(docSession),
cellCensor);
if (cellCensor) {
censor.filter(tables["_grist_Cells"]);
}
for (const tableId of STRUCTURAL_TABLES) {
censor.apply(tables[tableId]);
@ -899,6 +947,38 @@ export class GranularAccess implements GranularAccessForBundle {
return baseAccess;
}
public async createSnapshotWithCells(docActions?: DocAction[]) {
if (!docActions) {
if (!this._activeBundle) { throw new Error('no active bundle'); }
if (this._activeBundle.applied) {
throw new Error("Can't calculate last state for cell metadata");
}
docActions = this._activeBundle.docActions;
}
const rows = new Map(getRelatedRows(docActions));
const cellData = new CellData(this._docData);
for(const action of docActions) {
for(const cell of cellData.convertToCells(action)) {
if (!rows.has(cell.tableId)) { rows.set(cell.tableId, new Set()); }
rows.get(cell.tableId)?.add(cell.rowId);
}
}
// Don't need to sync _grist_Cells table, since we already have it.
rows.delete('_grist_Cells');
// Populate a minimal in-memory version of the database with these rows.
const docData = new DocData(
(tableId) => this._fetchQueryFromDB({tableId, filters: {id: [...rows.get(tableId)!]}}), {
_grist_Cells: this._docData.getMetaTable('_grist_Cells')!.getTableDataAction(),
// We need some basic table information to translate numeric ids to string ids (refs to ids).
_grist_Tables: this._docData.getMetaTable('_grist_Tables')!.getTableDataAction(),
_grist_Tables_column: this._docData.getMetaTable('_grist_Tables_column')!.getTableDataAction()
},
);
// Load pre-existing rows touched by the bundle.
await Promise.all([...rows.keys()].map(tableId => docData.syncTable(tableId)));
return docData;
}
/**
* An optimization to catch obvious access problems for simple data
* actions (such as UpdateRecord, BulkAddRecord, etc) early. Checks
@ -2067,9 +2147,9 @@ export class GranularAccess implements GranularAccessForBundle {
return dummyAccessCheck;
}
const tableId = getTableId(a);
if (tableId.startsWith('_grist') && tableId !== '_grist_Attachments') {
if (tableId.startsWith('_grist') && tableId !== '_grist_Attachments' && tableId !== '_grist_Cells') {
// Actions on any metadata table currently require the schemaEdit flag.
// Exception: the attachments table, which needs to be reworked to be compatible
// Exception: the attachments table and cell info table, which needs to be reworked to be compatible
// with granular access.
// Another exception: ensure owners always have full access to ACL tables, so they
@ -2088,6 +2168,105 @@ export class GranularAccess implements GranularAccessForBundle {
return accessChecks[severity].schemaEdit;
}
}
/**
* Filter outgoing actions and include or remove cell information from _grist_Cells.
*/
private async _filterOutgoingCellInfo(docSession: OptDocSession, before: DocAction[], after: DocAction[]) {
// Rewrite bundle, simplifying all actions that are touching cell metadata.
const cellView = new CellData(this._docData);
const patch = cellView.generatePatch(before);
// If there is nothing to do, just return after state.
if (!patch) { return after; }
// Now remove all action that modify cell metadata from after.
// We will use the patch to reconstruct the cell metadata.
const result = after.filter(action => !isCellDataAction(action));
// Prepare checker, we need to use checker from the last step.
const cursor = {
docSession,
action: before[before.length - 1],
actionIdx: before.length - 1
};
const ruler = await this._getRuler(cursor);
const permInfo = await ruler.getAccess(docSession);
const user = await this._getUser(docSession);
// Cache some data, as they are checked.
const readRows = memoize(this._fetchQueryFromDB.bind(this));
const hasAccess = async (cell: SingleCell) => {
// First check table access, maybe table is hidden.
const tableAccess = permInfo.getTableAccess(cell.tableId);
const access = this.getReadPermission(tableAccess);
if (access === 'deny') { return false; }
// Check, if table is fully allowed (no ACL column/rows rules).
if (access === 'allow') { return true; }
// Maybe there are only rules that hides this column completely.
if (access === 'mixedColumns') {
const collAccess = this.getReadPermission(permInfo.getColumnAccess(cell.tableId, cell.colId));
if (collAccess === 'deny') { return false; }
if (collAccess === 'allow') { return true; }
}
// Probably there are rules at the cell level, check them.
const rows = await readRows({
tableId: cell.tableId,
filters: { id: [cell.rowId] }
});
// Make sure we have row.
if (!rows || rows[2].length === 0) {
if (cell.rowId) {
return false;
}
}
const rec = rows ? new RecordView(rows, 0) : undefined;
const input: AclMatchInput = {user, rec, newRec: rec};
const rowPermInfo = new PermissionInfo(ruler.ruleCollection, input);
const rowAccess = rowPermInfo.getTableAccess(cell.tableId).perms.read;
if (rowAccess === 'deny') { return false; }
if (rowAccess !== 'allow') {
const colAccess = rowPermInfo.getColumnAccess(cell.tableId, cell.colId).perms.read;
if (colAccess === 'deny') { return false; }
}
return true;
};
// Now censor the patch, so it only contains cells content that user has access to.
await cellView.censorCells(patch, (cell) => hasAccess(cell));
// And append it to the result.
result.push(...patch);
return result;
}
/**
* Tests if the user can modify cell's data.
*/
private async _canApplyCellActions(currentUser: UserInfo, userIsOwner: boolean) {
// Owner can modify all comments, without exceptions.
if (userIsOwner) {
return;
}
if (!this._activeBundle) { throw new Error('no active bundle'); }
const {docActions, docSession} = this._activeBundle;
const snapShot = await this.createSnapshotWithCells();
const cellView = new CellData(snapShot);
await cellView.applyAndCheck(
docActions,
userIsOwner,
this._ruler.haveRules(),
currentUser.UserRef || '',
(cell, state) => this.hasCellAccess(docSession, cell, state),
);
}
private _createCellAccess(docSession: OptDocSession, docData?: DocData) {
return new CellAccessHelper(this, this._ruler, docSession, this._fetchQueryFromDB, docData);
}
}
/**
@ -2308,6 +2487,84 @@ const dummyAccessCheck: IAccessCheck = {
throwIfNotFullyAllowed() {}
};
/**
* Helper class to calculate access for a set of cells in bulk. Used for initial
* access check for a whole _grist_Cell table. Each cell can belong to a diffrent
* table and row, so here we will avoid loading rows multiple times and checking
* the table access multiple time.
*/
class CellAccessHelper {
private _tableAccess: Map<string, boolean> = new Map();
private _rowPermInfo: Map<string, Map<number, PermissionInfo>> = new Map();
private _rows: Map<string, TableDataAction> = new Map();
private _user!: UserInfo;
constructor(
private _granular: GranularAccess,
private _ruler: Ruler,
private _docSession: OptDocSession,
private _fetchQueryFromDB?: (query: ServerQuery) => Promise<TableDataAction>,
private _state?: DocData,
) { }
/**
* Resolves access for all cells, and save the results in the cache.
*/
public async calculate(cells: SingleCell[]) {
this._user = await this._granular.getUser(this._docSession);
const tableIds = new Set(cells.map(cell => cell.tableId));
for (const tableId of tableIds) {
this._tableAccess.set(tableId, await this._granular.hasTableAccess(this._docSession, tableId));
if (this._tableAccess.get(tableId)) {
const rowIds = new Set(cells.filter(cell => cell.tableId === tableId).map(cell => cell.rowId));
const rows = await this._getRows(tableId, rowIds);
for(const [idx, rowId] of rows[2].entries()) {
if (rowIds.has(rowId) === false) { continue; }
const rec = new RecordView(rows, idx);
const input: AclMatchInput = {user: this._user, rec, newRec: rec};
const rowPermInfo = new PermissionInfo(this._ruler.ruleCollection, input);
if (!this._rowPermInfo.has(tableId)) {
this._rowPermInfo.set(tableId, new Map());
}
this._rowPermInfo.get(tableId)!.set(rows[2][idx], rowPermInfo);
this._rows.set(tableId, rows);
}
}
}
}
/**
* Checks if user has a read access to a particular cell. Needs to be called after calculate().
*/
public hasAccess(cell: SingleCell) {
const rowPermInfo = this._rowPermInfo.get(cell.tableId)?.get(cell.rowId);
if (!rowPermInfo) { return true; }
const rowAccess = rowPermInfo.getTableAccess(cell.tableId).perms.read;
if (rowAccess === 'deny') { return true; }
if (rowAccess !== 'allow') {
const colAccess = rowPermInfo.getColumnAccess(cell.tableId, cell.colId).perms.read;
if (colAccess === 'deny') { return true; }
}
const colValues = this._rows.get(cell.tableId);
if (!colValues || !(cell.colId in colValues[3])) { return true; }
return false;
}
private async _getRows(tableId: string, rowIds: Set<number>) {
if (this._state) {
const rows = this._state.getTable(tableId)!.getTableDataAction();
return rows;
}
if (this._fetchQueryFromDB) {
return await this._fetchQueryFromDB({
tableId,
filters: { id: [...rowIds] }
});
}
return ['TableData', tableId, [], {}] as TableDataAction;
}
}
/**
* Manage censoring metadata.
@ -2325,23 +2582,27 @@ export class CensorshipInfo {
public censoredViews = new Set<number>();
public censoredColumns = new Set<number>();
public censoredFields = new Set<number>();
public censoredComments = new Set<number>();
public censored = {
_grist_Tables: this.censoredTables,
_grist_Tables_column: this.censoredColumns,
_grist_Views: this.censoredViews,
_grist_Views_section: this.censoredSections,
_grist_Views_section_field: this.censoredFields,
_grist_Cells: this.censoredComments,
};
public constructor(permInfo: PermissionInfo,
ruleCollection: ACLRuleCollection,
tables: {[key: string]: TableDataAction},
private _canViewACLs: boolean) {
private _canViewACLs: boolean,
cellAccessInfo?: CellAccessHelper) {
// Collect a list of censored columns (by "<tableRef> <colId>").
const columnCode = (tableRef: number, colId: string) => `${tableRef} ${colId}`;
const censoredColumnCodes: Set<string> = new Set();
const tableRefToTableId: Map<number, string> = new Map();
const tableRefToIndex: Map<number, number> = new Map();
const columnRefToColId: Map<number, string> = new Map();
const uncensoredTables: Set<number> = new Set();
// Scan for forbidden tables.
let rec = new RecordView(tables._grist_Tables, undefined);
@ -2365,10 +2626,12 @@ export class CensorshipInfo {
for (let idx = 0; idx < ids.length; idx++) {
rec.index = idx;
const tableRef = rec.get('parentId') as number;
const colId = rec.get('colId') as string;
const colRef = ids[idx];
columnRefToColId.set(colRef, colId);
if (uncensoredTables.has(tableRef)) { continue; }
const tableId = tableRefToTableId.get(tableRef);
if (!tableId) { throw new Error('table not found'); }
const colId = rec.get('colId') as string;
if (this.censoredTables.has(tableRef) ||
(colId !== 'manualSort' && permInfo.getColumnAccess(tableId, colId).perms.read === 'deny')) {
censoredColumnCodes.add(columnCode(tableRef, colId));
@ -2427,12 +2690,37 @@ export class CensorshipInfo {
const rawViewSectionRef = rec.get('rawViewSectionRef') as number;
this.censoredSections.delete(rawViewSectionRef);
}
// Collect a list of all cells metadata to which the user has no access.
rec = new RecordView(tables._grist_Cells, undefined);
ids = tables._grist_Cells ? getRowIdsFromDocAction(tables._grist_Cells) : [];
for (let idx = 0; idx < ids.length; idx++) {
rec.index = idx;
const isTableCensored = () => this.censoredTables.has(rec.get('tableRef') as number);
const isColumnCensored = () => this.censoredColumns.has(rec.get('colRef') as number);
const isCellCensored = () => {
if (!cellAccessInfo) { return false; }
const cell = {
tableId: tableRefToTableId.get(rec.get('tableRef') as number)!,
colId: columnRefToColId.get(rec.get('colRef') as number)!,
rowId: rec.get('rowId') as number
};
return !cell.tableId || !cell.colId || cellAccessInfo.hasAccess(cell);
};
if (isTableCensored() || isColumnCensored() || isCellCensored()) {
this.censoredComments.add(ids[idx]);
}
}
}
public apply(a: DataAction) {
const tableId = getTableId(a);
const ids = getRowIdsFromDocAction(a);
if (!STRUCTURAL_TABLES.has(tableId)) { return true; }
return this.filter(a);
}
public filter(a: DataAction) {
const tableId = getTableId(a);
if (!(tableId in this.censored)) {
if (!this._canViewACLs && a[0] === 'TableData') {
a[2] = [];
@ -2443,6 +2731,7 @@ export class CensorshipInfo {
const rec = new RecordEditor(a, undefined, true);
const method = getCensorMethod(getTableId(a));
const censoredRows = (this.censored as any)[tableId] as Set<number>;
const ids = getRowIdsFromDocAction(a);
for (const [index, id] of ids.entries()) {
if (censoredRows.has(id)) {
rec.index = index;
@ -2470,6 +2759,8 @@ function getCensorMethod(tableId: string): (rec: RecordEditor) => void {
return rec => rec;
case '_grist_ACLRules':
return rec => rec;
case '_grist_Cells':
return rec => rec.set('content', [GristObjCode.Censored]).set('userRef', '');
default:
throw new Error(`cannot censor ${tableId}`);
}
@ -2645,3 +2936,464 @@ function actionHasRuleChange(a: DocAction): boolean {
)
);
}
interface SingleCellInfo extends SingleCell {
userRef: string;
id: number;
}
/**
* Helper class that extends DocData with cell specific functions.
*/
export class CellData {
constructor(private _docData: DocData) {
}
public getCell(cellId: number) {
const row = this._docData.getMetaTable("_grist_Cells").getRecord(cellId);
return row ? this.convertToCellInfo(row) : null;
}
public getCellRecord(cellId: number) {
const row = this._docData.getMetaTable("_grist_Cells").getRecord(cellId);
return row || null;
}
/**
* Generates a patch for cell metadata. It assumes, that engine removes all
* cell metadata when cell (table/column/row) is removed and the bundle contains,
* all actions that are needed to remove the cell and cell metadata.
*/
public generatePatch(actions: DocAction[]) {
const removedCells: Set<number> = new Set();
const addedCells: Set<number> = new Set();
const updatedCells: Set<number> = new Set();
function applyCellAction(action: DataAction) {
if (isAddRecordAction(action) || isBulkAddRecord(action)) {
for(const id of getRowIdsFromDocAction(action)) {
if (removedCells.has(id)) {
removedCells.delete(id);
updatedCells.add(id);
} else {
addedCells.add(id);
}
}
} else if (isRemoveRecordAction(action) || isBulkRemoveRecord(action)) {
for(const id of getRowIdsFromDocAction(action)) {
if (addedCells.has(id)) {
addedCells.delete(id);
} else {
removedCells.add(id);
updatedCells.delete(id);
}
}
} else {
for(const id of getRowIdsFromDocAction(action)) {
if (addedCells.has(id)) {
// ignore
} else {
updatedCells.add(id);
}
}
}
}
// Scan all actions and collect all cell ids that are added, removed or updated.
// When some rows are updated, include all cells for that row. Keep track of table
// renames.
const updatedRows: Map<string, Set<number>> = new Map();
for(const action of actions) {
if (action[0] === 'RenameTable') {
updatedRows.set(action[2], updatedRows.get(action[1]) || new Set());
continue;
}
if (action[0] === 'RemoveTable') {
updatedRows.delete(action[1]);
continue;
}
if (isDataAction(action) && isCellDataAction(action)) {
applyCellAction(action);
continue;
}
if (!isDataAction(action)) { continue; }
// We don't care about new rows, as they don't have meta data at this moment.
// If regular rows are removed, we also don't care about them, as they will
// produce metadata removal.
// We only care about updates, as it might change the metadata visibility.
if (isUpdateRecord(action) || isBulkUpdateRecord(action)) {
if (getTableId(action).startsWith("_grist")) { continue; }
// Updating a row, for us means that all metadata for this row should be refreshed.
for(const rowId of getRowIdsFromDocAction(action)) {
getSetMapValue(updatedRows, getTableId(action), () => new Set()).add(rowId);
}
}
}
for(const [tableId, rowIds] of updatedRows) {
for(const {id} of this.readCells(tableId, rowIds)) {
if (addedCells.has(id) || updatedCells.has(id) || removedCells.has(id)) {
// If we have this cell id in the list of added/updated/removed cells, ignore it.
} else {
updatedCells.add(id);
}
}
}
const insert = this.generateInsert([...addedCells]);
const update = this.generateUpdate([...updatedCells]);
const removes = this.generateRemovals([...removedCells]);
const patch: DocAction[] = [insert, update, removes].filter(Boolean) as DocAction[];
return patch.length ? patch : null;
}
public async censorCells(
docActions: DocAction[],
hasAccess: (cell: SingleCellInfo) => Promise<boolean>
) {
for (const action of docActions) {
if (!isDataAction(action) || isRemoveRecordAction(action)) {
continue;
} else if (isDataAction(action) && getTableId(action) === '_grist_Cells') {
if (!isBulkAction(action)) {
const cell = this.getCell(action[2]);
if (!cell || !await hasAccess(cell)) {
action[3].content = [GristObjCode.Censored];
action[3].userRef = '';
}
} else {
for (let idx = 0; idx < action[2].length; idx++) {
const cell = this.getCell(action[2][idx]);
if (!cell || !await hasAccess(cell)) {
action[3].content[idx] = [GristObjCode.Censored];
action[3].userRef[idx] = '';
}
}
}
}
}
return docActions;
}
public convertToCellInfo(cell: MetaRowRecord<'_grist_Cells'>): SingleCellInfo {
const singleCell = {
tableId: this.getTableId(cell.tableRef) as string,
colId: this.getColId(cell.colRef) as string,
rowId: cell.rowId,
userRef: cell.userRef,
id: cell.id,
};
return singleCell;
}
public getColId(colRef: number) {
return this._docData.getMetaTable("_grist_Tables_column").getRecord(colRef)?.colId;
}
public getColRef(table: number|string, colId: string) {
const tableRef = typeof table === 'string' ? this.getTableRef(table) : table;
return this._docData.getMetaTable("_grist_Tables_column").filterRecords({colId})
.find(c => c.parentId === tableRef)?.id;
}
public getTableId(tableRef: number) {
return this._docData.getMetaTable("_grist_Tables").getRecord(tableRef)?.tableId;
}
public getTableRef(tableId: string) {
return this._docData.getMetaTable("_grist_Tables").findRow('tableId', tableId) || undefined;
}
/**
* Returns all cells for a given table and row ids.
*/
public readCells(tableId: string, rowIds: Set<number>) {
const tableRef = this.getTableRef(tableId);
const cells = this._docData.getMetaTable("_grist_Cells").filterRecords({
tableRef,
}).filter(r => rowIds.has(r.rowId));
return cells.map(this.convertToCellInfo.bind(this));
}
// Helper function that tells if a cell can be determined fully from the action itself.
// Otherwise we need to look in the docData.
public hasCellInfo(docAction: DocAction):
docAction is UpdateRecord|BulkUpdateRecord|AddRecord|BulkAddRecord {
if (!isDataAction(docAction)) { return false; }
if ((isAddRecordAction(docAction) || isUpdateRecord(docAction) || isBulkUpdateRecord(docAction))
&& docAction[3].tableRef && docAction[3].colRef && docAction[3].rowId && docAction[3].userRef) {
return true;
}
return false;
}
/**
* Checks if cell is 'attached', i.e. it has a tableRef, colRef, rowId and userRef.
*/
public isAttached(cell: SingleCellInfo) {
return Boolean(cell.tableId && cell.rowId && cell.colId && cell.userRef);
}
/**
* Reads all SingleCellInfo from docActions or from docData if action doesn't have enough enough
* information.
*/
public convertToCells(action: DocAction): SingleCellInfo[] {
if (!isDataAction(action)) { return []; }
if (getTableId(action) !== '_grist_Cells') { return []; }
const result: { tableId: string, rowId: number, colId: string, id: number, userRef: string}[] = [];
if (isBulkAction(action)) {
for (let idx = 0; idx < action[2].length; idx++) {
if (this.hasCellInfo(action)) {
result.push({
tableId: this.getTableId(action[3].tableRef[idx] as number) as string,
colId: this.getColId(action[3].colRef[idx] as number) as string,
rowId: action[3].rowId[idx] as number,
userRef: (action[3].userRef[idx] ?? '') as string,
id: action[2][idx],
});
} else {
const cellInfo = this.getCell(action[2][idx]);
if (cellInfo) {
result.push(cellInfo);
}
}
}
} else {
if (this.hasCellInfo(action)) {
result.push({
tableId: this.getTableId(action[3].tableRef as number) as string,
colId: this.getColId(action[3].colRef as number) as string,
rowId: action[3].rowId as number,
userRef: action[3].userRef as string,
id: action[2],
});
} else {
const cellInfo = this.getCell(action[2]);
if (cellInfo) {
result.push(cellInfo);
}
}
}
return result;
}
public generateInsert(ids: number[]): DataAction | null {
const action: BulkAddRecord = [
'BulkAddRecord',
'_grist_Cells',
[],
{
tableRef: [],
colRef: [],
type: [],
root: [],
content: [],
rowId: [],
userRef: [],
}
];
for(const cell of ids) {
const dataCell = this.getCellRecord(cell);
if (!dataCell) { continue; }
action[2].push(dataCell.id);
action[3].content.push(dataCell.content);
action[3].userRef.push(dataCell.userRef);
action[3].tableRef.push(dataCell.tableRef);
action[3].colRef.push(dataCell.colRef);
action[3].type.push(dataCell.type);
action[3].root.push(dataCell.root);
action[3].rowId.push(dataCell.rowId);
}
return action[2].length > 1 ? action :
action[2].length == 1 ? [...getSingleAction(action)][0] : null;
}
public generateRemovals(ids: number[]) {
const action: BulkRemoveRecord = [
'BulkRemoveRecord',
'_grist_Cells',
ids
];
return action[2].length > 1 ? action :
action[2].length == 1 ? [...getSingleAction(action)][0] : null;
}
public generateUpdate(ids: number[]) {
const action: BulkUpdateRecord = [
'BulkUpdateRecord',
'_grist_Cells',
[],
{
content: [],
userRef: [],
}
];
for(const cell of ids) {
const dataCell = this.getCellRecord(cell);
if (!dataCell) { continue; }
action[2].push(dataCell.id);
action[3].content.push(dataCell.content);
action[3].userRef.push(dataCell.userRef);
}
return action[2].length > 1 ? action :
action[2].length == 1 ? [...getSingleAction(action)][0] : null;
}
/**
* Tests if the user can modify cell's data. Will modify
*/
public async applyAndCheck(
docActions: DocAction[],
userIsOwner: boolean,
haveRules: boolean,
userRef: string,
hasAccess: (cell: SingleCellInfo, state: DocData) => Promise<boolean>
) {
// Owner can modify all comments, without exceptions.
if (userIsOwner) {
return;
}
// First check if we even have actions that modify cell's data.
const cellsActions = docActions.filter(
docAction => getTableId(docAction) === '_grist_Cells' && isDataAction(docAction)
);
// If we don't have any actions, we are good to go.
if (cellsActions.length === 0) { return; }
const fail = () => { throw new ErrorWithCode('ACL_DENY', 'Cannot access cell'); };
// In nutshell we will just test action one by one, and see if user
// can apply it. To do it, we need to keep track of a database state after
// each action (just like regular access is done). Unfortunately, cells' info
// can be partially updated, so we won't be able to determine what cells they
// are attached to. We will assume that bundle has a complete set of information, and
// with this assumption we will skip such actions, and wait for the whole cell to form.
// Create a minimal snapshot of all tables that will be touched by this bundle,
// with all cells info that is needed to check access.
const lastState = this._docData;
// Create a view for current state.
const cellData = this;
// Some cells meta data will be added before rows (for example, when undoing). We will
// postpone checking of such actions until we have a full set of information.
let postponed: Array<number> = [];
// Now one by one apply all actions to the snapshot recording all changes
// to the cell table.
for(const docAction of docActions) {
if (!(getTableId(docAction) === '_grist_Cells' && isDataAction(docAction))) {
lastState.receiveAction(docAction);
continue;
}
// Convert any bulk actions to normal actions
for(const single of getSingleAction(docAction)) {
const id = getRowIdsFromDocAction(single)[0];
if (isAddRecordAction(docAction)) {
// Apply this action, as it might not have full information yet.
lastState.receiveAction(single);
if (haveRules) {
const cell = cellData.getCell(id);
if (cell && cellData.isAttached(cell)) {
// If this is undo, action cell might not yet exist, so we need to check for that.
const record = lastState.getTable(cell.tableId)?.getRecord(cell.rowId);
if (!record) {
postponed.push(id);
} else if (!await hasAccess(cell, lastState)) {
fail();
}
} else {
postponed.push(id);
}
}
} else if (isRemoveRecordAction(docAction)) {
// See if we can remove this cell.
const cell = cellData.getCell(id);
lastState.receiveAction(single);
if (cell) {
// We can remove cell information for any row/column that was removed already.
const record = lastState.getTable(cell.tableId)?.getRecord(cell.rowId);
if (!record || !cell.colId || !(cell.colId in record)) {
continue;
}
if (cell.userRef && cell.userRef !== (userRef || '')) {
fail();
}
}
postponed = postponed.filter((i) => i !== id);
} else {
// We are updating a cell metadata. We will need to check if we can update it.
let cell = cellData.getCell(id);
if (!cell) {
return fail();
}
// We can't update cells, that are not ours.
if (cell.userRef && cell.userRef !== (userRef || '')) {
fail();
}
// And if the cell was attached before, we will need to check if we can access it.
if (cellData.isAttached(cell) && haveRules && !await hasAccess(cell, lastState)) {
fail();
}
// Now receive the action, and test if we can still see the cell (as the info might be moved
// to a diffrent cell).
lastState.receiveAction(single);
cell = cellData.getCell(id)!;
if (cellData.isAttached(cell) && haveRules && !await hasAccess(cell, lastState)) {
fail();
}
}
}
}
// Now test every cell that was added before row (so we added it, but without
// full information, like new rowId or tableId or colId).
for(const id of postponed) {
const cell = cellData.getCell(id);
if (cell && !this.isAttached(cell)) {
return fail();
}
if (haveRules && cell && !await hasAccess(cell, lastState)) {
fail();
}
}
}
}
/**
* Checks if the action is a data action that modifies a _grist_Cells table.
*/
export function isCellDataAction(a: DocAction) {
return getTableId(a) === '_grist_Cells' && isDataAction(a);
}
/**
* Converts a bulk like data action to its non-bulk equivalent. For actions like TableData or ReplaceTableData
* it will return a list of actions, one for each row.
*/
export function* getSingleAction(a: DataAction): Iterable<DataAction> {
if (isAddRecordAction(a) && isBulkAction(a)) {
for(let idx = 0; idx < a[2].length; idx++) {
yield ['AddRecord', a[1], a[2][idx], fromPairs(Object.keys(a[3]).map(key => [key, a[3][key][idx]]))];
}
} else if (isRemoveRecordAction(a) && isBulkAction(a)) {
for(const rowId of a[2]) {
yield ['RemoveRecord', a[1], rowId];
}
} else if (a[0] == 'BulkUpdateRecord') {
for(let idx = 0; idx < a[2].length; idx++) {
yield ['UpdateRecord', a[1], a[2][idx], fromPairs(Object.keys(a[3]).map(key => [key, a[3][key][idx]]))];
}
} else if (a[0] == 'TableData') {
for(let idx = 0; idx < a[2].length; idx++) {
yield ['TableData', a[1], [a[2][idx]],
fromPairs(Object.keys(a[3]).map(key => [key, [a[3][key][idx]]]))];
}
} else if (a[0] == 'ReplaceTableData') {
for(let idx = 0; idx < a[2].length; idx++) {
yield ['ReplaceTableData', a[1], [a[2][idx]], fromPairs(Object.keys(a[3]).map(key => [key, [a[3][key][idx]]]))];
}
} else {
yield a;
}
}

@ -76,3 +76,15 @@ export function getRowIdsFromDocAction(docActions: RemoveRecord | BulkRemoveReco
const ids = docActions[2];
return (typeof ids === 'number') ? [ids] : ids;
}
/**
* Tiny helper to get the row ids mentioned in a record-related DocAction as a list
* (even if the action is not a bulk action). When the action touches the whole row,
* it returns ["*"].
*/
export function getColIdsFromDocAction(docActions: RemoveRecord | BulkRemoveRecord | AddRecord |
BulkAddRecord | UpdateRecord | BulkUpdateRecord | ReplaceTableData |
TableDataAction) {
if (docActions[3]) { return Object.keys(docActions[3]); }
return ['*'];
}

@ -6,7 +6,7 @@ export const GRIST_DOC_SQL = `
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',32,'','');
INSERT INTO _grist_DocInfo VALUES(1,'','','',33,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0);
@ -34,6 +34,7 @@ INSERT INTO _grist_ACLPrincipals VALUES(3,'group','','','Editors','');
INSERT INTO _grist_ACLPrincipals VALUES(4,'group','','','Viewers','');
CREATE TABLE IF NOT EXISTS "_grist_ACLMemberships" (id INTEGER PRIMARY KEY, "parent" INTEGER DEFAULT 0, "child" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Filters" (id INTEGER PRIMARY KEY, "viewSectionRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Cells" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "rowId" INTEGER DEFAULT 0, "root" BOOLEAN DEFAULT 0, "parentId" INTEGER DEFAULT 0, "type" INTEGER DEFAULT 0, "content" TEXT DEFAULT '', "userRef" TEXT DEFAULT '');
CREATE INDEX _grist_Attachments_fileIdent ON _grist_Attachments(fileIdent);
COMMIT;
`;
@ -42,7 +43,7 @@ export const GRIST_DOC_WITH_TABLE1_SQL = `
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',32,'','');
INSERT INTO _grist_DocInfo VALUES(1,'','','',33,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0);
INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
@ -86,6 +87,7 @@ INSERT INTO _grist_ACLPrincipals VALUES(3,'group','','','Editors','');
INSERT INTO _grist_ACLPrincipals VALUES(4,'group','','','Viewers','');
CREATE TABLE IF NOT EXISTS "_grist_ACLMemberships" (id INTEGER PRIMARY KEY, "parent" INTEGER DEFAULT 0, "child" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Filters" (id INTEGER PRIMARY KEY, "viewSectionRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Cells" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "rowId" INTEGER DEFAULT 0, "root" BOOLEAN DEFAULT 0, "parentId" INTEGER DEFAULT 0, "type" INTEGER DEFAULT 0, "content" TEXT DEFAULT '', "userRef" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "Table1" (id INTEGER PRIMARY KEY, "manualSort" NUMERIC DEFAULT 1e999, "A" BLOB DEFAULT NULL, "B" BLOB DEFAULT NULL, "C" BLOB DEFAULT NULL);
CREATE INDEX _grist_Attachments_fileIdent ON _grist_Attachments(fileIdent);
COMMIT;

@ -59,6 +59,7 @@ export function makeGristConfig(homeUrl: string|null, extra: Partial<GristLoadCo
enableCustomCss: process.env.APP_STATIC_INCLUDE_CUSTOM_CSS === 'true',
supportedLngs: readLoadedLngs(req?.i18n),
namespaces: readLoadedNamespaces(req?.i18n),
featureComments: process.env.COMMENTS === "true",
...extra,
};
}

@ -147,6 +147,21 @@ class MetaTableExtras(object):
table.docmodel.setAutoRemove(rec, not rec.colRef)
class _grist_Cells(object):
def setAutoRemove(rec, table):
if rec.type == 1: # Cell info of type 1 == Comments
# Remove if discussion is removed.
noParent = not rec.root and not rec.parentId
if rec.tableRef and rec.rowId:
tableRef = table.docmodel.get_table(rec.tableRef.tableId)
row = tableRef.lookupOne(id=rec.rowId)
else:
row = False
# Remove if row is removed, column is removed, table is removed or all comments are removed.
no_cell = not rec.colRef or not rec.tableRef or not row
table.docmodel.setAutoRemove(rec, noParent or no_cell)
def enhance_model(model_class):
"""
Given a metadata model class, add all members (formula methods) to it from the same-named inner
@ -198,6 +213,7 @@ class DocModel(object):
self.aclResources = self._prep_table("_grist_ACLResources")
self.aclRules = self._prep_table("_grist_ACLRules")
self.filters = self._prep_table("_grist_Filters")
self.cells = self._prep_table("_grist_Cells")
def _prep_table(self, name):
"""

@ -32,7 +32,7 @@ log = logger.Logger(__name__, logger.INFO)
# After each migration you probably should run these commands:
# ./test/upgradeDocument public_samples/*.grist
# UPDATE_REGRESSION_DATA=1 GREP_TESTS=DocRegressionTests ./test/testrun.sh server
# ./test/upgradeDocument test/fixtures/docs/Hello.grist
# ./test/upgradeDocument core/test/fixtures/docs/Hello.grist
all_migrations = {}
@ -1092,3 +1092,23 @@ def migration32(tdset):
return tdset.apply_doc_actions([
add_column('_grist_Views_section', 'rules', 'RefList:_grist_Tables_column'),
])
@migration(schema_version=33)
def migration33(tdset):
"""
Add _grist_Cells table
"""
doc_actions = [
actions.AddTable('_grist_Cells', [
schema.make_column("tableRef", "Ref:_grist_Tables"),
schema.make_column("colRef", "Ref:_grist_Tables_column"),
schema.make_column("rowId", "Int"),
schema.make_column("root", "Bool"),
schema.make_column("parentId", "Ref:_grist_Cells"),
schema.make_column("type", "Int"),
schema.make_column("content", "Text"),
schema.make_column("userRef", "Text"),
]),
]
return tdset.apply_doc_actions(doc_actions)

@ -15,7 +15,7 @@ import six
import actions
SCHEMA_VERSION = 32
SCHEMA_VERSION = 33
def make_column(col_id, col_type, formula='', isFormula=False):
return {
@ -317,6 +317,21 @@ def schema_create_actions():
# Ex2: { excluded: ['apple', 'orange'] }
make_column("filter", "Text")
]),
# Additional metadata for cells
actions.AddTable('_grist_Cells', [
make_column("tableRef", "Ref:_grist_Tables"),
make_column("colRef", "Ref:_grist_Tables_column"),
make_column("rowId", "Int"),
# Cell metadata is stored as in hierarchical structure.
make_column("root", "Bool"),
make_column("parentId", "Ref:_grist_Cells"),
# Type of information, currently we have only one type Comments (with value 1).
make_column("type", "Int"),
# JSON representation of the metadata.
make_column("content", "Text"),
make_column("userRef", "Text"),
]),
]

@ -40,6 +40,7 @@
--icon-BarcodeQR: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYuNS41SC41VjYuNUg2LjVWLjV6TTYuNSA5LjVILjVWMTUuNUg2LjVWOS41ek0xNS41LjVIOS41VjYuNUgxNS41Vi41ek0xNS41IDE1LjVIOS41TTExLjUgOS41SDE1LjVWMTIuNSIgc3Ryb2tlPSIjMjEyMTIxIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg==');
--icon-BarcodeQR2: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYgN0gxQy40IDcgMCA2LjYgMCA2VjFDMCAuNC40IDAgMSAwSDZDNi42IDAgNyAuNCA3IDFWNkM3IDYuNiA2LjYgNyA2IDd6TTIgNUg1VjJIMlY1ek02IDE2SDFDLjQgMTYgMCAxNS42IDAgMTVWMTBDMCA5LjQuNCA5IDEgOUg2QzYuNiA5IDcgOS40IDcgMTBWMTVDNyAxNS42IDYuNiAxNiA2IDE2ek0yIDE0SDVWMTFIMlYxNHpNMTUgN0gxMEM5LjQgNyA5IDYuNiA5IDZWMUM5IC40IDkuNCAwIDEwIDBIMTVDMTUuNiAwIDE2IC40IDE2IDFWNkMxNiA2LjYgMTUuNiA3IDE1IDd6TTExIDVIMTRWMkgxMVY1eiIgZmlsbD0iIzE2QjM3OCIvPjxwYXRoIGQ9Ik00IDNIM1Y0SDRWM3pNNCAxMkgzVjEzSDRWMTJ6TTE2IDE0SDlWMTZIMTZWMTR6TTE2IDEzSDE0VjExSDExVjlIMTVDMTUuNiA5IDE2IDkuNCAxNiAxMFYxM3oiIGZpbGw9IiMxNkIzNzgiLz48cGF0aCBkPSJNMTMgMTJIMTJWMTNIMTNWMTJ6TTEzIDNIMTJWNEgxM1YzeiIgZmlsbD0iIzE2QjM3OCIvPjwvc3ZnPg==');
--icon-CenterAlign: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTUuNSw4LjUgQzUuMjIzODU3NjMsOC41IDUsOC4yNzYxNDIzNyA1LDggQzUsNy43MjM4NTc2MyA1LjIyMzg1NzYzLDcuNSA1LjUsNy41IEwxMC41LDcuNSBDMTAuNzc2MTQyNCw3LjUgMTEsNy43MjM4NTc2MyAxMSw4IEMxMSw4LjI3NjE0MjM3IDEwLjc3NjE0MjQsOC41IDEwLjUsOC41IEw1LjUsOC41IFogTTIuNSw0IEMyLjIyMzg1NzYzLDQgMiwzLjc3NjE0MjM3IDIsMy41IEMyLDMuMjIzODU3NjMgMi4yMjM4NTc2MywzIDIuNSwzIEwxMy41LDMgQzEzLjc3NjE0MjQsMyAxNCwzLjIyMzg1NzYzIDE0LDMuNSBDMTQsMy43NzYxNDIzNyAxMy43NzYxNDI0LDQgMTMuNSw0IEwyLjUsNCBaIE00LDEzIEMzLjcyMzg1NzYzLDEzIDMuNSwxMi43NzYxNDI0IDMuNSwxMi41IEMzLjUsMTIuMjIzODU3NiAzLjcyMzg1NzYzLDEyIDQsMTIgTDEyLDEyIEMxMi4yNzYxNDI0LDEyIDEyLjUsMTIuMjIzODU3NiAxMi41LDEyLjUgQzEyLjUsMTIuNzc2MTQyNCAxMi4yNzYxNDI0LDEzIDEyLDEzIEw0LDEzIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-Chat: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTkuNTYyIDNhNy41IDcuNSAwIDAgMC02Ljc5OCAxMC42NzNsLS43MjQgMi44NDJhMS4yNSAxLjI1IDAgMCAwIDEuNTA0IDEuNTI0Yy43NS0uMTggMS45MDMtLjQ1NyAyLjkzLS43MDJBNy41IDcuNSAwIDEgMCA5LjU2MSAzWm0tNiA3LjVhNiA2IDAgMSAxIDMuMzMgNS4zNzVsLS4yNDQtLjEyMS0uMjY0LjA2M2MtLjkyMy4yMi0xLjk5LjQ3NS0yLjc4OC42NjdsLjY5LTIuNzA4LjA3LS4yNzYtLjEzLS4yNTNhNS45NzEgNS45NzEgMCAwIDEtLjY2NC0yLjc0N1ptMTEgMTAuNWMtMS45NyAwLTMuNzYyLS43NTktNS4xLTJoLjFjLjcxOCAwIDEuNDE1LS4wODkgMi4wOC0uMjU3Ljg2NS40ODIgMS44Ni43NTcgMi45Mi43NTcuOTYgMCAxLjg2Ni0uMjI1IDIuNjctLjYyNWwuMjQzLS4xMjEuMjY0LjA2M2MuOTIyLjIyIDEuOTY2LjQ0NSAyLjc0LjYxLS4xNzUtLjc1MS0uNDE0LTEuNzU2LS42NDItMi42NTFsLS4wNy0uMjc2LjEzLS4yNTNhNS45NzEgNS45NzEgMCAwIDAgLjY2NS0yLjc0NyA1Ljk5NSA1Ljk5NSAwIDAgMC0yLjc0Ny01LjA0MiA4LjQ0IDguNDQgMCAwIDAtLjgtMi4wNDcgNy41MDMgNy41MDMgMCAwIDEgNC4zNDQgMTAuMjYzYy4yNTMgMS4wMDguNTA5IDIuMS42NzEgMi44MDNhMS4yNDQgMS4yNDQgMCAwIDEtMS40NjcgMS41IDEzMi42MiAxMzIuNjIgMCAwIDEtMi45MTMtLjY0IDcuNDc2IDcuNDc2IDAgMCAxLTMuMDg4LjY2M1oiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-Code: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEwLjAzMzMyNzIsMC4zMjA1MTA0NiBDMTAuMTMyNDU2NSwwLjA2Mjc3NDE4OCAxMC40MjE3NTMzLC0wLjA2NTgwMjEzODYgMTAuNjc5NDg5NSwwLjAzMzMyNzE5NjkgQzEwLjkzNzIyNTgsMC4xMzI0NTY1MzIgMTEuMDY1ODAyMSwwLjQyMTc1MzI2NyAxMC45NjY2NzI4LDAuNjc5NDg5NTQgTDUuOTY2NjcyOCwxMy42Nzk0ODk1IEM1Ljg2NzU0MzQ3LDEzLjkzNzIyNTggNS41NzgyNDY3MywxNC4wNjU4MDIxIDUuMzIwNTEwNDYsMTMuOTY2NjcyOCBDNS4wNjI3NzQxOSwxMy44Njc1NDM1IDQuOTM0MTk3ODYsMTMuNTc4MjQ2NyA1LjAzMzMyNzIsMTMuMzIwNTEwNSBMMTAuMDMzMzI3MiwwLjMyMDUxMDQ2IFogTTMuODUzNTUzMzksOS4xNDY0NDY2MSBDNC4wNDg4MTU1NCw5LjM0MTcwODc2IDQuMDQ4ODE1NTQsOS42NTgyOTEyNCAzLjg1MzU1MzM5LDkuODUzNTUzMzkgQzMuNjU4MjkxMjQsMTAuMDQ4ODE1NSAzLjM0MTcwODc2LDEwLjA0ODgxNTUgMy4xNDY0NDY2MSw5Ljg1MzU1MzM5IEwwLjE0NjQ0NjYwOSw2Ljg1MzU1MzM5IEMtMC4wNDg4MTU1MzY1LDYuNjU4MjkxMjQgLTAuMDQ4ODE1NTM2NSw2LjM0MTcwODc2IDAuMTQ2NDQ2NjA5LDYuMTQ2NDQ2NjEgTDMuMTQ2NDQ2NjEsMy4xNDY0NDY2MSBDMy4zNDE3MDg3NiwyLjk1MTE4NDQ2IDMuNjU4MjkxMjQsMi45NTExODQ0NiAzLjg1MzU1MzM5LDMuMTQ2NDQ2NjEgQzQuMDQ4ODE1NTQsMy4zNDE3MDg3NiA0LjA0ODgxNTU0LDMuNjU4MjkxMjQgMy44NTM1NTMzOSwzLjg1MzU1MzM5IEwxLjIwNzEwNjc4LDYuNSBMMy44NTM1NTMzOSw5LjE0NjQ0NjYxIFogTTEyLjE0NjQ0NjYsMy44NTM1NTMzOSBDMTEuOTUxMTg0NSwzLjY1ODI5MTI0IDExLjk1MTE4NDUsMy4zNDE3MDg3NiAxMi4xNDY0NDY2LDMuMTQ2NDQ2NjEgQzEyLjM0MTcwODgsMi45NTExODQ0NiAxMi42NTgyOTEyLDIuOTUxMTg0NDYgMTIuODUzNTUzNCwzLjE0NjQ0NjYxIEwxNS44NTM1NTM0LDYuMTQ2NDQ2NjEgQzE2LjA0ODgxNTUsNi4zNDE3MDg3NiAxNi4wNDg4MTU1LDYuNjU4MjkxMjQgMTUuODUzNTUzNCw2Ljg1MzU1MzM5IEwxMi44NTM1NTM0LDkuODUzNTUzMzkgQzEyLjY1ODI5MTIsMTAuMDQ4ODE1NSAxMi4zNDE3MDg4LDEwLjA0ODgxNTUgMTIuMTQ2NDQ2Niw5Ljg1MzU1MzM5IEMxMS45NTExODQ1LDkuNjU4MjkxMjQgMTEuOTUxMTg0NSw5LjM0MTcwODc2IDEyLjE0NjQ0NjYsOS4xNDY0NDY2MSBMMTQuNzkyODkzMiw2LjUgTDEyLjE0NjQ0NjYsMy44NTM1NTMzOSBaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIDEpIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
--icon-Collapse: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsOS4xNzQ2MzA1MiBMMTAuOTIxODI3Myw2LjE4OTAyMzIgQzExLjE2ODQ3NDIsNS45MzY5OTIyNyAxMS41NjgzNjc5LDUuOTM2OTkyMjcgMTEuODE1MDE0OCw2LjE4OTAyMzIgQzEyLjA2MTY2MTcsNi40NDEwNTQxMyAxMi4wNjE2NjE3LDYuODQ5Njc3MDEgMTEuODE1MDE0OCw3LjEwMTcwNzk0IEw4LDExIEw0LjE4NDk4NTE5LDcuMTAxNzA3OTQgQzMuOTM4MzM4MjcsNi44NDk2NzcwMSAzLjkzODMzODI3LDYuNDQxMDU0MTMgNC4xODQ5ODUxOSw2LjE4OTAyMzIgQzQuNDMxNjMyMTEsNS45MzY5OTIyNyA0LjgzMTUyNTc4LDUuOTM2OTkyMjcgNS4wNzgxNzI3LDYuMTg5MDIzMiBMOCw5LjE3NDYzMDUyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-Convert: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuNzU2MDEzNDUsMTIuMjQzOTg2NSBMMiwxNCBMMiw5IEw2LjUsOS41IEw0LjQ2MzE0MTk4LDExLjUzNjg1OCBDNS41MTUxNDg5NCwxMi41ODk5NDY4IDYuOTk5MzIzODMsMTMuMTMzMjk5OCA4LjUxNTU5MDEzLDEyLjk3NjIzNTIgQzEwLjM3OTM1LDEyLjc4MzE3NDggMTEuOTc4NzQxMSwxMS41NjQyNzc0IDEyLjY1OTEyODYsOS44MTg0NDA2IEMxMi43NTk0MDExLDkuNTYxMTQ2OTIgMTMuMDQ5MjY1Nyw5LjQzMzg1NjA5IDEzLjMwNjU1OTQsOS41MzQxMjg1OCBDMTMuNTYzODUzMSw5LjYzNDQwMTA2IDEzLjY5MTE0MzksOS45MjQyNjU3MiAxMy41OTA4NzE0LDEwLjE4MTU1OTQgQzEyLjc3NDQwNjQsMTIuMjc2NTYzNiAxMC44NTUxMzcyLDEzLjczOTI0MDUgOC42MTg2MjUyOSwxMy45NzA5MTI5IEM2Ljc5OTI2MjMyLDE0LjE1OTM3NDMgNS4wMTg0MDI0MSwxMy41MDc0NzkxIDMuNzU2MDEzNDUsMTIuMjQzOTg2NSBaIE0xMi4yNDM5ODY1LDMuNzU2MDEzNDUgTDE0LDIgTDE0LDcgTDkuNSw2LjUgTDExLjUzNjg1OCw0LjQ2MzE0MTk4IEMxMC40ODQ4NTExLDMuNDEwMDUzMTggOS4wMDA2NzYxNywyLjg2NjcwMDE1IDcuNDg0NDA5ODcsMy4wMjM3NjQ4MyBDNS42MjA2NDk5NiwzLjIxNjgyNTE2IDQuMDIxMjU4OSw0LjQzNTcyMjU5IDMuMzQwODcxNDIsNi4xODE1NTk0IEMzLjI0MDU5ODk0LDYuNDM4ODUzMDggMi45NTA3MzQyOCw2LjU2NjE0MzkxIDIuNjkzNDQwNiw2LjQ2NTg3MTQyIEMyLjQzNjE0NjkyLDYuMzY1NTk4OTQgMi4zMDg4NTYwOSw2LjA3NTczNDI4IDIuNDA5MTI4NTgsNS44MTg0NDA2IEMzLjIyNTU5MzU1LDMuNzIzNDM2NDMgNS4xNDQ4NjI4MiwyLjI2MDc1OTUxIDcuMzgxMzc0NzEsMi4wMjkwODcxMiBDOS4yMDA3Mzc2OCwxLjg0MDYyNTcyIDEwLjk4MTU5NzYsMi40OTI1MjA5MiAxMi4yNDM5ODY1LDMuNzU2MDEzNDUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
@ -76,6 +77,7 @@
--icon-Lock: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIj48cGF0aCBkPSJNMTIgNkwxMCA2IDEwIDRDMTAgMi44OTU0MzA1IDkuMTA0NTY5NSAyIDggMiA2Ljg5NTQzMDUgMiA2IDIuODk1NDMwNSA2IDRMNiA2IDQgNiA0IDRDNCAxLjc5MDg2MSA1Ljc5MDg2MSAwIDggMCAxMC4yMDkxMzkgMCAxMiAxLjc5MDg2MSAxMiA0TDEyIDZ6TTE0IDdMMiA3QzEuNDQ3NzE1MjUgNyAxIDcuNDQ3NzE1MjUgMSA4TDEgMTVDMSAxNS41NTIyODQ3IDEuNDQ3NzE1MjUgMTYgMiAxNkwxNCAxNkMxNC41NTIyODQ3IDE2IDE1IDE1LjU1MjI4NDcgMTUgMTVMMTUgOEMxNSA3LjQ0NzcxNTI1IDE0LjU1MjI4NDcgNyAxNCA3ek04IDEzQzYuODk1NDMwNSAxMyA2IDEyLjEwNDU2OTUgNiAxMSA2IDkuODk1NDMwNSA2Ljg5NTQzMDUgOSA4IDkgOS4xMDQ1Njk1IDkgMTAgOS44OTU0MzA1IDEwIDExIDEwIDEyLjEwNDU2OTUgOS4xMDQ1Njk1IDEzIDggMTN6Ii8+PC9nPjwvc3ZnPg==');
--icon-Log: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuNSw4LjUgTDYuNSw4LjUgQzYuMjIzODU3NjMsOC41IDYsOC4yNzYxNDIzNyA2LDggQzYsNy43MjM4NTc2MyA2LjIyMzg1NzYzLDcuNSA2LjUsNy41IEw3LjUsNy41IEw3LjUsMC41IEM3LjUsMC4yMjM4NTc2MjUgNy43MjM4NTc2MywwIDgsMCBDOC4yNzYxNDIzNywwIDguNSwwLjIyMzg1NzYyNSA4LjUsMC41IEw4LjUsMyBMOS41LDMgQzkuNzc2MTQyMzcsMyAxMCwzLjIyMzg1NzYzIDEwLDMuNSBDMTAsMy43NzYxNDIzNyA5Ljc3NjE0MjM3LDQgOS41LDQgTDguNSw0IEw4LjUsMTIgTDkuNSwxMiBDOS43NzYxNDIzNywxMiAxMCwxMi4yMjM4NTc2IDEwLDEyLjUgQzEwLDEyLjc3NjE0MjQgOS43NzYxNDIzNywxMyA5LjUsMTMgTDguNSwxMyBMOC41LDE1LjUgQzguNSwxNS43NzYxNDI0IDguMjc2MTQyMzcsMTYgOCwxNiBDNy43MjM4NTc2MywxNiA3LjUsMTUuNzc2MTQyNCA3LjUsMTUuNSBMNy41LDguNSBaIE0xMiwyIEwxMiw1IEwxNSw1IEwxNSwyIEwxMiwyIFogTTExLjUsMSBMMTUuNSwxIEMxNS43NzYxNDI0LDEgMTYsMS4yMjM4NTc2MyAxNiwxLjUgTDE2LDUuNSBDMTYsNS43NzYxNDIzNyAxNS43NzYxNDI0LDYgMTUuNSw2IEwxMS41LDYgQzExLjIyMzg1NzYsNiAxMSw1Ljc3NjE0MjM3IDExLDUuNSBMMTEsMS41IEMxMSwxLjIyMzg1NzYzIDExLjIyMzg1NzYsMSAxMS41LDEgWiBNMTIsMTEgTDEyLDE0IEwxNSwxNCBMMTUsMTEgTDEyLDExIFogTTExLjUsMTAgTDE1LjUsMTAgQzE1Ljc3NjE0MjQsMTAgMTYsMTAuMjIzODU3NiAxNiwxMC41IEwxNiwxNC41IEMxNiwxNC43NzYxNDI0IDE1Ljc3NjE0MjQsMTUgMTUuNSwxNSBMMTEuNSwxNSBDMTEuMjIzODU3NiwxNSAxMSwxNC43NzYxNDI0IDExLDE0LjUgTDExLDEwLjUgQzExLDEwLjIyMzg1NzYgMTEuMjIzODU3NiwxMCAxMS41LDEwIFogTTQsOS41IEw0LDYuNSBMMSw2LjUgTDEsOS41IEw0LDkuNSBaIE00LjUsMTAuNSBMMC41LDEwLjUgQzAuMjIzODU3NjI1LDEwLjUgMCwxMC4yNzYxNDI0IDAsMTAgTDguODgxNzg0MmUtMTYsNiBDOC44ODE3ODQyZS0xNiw1LjcyMzg1NzYzIDAuMjIzODU3NjI1LDUuNSAwLjUsNS41IEw0LjUsNS41IEM0Ljc3NjE0MjM3LDUuNSA1LDUuNzIzODU3NjMgNSw2IEw1LDEwIEM1LDEwLjI3NjE0MjQgNC43NzYxNDIzNywxMC41IDQuNSwxMC41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-Mail: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE0LjUsMTMgQzE0Ljc3NTg1NzYsMTMgMTUsMTIuNzc1ODU3NiAxNSwxMi41IEwxNSwxLjUgQzE1LDEuMjI0MTQyMzcgMTQuNzc1ODU3NiwxIDE0LjUsMSBMMS41LDEgQzEuMjI0MTQyMzcsMSAxLDEuMjI0MTQyMzcgMSwxLjUgTDEsMTIuNSBDMSwxMi43NzU4NTc2IDEuMjI0MTQyMzcsMTMgMS41LDEzIEwxNC41LDEzIFogTTE0LjUsMTQgTDEuNSwxNCBDMC42NzE4NTc2MjUsMTQgMCwxMy4zMjgxNDI0IDAsMTIuNSBMMCwxLjUgQzAsMC42NzE4NTc2MjUgMC42NzE4NTc2MjUsOC44ODE3ODQyZS0xNiAxLjUsOC44ODE3ODQyZS0xNiBMMTQuNSw4Ljg4MTc4NDJlLTE2IEMxNS4zMjgxNDI0LDguODgxNzg0MmUtMTYgMTYsMC42NzE4NTc2MjUgMTYsMS41IEwxNiwxMi41IEMxNiwxMy4zMjgxNDI0IDE1LjMyODE0MjQsMTQgMTQuNSwxNCBaIE0xMy4xODMzODExLDMuMTEzMDIxMzUgQzEzLjM5NzEwMzUsMi45MzgxNTc1NiAxMy43MTIxMTQ5LDIuOTY5NjU4NyAxMy44ODY5Nzg2LDMuMTgzMzgxMSBDMTQuMDYxODQyNCwzLjM5NzEwMzUxIDE0LjAzMDM0MTMsMy43MTIxMTQ4NiAxMy44MTY2MTg5LDMuODg2OTc4NjUgTDguMzE2NjE4OSw4LjM4Njk3ODY1IEM4LjEzMjQzNTk1LDguNTM3NjczNzggNy44Njc1NjQwNSw4LjUzNzY3Mzc4IDcuNjgzMzgxMSw4LjM4Njk3ODY1IEwyLjE4MzM4MTEsMy44ODY5Nzg2NSBDMS45Njk2NTg3LDMuNzEyMTE0ODYgMS45MzgxNTc1NiwzLjM5NzEwMzUxIDIuMTEzMDIxMzUsMy4xODMzODExIEMyLjI4Nzg4NTE0LDIuOTY5NjU4NyAyLjYwMjg5NjQ5LDIuOTM4MTU3NTYgMi44MTY2MTg5LDMuMTEzMDIxMzUgTDgsNy4zNTM5Njk1MyBMMTMuMTgzMzgxMSwzLjExMzAyMTM1IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-Message: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE1LjUgN0MxNS41IDMuNDEgMTIuMTQyIDAuNSA4IDAuNUMzLjg1OCAwLjUgMC41IDMuNDEgMC41IDdDMC41IDEwLjU5IDMuODU4IDEzLjUgOCAxMy41QzguNTI1IDEzLjUgOS4wMzcgMTMuNDUyIDkuNTMyIDEzLjM2M0wxMy41IDE1LjVWMTEuNDA5QzE0LjczOCAxMC4yNSAxNS41IDguNzA0IDE1LjUgN1oiIHN0cm9rZT0iIzkyOTI5OSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=');
--icon-Minus: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB4PSIyIiB5PSI3LjUiIHdpZHRoPSIxMiIgaGVpZ2h0PSIxIiByeD0iLjUiLz48L3N2Zz4=');
--icon-MobileChat: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjUgOC41VjE0LjVDMTEuNSAxNC43NjUyIDExLjM5NDYgMTUuMDE5NiAxMS4yMDcxIDE1LjIwNzFDMTEuMDE5NiAxNS4zOTQ2IDEwLjc2NTIgMTUuNSAxMC41IDE1LjVIMi41QzIuMjM0NzggMTUuNSAxLjk4MDQzIDE1LjM5NDYgMS43OTI4OSAxNS4yMDcxQzEuNjA1MzYgMTUuMDE5NiAxLjUgMTQuNzY1MiAxLjUgMTQuNVYyLjVDMS41IDIuMjM0NzggMS42MDUzNiAxLjk4MDQzIDEuNzkyODkgMS43OTI4OUMxLjk4MDQzIDEuNjA1MzYgMi4yMzQ3OCAxLjUgMi41IDEuNUg0LjUiIHN0cm9rZT0iIzI2MjYzMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTE0LjUgNi41SDkuNUw2LjUgOC41VjEuNUM2LjUgMS4yMzQ3OCA2LjYwNTM2IDAuOTgwNDMgNi43OTI4OSAwLjc5Mjg5M0M2Ljk4MDQzIDAuNjA1MzU3IDcuMjM0NzggMC41IDcuNSAwLjVIMTQuNUMxNC43NjUyIDAuNSAxNS4wMTk2IDAuNjA1MzU3IDE1LjIwNzEgMC43OTI4OTNDMTUuMzk0NiAwLjk4MDQzIDE1LjUgMS4yMzQ3OCAxNS41IDEuNVY1LjVDMTUuNSA1Ljc2NTIyIDE1LjM5NDYgNi4wMTk1NyAxNS4yMDcxIDYuMjA3MTFDMTUuMDE5NiA2LjM5NDY0IDE0Ljc2NTIgNi41IDE0LjUgNi41WiIgc3Ryb2tlPSIjMjYyNjMzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=');
--icon-MobileChat2: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTggMEgxNUMxNS4yNjUyIDAgMTUuNTE5NiAwLjEwNTM1NyAxNS43MDcxIDAuMjkyODkzQzE1Ljg5NDYgMC40ODA0MyAxNiAwLjczNDc4NCAxNiAxVjVDMTYgNS4yNjUyMiAxNS44OTQ2IDUuNTE5NTcgMTUuNzA3MSA1LjcwNzExQzE1LjUxOTYgNS44OTQ2NCAxNS4yNjUyIDYgMTUgNkgxMEw3IDhWMUM3IDAuNzM0Nzg0IDcuMTA1MzYgMC40ODA0MyA3LjI5Mjg5IDAuMjkyODkzQzcuNDgwNDMgMC4xMDUzNTcgNy43MzQ3OCAwIDggMFYwWiIgZmlsbD0iIzE2QjM3OCIvPjxwYXRoIGQ9Ik0xMCA3VjEzSDNWNEg2VjFIM0MyLjQ2OTU3IDEgMS45NjA4NiAxLjIxMDcxIDEuNTg1NzkgMS41ODU3OUMxLjIxMDcxIDEuOTYwODYgMSAyLjQ2OTU3IDEgM1YxNEMxIDE0LjUzMDQgMS4yMTA3MSAxNS4wMzkxIDEuNTg1NzkgMTUuNDE0MkMxLjk2MDg2IDE1Ljc4OTMgMi40Njk1NyAxNiAzIDE2SDEwQzEwLjUzMDQgMTYgMTEuMDM5MSAxNS43ODkzIDExLjQxNDIgMTUuNDE0MkMxMS43ODkzIDE1LjAzOTEgMTIgMTQuNTMwNCAxMiAxNFY3SDEwWiIgZmlsbD0iIzE2QjM3OCIvPjwvc3ZnPg==');
@ -91,6 +93,7 @@
--icon-Pivot: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMsMSBMMTMsMSBDMTQuMTA0NTY5NSwxIDE1LDEuODk1NDMwNSAxNSwzIEwxNSwxMyBDMTUsMTQuMTA0NTY5NSAxNC4xMDQ1Njk1LDE1IDEzLDE1IEwzLDE1IEMxLjg5NTQzMDUsMTUgMSwxNC4xMDQ1Njk1IDEsMTMgTDEsMyBDMSwxLjg5NTQzMDUgMS44OTU0MzA1LDEgMywxIFogTTUuMzc1LDQuNSBMNS4zNzUsNC43ODU0NjM0MSBMNy42MzQ2NTAxLDguMDIyNzA3MzIgTDUuMzc1LDExLjA5NDUxMjIgTDUuMzc1LDExLjUgTDEwLjYyNSwxMS41IEwxMC42MjUsMTAuMDMyODE3MSBMOS44MjY0OTkwMSwxMC43MjY0MTQ2IEw2LjkxOTczMTYxLDEwLjcyNjQxNDYgTDYuOTE5NzMxNjEsMTAuNDE2OTYzNCBMOC45MDI4MzMsNy42MTU4NTM2NiBMNi45MTk3MzE2MSw0Ljk5MDg1MzY2IEw5Ljc3NDMxMjEzLDQuOTg3MzUzNjYgTDEwLjYyNSw2LjA5NTMxNzA3IEwxMC42MjUsNC41IEw1LjM3NSw0LjUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--icon-PivotLight: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cmVjdCBzdHJva2U9IiM5Nzk3OTciIHg9Ii41IiB5PSIuNSIgd2lkdGg9IjE1IiBoZWlnaHQ9IjE1IiByeD0iMiIvPjxwYXRoIGZpbGw9IiM5Nzk3OTciIGQ9Ik01LjM3NSA0LjVMMTAuNjI1IDQuNSAxMC42MjUgNi4wOTUzMTcwNyA5Ljc3NDMxMjEzIDQuOTg3MzUzNjYgNi45MTk3MzE2MSA0Ljk5MDg1MzY2IDguOTAyODMzIDcuNjE1ODUzNjYgNi45MTk3MzE2MSAxMC40MTY5NjM0IDYuOTE5NzMxNjEgMTAuNzI2NDE0NiA5LjgyNjQ5OTAxIDEwLjcyNjQxNDYgMTAuNjI1IDEwLjAzMjgxNzEgMTAuNjI1IDExLjUgNS4zNzUgMTEuNSA1LjM3NSAxMS4wOTQ1MTIyIDcuNjM0NjUwMSA4LjAyMjcwNzMyIDUuMzc1IDQuNzg1NDYzNDF6Ii8+PC9nPjwvc3ZnPg==');
--icon-Plus: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTguNSw3LjUgTDEzLDcuNSBDMTMuMjc2MTQyNCw3LjUgMTMuNSw3LjcyMzg1NzYzIDEzLjUsOCBDMTMuNSw4LjI3NjE0MjM3IDEzLjI3NjE0MjQsOC41IDEzLDguNSBMOC41LDguNSBMOC41LDEzIEM4LjUsMTMuMjc2MTQyNCA4LjI3NjE0MjM3LDEzLjUgOCwxMy41IEM3LjcyMzg1NzYzLDEzLjUgNy41LDEzLjI3NjE0MjQgNy41LDEzIEw3LjUsOC41IEwzLDguNSBDMi43MjM4NTc2Myw4LjUgMi41LDguMjc2MTQyMzcgMi41LDggQzIuNSw3LjcyMzg1NzYzIDIuNzIzODU3NjMsNy41IDMsNy41IEw3LjUsNy41IEw3LjUsMyBDNy41LDIuNzIzODU3NjMgNy43MjM4NTc2MywyLjUgOCwyLjUgQzguMjc2MTQyMzcsMi41IDguNSwyLjcyMzg1NzYzIDguNSwzIEw4LjUsNy41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-Popup: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuNSA3LjVWMy41SDcuNU0zLjUgMy41TDcuNSA3LjUiIHN0cm9rZT0iIzkyOTI5OSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTIgMC41SDE0QzE0LjM5NzggMC41IDE0Ljc3OTQgMC42NTgwMzUgMTUuMDYwNyAwLjkzOTM0QzE1LjM0MiAxLjIyMDY0IDE1LjUgMS42MDIxOCAxNS41IDJWMTRDMTUuNSAxNC4zOTc4IDE1LjM0MiAxNC43Nzk0IDE1LjA2MDcgMTUuMDYwN0MxNC43Nzk0IDE1LjM0MiAxNC4zOTc4IDE1LjUgMTQgMTUuNUgyQzEuNjAyMTggMTUuNSAxLjIyMDY0IDE1LjM0MiAwLjkzOTM0IDE1LjA2MDdDMC42NTgwMzUgMTQuNzc5NCAwLjUgMTQuMzk3OCAwLjUgMTRWMkMwLjUgMS42MDIxOCAwLjY1ODAzNSAxLjIyMDY0IDAuOTM5MzQgMC45MzkzNEMxLjIyMDY0IDAuNjU4MDM1IDEuNjAyMTggMC41IDIgMC41VjAuNVoiIHN0cm9rZT0iIzkyOTI5OSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTEyLjUgOUg5LjVDOS4yMjM4NiA5IDkgOS4yMjM4NiA5IDkuNVYxMi41QzkgMTIuNzc2MSA5LjIyMzg2IDEzIDkuNSAxM0gxMi41QzEyLjc3NjEgMTMgMTMgMTIuNzc2MSAxMyAxMi41VjkuNUMxMyA5LjIyMzg2IDEyLjc3NjEgOSAxMi41IDlaIiBmaWxsPSIjOTI5Mjk5Ii8+PC9zdmc+');
--icon-Public: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMCAwSDE2VjE2SDB6Ii8+PGcgc3Ryb2tlPSIjMTZCMzc4IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik05Ljc3NSAxMS4yOTNMNy4zMTIgMTAuNTkzQzcuMTIyMjE2NjEgMTAuNTM4OTUxMSA2Ljk4MTk3MzAyIDEwLjM3ODMzNyA2Ljk1NCAxMC4xODNMNi43NDcgOC43MjZDNy44MTQxMjAyNiA4LjIzODMwNjE1IDguNDk5MDAyMDkgNy4xNzMyODE3IDguNSA2TDguNSA0LjYyNkM4LjUyNTE5NzU2IDIuOTczMDAzMjUgNy4yNDI1MDU0NyAxLjU5NDE1MzM3IDUuNTkyIDEuNSA0Ljc4MDUzMjcgMS40NzUxMDMyIDMuOTkzNjIwMiAxLjc4MDE0ODI3IDMuNDEwOTUzMjMgMi4zNDU0Nzg0NiAyLjgyODI4NjI1IDIuOTEwODA4NjQgMi40OTk2MTgxNiAzLjY4ODE1MDk1IDIuNSA0LjVMMi41IDZDMi41MDA5OTc5MSA3LjE3MzI4MTcgMy4xODU4Nzk3NCA4LjIzODMwNjE1IDQuMjUzIDguNzI2TDQuMDQ2IDEwLjE3OUM0LjAxODAyNjk4IDEwLjM3NDMzNyAzLjg3Nzc4MzM5IDEwLjUzNDk1MTEgMy42ODggMTAuNTg5TDEuMjI1IDExLjI4OUMuNzk1OTk1Njg4IDExLjQxMTcwNzIuNTAwMTk4MjMgMTEuODAzNzkxOC41IDEyLjI1TC41IDE0LjUgMTAuNSAxNC41IDEwLjUgMTIuMjU0QzEwLjQ5OTgwMTggMTEuODA3NzkxOCAxMC4yMDQwMDQzIDExLjQxNTcwNzIgOS43NzUgMTEuMjkzek0xMi41IDE0LjVMMTUuNSAxNC41IDE1LjUgMTEuMjgxQzE1LjQ5OTk4NzkgMTAuODIyMzIwNiAxNS4xODc5MzExIDEwLjQyMjQ1OTEgMTQuNzQzIDEwLjMxMUwxMS44MjYgOS41ODJDMTEuNjI4NDcxIDkuNTMyNzA4NDEgMTEuNDgwNTUyNCA5LjM2ODU3NDEgMTEuNDUyIDkuMTY3TDExLjI0NyA3LjcyNkMxMi4zMTQxMjAzIDcuMjM4MzA2MTUgMTIuOTk5MDAyMSA2LjE3MzI4MTcgMTMgNUwxMyAzLjYyNkMxMy4wMjUxOTc2IDEuOTczMDAzMjUgMTEuNzQyNTA1NS41OTQxNTMzNjUgMTAuMDkyLjUgOS41MzQ0NjYxNC40ODI3MzcyNzIgOC45ODMxNjAzOS42MjEyNTYzMDYgOC41LjkiLz48L2c+PC9nPjwvc3ZnPg==');
--icon-PublicColor: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTQiIHdpZHRoPSIxNiI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMCAwSDE2VjE2SDB6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIC0xKSIvPjxnIG9wYWNpdHk9Ii43ODMiPjxwYXRoIGQ9Ik0gMTUuMjc1LDEwLjI5MyAxMy4wODcsOS42NjggQyAxMi43MjY0NzgsOS41NjQ5MTc4IDEyLjQ1MzkyLDkuMjY4ODEwNSAxMi4zODEsOC45MDEgTCAxMi4yNDcsOC4yMjYgQyAxMy4zMTQxMiw3LjczODMwNjEgMTMuOTk5MDAyLDYuNjczMjgxNyAxNCw1LjUgViA0LjEyNiBDIDE0LjAyNTE5OCwyLjQ3MzAwMzMgMTIuNzQyNTA2LDEuMDk0MTUzNCAxMS4wOTIsMSA5Ljg3OTM3MDksMC45NjM0MTA4MiA4Ljc2NDA2NTUsMS42NjA3NzI3IDguMjY2LDIuNzY3IDguNzQzMDQ3OCwzLjQ2MTI3ODUgOC45OTg5MTk4LDQuMjgzNjI0NyA5LDUuMTI2IFYgNi41IEMgOC45OTgyMDc3LDYuODYzNzcwNiA4Ljk0NjA0NTYsNy4yMjU1NDA0IDguODQ1LDcuNTc1IDkuMTA0MjMyNSw3Ljg0Njk0MjMgOS40MTIyMzk1LDguMDY3NzcxMSA5Ljc1Myw4LjIyNiBMIDkuNjE5LDguOSBDIDkuNTQ2MDc5Niw5LjI2NzgxMDUgOS4yNzM1MjE5LDkuNTYzOTE3OCA4LjkxMyw5LjY2NyBMIDguMDcsOS45MDggOS41NSwxMC4zMzEgYyAwLjg1Njk2NCwwLjI0NzU1NCAxLjQ0NzcwNiwxLjAzMDk5OSAxLjQ1LDEuOTIzIFYgMTQuNSBjIC0wLjAwMTcsMC4xNzA3MiAtMC4wMzI3OCwwLjMzOTg3MSAtMC4wOTIsMC41IEggMTUuNSBjIDAuMjc2MTQyLDAgMC41LC0wLjIyMzg1OCAwLjUsLTAuNSB2IC0zLjI0NiBjIC0xLjk4ZS00LC0wLjQ0NjIwOCAtMC4yOTU5OTYsLTAuODM4MjkzIC0wLjcyNSwtMC45NjEgeiIgZmlsbD0iI2U2YTExNyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48cGF0aCBkPSJNIDkuMjc1LDExLjI5MyA3LjA4NywxMC42NjggQyA2LjcyNjIwMjMsMTAuNTY0NzU0IDYuNDUzNTgzLDEwLjI2ODE5NCA2LjM4MSw5LjkgTCA2LjI0Nyw5LjIyNSBDIDcuMzEzNzkxNCw4LjczNzQ1NjkgNy45OTg2MTEyLDcuNjcyOTE5NSA4LDYuNSBWIDUuMTI2IEMgOC4wMjUxOTc2LDMuNDczMDAzMyA2Ljc0MjUwNTUsMi4wOTQxNTM0IDUuMDkyLDIgNC4yODA1MzI3LDEuOTc1MTAzMiAzLjQ5MzYyMDIsMi4yODAxNDgzIDIuOTEwOTUzMiwyLjg0NTQ3ODUgMi4zMjgyODYzLDMuNDEwODA4NiAxLjk5OTYxODIsNC4xODgxNTA5IDIsNSB2IDEuNSBjIDkuOTc5ZS00LDEuMTczMjgxNyAwLjY4NTg3OTcsMi4yMzgzMDYyIDEuNzUzLDIuNzI2IEwgMy42MTksOS45IEMgMy41NDYwOCwxMC4yNjc4MTEgMy4yNzM1MjE5LDEwLjU2MzkxOCAyLjkxMywxMC42NjcgTCAwLjcyNSwxMS4yOTIgQyAwLjI5NTYzOTI2LDExLjQxNDgwOSAtMi40ODE3NzE2ZS00LDExLjgwNzQyMSAwLDEyLjI1NCBWIDE0LjUgQyAwLDE0Ljc3NjE0MiAwLjIyMzg1NzYzLDE1IDAuNSwxNSBoIDkgQyA5Ljc3NjE0MjQsMTUgMTAsMTQuNzc2MTQyIDEwLDE0LjUgViAxMi4yNTQgQyA5Ljk5OTgwMTgsMTEuODA3NzkyIDkuNzA0MDA0MywxMS40MTU3MDcgOS4yNzUsMTEuMjkzIFoiIGZpbGw9IiNmZmYiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAgLTEpIi8+PC9nPjwvZz48L3N2Zz4=');
--icon-PublicFilled: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMCAwSDE2VjE2SDB6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIC0xKSIvPjxnIGZpbGw9IiMxNkIzNzgiIGZpbGwtcnVsZT0ibm9uemVybyI+PHBhdGggZD0iTTE1LjI3NSwxMC4yOTMgTDEzLjA4Nyw5LjY2OCBDMTIuNzI2NDc4MSw5LjU2NDkxNzggMTIuNDUzOTIwNCw5LjI2ODgxMDUgMTIuMzgxLDguOTAxIEwxMi4yNDcsOC4yMjYgQzEzLjMxNDEyMDMsNy43MzgzMDYxNSAxMy45OTkwMDIxLDYuNjczMjgxNyAxNCw1LjUgTDE0LDQuMTI2IEMxNC4wMjUxOTc2LDIuNDczMDAzMjUgMTIuNzQyNTA1NSwxLjA5NDE1MzM3IDExLjA5MiwxIEM5Ljg3OTM3MDksMC45NjM0MTA4MjIgOC43NjQwNjU1LDEuNjYwNzcyNjkgOC4yNjYsMi43NjcgQzguNzQzMDQ3OCwzLjQ2MTI3ODUgOC45OTg5MTk4NCw0LjI4MzYyNDc0IDksNS4xMjYgTDksNi41IEM4Ljk5ODIwNzc0LDYuODYzNzcwNTcgOC45NDYwNDU1OCw3LjIyNTU0MDM4IDguODQ1LDcuNTc1IEM5LjEwNDIzMjUzLDcuODQ2OTQyMjYgOS40MTIyMzk1Myw4LjA2Nzc3MTA2IDkuNzUzLDguMjI2IEw5LjYxOSw4LjkgQzkuNTQ2MDc5NTcsOS4yNjc4MTA1IDkuMjczNTIxODcsOS41NjM5MTc4IDguOTEzLDkuNjY3IEw4LjA3LDkuOTA4IEw5LjU1LDEwLjMzMSBDMTAuNDA2OTY0MywxMC41Nzg1NTM2IDEwLjk5NzcwNTksMTEuMzYxOTk5MyAxMSwxMi4yNTQgTDExLDE0LjUgQzEwLjk5ODM0MjgsMTQuNjcwNzE5OCAxMC45NjcyMTg5LDE0LjgzOTg3MTUgMTAuOTA4LDE1IEwxNS41LDE1IEMxNS43NzYxNDI0LDE1IDE2LDE0Ljc3NjE0MjQgMTYsMTQuNSBMMTYsMTEuMjU0IEMxNS45OTk4MDE4LDEwLjgwNzc5MTggMTUuNzA0MDA0MywxMC40MTU3MDcyIDE1LjI3NSwxMC4yOTMgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48cGF0aCBkPSJNOS4yNzUsMTEuMjkzIEw3LjA4NywxMC42NjggQzYuNzI2MjAyMzIsMTAuNTY0NzUzOSA2LjQ1MzU4MywxMC4yNjgxOTM1IDYuMzgxLDkuOSBMNi4yNDcsOS4yMjUgQzcuMzEzNzkxNDEsOC43Mzc0NTY5MyA3Ljk5ODYxMTI1LDcuNjcyOTE5NTQgOCw2LjUgTDgsNS4xMjYgQzguMDI1MTk3NTYsMy40NzMwMDMyNSA2Ljc0MjUwNTQ3LDIuMDk0MTUzMzcgNS4wOTIsMiBDNC4yODA1MzI3LDEuOTc1MTAzMiAzLjQ5MzYyMDIsMi4yODAxNDgyNyAyLjkxMDk1MzIzLDIuODQ1NDc4NDYgQzIuMzI4Mjg2MjUsMy40MTA4MDg2NCAxLjk5OTYxODE2LDQuMTg4MTUwOTUgMiw1IEwyLDYuNSBDMi4wMDA5OTc5MSw3LjY3MzI4MTcgMi42ODU4Nzk3NCw4LjczODMwNjE1IDMuNzUzLDkuMjI2IEwzLjYxOSw5LjkgQzMuNTQ2MDc5NTcsMTAuMjY3ODEwNSAzLjI3MzUyMTg3LDEwLjU2MzkxNzggMi45MTMsMTAuNjY3IEwwLjcyNSwxMS4yOTIgQzAuMjk1NjM5MjYyLDExLjQxNDgwOTEgLTAuMDAwMjQ4MTc3MTU3LDExLjgwNzQyMTIgMCwxMi4yNTQgTDAsMTQuNSBDMy4zODE3NjU4MWUtMTcsMTQuNzc2MTQyNCAwLjIyMzg1NzYyNSwxNSAwLjUsMTUgTDkuNSwxNSBDOS43NzYxNDIzNywxNSAxMCwxNC43NzYxNDI0IDEwLDE0LjUgTDEwLDEyLjI1NCBDOS45OTk4MDE3NywxMS44MDc3OTE4IDkuNzA0MDA0MzEsMTEuNDE1NzA3MiA5LjI3NSwxMS4yOTMgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48L2c+PC9nPjwvc3ZnPg==');

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M9.562 3a7.5 7.5 0 0 0-6.798 10.673l-.724 2.842a1.25 1.25 0 0 0 1.504 1.524c.75-.18 1.903-.457 2.93-.702A7.5 7.5 0 1 0 9.561 3Zm-6 7.5a6 6 0 1 1 3.33 5.375l-.244-.121-.264.063c-.923.22-1.99.475-2.788.667l.69-2.708.07-.276-.13-.253a5.971 5.971 0 0 1-.664-2.747Zm11 10.5c-1.97 0-3.762-.759-5.1-2h.1c.718 0 1.415-.089 2.08-.257.865.482 1.86.757 2.92.757.96 0 1.866-.225 2.67-.625l.243-.121.264.063c.922.22 1.966.445 2.74.61-.175-.751-.414-1.756-.642-2.651l-.07-.276.13-.253a5.971 5.971 0 0 0 .665-2.747 5.995 5.995 0 0 0-2.747-5.042 8.44 8.44 0 0 0-.8-2.047 7.503 7.503 0 0 1 4.344 10.263c.253 1.008.509 2.1.671 2.803a1.244 1.244 0 0 1-1.467 1.5 132.62 132.62 0 0 1-2.913-.64 7.476 7.476 0 0 1-3.088.663Z" fill="#000000" fill-rule="nonzero" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.5 7C15.5 3.41 12.142 0.5 8 0.5C3.858 0.5 0.5 3.41 0.5 7C0.5 10.59 3.858 13.5 8 13.5C8.525 13.5 9.037 13.452 9.532 13.363L13.5 15.5V11.409C14.738 10.25 15.5 8.704 15.5 7Z" stroke="#929299" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 376 B

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.5 7.5V3.5H7.5" stroke="#929299" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.5 3.5L7.5 7.5" stroke="#929299" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 0.5H14C14.3978 0.5 14.7794 0.658035 15.0607 0.93934C15.342 1.22064 15.5 1.60218 15.5 2V14C15.5 14.3978 15.342 14.7794 15.0607 15.0607C14.7794 15.342 14.3978 15.5 14 15.5H2C1.60218 15.5 1.22064 15.342 0.93934 15.0607C0.658035 14.7794 0.5 14.3978 0.5 14V2C0.5 1.60218 0.658035 1.22064 0.93934 0.93934C1.22064 0.658035 1.60218 0.5 2 0.5V0.5Z" stroke="#929299" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.5 9H9.5C9.22386 9 9 9.22386 9 9.5V12.5C9 12.7761 9.22386 13 9.5 13H12.5C12.7761 13 13 12.7761 13 12.5V9.5C13 9.22386 12.7761 9 12.5 9Z" fill="#929299"/>
</svg>

After

Width:  |  Height:  |  Size: 873 B

Binary file not shown.

@ -68,13 +68,13 @@ describe('Pages', function() {
await gu.waitForServer();
// Click on a page; check the URL, selected item, and the title of the view section.
await clickPage(/Documents/)
await gu.openPage(/Documents/);
assert.match(await driver.getCurrentUrl(), /\/p\/3/);
assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Documents/);
assert.match(await gu.getActiveSectionTitle(), /Documents/i);
// Click on another page; check the URL, selected item, and the title of the view section.
await clickPage(/People/)
await gu.openPage(/People/);
assert.match(await driver.getCurrentUrl(), /\/p\/2/);
assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /People/);
assert.match(await gu.getActiveSectionTitle(), /People/i);
@ -109,7 +109,7 @@ describe('Pages', function() {
it('should allow renaming table when click on page selected label', async () => {
// do rename
await clickPage(/People/)
await gu.openPage(/People/);
await driver.findContent('.test-treeview-label', 'People').doClick();
await driver.find('.test-docpage-editor').sendKeys('PeopleRenamed', Key.ENTER);
await gu.waitForServer();
@ -214,7 +214,7 @@ describe('Pages', function() {
}
// goto page 'Interactions'
await clickPage(/Interactions/);
await gu.openPage(/Interactions/);
// check selected page
assert.match(await selectedPage(), /Interactions/);
@ -249,7 +249,7 @@ describe('Pages', function() {
it('undo/redo should update url', async () => {
// goto page 'Interactions' and send keys
await clickPage(/Interactions/);
await gu.openPage(/Interactions/);
assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Interactions/);
await driver.findContentWait('.gridview_data_row_num', /1/, 2000);
await driver.sendKeys(Key.ENTER, 'Foo', Key.ENTER);
@ -257,7 +257,7 @@ describe('Pages', function() {
assert.deepEqual(await gu.getVisibleGridCells(0, [1]), ['Foo']);
// goto page 'People' and click undo
await clickPage(/People/);
await gu.openPage(/People/);
await gu.waitForDocToLoad();
await gu.waitForUrl(/\/p\/2\b/); // check that url match p/2
@ -277,7 +277,7 @@ describe('Pages', function() {
it('Add new page should update url', async () => {
// goto page 'Interactions' and check that url updated
await clickPage(/Interactions/);
await gu.openPage(/Interactions/);
await gu.waitForUrl(/\/p\/1\b/);
// Add new Page, check that url updated and page is selected
@ -286,7 +286,7 @@ describe('Pages', function() {
assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Table1/);
// goto page 'Interactions' and check that url updated and page selectd
await clickPage(/Interactions/);
await gu.openPage(/Interactions/);
await gu.waitForUrl(/\/p\/1\b/);
assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Interactions/);
});
@ -496,7 +496,3 @@ async function movePage(page: RegExp, target: {before: RegExp}|{after: RegExp})
})
.release());
}
function clickPage(name: string|RegExp) {
return driver.findContent('.test-treeview-itemHeader', name).find(".test-docpage-initial").doClick();
}

@ -860,6 +860,11 @@ export function getPageItem(pageName: string|RegExp): WebElementPromise {
.findClosest('.test-treeview-itemHeaderWrapper');
}
export async function openPage(name: string|RegExp) {
await driver.findContentWait('.test-treeview-itemHeader', name, 500).find(".test-docpage-initial").doClick();
await waitForServer(); // wait for table load
}
/**
* Open the page menu for the specified page (by clicking the dots icon visible on hover).
*/
@ -1082,9 +1087,9 @@ export async function renameColumn(col: IColHeader, newName: string) {
}
/**
* Removes a table using RAW data view. Return back a current url.
* Removes a table using RAW data view. Returns a current url.
*/
export async function removeTable(tableId: string) {
export async function removeTable(tableId: string, goBack: boolean = false) {
const back = await driver.getCurrentUrl();
await driver.find(".test-tools-raw").click();
const tableIdList = await driver.findAll('.test-raw-data-table-id', e => e.getText());
@ -1096,6 +1101,10 @@ export async function removeTable(tableId: string) {
await driver.find(".test-raw-data-menu-remove").click();
await driver.find(".test-modal-confirm").click();
await waitForServer();
if (goBack) {
await driver.get(back);
await waitAppFocus();
}
return back;
}
@ -1400,6 +1409,11 @@ export function openColumnMenu(col: IColHeader|string, option?: string): WebElem
return new WebElementPromise(driver, openColumnMenuHelper(col, option));
}
export async function deleteColumn(col: IColHeader|string) {
await openColumnMenu(col, 'Delete column');
await waitForServer();
}
/**
* Sets the type of the currently selected field to value.
*/
@ -2554,13 +2568,30 @@ export async function changeBehavior(option: string|RegExp) {
/**
* Gets all available options in the behavior menu.
*/
export async function availableBehaviorOptions() {
export async function availableBehaviorOptions() {
await driver.find('.test-field-behaviour').click();
const list = await driver.findAll('.grist-floating-menu li', el => el.getText());
await driver.sendKeys(Key.ESCAPE);
return list;
}
export function withComments() {
let oldEnv: testUtils.EnvironmentSnapshot;
before(async () => {
if (process.env.COMMENTS !== 'true') {
oldEnv = new testUtils.EnvironmentSnapshot();
process.env.COMMENTS = 'true';
await server.restart();
}
});
after(async () => {
if (oldEnv) {
oldEnv.restore();
await server.restart();
}
});
}
} // end of namespace gristUtils
stackWrapOwnMethods(gristUtils);

Loading…
Cancel
Save