mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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
This commit is contained in:
parent
8be920dd25
commit
bfd7243fe2
@ -25,6 +25,8 @@ const {setTestState} = require('app/client/lib/testState');
|
|||||||
const {ExtraRows} = require('app/client/models/DataTableModelWithDiff');
|
const {ExtraRows} = require('app/client/models/DataTableModelWithDiff');
|
||||||
const {createFilterMenu} = require('app/client/ui/ColumnFilterMenu');
|
const {createFilterMenu} = require('app/client/ui/ColumnFilterMenu');
|
||||||
const {closeRegisteredMenu} = require('app/client/ui2018/menus');
|
const {closeRegisteredMenu} = require('app/client/ui2018/menus');
|
||||||
|
const {COMMENTS} = require('app/client/models/features');
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BaseView forms the basis for ViewSection classes.
|
* BaseView forms the basis for ViewSection classes.
|
||||||
@ -85,6 +87,8 @@ function BaseView(gristDoc, viewSectionModel, options) {
|
|||||||
this._filteredRowSource.updateFilter(filterFunc);
|
this._filteredRowSource.updateFilter(filterFunc);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
this.rowSource = this._filteredRowSource;
|
||||||
|
|
||||||
// Sorted collection of all rows to show in this view.
|
// Sorted collection of all rows to show in this view.
|
||||||
this.sortedRows = rowset.SortedRowSet.create(this, null, this.tableModel.tableData);
|
this.sortedRows = rowset.SortedRowSet.create(this, null, this.tableModel.tableData);
|
||||||
|
|
||||||
@ -238,7 +242,8 @@ BaseView.commonCommands = {
|
|||||||
showRawData: function() { this.showRawData().catch(reportError); },
|
showRawData: function() { this.showRawData().catch(reportError); },
|
||||||
|
|
||||||
filterByThisCellValue: function() { this.filterByThisCellValue(); },
|
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 || {});
|
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.
|
* 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 {IconName} from 'app/client/ui2018/IconList';
|
||||||
import {invokePrompt} from 'app/client/ui2018/modals';
|
import {invokePrompt} from 'app/client/ui2018/modals';
|
||||||
import {FieldEditor} from "app/client/widgets/FieldEditor";
|
import {FieldEditor} from "app/client/widgets/FieldEditor";
|
||||||
|
import {DiscussionPanel} from 'app/client/widgets/DiscussionEditor';
|
||||||
import {MinimalActionGroup} from 'app/common/ActionGroup';
|
import {MinimalActionGroup} from 'app/common/ActionGroup';
|
||||||
import {ClientQuery} from "app/common/ActiveDocAPI";
|
import {ClientQuery} from "app/common/ActiveDocAPI";
|
||||||
import {CommDocUsage, CommDocUserAction} from 'app/common/CommTypes';
|
import {CommDocUsage, CommDocUserAction} from 'app/common/CommTypes';
|
||||||
@ -66,6 +67,7 @@ import {
|
|||||||
bundleChanges,
|
bundleChanges,
|
||||||
Computed,
|
Computed,
|
||||||
dom,
|
dom,
|
||||||
|
DomContents,
|
||||||
Emitter,
|
Emitter,
|
||||||
fromKo,
|
fromKo,
|
||||||
Holder,
|
Holder,
|
||||||
@ -101,11 +103,11 @@ export interface TabOptions {
|
|||||||
category?: any;
|
category?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RightPanelTool = StringUnion("none", "docHistory", "validations");
|
const RightPanelTool = StringUnion("none", "docHistory", "validations", "discussion");
|
||||||
|
|
||||||
export interface IExtraTool {
|
export interface IExtraTool {
|
||||||
icon: IconName;
|
icon: IconName;
|
||||||
label: string;
|
label: DomContents;
|
||||||
content: TabContent[]|IDomComponent;
|
content: TabContent[]|IDomComponent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,6 +164,7 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null;
|
private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null;
|
||||||
private _rightPanelTabs = new Map<string, TabContent[]>();
|
private _rightPanelTabs = new Map<string, TabContent[]>();
|
||||||
private _docHistory: DocHistory;
|
private _docHistory: DocHistory;
|
||||||
|
private _discussionPanel: DiscussionPanel;
|
||||||
private _rightPanelTool = createSessionObs(this, "rightPanelTool", "none", RightPanelTool.guard);
|
private _rightPanelTool = createSessionObs(this, "rightPanelTool", "none", RightPanelTool.guard);
|
||||||
private _viewLayout: ViewLayout|null = null;
|
private _viewLayout: ViewLayout|null = null;
|
||||||
private _showGristTour = getUserOrgPrefObs(this.userOrgPrefs, 'showGristTour');
|
private _showGristTour = getUserOrgPrefObs(this.userOrgPrefs, 'showGristTour');
|
||||||
@ -317,6 +320,7 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
this._actionLog = this.autoDispose(ActionLog.create({ gristDoc: this }));
|
this._actionLog = this.autoDispose(ActionLog.create({ gristDoc: this }));
|
||||||
this._undoStack = this.autoDispose(UndoStack.create(openDocResponse.log, { gristDoc: this }));
|
this._undoStack = this.autoDispose(UndoStack.create(openDocResponse.log, { gristDoc: this }));
|
||||||
this._docHistory = DocHistory.create(this, this.docPageModel, this._actionLog);
|
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
|
// Tap into docData's sendActions method to save the cursor position with every action, so that
|
||||||
// undo/redo can jump to the right place.
|
// undo/redo can jump to the right place.
|
||||||
@ -404,6 +408,7 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
return this.docPageModel.currentDocId.get()!;
|
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 {
|
public addOptionsTab(label: string, iconElem: any, contentObj: TabContent[], options: TabOptions): IDisposable {
|
||||||
this._rightPanelTabs.set(label, contentObj);
|
this._rightPanelTabs.set(label, contentObj);
|
||||||
// Return a do-nothing disposable, to satisfy the previous interface.
|
// Return a do-nothing disposable, to satisfy the previous interface.
|
||||||
@ -857,7 +862,7 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
public async recursiveMoveToCursorPos(
|
public async recursiveMoveToCursorPos(
|
||||||
cursorPos: CursorPos,
|
cursorPos: CursorPos,
|
||||||
setAsActiveSection: boolean,
|
setAsActiveSection: boolean,
|
||||||
silent: boolean = false): Promise<void> {
|
silent: boolean = false): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
if (!cursorPos.sectionId) { throw new Error('sectionId required'); }
|
if (!cursorPos.sectionId) { throw new Error('sectionId required'); }
|
||||||
if (!cursorPos.rowId) { throw new Error('rowId 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
|
// 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)
|
// wait for a bit (scroll is done in a setTimeout 0)
|
||||||
await delay(0);
|
await delay(0);
|
||||||
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.debug(`_recursiveMoveToCursorPos(${JSON.stringify(cursorPos)}): ${e}`);
|
console.debug(`_recursiveMoveToCursorPos(${JSON.stringify(cursorPos)}): ${e}`);
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
throw new UserError('There was a problem finding the desired cell.');
|
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");
|
const content = this._rightPanelTabs.get("Validate Data");
|
||||||
return content ? {icon: 'Validation', label: 'Validation Rules', content} : null;
|
return content ? {icon: 'Validation', label: 'Validation Rules', content} : null;
|
||||||
}
|
}
|
||||||
|
case 'discussion': {
|
||||||
|
return {icon: 'Chat', label: this._discussionPanel.buildMenu(), content: this._discussionPanel};
|
||||||
|
}
|
||||||
case 'none':
|
case 'none':
|
||||||
default: {
|
default: {
|
||||||
return null;
|
return null;
|
||||||
|
@ -318,6 +318,10 @@ exports.groups = [{
|
|||||||
name: 'datepickerFocus',
|
name: 'datepickerFocus',
|
||||||
keys: ['Up', 'Down'],
|
keys: ['Up', 'Down'],
|
||||||
desc: null, // While editing a date cell, switch keyboard focus to the datepicker
|
desc: null, // While editing a date cell, switch keyboard focus to the datepicker
|
||||||
|
}, {
|
||||||
|
name: 'openDiscussion',
|
||||||
|
keys: ['Mod+Alt+M'],
|
||||||
|
desc: 'Comment',
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}, {
|
}, {
|
||||||
|
13
app/client/declarations.d.ts
vendored
13
app/client/declarations.d.ts
vendored
@ -56,6 +56,7 @@ declare module "app/client/components/BaseView" {
|
|||||||
public gristDoc: GristDoc;
|
public gristDoc: GristDoc;
|
||||||
public cursor: Cursor;
|
public cursor: Cursor;
|
||||||
public sortedRows: SortedRowSet;
|
public sortedRows: SortedRowSet;
|
||||||
|
public rowSource: RowSource;
|
||||||
public activeFieldBuilder: ko.Computed<FieldBuilder>;
|
public activeFieldBuilder: ko.Computed<FieldBuilder>;
|
||||||
public selectedColumns: ko.Computed<ViewFieldRec[]>|null;
|
public selectedColumns: ko.Computed<ViewFieldRec[]>|null;
|
||||||
public disableEditing: ko.Computed<boolean>;
|
public disableEditing: ko.Computed<boolean>;
|
||||||
@ -69,6 +70,7 @@ declare module "app/client/components/BaseView" {
|
|||||||
public buildTitleControls(): DomArg;
|
public buildTitleControls(): DomArg;
|
||||||
public getLoadingDonePromise(): Promise<void>;
|
public getLoadingDonePromise(): Promise<void>;
|
||||||
public activateEditorAtCursor(options?: Options): void;
|
public activateEditorAtCursor(options?: Options): void;
|
||||||
|
public openDiscussionAtCursor(discussionId?: number): boolean;
|
||||||
public onResize(): void;
|
public onResize(): void;
|
||||||
public prepareToPrint(onOff: boolean): void;
|
public prepareToPrint(onOff: boolean): void;
|
||||||
public moveEditRowToCursor(): DataRowModel;
|
public moveEditRowToCursor(): DataRowModel;
|
||||||
@ -140,10 +142,19 @@ declare module "app/client/models/BaseRowModel" {
|
|||||||
|
|
||||||
declare module "app/client/models/MetaRowModel" {
|
declare module "app/client/models/MetaRowModel" {
|
||||||
import BaseRowModel from "app/client/models/BaseRowModel";
|
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 {}
|
namespace MetaRowModel {}
|
||||||
class MetaRowModel extends BaseRowModel {
|
class MetaRowModel<TName extends (keyof SchemaTypes)|undefined = undefined> extends BaseRowModel {
|
||||||
public _isDeleted: ko.Observable<boolean>;
|
public _isDeleted: ko.Observable<boolean>;
|
||||||
public events: { trigger: (key: string) => void };
|
public events: { trigger: (key: string) => void };
|
||||||
|
public updateColValues(colValues: Values<TName>): Promise<void>;
|
||||||
}
|
}
|
||||||
export = MetaRowModel;
|
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 {createViewFieldRec, ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||||
import {createViewRec, ViewRec} from 'app/client/models/entities/ViewRec';
|
import {createViewRec, ViewRec} from 'app/client/models/entities/ViewRec';
|
||||||
import {createViewSectionRec, ViewSectionRec} from 'app/client/models/entities/ViewSectionRec';
|
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 {RefListValue} from 'app/common/gristTypes';
|
||||||
import {decodeObject} from 'app/plugin/objtypes';
|
import {decodeObject} from 'app/plugin/objtypes';
|
||||||
|
|
||||||
// Re-export all the entity types available. The recommended usage is like this:
|
// Re-export all the entity types available. The recommended usage is like this:
|
||||||
// import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel';
|
// import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel';
|
||||||
export type {ColumnRec, DocInfoRec, FilterRec, PageRec, TabBarRec, TableRec, ValidationRec,
|
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
|
* 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.
|
* 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.
|
* 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]>;
|
[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 pages: MTM<PageRec> = this._metaTableModel("_grist_Pages", createPageRec);
|
||||||
public rules: MTM<ACLRuleRec> = this._metaTableModel("_grist_ACLRules", createACLRuleRec);
|
public rules: MTM<ACLRuleRec> = this._metaTableModel("_grist_ACLRules", createACLRuleRec);
|
||||||
public filters: MTM<FilterRec> = this._metaTableModel("_grist_Filters", createFilterRec);
|
public filters: MTM<FilterRec> = this._metaTableModel("_grist_Filters", createFilterRec);
|
||||||
|
public cells: MTM<CellRec> = this._metaTableModel("_grist_Cells", createCellRec);
|
||||||
|
|
||||||
public docInfoRow: DocInfoRec;
|
public docInfoRow: DocInfoRec;
|
||||||
|
|
||||||
|
43
app/client/models/entities/CellRec.ts
Normal file
43
app/client/models/entities/CellRec.ts
Normal file
@ -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 {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 {jsonObservable, ObjObservable} from 'app/client/models/modelUtil';
|
||||||
import * as gristTypes from 'app/common/gristTypes';
|
import * as gristTypes from 'app/common/gristTypes';
|
||||||
import {getReferencedTableId} 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>;
|
visibleColModel: ko.Computed<ColumnRec>;
|
||||||
|
|
||||||
disableModifyBase: ko.Computed<boolean>; // True if column config can't be modified (name, type, etc.)
|
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.
|
disableEditData: ko.Computed<boolean>; // True to disable editing of the data in this column.
|
||||||
|
|
||||||
isHiddenCol: ko.Computed<boolean>;
|
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)
|
// (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` formats actual cell values, e.g. a whole list from the display column.
|
||||||
formatter: ko.Computed<BaseFormatter>;
|
formatter: ko.Computed<BaseFormatter>;
|
||||||
|
cells: ko.Computed<KoArray<CellRec>>;
|
||||||
|
|
||||||
// Helper which adds/removes/updates column's displayCol to match the formula.
|
// Helper which adds/removes/updates column's displayCol to match the formula.
|
||||||
saveDisplayFormula(formula: string): Promise<void>|undefined;
|
saveDisplayFormula(formula: string): Promise<void>|undefined;
|
||||||
@ -83,6 +85,7 @@ export function createColumnRec(this: ColumnRec, docModel: DocModel): void {
|
|||||||
this.widgetOptionsJson = jsonObservable(this.widgetOptions);
|
this.widgetOptionsJson = jsonObservable(this.widgetOptions);
|
||||||
this.viewFields = recordSet(this, docModel.viewFields, 'colRef');
|
this.viewFields = recordSet(this, docModel.viewFields, 'colRef');
|
||||||
this.summarySource = refRecord(docModel.columns, this.summarySourceCol);
|
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.
|
// Is this an empty column (undecided if formula or data); denoted by an empty formula.
|
||||||
this.isEmpty = ko.pureComputed(() => this.isFormula() && this.formula() === '');
|
this.isEmpty = ko.pureComputed(() => this.isFormula() && this.formula() === '');
|
||||||
|
12
app/client/models/features.ts
Normal file
12
app/client/models/features.ts
Normal file
@ -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 {CompareFunc, sortedIndex} from 'app/common/gutil';
|
||||||
import {SkippableRows} from 'app/common/TableData';
|
import {SkippableRows} from 'app/common/TableData';
|
||||||
import {RowFilterFunc} from "app/common/RowFilterFunc";
|
import {RowFilterFunc} from "app/common/RowFilterFunc";
|
||||||
|
import {Observable} from 'grainjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Special constant value that can be used for the `rows` array for the 'rowNotify'
|
* 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 {
|
function _addToMapOfArrays<K, V>(map: Map<K, V[]>, key: K, r: V): void {
|
||||||
let arr = map.get(key);
|
let arr = map.get(key);
|
||||||
if (!arr) { map.set(key, arr = []); }
|
if (!arr) { map.set(key, arr = []); }
|
||||||
@ -437,11 +441,6 @@ export class RowGrouping<Value> extends RowListener {
|
|||||||
|
|
||||||
// Implementation of the RowListener interface.
|
// 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) {
|
public onAddRows(rows: RowList) {
|
||||||
const groupedRows = new Map();
|
const groupedRows = new Map();
|
||||||
for (const r of rows) {
|
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) {
|
function isSmallChange(rows: RowList) {
|
||||||
return Array.isArray(rows) && rows.length <= 2;
|
return Array.isArray(rows) && rows.length <= 2;
|
||||||
}
|
}
|
||||||
|
@ -147,7 +147,7 @@ const cssAccountWidget = styled('div', `
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssUserIcon = styled('div', `
|
export const cssUserIcon = styled('div', `
|
||||||
height: 48px;
|
height: 48px;
|
||||||
width: 48px;
|
width: 48px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
@ -2,6 +2,7 @@ import { allCommands } from 'app/client/components/commands';
|
|||||||
import { menuDivider, menuItemCmd } from 'app/client/ui2018/menus';
|
import { menuDivider, menuItemCmd } from 'app/client/ui2018/menus';
|
||||||
import { IMultiColumnContextMenu } from 'app/client/ui/GridViewMenus';
|
import { IMultiColumnContextMenu } from 'app/client/ui/GridViewMenus';
|
||||||
import { IRowContextMenu } from 'app/client/ui/RowContextMenu';
|
import { IRowContextMenu } from 'app/client/ui/RowContextMenu';
|
||||||
|
import { COMMENTS } from 'app/client/models/features';
|
||||||
import { dom } from 'grainjs';
|
import { dom } from 'grainjs';
|
||||||
|
|
||||||
export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiColumnContextMenu) {
|
export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiColumnContextMenu) {
|
||||||
@ -9,6 +10,8 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC
|
|||||||
const { disableInsert, disableDelete, isViewSorted } = rowOptions;
|
const { disableInsert, disableDelete, isViewSorted } = rowOptions;
|
||||||
const { disableModify, isReadonly } = colOptions;
|
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 disableForReadonlyColumn = dom.cls('disabled', Boolean(disableModify) || isReadonly);
|
||||||
const disableForReadonlyView = dom.cls('disabled', isReadonly);
|
const disableForReadonlyView = dom.cls('disabled', isReadonly);
|
||||||
|
|
||||||
@ -32,7 +35,7 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC
|
|||||||
colOptions.isFormula ?
|
colOptions.isFormula ?
|
||||||
null :
|
null :
|
||||||
menuItemCmd(allCommands.clearValues, nameClearCells, disableForReadonlyColumn),
|
menuItemCmd(allCommands.clearValues, nameClearCells, disableForReadonlyColumn),
|
||||||
menuItemCmd(allCommands.clearColumns, nameClearColumns, disableForReadonlyColumn),
|
menuItemCmd(allCommands.clearColumns, nameClearColumns, disableForReadonlyColumn),
|
||||||
|
|
||||||
...(
|
...(
|
||||||
(numCols > 1 || numRows > 1) ? [] : [
|
(numCols > 1 || numRows > 1) ? [] : [
|
||||||
@ -40,6 +43,9 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC
|
|||||||
menuItemCmd(allCommands.copyLink, 'Copy anchor link'),
|
menuItemCmd(allCommands.copyLink, 'Copy anchor link'),
|
||||||
menuDivider(),
|
menuDivider(),
|
||||||
menuItemCmd(allCommands.filterByThisCellValue, `Filter by this value`),
|
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(),
|
menuDivider(),
|
||||||
|
|
||||||
// deletes
|
// deletes
|
||||||
menuItemCmd(allCommands.deleteRecords, nameDeleteRows,
|
menuItemCmd(allCommands.deleteRecords, nameDeleteRows, dom.cls('disabled', disableDelete)),
|
||||||
dom.cls('disabled', disableDelete)),
|
|
||||||
|
|
||||||
menuItemCmd(allCommands.deleteFields, nameDeleteColumns, disableForReadonlyColumn),
|
menuItemCmd(allCommands.deleteFields, nameDeleteColumns, disableForReadonlyColumn),
|
||||||
|
|
||||||
|
@ -37,7 +37,7 @@ export interface IMultiColumnContextMenu {
|
|||||||
// true for some columns, but not all.
|
// true for some columns, but not all.
|
||||||
numColumns: number;
|
numColumns: number;
|
||||||
numFrozen: 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;
|
isReadonly: boolean;
|
||||||
isRaw: boolean;
|
isRaw: boolean;
|
||||||
isFiltered: boolean; // If this view shows a proper subset of all rows in the table.
|
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 {basicButton} from 'app/client/ui2018/buttons';
|
||||||
import {cssHideForNarrowScreen, testId, theme} from 'app/client/ui2018/cssVars';
|
import {cssHideForNarrowScreen, testId, theme} from 'app/client/ui2018/cssVars';
|
||||||
import {IconName} from 'app/client/ui2018/IconList';
|
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 {waitGrainObs} from 'app/common/gutil';
|
||||||
import * as roles from 'app/common/roles';
|
import * as roles from 'app/common/roles';
|
||||||
import {Computed, dom, DomElementArg, makeTestId, MultiHolder, Observable, styled} from 'grainjs';
|
import {Computed, dom, DomElementArg, makeTestId, MultiHolder, Observable, styled} from 'grainjs';
|
||||||
@ -92,6 +94,14 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode
|
|||||||
|
|
||||||
buildShareMenuButton(pageModel),
|
buildShareMenuButton(pageModel),
|
||||||
|
|
||||||
|
dom.maybe(use =>
|
||||||
|
(
|
||||||
|
use(pageModel.gristDoc)
|
||||||
|
&& !use(use(pageModel.gristDoc)!.isReadonly)
|
||||||
|
&& use(COMMENTS())
|
||||||
|
),
|
||||||
|
() => buildShowDiscussionButton(pageModel)),
|
||||||
|
|
||||||
dom.update(
|
dom.update(
|
||||||
buildNotifyMenuButton(appModel.notifier, appModel),
|
buildNotifyMenuButton(appModel.notifier, appModel),
|
||||||
cssHideForNarrowScreen.cls(''),
|
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.
|
// 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.
|
// 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> {
|
function getRenamePageFn(gristDoc: GristDoc): (val: string) => Promise<void> {
|
||||||
|
@ -39,6 +39,7 @@ export type IconName = "ChartArea" |
|
|||||||
"BarcodeQR" |
|
"BarcodeQR" |
|
||||||
"BarcodeQR2" |
|
"BarcodeQR2" |
|
||||||
"CenterAlign" |
|
"CenterAlign" |
|
||||||
|
"Chat" |
|
||||||
"Code" |
|
"Code" |
|
||||||
"Collapse" |
|
"Collapse" |
|
||||||
"Convert" |
|
"Convert" |
|
||||||
@ -75,6 +76,7 @@ export type IconName = "ChartArea" |
|
|||||||
"Lock" |
|
"Lock" |
|
||||||
"Log" |
|
"Log" |
|
||||||
"Mail" |
|
"Mail" |
|
||||||
|
"Message" |
|
||||||
"Minus" |
|
"Minus" |
|
||||||
"MobileChat" |
|
"MobileChat" |
|
||||||
"MobileChat2" |
|
"MobileChat2" |
|
||||||
@ -90,6 +92,7 @@ export type IconName = "ChartArea" |
|
|||||||
"Pivot" |
|
"Pivot" |
|
||||||
"PivotLight" |
|
"PivotLight" |
|
||||||
"Plus" |
|
"Plus" |
|
||||||
|
"Popup" |
|
||||||
"Public" |
|
"Public" |
|
||||||
"PublicColor" |
|
"PublicColor" |
|
||||||
"PublicFilled" |
|
"PublicFilled" |
|
||||||
@ -165,6 +168,7 @@ export const IconList: IconName[] = ["ChartArea",
|
|||||||
"BarcodeQR",
|
"BarcodeQR",
|
||||||
"BarcodeQR2",
|
"BarcodeQR2",
|
||||||
"CenterAlign",
|
"CenterAlign",
|
||||||
|
"Chat",
|
||||||
"Code",
|
"Code",
|
||||||
"Collapse",
|
"Collapse",
|
||||||
"Convert",
|
"Convert",
|
||||||
@ -201,6 +205,7 @@ export const IconList: IconName[] = ["ChartArea",
|
|||||||
"Lock",
|
"Lock",
|
||||||
"Log",
|
"Log",
|
||||||
"Mail",
|
"Mail",
|
||||||
|
"Message",
|
||||||
"Minus",
|
"Minus",
|
||||||
"MobileChat",
|
"MobileChat",
|
||||||
"MobileChat2",
|
"MobileChat2",
|
||||||
@ -216,6 +221,7 @@ export const IconList: IconName[] = ["ChartArea",
|
|||||||
"Pivot",
|
"Pivot",
|
||||||
"PivotLight",
|
"PivotLight",
|
||||||
"Plus",
|
"Plus",
|
||||||
|
"Popup",
|
||||||
"Public",
|
"Public",
|
||||||
"PublicColor",
|
"PublicColor",
|
||||||
"PublicFilled",
|
"PublicFilled",
|
||||||
|
@ -50,6 +50,7 @@ export const colors = {
|
|||||||
|
|
||||||
light: new CustomProp('color-light', '#FFFFFF'),
|
light: new CustomProp('color-light', '#FFFFFF'),
|
||||||
dark: new CustomProp('color-dark', '#262633'),
|
dark: new CustomProp('color-dark', '#262633'),
|
||||||
|
darkText: new CustomProp('color-dark-text', '#494949'),
|
||||||
darkBg: new CustomProp('color-dark-bg', '#262633'),
|
darkBg: new CustomProp('color-dark-bg', '#262633'),
|
||||||
slate: new CustomProp('color-slate', '#929299'),
|
slate: new CustomProp('color-slate', '#929299'),
|
||||||
|
|
||||||
|
1382
app/client/widgets/DiscussionEditor.ts
Normal file
1382
app/client/widgets/DiscussionEditor.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -30,3 +30,18 @@
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background-color: var(--grist-theme-right-panel-field-settings-button-bg, lightgrey);
|
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 { ColumnRec, DocModel, ViewFieldRec } from 'app/client/models/DocModel';
|
||||||
import { SaveableObjObservable, setSaveValue } from 'app/client/models/modelUtil';
|
import { SaveableObjObservable, setSaveValue } from 'app/client/models/modelUtil';
|
||||||
import { CombinedStyle, Style } from 'app/client/models/Styles';
|
import { CombinedStyle, Style } from 'app/client/models/Styles';
|
||||||
|
import { COMMENTS } from 'app/client/models/features';
|
||||||
import { FieldSettingsMenu } from 'app/client/ui/FieldMenus';
|
import { FieldSettingsMenu } from 'app/client/ui/FieldMenus';
|
||||||
import { cssBlockedCursor, cssLabel, cssRow } from 'app/client/ui/RightPanelStyles';
|
import { cssBlockedCursor, cssLabel, cssRow } from 'app/client/ui/RightPanelStyles';
|
||||||
import { buttonSelect, cssButtonSelect } from 'app/client/ui2018/buttonSelect';
|
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 { DiffBox } from 'app/client/widgets/DiffBox';
|
||||||
import { buildErrorDom } from 'app/client/widgets/ErrorDom';
|
import { buildErrorDom } from 'app/client/widgets/ErrorDom';
|
||||||
import { FieldEditor, saveWithoutEditor, setupEditorCleanup } from 'app/client/widgets/FieldEditor';
|
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 { openFormulaEditor } from 'app/client/widgets/FormulaEditor';
|
||||||
import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget';
|
import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget';
|
||||||
import { NewBaseEditor } from "app/client/widgets/NewBaseEditor";
|
import { NewBaseEditor } from "app/client/widgets/NewBaseEditor";
|
||||||
@ -37,6 +39,8 @@ import * as _ from 'underscore';
|
|||||||
|
|
||||||
const testId = makeTestId('test-fbuilder-');
|
const testId = makeTestId('test-fbuilder-');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Creates a FieldBuilder object for each field in viewFields
|
// Creates a FieldBuilder object for each field in viewFields
|
||||||
export function createAllFieldWidgets(gristDoc: GristDoc, viewFields: ko.Computed<KoArray<ViewFieldRec>>,
|
export function createAllFieldWidgets(gristDoc: GristDoc, viewFields: ko.Computed<KoArray<ViewFieldRec>>,
|
||||||
cursor: Cursor, options: { isPreview?: boolean } = {}) {
|
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 _widgetCons: ko.Computed<{create: (...args: any[]) => NewAbstractWidget}>;
|
||||||
private readonly _docModel: DocModel;
|
private readonly _docModel: DocModel;
|
||||||
private readonly _readonly: Computed<boolean>;
|
private readonly _readonly: Computed<boolean>;
|
||||||
|
private readonly _comments: ko.Computed<boolean>;
|
||||||
|
|
||||||
public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec,
|
public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec,
|
||||||
private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) {
|
private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) {
|
||||||
@ -107,6 +112,7 @@ export class FieldBuilder extends Disposable {
|
|||||||
this._docModel = gristDoc.docModel;
|
this._docModel = gristDoc.docModel;
|
||||||
this.origColumn = field.column();
|
this.origColumn = field.column();
|
||||||
this.options = field.widgetOptionsJson;
|
this.options = field.widgetOptionsJson;
|
||||||
|
this._comments = ko.pureComputed(() => toKo(ko, COMMENTS())());
|
||||||
|
|
||||||
this._readOnlyPureType = ko.pureComputed(() => this.field.column().pureType());
|
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 errorInStyle = ko.pureComputed(() => Boolean(computedRule()?.error));
|
||||||
|
|
||||||
const cellText = ko.pureComputed(() => this.field.textColor() || '');
|
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) => {
|
return (elem: Element) => {
|
||||||
this._rowMap.set(row, elem);
|
this._rowMap.set(row, elem);
|
||||||
dom(elem,
|
dom(elem,
|
||||||
dom.autoDispose(widgetObs),
|
dom.autoDispose(domHolder),
|
||||||
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),
|
|
||||||
kd.style('--grist-cell-color', cellText),
|
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-rule-color', ruleText),
|
||||||
kd.style('--grist-column-rule-background-color', ruleFill),
|
kd.style('--grist-column-rule-background-color', ruleFill),
|
||||||
this._options.isPreview ? null : kd.cssClass(this.field.formulaCssClass),
|
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.toggleClass("readonly", toKo(ko, this._readonly)),
|
||||||
kd.maybe(isSelected, () => dom('div.selected_cursor',
|
kd.maybe(isSelected, () => dom('div.selected_cursor',
|
||||||
kd.toggleClass('active_cursor', isActive)
|
kd.toggleClass('active_cursor', isActive)
|
||||||
@ -671,6 +697,43 @@ export class FieldBuilder extends Disposable {
|
|||||||
this.gristDoc.activeEditor.set(fieldEditor);
|
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() {
|
public isEditorActive() {
|
||||||
return !this._fieldEditorHolder.isEmpty();
|
return !this._fieldEditorHolder.isEmpty();
|
||||||
}
|
}
|
||||||
|
@ -334,7 +334,7 @@ export class FieldEditor extends Disposable {
|
|||||||
let waitPromise: Promise<unknown>|null = null;
|
let waitPromise: Promise<unknown>|null = null;
|
||||||
|
|
||||||
if (isFormula) {
|
if (isFormula) {
|
||||||
const formula = editor.getCellValue();
|
const formula = String(editor.getCellValue() ?? '');
|
||||||
// Bundle multiple changes so that we can undo them in one step.
|
// Bundle multiple changes so that we can undo them in one step.
|
||||||
if (isFormula !== col.isFormula.peek() || formula !== col.formula.peek()) {
|
if (isFormula !== col.isFormula.peek() || formula !== col.formula.peek()) {
|
||||||
waitPromise = this._gristDoc.docData.bundleActions(null, () => Promise.all([
|
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.
|
// AsyncOnce ensures it's called once even if triggered multiple times.
|
||||||
const saveEdit = asyncOnce(async () => {
|
const saveEdit = asyncOnce(async () => {
|
||||||
const formula = editor.getCellValue();
|
const formula = String(editor.getCellValue());
|
||||||
if (formula !== column.formula.peek()) {
|
if (formula !== column.formula.peek()) {
|
||||||
if (options.onSave) {
|
if (options.onSave) {
|
||||||
await options.onSave(column, formula as string);
|
await options.onSave(column, formula);
|
||||||
} else {
|
} else {
|
||||||
await column.updateColValues({formula});
|
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',
|
const SCHEMA_ACTIONS = new Set(['AddTable', 'RemoveTable', 'RenameTable', 'AddColumn',
|
||||||
'RemoveColumn', 'RenameColumn', 'ModifyColumn']);
|
'RemoveColumn', 'RenameColumn', 'ModifyColumn']);
|
||||||
|
|
||||||
// Maps each data action to whether it's a bulk action.
|
|
||||||
const DATA_ACTIONS = new Set(['AddRecord', 'RemoveRecord', 'UpdateRecord', 'BulkAddRecord',
|
const DATA_ACTIONS = new Set(['AddRecord', 'RemoveRecord', 'UpdateRecord', 'BulkAddRecord',
|
||||||
'BulkRemoveRecord', 'BulkUpdateRecord', 'ReplaceTableData', 'TableData']);
|
'BulkRemoveRecord', 'BulkUpdateRecord', 'ReplaceTableData', 'TableData']);
|
||||||
|
|
||||||
|
@ -45,6 +45,7 @@ export interface UserInfo {
|
|||||||
Origin: string | null;
|
Origin: string | null;
|
||||||
LinkKey: Record<string, string | undefined>;
|
LinkKey: Record<string, string | undefined>;
|
||||||
UserID: number | null;
|
UserID: number | null;
|
||||||
|
UserRef: string | null;
|
||||||
[attributes: string]: unknown;
|
[attributes: string]: unknown;
|
||||||
toJSON(): {[key: string]: any};
|
toJSON(): {[key: string]: any};
|
||||||
}
|
}
|
||||||
|
@ -175,6 +175,7 @@ export interface UserAccessData {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
ref?: string|null;
|
||||||
picture?: string|null; // When present, a url to a public image of unspecified dimensions.
|
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
|
// Represents the user's direct access to the resource of interest. Lack of access to a resource
|
||||||
// is represented by a null value.
|
// is represented by a null value.
|
||||||
|
@ -344,3 +344,10 @@ export function isValidRuleValue(value: CellValue|undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type RefListValue = [GristObjCode.List, ...number[]]|null;
|
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.
|
// Loaded namespaces for translations.
|
||||||
namespaces?: readonly string[];
|
namespaces?: readonly string[];
|
||||||
|
|
||||||
|
// TODO: remove when comments will be released.
|
||||||
|
featureComments?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HideableUiElements = StringUnion("helpCenter", "billing", "templates", "multiSite", "multiAccounts");
|
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
|
// tslint:disable:object-literal-key-quotes
|
||||||
|
|
||||||
export const SCHEMA_VERSION = 32;
|
export const SCHEMA_VERSION = 33;
|
||||||
|
|
||||||
export const schema = {
|
export const schema = {
|
||||||
|
|
||||||
@ -196,6 +196,17 @@ export const schema = {
|
|||||||
filter : "Text",
|
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 {
|
export interface SchemaTypes {
|
||||||
@ -388,4 +399,15 @@ export interface SchemaTypes {
|
|||||||
filter: string;
|
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,
|
email: user.logins[0].displayEmail,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
picture: user.picture,
|
picture: user.picture,
|
||||||
|
ref: user.ref,
|
||||||
};
|
};
|
||||||
if (this.getAnonymousUserId() === user.id) {
|
if (this.getAnonymousUserId() === user.id) {
|
||||||
result.anonymous = true;
|
result.anonymous = true;
|
||||||
|
@ -10,6 +10,11 @@ import {
|
|||||||
BulkColValues,
|
BulkColValues,
|
||||||
BulkRemoveRecord,
|
BulkRemoveRecord,
|
||||||
BulkUpdateRecord,
|
BulkUpdateRecord,
|
||||||
|
getColValues,
|
||||||
|
isBulkAddRecord,
|
||||||
|
isBulkRemoveRecord,
|
||||||
|
isBulkUpdateRecord,
|
||||||
|
isUpdateRecord,
|
||||||
} from 'app/common/DocActions';
|
} from 'app/common/DocActions';
|
||||||
import { RemoveRecord, ReplaceTableData, UpdateRecord } from 'app/common/DocActions';
|
import { RemoveRecord, ReplaceTableData, UpdateRecord } from 'app/common/DocActions';
|
||||||
import { CellValue, ColValues, DocAction, getTableId, isSchemaAction } 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 { UserInfo } from 'app/common/GranularAccessClause';
|
||||||
import * as gristTypes from 'app/common/gristTypes';
|
import * as gristTypes from 'app/common/gristTypes';
|
||||||
import { getSetMapValue, isNonNullish, pruneArray } from 'app/common/gutil';
|
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 { canEdit, canView, isValidRole, Role } from 'app/common/roles';
|
||||||
import { FullUser, UserAccessData } from 'app/common/UserAPI';
|
import { FullUser, UserAccessData } from 'app/common/UserAPI';
|
||||||
import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager';
|
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 { getRelatedRows, getRowIdsFromDocAction } from 'app/server/lib/RowAccess';
|
||||||
import cloneDeep = require('lodash/cloneDeep');
|
import cloneDeep = require('lodash/cloneDeep');
|
||||||
import fromPairs = require('lodash/fromPairs');
|
import fromPairs = require('lodash/fromPairs');
|
||||||
|
import memoize = require('lodash/memoize');
|
||||||
import get = require('lodash/get');
|
import get = require('lodash/get');
|
||||||
|
|
||||||
// tslint:disable:no-bitwise
|
// tslint:disable:no-bitwise
|
||||||
@ -208,7 +214,7 @@ export interface GranularAccessForBundle {
|
|||||||
* will be abandoned.
|
* will be abandoned.
|
||||||
* - appliedBundle(), called when DocActions have been applied to the DB, but before
|
* - appliedBundle(), called when DocActions have been applied to the DB, but before
|
||||||
* those changes have been sent to clients.
|
* 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.
|
* client of changes.
|
||||||
* - finishedBundle(), called when completely done with modification and any needed
|
* - finishedBundle(), called when completely done with modification and any needed
|
||||||
* client notifications, whether successful or failed.
|
* 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.
|
* 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 {
|
function fail(): never {
|
||||||
throw new ErrorWithCode('ACL_DENY', 'Cannot access cell');
|
throw new ErrorWithCode('ACL_DENY', 'Cannot access cell');
|
||||||
}
|
}
|
||||||
const pset = await this.getTableAccess(docSession, cell.tableId);
|
if (!await this.hasTableAccess(docSession, cell.tableId)) { fail(); }
|
||||||
const tableAccess = this.getReadPermission(pset);
|
let rows: TableDataAction|null = null;
|
||||||
if (tableAccess === 'deny') { fail(); }
|
if (docData) {
|
||||||
const rows = await this._fetchQueryFromDB({
|
const record = docData.getTable(cell.tableId)?.getRecord(cell.rowId);
|
||||||
tableId: cell.tableId,
|
if (record) {
|
||||||
filters: { id: [cell.rowId] }
|
rows = ['TableData', cell.tableId, [cell.rowId], getColValues([record])];
|
||||||
});
|
}
|
||||||
if (!rows || rows[2].length === 0) { fail(); }
|
} 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 rec = new RecordView(rows, 0);
|
||||||
const input: AclMatchInput = {user: await this._getUser(docSession), rec, newRec: rec};
|
const input: AclMatchInput = {user: await this._getUser(docSession), rec, newRec: rec};
|
||||||
const rowPermInfo = new PermissionInfo(this._ruler.ruleCollection, input);
|
const rowPermInfo = new PermissionInfo(this._ruler.ruleCollection, input);
|
||||||
@ -361,7 +390,9 @@ export class GranularAccess implements GranularAccessForBundle {
|
|||||||
public async canApplyBundle() {
|
public async canApplyBundle() {
|
||||||
if (!this._activeBundle) { throw new Error('no active bundle'); }
|
if (!this._activeBundle) { throw new Error('no active bundle'); }
|
||||||
const {docActions, docSession, isDirect} = this._activeBundle;
|
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');
|
throw new ErrorWithCode('ACL_DENY', 'Only owners can modify access rules');
|
||||||
}
|
}
|
||||||
// Normally, viewer requests would never reach this point, but they can happen
|
// 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) {
|
if (this._recoveryMode) {
|
||||||
// Don't do any further checking in recovery mode.
|
// Don't do any further checking in recovery mode.
|
||||||
return;
|
return;
|
||||||
@ -483,9 +516,12 @@ export class GranularAccess implements GranularAccessForBundle {
|
|||||||
|
|
||||||
const actions = await Promise.all(
|
const actions = await Promise.all(
|
||||||
docActions.map((action, actionIdx) => this._filterOutgoingDocAction({docSession, action, actionIdx})));
|
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.
|
* 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.
|
// If we are going to modify metadata, make a copy.
|
||||||
tables = cloneDeep(tables);
|
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 permInfo = await this._getAccess(docSession);
|
||||||
const censor = new CensorshipInfo(permInfo, this._ruler.ruleCollection, tables,
|
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) {
|
for (const tableId of STRUCTURAL_TABLES) {
|
||||||
censor.apply(tables[tableId]);
|
censor.apply(tables[tableId]);
|
||||||
@ -899,6 +947,38 @@ export class GranularAccess implements GranularAccessForBundle {
|
|||||||
return baseAccess;
|
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
|
* An optimization to catch obvious access problems for simple data
|
||||||
* actions (such as UpdateRecord, BulkAddRecord, etc) early. Checks
|
* actions (such as UpdateRecord, BulkAddRecord, etc) early. Checks
|
||||||
@ -2067,9 +2147,9 @@ export class GranularAccess implements GranularAccessForBundle {
|
|||||||
return dummyAccessCheck;
|
return dummyAccessCheck;
|
||||||
}
|
}
|
||||||
const tableId = getTableId(a);
|
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.
|
// 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.
|
// with granular access.
|
||||||
|
|
||||||
// Another exception: ensure owners always have full access to ACL tables, so they
|
// 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;
|
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() {}
|
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.
|
* Manage censoring metadata.
|
||||||
@ -2325,23 +2582,27 @@ export class CensorshipInfo {
|
|||||||
public censoredViews = new Set<number>();
|
public censoredViews = new Set<number>();
|
||||||
public censoredColumns = new Set<number>();
|
public censoredColumns = new Set<number>();
|
||||||
public censoredFields = new Set<number>();
|
public censoredFields = new Set<number>();
|
||||||
|
public censoredComments = new Set<number>();
|
||||||
public censored = {
|
public censored = {
|
||||||
_grist_Tables: this.censoredTables,
|
_grist_Tables: this.censoredTables,
|
||||||
_grist_Tables_column: this.censoredColumns,
|
_grist_Tables_column: this.censoredColumns,
|
||||||
_grist_Views: this.censoredViews,
|
_grist_Views: this.censoredViews,
|
||||||
_grist_Views_section: this.censoredSections,
|
_grist_Views_section: this.censoredSections,
|
||||||
_grist_Views_section_field: this.censoredFields,
|
_grist_Views_section_field: this.censoredFields,
|
||||||
|
_grist_Cells: this.censoredComments,
|
||||||
};
|
};
|
||||||
|
|
||||||
public constructor(permInfo: PermissionInfo,
|
public constructor(permInfo: PermissionInfo,
|
||||||
ruleCollection: ACLRuleCollection,
|
ruleCollection: ACLRuleCollection,
|
||||||
tables: {[key: string]: TableDataAction},
|
tables: {[key: string]: TableDataAction},
|
||||||
private _canViewACLs: boolean) {
|
private _canViewACLs: boolean,
|
||||||
|
cellAccessInfo?: CellAccessHelper) {
|
||||||
// Collect a list of censored columns (by "<tableRef> <colId>").
|
// Collect a list of censored columns (by "<tableRef> <colId>").
|
||||||
const columnCode = (tableRef: number, colId: string) => `${tableRef} ${colId}`;
|
const columnCode = (tableRef: number, colId: string) => `${tableRef} ${colId}`;
|
||||||
const censoredColumnCodes: Set<string> = new Set();
|
const censoredColumnCodes: Set<string> = new Set();
|
||||||
const tableRefToTableId: Map<number, string> = new Map();
|
const tableRefToTableId: Map<number, string> = new Map();
|
||||||
const tableRefToIndex: Map<number, number> = new Map();
|
const tableRefToIndex: Map<number, number> = new Map();
|
||||||
|
const columnRefToColId: Map<number, string> = new Map();
|
||||||
const uncensoredTables: Set<number> = new Set();
|
const uncensoredTables: Set<number> = new Set();
|
||||||
// Scan for forbidden tables.
|
// Scan for forbidden tables.
|
||||||
let rec = new RecordView(tables._grist_Tables, undefined);
|
let rec = new RecordView(tables._grist_Tables, undefined);
|
||||||
@ -2365,10 +2626,12 @@ export class CensorshipInfo {
|
|||||||
for (let idx = 0; idx < ids.length; idx++) {
|
for (let idx = 0; idx < ids.length; idx++) {
|
||||||
rec.index = idx;
|
rec.index = idx;
|
||||||
const tableRef = rec.get('parentId') as number;
|
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; }
|
if (uncensoredTables.has(tableRef)) { continue; }
|
||||||
const tableId = tableRefToTableId.get(tableRef);
|
const tableId = tableRefToTableId.get(tableRef);
|
||||||
if (!tableId) { throw new Error('table not found'); }
|
if (!tableId) { throw new Error('table not found'); }
|
||||||
const colId = rec.get('colId') as string;
|
|
||||||
if (this.censoredTables.has(tableRef) ||
|
if (this.censoredTables.has(tableRef) ||
|
||||||
(colId !== 'manualSort' && permInfo.getColumnAccess(tableId, colId).perms.read === 'deny')) {
|
(colId !== 'manualSort' && permInfo.getColumnAccess(tableId, colId).perms.read === 'deny')) {
|
||||||
censoredColumnCodes.add(columnCode(tableRef, colId));
|
censoredColumnCodes.add(columnCode(tableRef, colId));
|
||||||
@ -2427,12 +2690,37 @@ export class CensorshipInfo {
|
|||||||
const rawViewSectionRef = rec.get('rawViewSectionRef') as number;
|
const rawViewSectionRef = rec.get('rawViewSectionRef') as number;
|
||||||
this.censoredSections.delete(rawViewSectionRef);
|
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) {
|
public apply(a: DataAction) {
|
||||||
const tableId = getTableId(a);
|
const tableId = getTableId(a);
|
||||||
const ids = getRowIdsFromDocAction(a);
|
|
||||||
if (!STRUCTURAL_TABLES.has(tableId)) { return true; }
|
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 (!(tableId in this.censored)) {
|
||||||
if (!this._canViewACLs && a[0] === 'TableData') {
|
if (!this._canViewACLs && a[0] === 'TableData') {
|
||||||
a[2] = [];
|
a[2] = [];
|
||||||
@ -2443,6 +2731,7 @@ export class CensorshipInfo {
|
|||||||
const rec = new RecordEditor(a, undefined, true);
|
const rec = new RecordEditor(a, undefined, true);
|
||||||
const method = getCensorMethod(getTableId(a));
|
const method = getCensorMethod(getTableId(a));
|
||||||
const censoredRows = (this.censored as any)[tableId] as Set<number>;
|
const censoredRows = (this.censored as any)[tableId] as Set<number>;
|
||||||
|
const ids = getRowIdsFromDocAction(a);
|
||||||
for (const [index, id] of ids.entries()) {
|
for (const [index, id] of ids.entries()) {
|
||||||
if (censoredRows.has(id)) {
|
if (censoredRows.has(id)) {
|
||||||
rec.index = index;
|
rec.index = index;
|
||||||
@ -2470,6 +2759,8 @@ function getCensorMethod(tableId: string): (rec: RecordEditor) => void {
|
|||||||
return rec => rec;
|
return rec => rec;
|
||||||
case '_grist_ACLRules':
|
case '_grist_ACLRules':
|
||||||
return rec => rec;
|
return rec => rec;
|
||||||
|
case '_grist_Cells':
|
||||||
|
return rec => rec.set('content', [GristObjCode.Censored]).set('userRef', '');
|
||||||
default:
|
default:
|
||||||
throw new Error(`cannot censor ${tableId}`);
|
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];
|
const ids = docActions[2];
|
||||||
return (typeof ids === 'number') ? [ids] : ids;
|
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;
|
PRAGMA foreign_keys=OFF;
|
||||||
BEGIN TRANSACTION;
|
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 '');
|
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" (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_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);
|
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','');
|
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_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_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);
|
CREATE INDEX _grist_Attachments_fileIdent ON _grist_Attachments(fileIdent);
|
||||||
COMMIT;
|
COMMIT;
|
||||||
`;
|
`;
|
||||||
@ -42,7 +43,7 @@ export const GRIST_DOC_WITH_TABLE1_SQL = `
|
|||||||
PRAGMA foreign_keys=OFF;
|
PRAGMA foreign_keys=OFF;
|
||||||
BEGIN TRANSACTION;
|
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 '');
|
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" (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);
|
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);
|
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','');
|
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_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_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 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);
|
CREATE INDEX _grist_Attachments_fileIdent ON _grist_Attachments(fileIdent);
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
@ -59,6 +59,7 @@ export function makeGristConfig(homeUrl: string|null, extra: Partial<GristLoadCo
|
|||||||
enableCustomCss: process.env.APP_STATIC_INCLUDE_CUSTOM_CSS === 'true',
|
enableCustomCss: process.env.APP_STATIC_INCLUDE_CUSTOM_CSS === 'true',
|
||||||
supportedLngs: readLoadedLngs(req?.i18n),
|
supportedLngs: readLoadedLngs(req?.i18n),
|
||||||
namespaces: readLoadedNamespaces(req?.i18n),
|
namespaces: readLoadedNamespaces(req?.i18n),
|
||||||
|
featureComments: process.env.COMMENTS === "true",
|
||||||
...extra,
|
...extra,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -147,6 +147,21 @@ class MetaTableExtras(object):
|
|||||||
table.docmodel.setAutoRemove(rec, not rec.colRef)
|
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):
|
def enhance_model(model_class):
|
||||||
"""
|
"""
|
||||||
Given a metadata model class, add all members (formula methods) to it from the same-named inner
|
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.aclResources = self._prep_table("_grist_ACLResources")
|
||||||
self.aclRules = self._prep_table("_grist_ACLRules")
|
self.aclRules = self._prep_table("_grist_ACLRules")
|
||||||
self.filters = self._prep_table("_grist_Filters")
|
self.filters = self._prep_table("_grist_Filters")
|
||||||
|
self.cells = self._prep_table("_grist_Cells")
|
||||||
|
|
||||||
def _prep_table(self, name):
|
def _prep_table(self, name):
|
||||||
"""
|
"""
|
||||||
|
@ -32,7 +32,7 @@ log = logger.Logger(__name__, logger.INFO)
|
|||||||
# After each migration you probably should run these commands:
|
# After each migration you probably should run these commands:
|
||||||
# ./test/upgradeDocument public_samples/*.grist
|
# ./test/upgradeDocument public_samples/*.grist
|
||||||
# UPDATE_REGRESSION_DATA=1 GREP_TESTS=DocRegressionTests ./test/testrun.sh server
|
# 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 = {}
|
all_migrations = {}
|
||||||
|
|
||||||
@ -1092,3 +1092,23 @@ def migration32(tdset):
|
|||||||
return tdset.apply_doc_actions([
|
return tdset.apply_doc_actions([
|
||||||
add_column('_grist_Views_section', 'rules', 'RefList:_grist_Tables_column'),
|
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
|
import actions
|
||||||
|
|
||||||
SCHEMA_VERSION = 32
|
SCHEMA_VERSION = 33
|
||||||
|
|
||||||
def make_column(col_id, col_type, formula='', isFormula=False):
|
def make_column(col_id, col_type, formula='', isFormula=False):
|
||||||
return {
|
return {
|
||||||
@ -317,6 +317,21 @@ def schema_create_actions():
|
|||||||
# Ex2: { excluded: ['apple', 'orange'] }
|
# Ex2: { excluded: ['apple', 'orange'] }
|
||||||
make_column("filter", "Text")
|
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-BarcodeQR: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYuNS41SC41VjYuNUg2LjVWLjV6TTYuNSA5LjVILjVWMTUuNUg2LjVWOS41ek0xNS41LjVIOS41VjYuNUgxNS41Vi41ek0xNS41IDE1LjVIOS41TTExLjUgOS41SDE1LjVWMTIuNSIgc3Ryb2tlPSIjMjEyMTIxIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg==');
|
||||||
--icon-BarcodeQR2: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYgN0gxQy40IDcgMCA2LjYgMCA2VjFDMCAuNC40IDAgMSAwSDZDNi42IDAgNyAuNCA3IDFWNkM3IDYuNiA2LjYgNyA2IDd6TTIgNUg1VjJIMlY1ek02IDE2SDFDLjQgMTYgMCAxNS42IDAgMTVWMTBDMCA5LjQuNCA5IDEgOUg2QzYuNiA5IDcgOS40IDcgMTBWMTVDNyAxNS42IDYuNiAxNiA2IDE2ek0yIDE0SDVWMTFIMlYxNHpNMTUgN0gxMEM5LjQgNyA5IDYuNiA5IDZWMUM5IC40IDkuNCAwIDEwIDBIMTVDMTUuNiAwIDE2IC40IDE2IDFWNkMxNiA2LjYgMTUuNiA3IDE1IDd6TTExIDVIMTRWMkgxMVY1eiIgZmlsbD0iIzE2QjM3OCIvPjxwYXRoIGQ9Ik00IDNIM1Y0SDRWM3pNNCAxMkgzVjEzSDRWMTJ6TTE2IDE0SDlWMTZIMTZWMTR6TTE2IDEzSDE0VjExSDExVjlIMTVDMTUuNiA5IDE2IDkuNCAxNiAxMFYxM3oiIGZpbGw9IiMxNkIzNzgiLz48cGF0aCBkPSJNMTMgMTJIMTJWMTNIMTNWMTJ6TTEzIDNIMTJWNEgxM1YzeiIgZmlsbD0iIzE2QjM3OCIvPjwvc3ZnPg==');
|
--icon-BarcodeQR2: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYgN0gxQy40IDcgMCA2LjYgMCA2VjFDMCAuNC40IDAgMSAwSDZDNi42IDAgNyAuNCA3IDFWNkM3IDYuNiA2LjYgNyA2IDd6TTIgNUg1VjJIMlY1ek02IDE2SDFDLjQgMTYgMCAxNS42IDAgMTVWMTBDMCA5LjQuNCA5IDEgOUg2QzYuNiA5IDcgOS40IDcgMTBWMTVDNyAxNS42IDYuNiAxNiA2IDE2ek0yIDE0SDVWMTFIMlYxNHpNMTUgN0gxMEM5LjQgNyA5IDYuNiA5IDZWMUM5IC40IDkuNCAwIDEwIDBIMTVDMTUuNiAwIDE2IC40IDE2IDFWNkMxNiA2LjYgMTUuNiA3IDE1IDd6TTExIDVIMTRWMkgxMVY1eiIgZmlsbD0iIzE2QjM3OCIvPjxwYXRoIGQ9Ik00IDNIM1Y0SDRWM3pNNCAxMkgzVjEzSDRWMTJ6TTE2IDE0SDlWMTZIMTZWMTR6TTE2IDEzSDE0VjExSDExVjlIMTVDMTUuNiA5IDE2IDkuNCAxNiAxMFYxM3oiIGZpbGw9IiMxNkIzNzgiLz48cGF0aCBkPSJNMTMgMTJIMTJWMTNIMTNWMTJ6TTEzIDNIMTJWNEgxM1YzeiIgZmlsbD0iIzE2QjM3OCIvPjwvc3ZnPg==');
|
||||||
--icon-CenterAlign: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTUuNSw4LjUgQzUuMjIzODU3NjMsOC41IDUsOC4yNzYxNDIzNyA1LDggQzUsNy43MjM4NTc2MyA1LjIyMzg1NzYzLDcuNSA1LjUsNy41IEwxMC41LDcuNSBDMTAuNzc2MTQyNCw3LjUgMTEsNy43MjM4NTc2MyAxMSw4IEMxMSw4LjI3NjE0MjM3IDEwLjc3NjE0MjQsOC41IDEwLjUsOC41IEw1LjUsOC41IFogTTIuNSw0IEMyLjIyMzg1NzYzLDQgMiwzLjc3NjE0MjM3IDIsMy41IEMyLDMuMjIzODU3NjMgMi4yMjM4NTc2MywzIDIuNSwzIEwxMy41LDMgQzEzLjc3NjE0MjQsMyAxNCwzLjIyMzg1NzYzIDE0LDMuNSBDMTQsMy43NzYxNDIzNyAxMy43NzYxNDI0LDQgMTMuNSw0IEwyLjUsNCBaIE00LDEzIEMzLjcyMzg1NzYzLDEzIDMuNSwxMi43NzYxNDI0IDMuNSwxMi41IEMzLjUsMTIuMjIzODU3NiAzLjcyMzg1NzYzLDEyIDQsMTIgTDEyLDEyIEMxMi4yNzYxNDI0LDEyIDEyLjUsMTIuMjIzODU3NiAxMi41LDEyLjUgQzEyLjUsMTIuNzc2MTQyNCAxMi4yNzYxNDI0LDEzIDEyLDEzIEw0LDEzIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
--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-Code: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEwLjAzMzMyNzIsMC4zMjA1MTA0NiBDMTAuMTMyNDU2NSwwLjA2Mjc3NDE4OCAxMC40MjE3NTMzLC0wLjA2NTgwMjEzODYgMTAuNjc5NDg5NSwwLjAzMzMyNzE5NjkgQzEwLjkzNzIyNTgsMC4xMzI0NTY1MzIgMTEuMDY1ODAyMSwwLjQyMTc1MzI2NyAxMC45NjY2NzI4LDAuNjc5NDg5NTQgTDUuOTY2NjcyOCwxMy42Nzk0ODk1IEM1Ljg2NzU0MzQ3LDEzLjkzNzIyNTggNS41NzgyNDY3MywxNC4wNjU4MDIxIDUuMzIwNTEwNDYsMTMuOTY2NjcyOCBDNS4wNjI3NzQxOSwxMy44Njc1NDM1IDQuOTM0MTk3ODYsMTMuNTc4MjQ2NyA1LjAzMzMyNzIsMTMuMzIwNTEwNSBMMTAuMDMzMzI3MiwwLjMyMDUxMDQ2IFogTTMuODUzNTUzMzksOS4xNDY0NDY2MSBDNC4wNDg4MTU1NCw5LjM0MTcwODc2IDQuMDQ4ODE1NTQsOS42NTgyOTEyNCAzLjg1MzU1MzM5LDkuODUzNTUzMzkgQzMuNjU4MjkxMjQsMTAuMDQ4ODE1NSAzLjM0MTcwODc2LDEwLjA0ODgxNTUgMy4xNDY0NDY2MSw5Ljg1MzU1MzM5IEwwLjE0NjQ0NjYwOSw2Ljg1MzU1MzM5IEMtMC4wNDg4MTU1MzY1LDYuNjU4MjkxMjQgLTAuMDQ4ODE1NTM2NSw2LjM0MTcwODc2IDAuMTQ2NDQ2NjA5LDYuMTQ2NDQ2NjEgTDMuMTQ2NDQ2NjEsMy4xNDY0NDY2MSBDMy4zNDE3MDg3NiwyLjk1MTE4NDQ2IDMuNjU4MjkxMjQsMi45NTExODQ0NiAzLjg1MzU1MzM5LDMuMTQ2NDQ2NjEgQzQuMDQ4ODE1NTQsMy4zNDE3MDg3NiA0LjA0ODgxNTU0LDMuNjU4MjkxMjQgMy44NTM1NTMzOSwzLjg1MzU1MzM5IEwxLjIwNzEwNjc4LDYuNSBMMy44NTM1NTMzOSw5LjE0NjQ0NjYxIFogTTEyLjE0NjQ0NjYsMy44NTM1NTMzOSBDMTEuOTUxMTg0NSwzLjY1ODI5MTI0IDExLjk1MTE4NDUsMy4zNDE3MDg3NiAxMi4xNDY0NDY2LDMuMTQ2NDQ2NjEgQzEyLjM0MTcwODgsMi45NTExODQ0NiAxMi42NTgyOTEyLDIuOTUxMTg0NDYgMTIuODUzNTUzNCwzLjE0NjQ0NjYxIEwxNS44NTM1NTM0LDYuMTQ2NDQ2NjEgQzE2LjA0ODgxNTUsNi4zNDE3MDg3NiAxNi4wNDg4MTU1LDYuNjU4MjkxMjQgMTUuODUzNTUzNCw2Ljg1MzU1MzM5IEwxMi44NTM1NTM0LDkuODUzNTUzMzkgQzEyLjY1ODI5MTIsMTAuMDQ4ODE1NSAxMi4zNDE3MDg4LDEwLjA0ODgxNTUgMTIuMTQ2NDQ2Niw5Ljg1MzU1MzM5IEMxMS45NTExODQ1LDkuNjU4MjkxMjQgMTEuOTUxMTg0NSw5LjM0MTcwODc2IDEyLjE0NjQ0NjYsOS4xNDY0NDY2MSBMMTQuNzkyODkzMiw2LjUgTDEyLjE0NjQ0NjYsMy44NTM1NTMzOSBaIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIDEpIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
|
||||||
--icon-Collapse: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsOS4xNzQ2MzA1MiBMMTAuOTIxODI3Myw2LjE4OTAyMzIgQzExLjE2ODQ3NDIsNS45MzY5OTIyNyAxMS41NjgzNjc5LDUuOTM2OTkyMjcgMTEuODE1MDE0OCw2LjE4OTAyMzIgQzEyLjA2MTY2MTcsNi40NDEwNTQxMyAxMi4wNjE2NjE3LDYuODQ5Njc3MDEgMTEuODE1MDE0OCw3LjEwMTcwNzk0IEw4LDExIEw0LjE4NDk4NTE5LDcuMTAxNzA3OTQgQzMuOTM4MzM4MjcsNi44NDk2NzcwMSAzLjkzODMzODI3LDYuNDQxMDU0MTMgNC4xODQ5ODUxOSw2LjE4OTAyMzIgQzQuNDMxNjMyMTEsNS45MzY5OTIyNyA0LjgzMTUyNTc4LDUuOTM2OTkyMjcgNS4wNzgxNzI3LDYuMTg5MDIzMiBMOCw5LjE3NDYzMDUyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
--icon-Collapse: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsOS4xNzQ2MzA1MiBMMTAuOTIxODI3Myw2LjE4OTAyMzIgQzExLjE2ODQ3NDIsNS45MzY5OTIyNyAxMS41NjgzNjc5LDUuOTM2OTkyMjcgMTEuODE1MDE0OCw2LjE4OTAyMzIgQzEyLjA2MTY2MTcsNi40NDEwNTQxMyAxMi4wNjE2NjE3LDYuODQ5Njc3MDEgMTEuODE1MDE0OCw3LjEwMTcwNzk0IEw4LDExIEw0LjE4NDk4NTE5LDcuMTAxNzA3OTQgQzMuOTM4MzM4MjcsNi44NDk2NzcwMSAzLjkzODMzODI3LDYuNDQxMDU0MTMgNC4xODQ5ODUxOSw2LjE4OTAyMzIgQzQuNDMxNjMyMTEsNS45MzY5OTIyNyA0LjgzMTUyNTc4LDUuOTM2OTkyMjcgNS4wNzgxNzI3LDYuMTg5MDIzMiBMOCw5LjE3NDYzMDUyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
||||||
--icon-Convert: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuNzU2MDEzNDUsMTIuMjQzOTg2NSBMMiwxNCBMMiw5IEw2LjUsOS41IEw0LjQ2MzE0MTk4LDExLjUzNjg1OCBDNS41MTUxNDg5NCwxMi41ODk5NDY4IDYuOTk5MzIzODMsMTMuMTMzMjk5OCA4LjUxNTU5MDEzLDEyLjk3NjIzNTIgQzEwLjM3OTM1LDEyLjc4MzE3NDggMTEuOTc4NzQxMSwxMS41NjQyNzc0IDEyLjY1OTEyODYsOS44MTg0NDA2IEMxMi43NTk0MDExLDkuNTYxMTQ2OTIgMTMuMDQ5MjY1Nyw5LjQzMzg1NjA5IDEzLjMwNjU1OTQsOS41MzQxMjg1OCBDMTMuNTYzODUzMSw5LjYzNDQwMTA2IDEzLjY5MTE0MzksOS45MjQyNjU3MiAxMy41OTA4NzE0LDEwLjE4MTU1OTQgQzEyLjc3NDQwNjQsMTIuMjc2NTYzNiAxMC44NTUxMzcyLDEzLjczOTI0MDUgOC42MTg2MjUyOSwxMy45NzA5MTI5IEM2Ljc5OTI2MjMyLDE0LjE1OTM3NDMgNS4wMTg0MDI0MSwxMy41MDc0NzkxIDMuNzU2MDEzNDUsMTIuMjQzOTg2NSBaIE0xMi4yNDM5ODY1LDMuNzU2MDEzNDUgTDE0LDIgTDE0LDcgTDkuNSw2LjUgTDExLjUzNjg1OCw0LjQ2MzE0MTk4IEMxMC40ODQ4NTExLDMuNDEwMDUzMTggOS4wMDA2NzYxNywyLjg2NjcwMDE1IDcuNDg0NDA5ODcsMy4wMjM3NjQ4MyBDNS42MjA2NDk5NiwzLjIxNjgyNTE2IDQuMDIxMjU4OSw0LjQzNTcyMjU5IDMuMzQwODcxNDIsNi4xODE1NTk0IEMzLjI0MDU5ODk0LDYuNDM4ODUzMDggMi45NTA3MzQyOCw2LjU2NjE0MzkxIDIuNjkzNDQwNiw2LjQ2NTg3MTQyIEMyLjQzNjE0NjkyLDYuMzY1NTk4OTQgMi4zMDg4NTYwOSw2LjA3NTczNDI4IDIuNDA5MTI4NTgsNS44MTg0NDA2IEMzLjIyNTU5MzU1LDMuNzIzNDM2NDMgNS4xNDQ4NjI4MiwyLjI2MDc1OTUxIDcuMzgxMzc0NzEsMi4wMjkwODcxMiBDOS4yMDA3Mzc2OCwxLjg0MDYyNTcyIDEwLjk4MTU5NzYsMi40OTI1MjA5MiAxMi4yNDM5ODY1LDMuNzU2MDEzNDUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
|
--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-Lock: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIj48cGF0aCBkPSJNMTIgNkwxMCA2IDEwIDRDMTAgMi44OTU0MzA1IDkuMTA0NTY5NSAyIDggMiA2Ljg5NTQzMDUgMiA2IDIuODk1NDMwNSA2IDRMNiA2IDQgNiA0IDRDNCAxLjc5MDg2MSA1Ljc5MDg2MSAwIDggMCAxMC4yMDkxMzkgMCAxMiAxLjc5MDg2MSAxMiA0TDEyIDZ6TTE0IDdMMiA3QzEuNDQ3NzE1MjUgNyAxIDcuNDQ3NzE1MjUgMSA4TDEgMTVDMSAxNS41NTIyODQ3IDEuNDQ3NzE1MjUgMTYgMiAxNkwxNCAxNkMxNC41NTIyODQ3IDE2IDE1IDE1LjU1MjI4NDcgMTUgMTVMMTUgOEMxNSA3LjQ0NzcxNTI1IDE0LjU1MjI4NDcgNyAxNCA3ek04IDEzQzYuODk1NDMwNSAxMyA2IDEyLjEwNDU2OTUgNiAxMSA2IDkuODk1NDMwNSA2Ljg5NTQzMDUgOSA4IDkgOS4xMDQ1Njk1IDkgMTAgOS44OTU0MzA1IDEwIDExIDEwIDEyLjEwNDU2OTUgOS4xMDQ1Njk1IDEzIDggMTN6Ii8+PC9nPjwvc3ZnPg==');
|
||||||
--icon-Log: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuNSw4LjUgTDYuNSw4LjUgQzYuMjIzODU3NjMsOC41IDYsOC4yNzYxNDIzNyA2LDggQzYsNy43MjM4NTc2MyA2LjIyMzg1NzYzLDcuNSA2LjUsNy41IEw3LjUsNy41IEw3LjUsMC41IEM3LjUsMC4yMjM4NTc2MjUgNy43MjM4NTc2MywwIDgsMCBDOC4yNzYxNDIzNywwIDguNSwwLjIyMzg1NzYyNSA4LjUsMC41IEw4LjUsMyBMOS41LDMgQzkuNzc2MTQyMzcsMyAxMCwzLjIyMzg1NzYzIDEwLDMuNSBDMTAsMy43NzYxNDIzNyA5Ljc3NjE0MjM3LDQgOS41LDQgTDguNSw0IEw4LjUsMTIgTDkuNSwxMiBDOS43NzYxNDIzNywxMiAxMCwxMi4yMjM4NTc2IDEwLDEyLjUgQzEwLDEyLjc3NjE0MjQgOS43NzYxNDIzNywxMyA5LjUsMTMgTDguNSwxMyBMOC41LDE1LjUgQzguNSwxNS43NzYxNDI0IDguMjc2MTQyMzcsMTYgOCwxNiBDNy43MjM4NTc2MywxNiA3LjUsMTUuNzc2MTQyNCA3LjUsMTUuNSBMNy41LDguNSBaIE0xMiwyIEwxMiw1IEwxNSw1IEwxNSwyIEwxMiwyIFogTTExLjUsMSBMMTUuNSwxIEMxNS43NzYxNDI0LDEgMTYsMS4yMjM4NTc2MyAxNiwxLjUgTDE2LDUuNSBDMTYsNS43NzYxNDIzNyAxNS43NzYxNDI0LDYgMTUuNSw2IEwxMS41LDYgQzExLjIyMzg1NzYsNiAxMSw1Ljc3NjE0MjM3IDExLDUuNSBMMTEsMS41IEMxMSwxLjIyMzg1NzYzIDExLjIyMzg1NzYsMSAxMS41LDEgWiBNMTIsMTEgTDEyLDE0IEwxNSwxNCBMMTUsMTEgTDEyLDExIFogTTExLjUsMTAgTDE1LjUsMTAgQzE1Ljc3NjE0MjQsMTAgMTYsMTAuMjIzODU3NiAxNiwxMC41IEwxNiwxNC41IEMxNiwxNC43NzYxNDI0IDE1Ljc3NjE0MjQsMTUgMTUuNSwxNSBMMTEuNSwxNSBDMTEuMjIzODU3NiwxNSAxMSwxNC43NzYxNDI0IDExLDE0LjUgTDExLDEwLjUgQzExLDEwLjIyMzg1NzYgMTEuMjIzODU3NiwxMCAxMS41LDEwIFogTTQsOS41IEw0LDYuNSBMMSw2LjUgTDEsOS41IEw0LDkuNSBaIE00LjUsMTAuNSBMMC41LDEwLjUgQzAuMjIzODU3NjI1LDEwLjUgMCwxMC4yNzYxNDI0IDAsMTAgTDguODgxNzg0MmUtMTYsNiBDOC44ODE3ODQyZS0xNiw1LjcyMzg1NzYzIDAuMjIzODU3NjI1LDUuNSAwLjUsNS41IEw0LjUsNS41IEM0Ljc3NjE0MjM3LDUuNSA1LDUuNzIzODU3NjMgNSw2IEw1LDEwIEM1LDEwLjI3NjE0MjQgNC43NzYxNDIzNywxMC41IDQuNSwxMC41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
--icon-Log: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuNSw4LjUgTDYuNSw4LjUgQzYuMjIzODU3NjMsOC41IDYsOC4yNzYxNDIzNyA2LDggQzYsNy43MjM4NTc2MyA2LjIyMzg1NzYzLDcuNSA2LjUsNy41IEw3LjUsNy41IEw3LjUsMC41IEM3LjUsMC4yMjM4NTc2MjUgNy43MjM4NTc2MywwIDgsMCBDOC4yNzYxNDIzNywwIDguNSwwLjIyMzg1NzYyNSA4LjUsMC41IEw4LjUsMyBMOS41LDMgQzkuNzc2MTQyMzcsMyAxMCwzLjIyMzg1NzYzIDEwLDMuNSBDMTAsMy43NzYxNDIzNyA5Ljc3NjE0MjM3LDQgOS41LDQgTDguNSw0IEw4LjUsMTIgTDkuNSwxMiBDOS43NzYxNDIzNywxMiAxMCwxMi4yMjM4NTc2IDEwLDEyLjUgQzEwLDEyLjc3NjE0MjQgOS43NzYxNDIzNywxMyA5LjUsMTMgTDguNSwxMyBMOC41LDE1LjUgQzguNSwxNS43NzYxNDI0IDguMjc2MTQyMzcsMTYgOCwxNiBDNy43MjM4NTc2MywxNiA3LjUsMTUuNzc2MTQyNCA3LjUsMTUuNSBMNy41LDguNSBaIE0xMiwyIEwxMiw1IEwxNSw1IEwxNSwyIEwxMiwyIFogTTExLjUsMSBMMTUuNSwxIEMxNS43NzYxNDI0LDEgMTYsMS4yMjM4NTc2MyAxNiwxLjUgTDE2LDUuNSBDMTYsNS43NzYxNDIzNyAxNS43NzYxNDI0LDYgMTUuNSw2IEwxMS41LDYgQzExLjIyMzg1NzYsNiAxMSw1Ljc3NjE0MjM3IDExLDUuNSBMMTEsMS41IEMxMSwxLjIyMzg1NzYzIDExLjIyMzg1NzYsMSAxMS41LDEgWiBNMTIsMTEgTDEyLDE0IEwxNSwxNCBMMTUsMTEgTDEyLDExIFogTTExLjUsMTAgTDE1LjUsMTAgQzE1Ljc3NjE0MjQsMTAgMTYsMTAuMjIzODU3NiAxNiwxMC41IEwxNiwxNC41IEMxNiwxNC43NzYxNDI0IDE1Ljc3NjE0MjQsMTUgMTUuNSwxNSBMMTEuNSwxNSBDMTEuMjIzODU3NiwxNSAxMSwxNC43NzYxNDI0IDExLDE0LjUgTDExLDEwLjUgQzExLDEwLjIyMzg1NzYgMTEuMjIzODU3NiwxMCAxMS41LDEwIFogTTQsOS41IEw0LDYuNSBMMSw2LjUgTDEsOS41IEw0LDkuNSBaIE00LjUsMTAuNSBMMC41LDEwLjUgQzAuMjIzODU3NjI1LDEwLjUgMCwxMC4yNzYxNDI0IDAsMTAgTDguODgxNzg0MmUtMTYsNiBDOC44ODE3ODQyZS0xNiw1LjcyMzg1NzYzIDAuMjIzODU3NjI1LDUuNSAwLjUsNS41IEw0LjUsNS41IEM0Ljc3NjE0MjM3LDUuNSA1LDUuNzIzODU3NjMgNSw2IEw1LDEwIEM1LDEwLjI3NjE0MjQgNC43NzYxNDIzNywxMC41IDQuNSwxMC41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
||||||
--icon-Mail: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE0LjUsMTMgQzE0Ljc3NTg1NzYsMTMgMTUsMTIuNzc1ODU3NiAxNSwxMi41IEwxNSwxLjUgQzE1LDEuMjI0MTQyMzcgMTQuNzc1ODU3NiwxIDE0LjUsMSBMMS41LDEgQzEuMjI0MTQyMzcsMSAxLDEuMjI0MTQyMzcgMSwxLjUgTDEsMTIuNSBDMSwxMi43NzU4NTc2IDEuMjI0MTQyMzcsMTMgMS41LDEzIEwxNC41LDEzIFogTTE0LjUsMTQgTDEuNSwxNCBDMC42NzE4NTc2MjUsMTQgMCwxMy4zMjgxNDI0IDAsMTIuNSBMMCwxLjUgQzAsMC42NzE4NTc2MjUgMC42NzE4NTc2MjUsOC44ODE3ODQyZS0xNiAxLjUsOC44ODE3ODQyZS0xNiBMMTQuNSw4Ljg4MTc4NDJlLTE2IEMxNS4zMjgxNDI0LDguODgxNzg0MmUtMTYgMTYsMC42NzE4NTc2MjUgMTYsMS41IEwxNiwxMi41IEMxNiwxMy4zMjgxNDI0IDE1LjMyODE0MjQsMTQgMTQuNSwxNCBaIE0xMy4xODMzODExLDMuMTEzMDIxMzUgQzEzLjM5NzEwMzUsMi45MzgxNTc1NiAxMy43MTIxMTQ5LDIuOTY5NjU4NyAxMy44ODY5Nzg2LDMuMTgzMzgxMSBDMTQuMDYxODQyNCwzLjM5NzEwMzUxIDE0LjAzMDM0MTMsMy43MTIxMTQ4NiAxMy44MTY2MTg5LDMuODg2OTc4NjUgTDguMzE2NjE4OSw4LjM4Njk3ODY1IEM4LjEzMjQzNTk1LDguNTM3NjczNzggNy44Njc1NjQwNSw4LjUzNzY3Mzc4IDcuNjgzMzgxMSw4LjM4Njk3ODY1IEwyLjE4MzM4MTEsMy44ODY5Nzg2NSBDMS45Njk2NTg3LDMuNzEyMTE0ODYgMS45MzgxNTc1NiwzLjM5NzEwMzUxIDIuMTEzMDIxMzUsMy4xODMzODExIEMyLjI4Nzg4NTE0LDIuOTY5NjU4NyAyLjYwMjg5NjQ5LDIuOTM4MTU3NTYgMi44MTY2MTg5LDMuMTEzMDIxMzUgTDgsNy4zNTM5Njk1MyBMMTMuMTgzMzgxMSwzLjExMzAyMTM1IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
--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-Minus: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB4PSIyIiB5PSI3LjUiIHdpZHRoPSIxMiIgaGVpZ2h0PSIxIiByeD0iLjUiLz48L3N2Zz4=');
|
||||||
--icon-MobileChat: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjUgOC41VjE0LjVDMTEuNSAxNC43NjUyIDExLjM5NDYgMTUuMDE5NiAxMS4yMDcxIDE1LjIwNzFDMTEuMDE5NiAxNS4zOTQ2IDEwLjc2NTIgMTUuNSAxMC41IDE1LjVIMi41QzIuMjM0NzggMTUuNSAxLjk4MDQzIDE1LjM5NDYgMS43OTI4OSAxNS4yMDcxQzEuNjA1MzYgMTUuMDE5NiAxLjUgMTQuNzY1MiAxLjUgMTQuNVYyLjVDMS41IDIuMjM0NzggMS42MDUzNiAxLjk4MDQzIDEuNzkyODkgMS43OTI4OUMxLjk4MDQzIDEuNjA1MzYgMi4yMzQ3OCAxLjUgMi41IDEuNUg0LjUiIHN0cm9rZT0iIzI2MjYzMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTE0LjUgNi41SDkuNUw2LjUgOC41VjEuNUM2LjUgMS4yMzQ3OCA2LjYwNTM2IDAuOTgwNDMgNi43OTI4OSAwLjc5Mjg5M0M2Ljk4MDQzIDAuNjA1MzU3IDcuMjM0NzggMC41IDcuNSAwLjVIMTQuNUMxNC43NjUyIDAuNSAxNS4wMTk2IDAuNjA1MzU3IDE1LjIwNzEgMC43OTI4OTNDMTUuMzk0NiAwLjk4MDQzIDE1LjUgMS4yMzQ3OCAxNS41IDEuNVY1LjVDMTUuNSA1Ljc2NTIyIDE1LjM5NDYgNi4wMTk1NyAxNS4yMDcxIDYuMjA3MTFDMTUuMDE5NiA2LjM5NDY0IDE0Ljc2NTIgNi41IDE0LjUgNi41WiIgc3Ryb2tlPSIjMjYyNjMzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=');
|
--icon-MobileChat: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjUgOC41VjE0LjVDMTEuNSAxNC43NjUyIDExLjM5NDYgMTUuMDE5NiAxMS4yMDcxIDE1LjIwNzFDMTEuMDE5NiAxNS4zOTQ2IDEwLjc2NTIgMTUuNSAxMC41IDE1LjVIMi41QzIuMjM0NzggMTUuNSAxLjk4MDQzIDE1LjM5NDYgMS43OTI4OSAxNS4yMDcxQzEuNjA1MzYgMTUuMDE5NiAxLjUgMTQuNzY1MiAxLjUgMTQuNVYyLjVDMS41IDIuMjM0NzggMS42MDUzNiAxLjk4MDQzIDEuNzkyODkgMS43OTI4OUMxLjk4MDQzIDEuNjA1MzYgMi4yMzQ3OCAxLjUgMi41IDEuNUg0LjUiIHN0cm9rZT0iIzI2MjYzMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTE0LjUgNi41SDkuNUw2LjUgOC41VjEuNUM2LjUgMS4yMzQ3OCA2LjYwNTM2IDAuOTgwNDMgNi43OTI4OSAwLjc5Mjg5M0M2Ljk4MDQzIDAuNjA1MzU3IDcuMjM0NzggMC41IDcuNSAwLjVIMTQuNUMxNC43NjUyIDAuNSAxNS4wMTk2IDAuNjA1MzU3IDE1LjIwNzEgMC43OTI4OTNDMTUuMzk0NiAwLjk4MDQzIDE1LjUgMS4yMzQ3OCAxNS41IDEuNVY1LjVDMTUuNSA1Ljc2NTIyIDE1LjM5NDYgNi4wMTk1NyAxNS4yMDcxIDYuMjA3MTFDMTUuMDE5NiA2LjM5NDY0IDE0Ljc2NTIgNi41IDE0LjUgNi41WiIgc3Ryb2tlPSIjMjYyNjMzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=');
|
||||||
--icon-MobileChat2: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTggMEgxNUMxNS4yNjUyIDAgMTUuNTE5NiAwLjEwNTM1NyAxNS43MDcxIDAuMjkyODkzQzE1Ljg5NDYgMC40ODA0MyAxNiAwLjczNDc4NCAxNiAxVjVDMTYgNS4yNjUyMiAxNS44OTQ2IDUuNTE5NTcgMTUuNzA3MSA1LjcwNzExQzE1LjUxOTYgNS44OTQ2NCAxNS4yNjUyIDYgMTUgNkgxMEw3IDhWMUM3IDAuNzM0Nzg0IDcuMTA1MzYgMC40ODA0MyA3LjI5Mjg5IDAuMjkyODkzQzcuNDgwNDMgMC4xMDUzNTcgNy43MzQ3OCAwIDggMFYwWiIgZmlsbD0iIzE2QjM3OCIvPjxwYXRoIGQ9Ik0xMCA3VjEzSDNWNEg2VjFIM0MyLjQ2OTU3IDEgMS45NjA4NiAxLjIxMDcxIDEuNTg1NzkgMS41ODU3OUMxLjIxMDcxIDEuOTYwODYgMSAyLjQ2OTU3IDEgM1YxNEMxIDE0LjUzMDQgMS4yMTA3MSAxNS4wMzkxIDEuNTg1NzkgMTUuNDE0MkMxLjk2MDg2IDE1Ljc4OTMgMi40Njk1NyAxNiAzIDE2SDEwQzEwLjUzMDQgMTYgMTEuMDM5MSAxNS43ODkzIDExLjQxNDIgMTUuNDE0MkMxMS43ODkzIDE1LjAzOTEgMTIgMTQuNTMwNCAxMiAxNFY3SDEwWiIgZmlsbD0iIzE2QjM3OCIvPjwvc3ZnPg==');
|
--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-Pivot: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMsMSBMMTMsMSBDMTQuMTA0NTY5NSwxIDE1LDEuODk1NDMwNSAxNSwzIEwxNSwxMyBDMTUsMTQuMTA0NTY5NSAxNC4xMDQ1Njk1LDE1IDEzLDE1IEwzLDE1IEMxLjg5NTQzMDUsMTUgMSwxNC4xMDQ1Njk1IDEsMTMgTDEsMyBDMSwxLjg5NTQzMDUgMS44OTU0MzA1LDEgMywxIFogTTUuMzc1LDQuNSBMNS4zNzUsNC43ODU0NjM0MSBMNy42MzQ2NTAxLDguMDIyNzA3MzIgTDUuMzc1LDExLjA5NDUxMjIgTDUuMzc1LDExLjUgTDEwLjYyNSwxMS41IEwxMC42MjUsMTAuMDMyODE3MSBMOS44MjY0OTkwMSwxMC43MjY0MTQ2IEw2LjkxOTczMTYxLDEwLjcyNjQxNDYgTDYuOTE5NzMxNjEsMTAuNDE2OTYzNCBMOC45MDI4MzMsNy42MTU4NTM2NiBMNi45MTk3MzE2MSw0Ljk5MDg1MzY2IEw5Ljc3NDMxMjEzLDQuOTg3MzUzNjYgTDEwLjYyNSw2LjA5NTMxNzA3IEwxMC42MjUsNC41IEw1LjM3NSw0LjUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
|
||||||
--icon-PivotLight: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cmVjdCBzdHJva2U9IiM5Nzk3OTciIHg9Ii41IiB5PSIuNSIgd2lkdGg9IjE1IiBoZWlnaHQ9IjE1IiByeD0iMiIvPjxwYXRoIGZpbGw9IiM5Nzk3OTciIGQ9Ik01LjM3NSA0LjVMMTAuNjI1IDQuNSAxMC42MjUgNi4wOTUzMTcwNyA5Ljc3NDMxMjEzIDQuOTg3MzUzNjYgNi45MTk3MzE2MSA0Ljk5MDg1MzY2IDguOTAyODMzIDcuNjE1ODUzNjYgNi45MTk3MzE2MSAxMC40MTY5NjM0IDYuOTE5NzMxNjEgMTAuNzI2NDE0NiA5LjgyNjQ5OTAxIDEwLjcyNjQxNDYgMTAuNjI1IDEwLjAzMjgxNzEgMTAuNjI1IDExLjUgNS4zNzUgMTEuNSA1LjM3NSAxMS4wOTQ1MTIyIDcuNjM0NjUwMSA4LjAyMjcwNzMyIDUuMzc1IDQuNzg1NDYzNDF6Ii8+PC9nPjwvc3ZnPg==');
|
--icon-PivotLight: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cmVjdCBzdHJva2U9IiM5Nzk3OTciIHg9Ii41IiB5PSIuNSIgd2lkdGg9IjE1IiBoZWlnaHQ9IjE1IiByeD0iMiIvPjxwYXRoIGZpbGw9IiM5Nzk3OTciIGQ9Ik01LjM3NSA0LjVMMTAuNjI1IDQuNSAxMC42MjUgNi4wOTUzMTcwNyA5Ljc3NDMxMjEzIDQuOTg3MzUzNjYgNi45MTk3MzE2MSA0Ljk5MDg1MzY2IDguOTAyODMzIDcuNjE1ODUzNjYgNi45MTk3MzE2MSAxMC40MTY5NjM0IDYuOTE5NzMxNjEgMTAuNzI2NDE0NiA5LjgyNjQ5OTAxIDEwLjcyNjQxNDYgMTAuNjI1IDEwLjAzMjgxNzEgMTAuNjI1IDExLjUgNS4zNzUgMTEuNSA1LjM3NSAxMS4wOTQ1MTIyIDcuNjM0NjUwMSA4LjAyMjcwNzMyIDUuMzc1IDQuNzg1NDYzNDF6Ii8+PC9nPjwvc3ZnPg==');
|
||||||
--icon-Plus: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTguNSw3LjUgTDEzLDcuNSBDMTMuMjc2MTQyNCw3LjUgMTMuNSw3LjcyMzg1NzYzIDEzLjUsOCBDMTMuNSw4LjI3NjE0MjM3IDEzLjI3NjE0MjQsOC41IDEzLDguNSBMOC41LDguNSBMOC41LDEzIEM4LjUsMTMuMjc2MTQyNCA4LjI3NjE0MjM3LDEzLjUgOCwxMy41IEM3LjcyMzg1NzYzLDEzLjUgNy41LDEzLjI3NjE0MjQgNy41LDEzIEw3LjUsOC41IEwzLDguNSBDMi43MjM4NTc2Myw4LjUgMi41LDguMjc2MTQyMzcgMi41LDggQzIuNSw3LjcyMzg1NzYzIDIuNzIzODU3NjMsNy41IDMsNy41IEw3LjUsNy41IEw3LjUsMyBDNy41LDIuNzIzODU3NjMgNy43MjM4NTc2MywyLjUgOCwyLjUgQzguMjc2MTQyMzcsMi41IDguNSwyLjcyMzg1NzYzIDguNSwzIEw4LjUsNy41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
|
--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-Public: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMCAwSDE2VjE2SDB6Ii8+PGcgc3Ryb2tlPSIjMTZCMzc4IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik05Ljc3NSAxMS4yOTNMNy4zMTIgMTAuNTkzQzcuMTIyMjE2NjEgMTAuNTM4OTUxMSA2Ljk4MTk3MzAyIDEwLjM3ODMzNyA2Ljk1NCAxMC4xODNMNi43NDcgOC43MjZDNy44MTQxMjAyNiA4LjIzODMwNjE1IDguNDk5MDAyMDkgNy4xNzMyODE3IDguNSA2TDguNSA0LjYyNkM4LjUyNTE5NzU2IDIuOTczMDAzMjUgNy4yNDI1MDU0NyAxLjU5NDE1MzM3IDUuNTkyIDEuNSA0Ljc4MDUzMjcgMS40NzUxMDMyIDMuOTkzNjIwMiAxLjc4MDE0ODI3IDMuNDEwOTUzMjMgMi4zNDU0Nzg0NiAyLjgyODI4NjI1IDIuOTEwODA4NjQgMi40OTk2MTgxNiAzLjY4ODE1MDk1IDIuNSA0LjVMMi41IDZDMi41MDA5OTc5MSA3LjE3MzI4MTcgMy4xODU4Nzk3NCA4LjIzODMwNjE1IDQuMjUzIDguNzI2TDQuMDQ2IDEwLjE3OUM0LjAxODAyNjk4IDEwLjM3NDMzNyAzLjg3Nzc4MzM5IDEwLjUzNDk1MTEgMy42ODggMTAuNTg5TDEuMjI1IDExLjI4OUMuNzk1OTk1Njg4IDExLjQxMTcwNzIuNTAwMTk4MjMgMTEuODAzNzkxOC41IDEyLjI1TC41IDE0LjUgMTAuNSAxNC41IDEwLjUgMTIuMjU0QzEwLjQ5OTgwMTggMTEuODA3NzkxOCAxMC4yMDQwMDQzIDExLjQxNTcwNzIgOS43NzUgMTEuMjkzek0xMi41IDE0LjVMMTUuNSAxNC41IDE1LjUgMTEuMjgxQzE1LjQ5OTk4NzkgMTAuODIyMzIwNiAxNS4xODc5MzExIDEwLjQyMjQ1OTEgMTQuNzQzIDEwLjMxMUwxMS44MjYgOS41ODJDMTEuNjI4NDcxIDkuNTMyNzA4NDEgMTEuNDgwNTUyNCA5LjM2ODU3NDEgMTEuNDUyIDkuMTY3TDExLjI0NyA3LjcyNkMxMi4zMTQxMjAzIDcuMjM4MzA2MTUgMTIuOTk5MDAyMSA2LjE3MzI4MTcgMTMgNUwxMyAzLjYyNkMxMy4wMjUxOTc2IDEuOTczMDAzMjUgMTEuNzQyNTA1NS41OTQxNTMzNjUgMTAuMDkyLjUgOS41MzQ0NjYxNC40ODI3MzcyNzIgOC45ODMxNjAzOS42MjEyNTYzMDYgOC41LjkiLz48L2c+PC9nPjwvc3ZnPg==');
|
||||||
--icon-PublicColor: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTQiIHdpZHRoPSIxNiI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMCAwSDE2VjE2SDB6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIC0xKSIvPjxnIG9wYWNpdHk9Ii43ODMiPjxwYXRoIGQ9Ik0gMTUuMjc1LDEwLjI5MyAxMy4wODcsOS42NjggQyAxMi43MjY0NzgsOS41NjQ5MTc4IDEyLjQ1MzkyLDkuMjY4ODEwNSAxMi4zODEsOC45MDEgTCAxMi4yNDcsOC4yMjYgQyAxMy4zMTQxMiw3LjczODMwNjEgMTMuOTk5MDAyLDYuNjczMjgxNyAxNCw1LjUgViA0LjEyNiBDIDE0LjAyNTE5OCwyLjQ3MzAwMzMgMTIuNzQyNTA2LDEuMDk0MTUzNCAxMS4wOTIsMSA5Ljg3OTM3MDksMC45NjM0MTA4MiA4Ljc2NDA2NTUsMS42NjA3NzI3IDguMjY2LDIuNzY3IDguNzQzMDQ3OCwzLjQ2MTI3ODUgOC45OTg5MTk4LDQuMjgzNjI0NyA5LDUuMTI2IFYgNi41IEMgOC45OTgyMDc3LDYuODYzNzcwNiA4Ljk0NjA0NTYsNy4yMjU1NDA0IDguODQ1LDcuNTc1IDkuMTA0MjMyNSw3Ljg0Njk0MjMgOS40MTIyMzk1LDguMDY3NzcxMSA5Ljc1Myw4LjIyNiBMIDkuNjE5LDguOSBDIDkuNTQ2MDc5Niw5LjI2NzgxMDUgOS4yNzM1MjE5LDkuNTYzOTE3OCA4LjkxMyw5LjY2NyBMIDguMDcsOS45MDggOS41NSwxMC4zMzEgYyAwLjg1Njk2NCwwLjI0NzU1NCAxLjQ0NzcwNiwxLjAzMDk5OSAxLjQ1LDEuOTIzIFYgMTQuNSBjIC0wLjAwMTcsMC4xNzA3MiAtMC4wMzI3OCwwLjMzOTg3MSAtMC4wOTIsMC41IEggMTUuNSBjIDAuMjc2MTQyLDAgMC41LC0wLjIyMzg1OCAwLjUsLTAuNSB2IC0zLjI0NiBjIC0xLjk4ZS00LC0wLjQ0NjIwOCAtMC4yOTU5OTYsLTAuODM4MjkzIC0wLjcyNSwtMC45NjEgeiIgZmlsbD0iI2U2YTExNyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48cGF0aCBkPSJNIDkuMjc1LDExLjI5MyA3LjA4NywxMC42NjggQyA2LjcyNjIwMjMsMTAuNTY0NzU0IDYuNDUzNTgzLDEwLjI2ODE5NCA2LjM4MSw5LjkgTCA2LjI0Nyw5LjIyNSBDIDcuMzEzNzkxNCw4LjczNzQ1NjkgNy45OTg2MTEyLDcuNjcyOTE5NSA4LDYuNSBWIDUuMTI2IEMgOC4wMjUxOTc2LDMuNDczMDAzMyA2Ljc0MjUwNTUsMi4wOTQxNTM0IDUuMDkyLDIgNC4yODA1MzI3LDEuOTc1MTAzMiAzLjQ5MzYyMDIsMi4yODAxNDgzIDIuOTEwOTUzMiwyLjg0NTQ3ODUgMi4zMjgyODYzLDMuNDEwODA4NiAxLjk5OTYxODIsNC4xODgxNTA5IDIsNSB2IDEuNSBjIDkuOTc5ZS00LDEuMTczMjgxNyAwLjY4NTg3OTcsMi4yMzgzMDYyIDEuNzUzLDIuNzI2IEwgMy42MTksOS45IEMgMy41NDYwOCwxMC4yNjc4MTEgMy4yNzM1MjE5LDEwLjU2MzkxOCAyLjkxMywxMC42NjcgTCAwLjcyNSwxMS4yOTIgQyAwLjI5NTYzOTI2LDExLjQxNDgwOSAtMi40ODE3NzE2ZS00LDExLjgwNzQyMSAwLDEyLjI1NCBWIDE0LjUgQyAwLDE0Ljc3NjE0MiAwLjIyMzg1NzYzLDE1IDAuNSwxNSBoIDkgQyA5Ljc3NjE0MjQsMTUgMTAsMTQuNzc2MTQyIDEwLDE0LjUgViAxMi4yNTQgQyA5Ljk5OTgwMTgsMTEuODA3NzkyIDkuNzA0MDA0MywxMS40MTU3MDcgOS4yNzUsMTEuMjkzIFoiIGZpbGw9IiNmZmYiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAgLTEpIi8+PC9nPjwvZz48L3N2Zz4=');
|
--icon-PublicColor: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTQiIHdpZHRoPSIxNiI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMCAwSDE2VjE2SDB6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIC0xKSIvPjxnIG9wYWNpdHk9Ii43ODMiPjxwYXRoIGQ9Ik0gMTUuMjc1LDEwLjI5MyAxMy4wODcsOS42NjggQyAxMi43MjY0NzgsOS41NjQ5MTc4IDEyLjQ1MzkyLDkuMjY4ODEwNSAxMi4zODEsOC45MDEgTCAxMi4yNDcsOC4yMjYgQyAxMy4zMTQxMiw3LjczODMwNjEgMTMuOTk5MDAyLDYuNjczMjgxNyAxNCw1LjUgViA0LjEyNiBDIDE0LjAyNTE5OCwyLjQ3MzAwMzMgMTIuNzQyNTA2LDEuMDk0MTUzNCAxMS4wOTIsMSA5Ljg3OTM3MDksMC45NjM0MTA4MiA4Ljc2NDA2NTUsMS42NjA3NzI3IDguMjY2LDIuNzY3IDguNzQzMDQ3OCwzLjQ2MTI3ODUgOC45OTg5MTk4LDQuMjgzNjI0NyA5LDUuMTI2IFYgNi41IEMgOC45OTgyMDc3LDYuODYzNzcwNiA4Ljk0NjA0NTYsNy4yMjU1NDA0IDguODQ1LDcuNTc1IDkuMTA0MjMyNSw3Ljg0Njk0MjMgOS40MTIyMzk1LDguMDY3NzcxMSA5Ljc1Myw4LjIyNiBMIDkuNjE5LDguOSBDIDkuNTQ2MDc5Niw5LjI2NzgxMDUgOS4yNzM1MjE5LDkuNTYzOTE3OCA4LjkxMyw5LjY2NyBMIDguMDcsOS45MDggOS41NSwxMC4zMzEgYyAwLjg1Njk2NCwwLjI0NzU1NCAxLjQ0NzcwNiwxLjAzMDk5OSAxLjQ1LDEuOTIzIFYgMTQuNSBjIC0wLjAwMTcsMC4xNzA3MiAtMC4wMzI3OCwwLjMzOTg3MSAtMC4wOTIsMC41IEggMTUuNSBjIDAuMjc2MTQyLDAgMC41LC0wLjIyMzg1OCAwLjUsLTAuNSB2IC0zLjI0NiBjIC0xLjk4ZS00LC0wLjQ0NjIwOCAtMC4yOTU5OTYsLTAuODM4MjkzIC0wLjcyNSwtMC45NjEgeiIgZmlsbD0iI2U2YTExNyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48cGF0aCBkPSJNIDkuMjc1LDExLjI5MyA3LjA4NywxMC42NjggQyA2LjcyNjIwMjMsMTAuNTY0NzU0IDYuNDUzNTgzLDEwLjI2ODE5NCA2LjM4MSw5LjkgTCA2LjI0Nyw5LjIyNSBDIDcuMzEzNzkxNCw4LjczNzQ1NjkgNy45OTg2MTEyLDcuNjcyOTE5NSA4LDYuNSBWIDUuMTI2IEMgOC4wMjUxOTc2LDMuNDczMDAzMyA2Ljc0MjUwNTUsMi4wOTQxNTM0IDUuMDkyLDIgNC4yODA1MzI3LDEuOTc1MTAzMiAzLjQ5MzYyMDIsMi4yODAxNDgzIDIuOTEwOTUzMiwyLjg0NTQ3ODUgMi4zMjgyODYzLDMuNDEwODA4NiAxLjk5OTYxODIsNC4xODgxNTA5IDIsNSB2IDEuNSBjIDkuOTc5ZS00LDEuMTczMjgxNyAwLjY4NTg3OTcsMi4yMzgzMDYyIDEuNzUzLDIuNzI2IEwgMy42MTksOS45IEMgMy41NDYwOCwxMC4yNjc4MTEgMy4yNzM1MjE5LDEwLjU2MzkxOCAyLjkxMywxMC42NjcgTCAwLjcyNSwxMS4yOTIgQyAwLjI5NTYzOTI2LDExLjQxNDgwOSAtMi40ODE3NzE2ZS00LDExLjgwNzQyMSAwLDEyLjI1NCBWIDE0LjUgQyAwLDE0Ljc3NjE0MiAwLjIyMzg1NzYzLDE1IDAuNSwxNSBoIDkgQyA5Ljc3NjE0MjQsMTUgMTAsMTQuNzc2MTQyIDEwLDE0LjUgViAxMi4yNTQgQyA5Ljk5OTgwMTgsMTEuODA3NzkyIDkuNzA0MDA0MywxMS40MTU3MDcgOS4yNzUsMTEuMjkzIFoiIGZpbGw9IiNmZmYiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAgLTEpIi8+PC9nPjwvZz48L3N2Zz4=');
|
||||||
--icon-PublicFilled: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMCAwSDE2VjE2SDB6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIC0xKSIvPjxnIGZpbGw9IiMxNkIzNzgiIGZpbGwtcnVsZT0ibm9uemVybyI+PHBhdGggZD0iTTE1LjI3NSwxMC4yOTMgTDEzLjA4Nyw5LjY2OCBDMTIuNzI2NDc4MSw5LjU2NDkxNzggMTIuNDUzOTIwNCw5LjI2ODgxMDUgMTIuMzgxLDguOTAxIEwxMi4yNDcsOC4yMjYgQzEzLjMxNDEyMDMsNy43MzgzMDYxNSAxMy45OTkwMDIxLDYuNjczMjgxNyAxNCw1LjUgTDE0LDQuMTI2IEMxNC4wMjUxOTc2LDIuNDczMDAzMjUgMTIuNzQyNTA1NSwxLjA5NDE1MzM3IDExLjA5MiwxIEM5Ljg3OTM3MDksMC45NjM0MTA4MjIgOC43NjQwNjU1LDEuNjYwNzcyNjkgOC4yNjYsMi43NjcgQzguNzQzMDQ3OCwzLjQ2MTI3ODUgOC45OTg5MTk4NCw0LjI4MzYyNDc0IDksNS4xMjYgTDksNi41IEM4Ljk5ODIwNzc0LDYuODYzNzcwNTcgOC45NDYwNDU1OCw3LjIyNTU0MDM4IDguODQ1LDcuNTc1IEM5LjEwNDIzMjUzLDcuODQ2OTQyMjYgOS40MTIyMzk1Myw4LjA2Nzc3MTA2IDkuNzUzLDguMjI2IEw5LjYxOSw4LjkgQzkuNTQ2MDc5NTcsOS4yNjc4MTA1IDkuMjczNTIxODcsOS41NjM5MTc4IDguOTEzLDkuNjY3IEw4LjA3LDkuOTA4IEw5LjU1LDEwLjMzMSBDMTAuNDA2OTY0MywxMC41Nzg1NTM2IDEwLjk5NzcwNTksMTEuMzYxOTk5MyAxMSwxMi4yNTQgTDExLDE0LjUgQzEwLjk5ODM0MjgsMTQuNjcwNzE5OCAxMC45NjcyMTg5LDE0LjgzOTg3MTUgMTAuOTA4LDE1IEwxNS41LDE1IEMxNS43NzYxNDI0LDE1IDE2LDE0Ljc3NjE0MjQgMTYsMTQuNSBMMTYsMTEuMjU0IEMxNS45OTk4MDE4LDEwLjgwNzc5MTggMTUuNzA0MDA0MywxMC40MTU3MDcyIDE1LjI3NSwxMC4yOTMgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48cGF0aCBkPSJNOS4yNzUsMTEuMjkzIEw3LjA4NywxMC42NjggQzYuNzI2MjAyMzIsMTAuNTY0NzUzOSA2LjQ1MzU4MywxMC4yNjgxOTM1IDYuMzgxLDkuOSBMNi4yNDcsOS4yMjUgQzcuMzEzNzkxNDEsOC43Mzc0NTY5MyA3Ljk5ODYxMTI1LDcuNjcyOTE5NTQgOCw2LjUgTDgsNS4xMjYgQzguMDI1MTk3NTYsMy40NzMwMDMyNSA2Ljc0MjUwNTQ3LDIuMDk0MTUzMzcgNS4wOTIsMiBDNC4yODA1MzI3LDEuOTc1MTAzMiAzLjQ5MzYyMDIsMi4yODAxNDgyNyAyLjkxMDk1MzIzLDIuODQ1NDc4NDYgQzIuMzI4Mjg2MjUsMy40MTA4MDg2NCAxLjk5OTYxODE2LDQuMTg4MTUwOTUgMiw1IEwyLDYuNSBDMi4wMDA5OTc5MSw3LjY3MzI4MTcgMi42ODU4Nzk3NCw4LjczODMwNjE1IDMuNzUzLDkuMjI2IEwzLjYxOSw5LjkgQzMuNTQ2MDc5NTcsMTAuMjY3ODEwNSAzLjI3MzUyMTg3LDEwLjU2MzkxNzggMi45MTMsMTAuNjY3IEwwLjcyNSwxMS4yOTIgQzAuMjk1NjM5MjYyLDExLjQxNDgwOTEgLTAuMDAwMjQ4MTc3MTU3LDExLjgwNzQyMTIgMCwxMi4yNTQgTDAsMTQuNSBDMy4zODE3NjU4MWUtMTcsMTQuNzc2MTQyNCAwLjIyMzg1NzYyNSwxNSAwLjUsMTUgTDkuNSwxNSBDOS43NzYxNDIzNywxNSAxMCwxNC43NzYxNDI0IDEwLDE0LjUgTDEwLDEyLjI1NCBDOS45OTk4MDE3NywxMS44MDc3OTE4IDkuNzA0MDA0MzEsMTEuNDE1NzA3MiA5LjI3NSwxMS4yOTMgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48L2c+PC9nPjwvc3ZnPg==');
|
--icon-PublicFilled: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cGF0aCBkPSJNMCAwSDE2VjE2SDB6IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIC0xKSIvPjxnIGZpbGw9IiMxNkIzNzgiIGZpbGwtcnVsZT0ibm9uemVybyI+PHBhdGggZD0iTTE1LjI3NSwxMC4yOTMgTDEzLjA4Nyw5LjY2OCBDMTIuNzI2NDc4MSw5LjU2NDkxNzggMTIuNDUzOTIwNCw5LjI2ODgxMDUgMTIuMzgxLDguOTAxIEwxMi4yNDcsOC4yMjYgQzEzLjMxNDEyMDMsNy43MzgzMDYxNSAxMy45OTkwMDIxLDYuNjczMjgxNyAxNCw1LjUgTDE0LDQuMTI2IEMxNC4wMjUxOTc2LDIuNDczMDAzMjUgMTIuNzQyNTA1NSwxLjA5NDE1MzM3IDExLjA5MiwxIEM5Ljg3OTM3MDksMC45NjM0MTA4MjIgOC43NjQwNjU1LDEuNjYwNzcyNjkgOC4yNjYsMi43NjcgQzguNzQzMDQ3OCwzLjQ2MTI3ODUgOC45OTg5MTk4NCw0LjI4MzYyNDc0IDksNS4xMjYgTDksNi41IEM4Ljk5ODIwNzc0LDYuODYzNzcwNTcgOC45NDYwNDU1OCw3LjIyNTU0MDM4IDguODQ1LDcuNTc1IEM5LjEwNDIzMjUzLDcuODQ2OTQyMjYgOS40MTIyMzk1Myw4LjA2Nzc3MTA2IDkuNzUzLDguMjI2IEw5LjYxOSw4LjkgQzkuNTQ2MDc5NTcsOS4yNjc4MTA1IDkuMjczNTIxODcsOS41NjM5MTc4IDguOTEzLDkuNjY3IEw4LjA3LDkuOTA4IEw5LjU1LDEwLjMzMSBDMTAuNDA2OTY0MywxMC41Nzg1NTM2IDEwLjk5NzcwNTksMTEuMzYxOTk5MyAxMSwxMi4yNTQgTDExLDE0LjUgQzEwLjk5ODM0MjgsMTQuNjcwNzE5OCAxMC45NjcyMTg5LDE0LjgzOTg3MTUgMTAuOTA4LDE1IEwxNS41LDE1IEMxNS43NzYxNDI0LDE1IDE2LDE0Ljc3NjE0MjQgMTYsMTQuNSBMMTYsMTEuMjU0IEMxNS45OTk4MDE4LDEwLjgwNzc5MTggMTUuNzA0MDA0MywxMC40MTU3MDcyIDE1LjI3NSwxMC4yOTMgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48cGF0aCBkPSJNOS4yNzUsMTEuMjkzIEw3LjA4NywxMC42NjggQzYuNzI2MjAyMzIsMTAuNTY0NzUzOSA2LjQ1MzU4MywxMC4yNjgxOTM1IDYuMzgxLDkuOSBMNi4yNDcsOS4yMjUgQzcuMzEzNzkxNDEsOC43Mzc0NTY5MyA3Ljk5ODYxMTI1LDcuNjcyOTE5NTQgOCw2LjUgTDgsNS4xMjYgQzguMDI1MTk3NTYsMy40NzMwMDMyNSA2Ljc0MjUwNTQ3LDIuMDk0MTUzMzcgNS4wOTIsMiBDNC4yODA1MzI3LDEuOTc1MTAzMiAzLjQ5MzYyMDIsMi4yODAxNDgyNyAyLjkxMDk1MzIzLDIuODQ1NDc4NDYgQzIuMzI4Mjg2MjUsMy40MTA4MDg2NCAxLjk5OTYxODE2LDQuMTg4MTUwOTUgMiw1IEwyLDYuNSBDMi4wMDA5OTc5MSw3LjY3MzI4MTcgMi42ODU4Nzk3NCw4LjczODMwNjE1IDMuNzUzLDkuMjI2IEwzLjYxOSw5LjkgQzMuNTQ2MDc5NTcsMTAuMjY3ODEwNSAzLjI3MzUyMTg3LDEwLjU2MzkxNzggMi45MTMsMTAuNjY3IEwwLjcyNSwxMS4yOTIgQzAuMjk1NjM5MjYyLDExLjQxNDgwOTEgLTAuMDAwMjQ4MTc3MTU3LDExLjgwNzQyMTIgMCwxMi4yNTQgTDAsMTQuNSBDMy4zODE3NjU4MWUtMTcsMTQuNzc2MTQyNCAwLjIyMzg1NzYyNSwxNSAwLjUsMTUgTDkuNSwxNSBDOS43NzYxNDIzNywxNSAxMCwxNC43NzYxNDI0IDEwLDE0LjUgTDEwLDEyLjI1NCBDOS45OTk4MDE3NywxMS44MDc3OTE4IDkuNzA0MDA0MzEsMTEuNDE1NzA3MiA5LjI3NSwxMS4yOTMgWiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMSkiLz48L2c+PC9nPjwvc3ZnPg==');
|
||||||
|
8
static/ui-icons/UI/Chat.svg
Normal file
8
static/ui-icons/UI/Chat.svg
Normal file
@ -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 |
3
static/ui-icons/UI/Message.svg
Normal file
3
static/ui-icons/UI/Message.svg
Normal file
@ -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 |
6
static/ui-icons/UI/Popup.svg
Normal file
6
static/ui-icons/UI/Popup.svg
Normal file
@ -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 |
BIN
test/fixtures/docs/Hello.grist
vendored
BIN
test/fixtures/docs/Hello.grist
vendored
Binary file not shown.
@ -68,13 +68,13 @@ describe('Pages', function() {
|
|||||||
await gu.waitForServer();
|
await gu.waitForServer();
|
||||||
|
|
||||||
// Click on a page; check the URL, selected item, and the title of the view section.
|
// 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.getCurrentUrl(), /\/p\/3/);
|
||||||
assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Documents/);
|
assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Documents/);
|
||||||
assert.match(await gu.getActiveSectionTitle(), /Documents/i);
|
assert.match(await gu.getActiveSectionTitle(), /Documents/i);
|
||||||
|
|
||||||
// Click on another page; check the URL, selected item, and the title of the view section.
|
// 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.getCurrentUrl(), /\/p\/2/);
|
||||||
assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /People/);
|
assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /People/);
|
||||||
assert.match(await gu.getActiveSectionTitle(), /People/i);
|
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 () => {
|
it('should allow renaming table when click on page selected label', async () => {
|
||||||
// do rename
|
// do rename
|
||||||
await clickPage(/People/)
|
await gu.openPage(/People/);
|
||||||
await driver.findContent('.test-treeview-label', 'People').doClick();
|
await driver.findContent('.test-treeview-label', 'People').doClick();
|
||||||
await driver.find('.test-docpage-editor').sendKeys('PeopleRenamed', Key.ENTER);
|
await driver.find('.test-docpage-editor').sendKeys('PeopleRenamed', Key.ENTER);
|
||||||
await gu.waitForServer();
|
await gu.waitForServer();
|
||||||
@ -214,7 +214,7 @@ describe('Pages', function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// goto page 'Interactions'
|
// goto page 'Interactions'
|
||||||
await clickPage(/Interactions/);
|
await gu.openPage(/Interactions/);
|
||||||
|
|
||||||
// check selected page
|
// check selected page
|
||||||
assert.match(await selectedPage(), /Interactions/);
|
assert.match(await selectedPage(), /Interactions/);
|
||||||
@ -249,7 +249,7 @@ describe('Pages', function() {
|
|||||||
it('undo/redo should update url', async () => {
|
it('undo/redo should update url', async () => {
|
||||||
|
|
||||||
// goto page 'Interactions' and send keys
|
// goto page 'Interactions' and send keys
|
||||||
await clickPage(/Interactions/);
|
await gu.openPage(/Interactions/);
|
||||||
assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Interactions/);
|
assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Interactions/);
|
||||||
await driver.findContentWait('.gridview_data_row_num', /1/, 2000);
|
await driver.findContentWait('.gridview_data_row_num', /1/, 2000);
|
||||||
await driver.sendKeys(Key.ENTER, 'Foo', Key.ENTER);
|
await driver.sendKeys(Key.ENTER, 'Foo', Key.ENTER);
|
||||||
@ -257,7 +257,7 @@ describe('Pages', function() {
|
|||||||
assert.deepEqual(await gu.getVisibleGridCells(0, [1]), ['Foo']);
|
assert.deepEqual(await gu.getVisibleGridCells(0, [1]), ['Foo']);
|
||||||
|
|
||||||
// goto page 'People' and click undo
|
// goto page 'People' and click undo
|
||||||
await clickPage(/People/);
|
await gu.openPage(/People/);
|
||||||
await gu.waitForDocToLoad();
|
await gu.waitForDocToLoad();
|
||||||
await gu.waitForUrl(/\/p\/2\b/); // check that url match p/2
|
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 () => {
|
it('Add new page should update url', async () => {
|
||||||
// goto page 'Interactions' and check that url updated
|
// goto page 'Interactions' and check that url updated
|
||||||
await clickPage(/Interactions/);
|
await gu.openPage(/Interactions/);
|
||||||
await gu.waitForUrl(/\/p\/1\b/);
|
await gu.waitForUrl(/\/p\/1\b/);
|
||||||
|
|
||||||
// Add new Page, check that url updated and page is selected
|
// 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/);
|
assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Table1/);
|
||||||
|
|
||||||
// goto page 'Interactions' and check that url updated and page selectd
|
// goto page 'Interactions' and check that url updated and page selectd
|
||||||
await clickPage(/Interactions/);
|
await gu.openPage(/Interactions/);
|
||||||
await gu.waitForUrl(/\/p\/1\b/);
|
await gu.waitForUrl(/\/p\/1\b/);
|
||||||
assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Interactions/);
|
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());
|
.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');
|
.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).
|
* 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();
|
const back = await driver.getCurrentUrl();
|
||||||
await driver.find(".test-tools-raw").click();
|
await driver.find(".test-tools-raw").click();
|
||||||
const tableIdList = await driver.findAll('.test-raw-data-table-id', e => e.getText());
|
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-raw-data-menu-remove").click();
|
||||||
await driver.find(".test-modal-confirm").click();
|
await driver.find(".test-modal-confirm").click();
|
||||||
await waitForServer();
|
await waitForServer();
|
||||||
|
if (goBack) {
|
||||||
|
await driver.get(back);
|
||||||
|
await waitAppFocus();
|
||||||
|
}
|
||||||
return back;
|
return back;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1400,6 +1409,11 @@ export function openColumnMenu(col: IColHeader|string, option?: string): WebElem
|
|||||||
return new WebElementPromise(driver, openColumnMenuHelper(col, option));
|
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.
|
* 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.
|
* Gets all available options in the behavior menu.
|
||||||
*/
|
*/
|
||||||
export async function availableBehaviorOptions() {
|
export async function availableBehaviorOptions() {
|
||||||
await driver.find('.test-field-behaviour').click();
|
await driver.find('.test-field-behaviour').click();
|
||||||
const list = await driver.findAll('.grist-floating-menu li', el => el.getText());
|
const list = await driver.findAll('.grist-floating-menu li', el => el.getText());
|
||||||
await driver.sendKeys(Key.ESCAPE);
|
await driver.sendKeys(Key.ESCAPE);
|
||||||
return list;
|
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
|
} // end of namespace gristUtils
|
||||||
|
|
||||||
stackWrapOwnMethods(gristUtils);
|
stackWrapOwnMethods(gristUtils);
|
||||||
|
Loading…
Reference in New Issue
Block a user