From bfd7243fe2fd2df13cf89515a06a457f27f050f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Mon, 17 Oct 2022 11:47:16 +0200 Subject: [PATCH] (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 --- app/client/components/BaseView.js | 31 +- app/client/components/GristDoc.ts | 16 +- app/client/components/commandList.js | 4 + app/client/declarations.d.ts | 13 +- app/client/models/DocModel.ts | 7 +- app/client/models/entities/CellRec.ts | 43 + app/client/models/entities/ColumnRec.ts | 7 +- app/client/models/features.ts | 12 + app/client/models/rowset.ts | 46 +- app/client/ui/AccountWidget.ts | 2 +- app/client/ui/CellContextMenu.ts | 11 +- app/client/ui/GridViewMenus.ts | 2 +- app/client/ui/TopBar.ts | 26 + app/client/ui2018/IconList.ts | 6 + app/client/ui2018/cssVars.ts | 1 + app/client/widgets/DiscussionEditor.ts | 1382 +++++++++++++++++++++++ app/client/widgets/FieldBuilder.css | 15 + app/client/widgets/FieldBuilder.ts | 87 +- app/client/widgets/FieldEditor.ts | 2 +- app/client/widgets/FormulaEditor.ts | 4 +- app/common/DocActions.ts | 1 - app/common/GranularAccessClause.ts | 1 + app/common/UserAPI.ts | 1 + app/common/gristTypes.ts | 7 + app/common/gristUrls.ts | 3 + app/common/schema.ts | 24 +- app/gen-server/lib/HomeDBManager.ts | 1 + app/server/lib/GranularAccess.ts | 792 ++++++++++++- app/server/lib/RowAccess.ts | 12 + app/server/lib/initialDocSql.ts | 6 +- app/server/lib/sendAppPage.ts | 1 + sandbox/grist/docmodel.py | 16 + sandbox/grist/migrations.py | 22 +- sandbox/grist/schema.py | 17 +- static/icons/icons.css | 3 + static/ui-icons/UI/Chat.svg | 8 + static/ui-icons/UI/Message.svg | 3 + static/ui-icons/UI/Popup.svg | 6 + test/fixtures/docs/Hello.grist | Bin 59392 -> 61440 bytes test/nbrowser/Pages.ts | 20 +- test/nbrowser/gristUtils.ts | 37 +- 41 files changed, 2621 insertions(+), 77 deletions(-) create mode 100644 app/client/models/entities/CellRec.ts create mode 100644 app/client/models/features.ts create mode 100644 app/client/widgets/DiscussionEditor.ts create mode 100644 static/ui-icons/UI/Chat.svg create mode 100644 static/ui-icons/UI/Message.svg create mode 100644 static/ui-icons/UI/Popup.svg diff --git a/app/client/components/BaseView.js b/app/client/components/BaseView.js index d6869cae..eaafe193 100644 --- a/app/client/components/BaseView.js +++ b/app/client/components/BaseView.js @@ -25,6 +25,8 @@ const {setTestState} = require('app/client/lib/testState'); const {ExtraRows} = require('app/client/models/DataTableModelWithDiff'); const {createFilterMenu} = require('app/client/ui/ColumnFilterMenu'); const {closeRegisteredMenu} = require('app/client/ui2018/menus'); +const {COMMENTS} = require('app/client/models/features'); + /** * BaseView forms the basis for ViewSection classes. @@ -85,6 +87,8 @@ function BaseView(gristDoc, viewSectionModel, options) { this._filteredRowSource.updateFilter(filterFunc); })); + this.rowSource = this._filteredRowSource; + // Sorted collection of all rows to show in this view. this.sortedRows = rowset.SortedRowSet.create(this, null, this.tableModel.tableData); @@ -238,7 +242,8 @@ BaseView.commonCommands = { showRawData: function() { this.showRawData().catch(reportError); }, filterByThisCellValue: function() { this.filterByThisCellValue(); }, - duplicateRows: function() { this._duplicateRows().catch(reportError); } + duplicateRows: function() { this._duplicateRows().catch(reportError); }, + openDiscussion: function() { this.openDiscussionAtCursor(); }, }; /** @@ -288,6 +293,30 @@ BaseView.prototype.activateEditorAtCursor = function(options) { builder.buildEditorDom(this.editRowModel, lazyRow, options || {}); }; + +/** + * Opens discussion panel at the cursor position. Returns true if discussion panel was opened. + */ + BaseView.prototype.openDiscussionAtCursor = function(id) { + if (!COMMENTS().get()) { return false; } + var builder = this.activeFieldBuilder(); + if (builder.isEditorActive()) { + return false; + } + var rowId = this.viewData.getRowId(this.cursor.rowIndex()); + // LazyArrayModel row model which is also used to build the cell dom. Needed since + // it may be used as a key to retrieve the cell dom, which is useful for editor placement. + var lazyRow = this.getRenderedRowModel(rowId); + if (!lazyRow) { + // TODO scroll into view. For now, just don't start discussion. + return false; + } + this.editRowModel.assign(rowId); + builder.buildDiscussionPopup(this.editRowModel, lazyRow, id); + return true; +}; + + /** * Move the floating RowModel for editing to the current cursor position, and return it. * diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 112ba9fc..7f6177a7 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -48,6 +48,7 @@ import {isNarrowScreen, mediaSmall, testId} from 'app/client/ui2018/cssVars'; import {IconName} from 'app/client/ui2018/IconList'; import {invokePrompt} from 'app/client/ui2018/modals'; import {FieldEditor} from "app/client/widgets/FieldEditor"; +import {DiscussionPanel} from 'app/client/widgets/DiscussionEditor'; import {MinimalActionGroup} from 'app/common/ActionGroup'; import {ClientQuery} from "app/common/ActiveDocAPI"; import {CommDocUsage, CommDocUserAction} from 'app/common/CommTypes'; @@ -66,6 +67,7 @@ import { bundleChanges, Computed, dom, + DomContents, Emitter, fromKo, Holder, @@ -101,11 +103,11 @@ export interface TabOptions { category?: any; } -const RightPanelTool = StringUnion("none", "docHistory", "validations"); +const RightPanelTool = StringUnion("none", "docHistory", "validations", "discussion"); export interface IExtraTool { icon: IconName; - label: string; + label: DomContents; content: TabContent[]|IDomComponent; } @@ -162,6 +164,7 @@ export class GristDoc extends DisposableWithEvents { private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null; private _rightPanelTabs = new Map(); private _docHistory: DocHistory; + private _discussionPanel: DiscussionPanel; private _rightPanelTool = createSessionObs(this, "rightPanelTool", "none", RightPanelTool.guard); private _viewLayout: ViewLayout|null = null; private _showGristTour = getUserOrgPrefObs(this.userOrgPrefs, 'showGristTour'); @@ -317,6 +320,7 @@ export class GristDoc extends DisposableWithEvents { this._actionLog = this.autoDispose(ActionLog.create({ gristDoc: this })); this._undoStack = this.autoDispose(UndoStack.create(openDocResponse.log, { gristDoc: this })); this._docHistory = DocHistory.create(this, this.docPageModel, this._actionLog); + this._discussionPanel = DiscussionPanel.create(this, this); // Tap into docData's sendActions method to save the cursor position with every action, so that // undo/redo can jump to the right place. @@ -404,6 +408,7 @@ export class GristDoc extends DisposableWithEvents { return this.docPageModel.currentDocId.get()!; } + // DEPRECATED This is used only for validation, which is not used anymore. public addOptionsTab(label: string, iconElem: any, contentObj: TabContent[], options: TabOptions): IDisposable { this._rightPanelTabs.set(label, contentObj); // Return a do-nothing disposable, to satisfy the previous interface. @@ -857,7 +862,7 @@ export class GristDoc extends DisposableWithEvents { public async recursiveMoveToCursorPos( cursorPos: CursorPos, setAsActiveSection: boolean, - silent: boolean = false): Promise { + silent: boolean = false): Promise { try { if (!cursorPos.sectionId) { throw new Error('sectionId required'); } if (!cursorPos.rowId) { throw new Error('rowId required'); } @@ -931,11 +936,13 @@ export class GristDoc extends DisposableWithEvents { // even though the cursor is at right place, the scroll could not have yet happened // wait for a bit (scroll is done in a setTimeout 0) await delay(0); + return true; } catch (e) { console.debug(`_recursiveMoveToCursorPos(${JSON.stringify(cursorPos)}): ${e}`); if (!silent) { throw new UserError('There was a problem finding the desired cell.'); } + return false; } } @@ -1051,6 +1058,9 @@ export class GristDoc extends DisposableWithEvents { const content = this._rightPanelTabs.get("Validate Data"); return content ? {icon: 'Validation', label: 'Validation Rules', content} : null; } + case 'discussion': { + return {icon: 'Chat', label: this._discussionPanel.buildMenu(), content: this._discussionPanel}; + } case 'none': default: { return null; diff --git a/app/client/components/commandList.js b/app/client/components/commandList.js index 6a391fbe..867d565d 100644 --- a/app/client/components/commandList.js +++ b/app/client/components/commandList.js @@ -318,6 +318,10 @@ exports.groups = [{ name: 'datepickerFocus', keys: ['Up', 'Down'], desc: null, // While editing a date cell, switch keyboard focus to the datepicker + }, { + name: 'openDiscussion', + keys: ['Mod+Alt+M'], + desc: 'Comment', } ], }, { diff --git a/app/client/declarations.d.ts b/app/client/declarations.d.ts index ed77f2fd..680d18a3 100644 --- a/app/client/declarations.d.ts +++ b/app/client/declarations.d.ts @@ -56,6 +56,7 @@ declare module "app/client/components/BaseView" { public gristDoc: GristDoc; public cursor: Cursor; public sortedRows: SortedRowSet; + public rowSource: RowSource; public activeFieldBuilder: ko.Computed; public selectedColumns: ko.Computed|null; public disableEditing: ko.Computed; @@ -69,6 +70,7 @@ declare module "app/client/components/BaseView" { public buildTitleControls(): DomArg; public getLoadingDonePromise(): Promise; public activateEditorAtCursor(options?: Options): void; + public openDiscussionAtCursor(discussionId?: number): boolean; public onResize(): void; public prepareToPrint(onOff: boolean): void; public moveEditRowToCursor(): DataRowModel; @@ -140,10 +142,19 @@ declare module "app/client/models/BaseRowModel" { declare module "app/client/models/MetaRowModel" { import BaseRowModel from "app/client/models/BaseRowModel"; + import {ColValues} from 'app/common/DocActions'; + import {SchemaTypes} from 'app/common/schema'; + + type NPartial = { + [P in keyof T]?: T[P]|null; + }; + type Values = T extends keyof SchemaTypes ? NPartial : ColValues; + namespace MetaRowModel {} - class MetaRowModel extends BaseRowModel { + class MetaRowModel extends BaseRowModel { public _isDeleted: ko.Observable; public events: { trigger: (key: string) => void }; + public updateColValues(colValues: Values): Promise; } export = MetaRowModel; } diff --git a/app/client/models/DocModel.ts b/app/client/models/DocModel.ts index 84e244ed..c5f81530 100644 --- a/app/client/models/DocModel.ts +++ b/app/client/models/DocModel.ts @@ -37,21 +37,21 @@ import {createValidationRec, ValidationRec} from 'app/client/models/entities/Val import {createViewFieldRec, ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; import {createViewRec, ViewRec} from 'app/client/models/entities/ViewRec'; import {createViewSectionRec, ViewSectionRec} from 'app/client/models/entities/ViewSectionRec'; +import {CellRec, createCellRec} from 'app/client/models/entities/CellRec'; import {RefListValue} from 'app/common/gristTypes'; import {decodeObject} from 'app/plugin/objtypes'; // Re-export all the entity types available. The recommended usage is like this: // import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel'; export type {ColumnRec, DocInfoRec, FilterRec, PageRec, TabBarRec, TableRec, ValidationRec, - ViewFieldRec, ViewRec, ViewSectionRec}; - + ViewFieldRec, ViewRec, ViewSectionRec, CellRec}; /** * Creates the type for a MetaRowModel containing a KoSaveableObservable for each field listed in * the auto-generated app/common/schema.ts. It represents the metadata record in the database. * Particular DocModel entities derive from this, and add other helpful computed values. */ -export type IRowModel = MetaRowModel & { +export type IRowModel = MetaRowModel & { [ColId in keyof SchemaTypes[TName]]: KoSaveableObservable; }; @@ -124,6 +124,7 @@ export class DocModel { public pages: MTM = this._metaTableModel("_grist_Pages", createPageRec); public rules: MTM = this._metaTableModel("_grist_ACLRules", createACLRuleRec); public filters: MTM = this._metaTableModel("_grist_Filters", createFilterRec); + public cells: MTM = this._metaTableModel("_grist_Cells", createCellRec); public docInfoRow: DocInfoRec; diff --git a/app/client/models/entities/CellRec.ts b/app/client/models/entities/CellRec.ts new file mode 100644 index 00000000..e1054f7e --- /dev/null +++ b/app/client/models/entities/CellRec.ts @@ -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; + table: ko.Computed; + children: ko.Computed>; + hidden: ko.Computed; + parent: ko.Computed; + + text: modelUtil.KoSaveableObservable; + userName: modelUtil.KoSaveableObservable; + timeCreated: modelUtil.KoSaveableObservable; + timeUpdated: modelUtil.KoSaveableObservable; + resolved: modelUtil.KoSaveableObservable; + resolvedBy: modelUtil.KoSaveableObservable; +} + +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'); +} diff --git a/app/client/models/entities/ColumnRec.ts b/app/client/models/entities/ColumnRec.ts index 0a372bd6..71c67f2a 100644 --- a/app/client/models/entities/ColumnRec.ts +++ b/app/client/models/entities/ColumnRec.ts @@ -1,5 +1,6 @@ import {KoArray} from 'app/client/lib/koArray'; -import {DocModel, IRowModel, recordSet, refRecord, TableRec, ViewFieldRec} from 'app/client/models/DocModel'; +import {CellRec, DocModel, IRowModel, recordSet, + refRecord, TableRec, ViewFieldRec} from 'app/client/models/DocModel'; import {jsonObservable, ObjObservable} from 'app/client/models/modelUtil'; import * as gristTypes from 'app/common/gristTypes'; import {getReferencedTableId} from 'app/common/gristTypes'; @@ -55,7 +56,7 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> { visibleColModel: ko.Computed; disableModifyBase: ko.Computed; // True if column config can't be modified (name, type, etc.) - disableModify: ko.Computed; // True if column can't be modified or is being transformed. + disableModify: ko.Computed; // True if column can't be modified (is summary) or is being transformed. disableEditData: ko.Computed; // True to disable editing of the data in this column. isHiddenCol: ko.Computed; @@ -73,6 +74,7 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> { // (i.e. they aren't actually referenced but they exist in the visible column and are relevant to e.g. autocomplete) // `formatter` formats actual cell values, e.g. a whole list from the display column. formatter: ko.Computed; + cells: ko.Computed>; // Helper which adds/removes/updates column's displayCol to match the formula. saveDisplayFormula(formula: string): Promise|undefined; @@ -83,6 +85,7 @@ export function createColumnRec(this: ColumnRec, docModel: DocModel): void { this.widgetOptionsJson = jsonObservable(this.widgetOptions); this.viewFields = recordSet(this, docModel.viewFields, 'colRef'); this.summarySource = refRecord(docModel.columns, this.summarySourceCol); + this.cells = recordSet(this, docModel.cells, 'colRef'); // Is this an empty column (undecided if formula or data); denoted by an empty formula. this.isEmpty = ko.pureComputed(() => this.isFormula() && this.formula() === ''); diff --git a/app/client/models/features.ts b/app/client/models/features.ts new file mode 100644 index 00000000..8617e75e --- /dev/null +++ b/app/client/models/features.ts @@ -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 { + const G = getBrowserGlobals('document', 'window'); + if (!G.window.COMMENTS) { + G.window.COMMENTS = localStorageBoolObs('feature-comments', Boolean(getGristConfig().featureComments)); + } + return G.window.COMMENTS; +} diff --git a/app/client/models/rowset.ts b/app/client/models/rowset.ts index 0b3468a8..0f4ab32c 100644 --- a/app/client/models/rowset.ts +++ b/app/client/models/rowset.ts @@ -26,6 +26,7 @@ import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; import {CompareFunc, sortedIndex} from 'app/common/gutil'; import {SkippableRows} from 'app/common/TableData'; import {RowFilterFunc} from "app/common/RowFilterFunc"; +import {Observable} from 'grainjs'; /** * Special constant value that can be used for the `rows` array for the 'rowNotify' @@ -390,7 +391,10 @@ class RowGroupHelper extends RowSource { } // ---------------------------------------------------------------------- - +/** + * Helper function that does map.get(key).push(r), creating an Array for the given key if + * necessary. + */ function _addToMapOfArrays(map: Map, key: K, r: V): void { let arr = map.get(key); if (!arr) { map.set(key, arr = []); } @@ -437,11 +441,6 @@ export class RowGrouping extends RowListener { // Implementation of the RowListener interface. - /** - * Helper function that does map.get(key).push(r), creating an Array for the given key if - * necessary. - */ - public onAddRows(rows: RowList) { const groupedRows = new Map(); for (const r of rows) { @@ -707,6 +706,41 @@ export class SortedRowSet extends RowListener { } } +type RowTester = (rowId: RowId) => boolean; +/** + * RowWatcher is a RowListener that maintains an observable function that checks whether a row + * is in the connected RowSource. + */ +export class RowWatcher extends RowListener { + /** + * Observable function that returns true if the row is in the connected RowSource. + */ + public rowFilter: Observable = 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 = new Map(); + + public clear() { + this._rowCounter.clear(); + this.rowFilter.set(() => false); + this.stopListening(); + } + + protected onAddRows(rows: RowList) { + for (const r of rows) { + this._rowCounter.set(r, (this._rowCounter.get(r) || 0) + 1); + } + this.rowFilter.set((row) => (this._rowCounter.get(row) ?? 0) > 0); + } + + protected onRemoveRows(rows: RowList) { + for (const r of rows) { + this._rowCounter.set(r, (this._rowCounter.get(r) || 0) - 1); + } + this.rowFilter.set((row) => (this._rowCounter.get(row) ?? 0) > 0); + } +} + function isSmallChange(rows: RowList) { return Array.isArray(rows) && rows.length <= 2; } diff --git a/app/client/ui/AccountWidget.ts b/app/client/ui/AccountWidget.ts index 9adc5182..6b4d2057 100644 --- a/app/client/ui/AccountWidget.ts +++ b/app/client/ui/AccountWidget.ts @@ -147,7 +147,7 @@ const cssAccountWidget = styled('div', ` white-space: nowrap; `); -const cssUserIcon = styled('div', ` +export const cssUserIcon = styled('div', ` height: 48px; width: 48px; padding: 8px; diff --git a/app/client/ui/CellContextMenu.ts b/app/client/ui/CellContextMenu.ts index b6a6c3bb..000f6197 100644 --- a/app/client/ui/CellContextMenu.ts +++ b/app/client/ui/CellContextMenu.ts @@ -2,6 +2,7 @@ import { allCommands } from 'app/client/components/commands'; import { menuDivider, menuItemCmd } from 'app/client/ui2018/menus'; import { IMultiColumnContextMenu } from 'app/client/ui/GridViewMenus'; import { IRowContextMenu } from 'app/client/ui/RowContextMenu'; +import { COMMENTS } from 'app/client/models/features'; import { dom } from 'grainjs'; export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiColumnContextMenu) { @@ -9,6 +10,8 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC const { disableInsert, disableDelete, isViewSorted } = rowOptions; const { disableModify, isReadonly } = colOptions; + // disableModify is true if the column is a summary column or is being transformed. + // isReadonly is true for readonly mode. const disableForReadonlyColumn = dom.cls('disabled', Boolean(disableModify) || isReadonly); const disableForReadonlyView = dom.cls('disabled', isReadonly); @@ -32,7 +35,7 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC colOptions.isFormula ? null : menuItemCmd(allCommands.clearValues, nameClearCells, disableForReadonlyColumn), - menuItemCmd(allCommands.clearColumns, nameClearColumns, disableForReadonlyColumn), + menuItemCmd(allCommands.clearColumns, nameClearColumns, disableForReadonlyColumn), ...( (numCols > 1 || numRows > 1) ? [] : [ @@ -40,6 +43,9 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC menuItemCmd(allCommands.copyLink, 'Copy anchor link'), menuDivider(), menuItemCmd(allCommands.filterByThisCellValue, `Filter by this value`), + menuItemCmd(allCommands.openDiscussion, 'Comment', dom.cls('disabled', ( + isReadonly || numRows === 0 || numCols === 0 + )), dom.hide(use => !use(COMMENTS()))) ] ), @@ -70,8 +76,7 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC menuDivider(), // deletes - menuItemCmd(allCommands.deleteRecords, nameDeleteRows, - dom.cls('disabled', disableDelete)), + menuItemCmd(allCommands.deleteRecords, nameDeleteRows, dom.cls('disabled', disableDelete)), menuItemCmd(allCommands.deleteFields, nameDeleteColumns, disableForReadonlyColumn), diff --git a/app/client/ui/GridViewMenus.ts b/app/client/ui/GridViewMenus.ts index 418b108b..5a7e7950 100644 --- a/app/client/ui/GridViewMenus.ts +++ b/app/client/ui/GridViewMenus.ts @@ -37,7 +37,7 @@ export interface IMultiColumnContextMenu { // true for some columns, but not all. numColumns: number; numFrozen: number; - disableModify: boolean|'mixed'; // If the columns are read-only. + disableModify: boolean|'mixed'; // If the columns are read-only. Mixed for multiple columns where some are read-only. isReadonly: boolean; isRaw: boolean; isFiltered: boolean; // If this view shows a proper subset of all rows in the table. diff --git a/app/client/ui/TopBar.ts b/app/client/ui/TopBar.ts index 7ea4753f..55eb5467 100644 --- a/app/client/ui/TopBar.ts +++ b/app/client/ui/TopBar.ts @@ -12,6 +12,8 @@ import {docBreadcrumbs} from 'app/client/ui2018/breadcrumbs'; import {basicButton} from 'app/client/ui2018/buttons'; import {cssHideForNarrowScreen, testId, theme} from 'app/client/ui2018/cssVars'; import {IconName} from 'app/client/ui2018/IconList'; +import {menuAnnotate} from 'app/client/ui2018/menus'; +import {COMMENTS} from 'app/client/models/features'; import {waitGrainObs} from 'app/common/gutil'; import * as roles from 'app/common/roles'; import {Computed, dom, DomElementArg, makeTestId, MultiHolder, Observable, styled} from 'grainjs'; @@ -92,6 +94,14 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode buildShareMenuButton(pageModel), + dom.maybe(use => + ( + use(pageModel.gristDoc) + && !use(use(pageModel.gristDoc)!.isReadonly) + && use(COMMENTS()) + ), + () => buildShowDiscussionButton(pageModel)), + dom.update( buildNotifyMenuButton(appModel.notifier, appModel), cssHideForNarrowScreen.cls(''), @@ -101,6 +111,22 @@ export function createTopBarDoc(owner: MultiHolder, appModel: AppModel, pageMode ]; } +function buildShowDiscussionButton(pageModel: DocPageModel) { + return cssHoverCircle({ style: `margin: 5px; position: relative;` }, + cssTopBarBtn('Chat', dom.cls('tour-share-icon')), + cssBeta('Beta'), + testId('open-discussion'), + dom.on('click', () => pageModel.gristDoc.get()!.showTool('discussion')) + ); +} + +const cssBeta = styled(menuAnnotate, ` + position: absolute; + top: 4px; + right: -9px; + font-weight: bold; +`); + // Given the GristDoc instance, returns a rename function for the current active page. // If the current page is not able to be renamed or the new name is invalid, the function is a noop. function getRenamePageFn(gristDoc: GristDoc): (val: string) => Promise { diff --git a/app/client/ui2018/IconList.ts b/app/client/ui2018/IconList.ts index 009ac010..b93797aa 100644 --- a/app/client/ui2018/IconList.ts +++ b/app/client/ui2018/IconList.ts @@ -39,6 +39,7 @@ export type IconName = "ChartArea" | "BarcodeQR" | "BarcodeQR2" | "CenterAlign" | + "Chat" | "Code" | "Collapse" | "Convert" | @@ -75,6 +76,7 @@ export type IconName = "ChartArea" | "Lock" | "Log" | "Mail" | + "Message" | "Minus" | "MobileChat" | "MobileChat2" | @@ -90,6 +92,7 @@ export type IconName = "ChartArea" | "Pivot" | "PivotLight" | "Plus" | + "Popup" | "Public" | "PublicColor" | "PublicFilled" | @@ -165,6 +168,7 @@ export const IconList: IconName[] = ["ChartArea", "BarcodeQR", "BarcodeQR2", "CenterAlign", + "Chat", "Code", "Collapse", "Convert", @@ -201,6 +205,7 @@ export const IconList: IconName[] = ["ChartArea", "Lock", "Log", "Mail", + "Message", "Minus", "MobileChat", "MobileChat2", @@ -216,6 +221,7 @@ export const IconList: IconName[] = ["ChartArea", "Pivot", "PivotLight", "Plus", + "Popup", "Public", "PublicColor", "PublicFilled", diff --git a/app/client/ui2018/cssVars.ts b/app/client/ui2018/cssVars.ts index 3ba601b9..19cd6812 100644 --- a/app/client/ui2018/cssVars.ts +++ b/app/client/ui2018/cssVars.ts @@ -50,6 +50,7 @@ export const colors = { light: new CustomProp('color-light', '#FFFFFF'), dark: new CustomProp('color-dark', '#262633'), + darkText: new CustomProp('color-dark-text', '#494949'), darkBg: new CustomProp('color-dark-bg', '#262633'), slate: new CustomProp('color-slate', '#929299'), diff --git a/app/client/widgets/DiscussionEditor.ts b/app/client/widgets/DiscussionEditor.ts new file mode 100644 index 00000000..54e80de3 --- /dev/null +++ b/app/client/widgets/DiscussionEditor.ts @@ -0,0 +1,1382 @@ +import {GristDoc} from 'app/client/components/GristDoc'; +import {FocusLayer} from 'app/client/lib/FocusLayer'; +import {createObsArray} from 'app/client/lib/koArrayWrap'; +import {localStorageBoolObs} from 'app/client/lib/localStorageObs'; +import {CellRec, ColumnRec, ViewSectionRec} from 'app/client/models/DocModel'; +import {reportError} from 'app/client/models/errors'; +import {RowSource, RowWatcher} from 'app/client/models/rowset'; +import {createUserImage} from 'app/client/ui/UserImage'; +import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons'; +import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox'; +import {colors, vars} from 'app/client/ui2018/cssVars'; +import {icon} from 'app/client/ui2018/icons'; +import {menu, menuItem} from 'app/client/ui2018/menus'; +import {CellInfoType} from 'app/common/gristTypes'; +import {FullUser} from 'app/common/UserAPI'; +import { + bundleChanges, + Computed, + Disposable, + dom, + DomArg, + DomContents, + DomElementArg, + IDomComponent, + makeTestId, + MultiHolder, + ObsArray, + Observable, + styled +} from 'grainjs'; +import {createPopper, Options as PopperOptions} from '@popperjs/core'; +import * as ko from 'knockout'; +import moment from 'moment'; +import maxSize from 'popper-max-size-modifier'; +import flatMap = require('lodash/flatMap'); + +const testId = makeTestId('test-discussion-'); +const COMMENTS_LIMIT = 200; + +interface DiscussionPopupProps { + domEl: Element, + topic: ICellView, + discussionId?: number, + gristDoc: GristDoc, + closeClicked: () => void; +} + +interface ICellView { + comments: Observable, + comment(text: string): Promise; + reply(discussion: CellRec, text: string): Promise; + resolve(discussion: CellRec): Promise; + update(comment: CellRec, text: string): Promise; + open(discussion: CellRec): Promise; + remove(comment: CellRec): Promise; +} + +export class CellWithComments extends Disposable implements ICellView { + public comments: Observable; + + constructor(protected gristDoc: GristDoc) { + super(); + } + + public async comment(text: string): Promise { + // To override + } + + public async reply(comment: CellRec, text: string): Promise { + const author = commentAuthor(this.gristDoc); + await this.gristDoc.docData.bundleActions("Reply to a comment", () => Promise.all([ + this.gristDoc.docModel.cells.sendTableAction([ + "AddRecord", + null, + { + parentId: comment.id.peek(), + root: false, + type: CellInfoType.COMMENT, + userRef: author?.ref ?? '', + content: JSON.stringify({ + userName: author?.name ?? '', + timeCreated: Date.now(), + timeUpdated: null, + text + }), + tableRef: comment.tableRef.peek(), + colRef: comment.colRef.peek(), + rowId: comment.rowId.peek(), + } + ]) + ])); + } + public resolve(comment: CellRec): Promise { + const author = commentAuthor(this.gristDoc); + comment.resolved(true); + comment.resolvedBy(author?.email ?? ''); + return comment.timeUpdated.setAndSave((Date.now())); + } + + public async update(comment: CellRec, text: string): Promise { + const timeUpdated = Date.now(); + comment.text(text.trim()); + return comment.timeUpdated.setAndSave(timeUpdated); + } + public async open(comment: CellRec): Promise { + comment.resolved(false); + comment.resolvedBy(''); + return comment.timeUpdated.setAndSave((Date.now())); + } + + public async remove(comment: CellRec): Promise { + await comment._table.sendTableAction(["RemoveRecord", comment.id.peek()]); + } +} + +export class EmptyCell extends CellWithComments implements ICellView { + constructor(public props: { + gristDoc: GristDoc, + column: ColumnRec, + rowId: number, + tableRef: number, + }) { + super(props.gristDoc); + const {column, rowId} = props; + this.comments = Computed.create(this, use => { + const fromColumn = use(use(column.cells).getObservable()); + const forRow = fromColumn.filter(d => use(d.rowId) === rowId && use(d.root) && !use(d.hidden)); + return forRow; + }); + } + public async comment(text: string): Promise { + const props = this.props; + const author = commentAuthor(props.gristDoc); + const now = Date.now(); + const addComment = [ + "AddRecord", + "_grist_Cells", + null, + { + tableRef: props.tableRef, + colRef: props.column.id.peek(), + rowId: props.rowId, + type: CellInfoType.COMMENT, + root: true, + userRef: author?.ref ?? '', + content: JSON.stringify({ + timeCreated: now, + text: text, + userName: author?.name ?? '', + }) + } + ]; + await props.gristDoc.docData.sendActions([addComment], 'Started discussion'); + } +} + +/** + * Discussion popup that is attached to a cell. + */ +export class CellDiscussionPopup extends Disposable { + private _isEmpty: Computed; + + constructor(public props: DiscussionPopupProps) { + super(); + this._isEmpty = Computed.create(this, use => { + const discussions = use(props.topic.comments); + const notResolved = discussions.filter(d => !use(d.resolved)); + const visible = notResolved.filter(d => !use(d.hidden)); + return visible.length === 0; + }); + const content = dom('div', + testId('popup'), + dom.domComputed(use => use(this._isEmpty), empty => { + if (!empty) { + return dom.create(CellWithCommentsView, { + topic: props.topic, + readonly: props.gristDoc.isReadonly, + gristDoc: props.gristDoc, + panel: false, + closeClicked: props.closeClicked, + }); + } else { + return dom.create(EmptyCellView, { + closeClicked: props.closeClicked, + onSave: (text) => this.props.topic.comment(text), + }); + } + }) + ); + buildPopup(this, props.domEl, content, cellPopperOptions, this.props.closeClicked); + } +} + +/** + * Component for starting discussion on a cell. Displays simple textbox and a button to start discussion. + */ +class EmptyCellView extends Disposable { + private _newText = Observable.create(this, ''); + + constructor(public props: { + closeClicked: () => void, + onSave: (text: string) => any + }) { + super(); + } + + public buildDom() { + return cssTopic( + testId('topic-empty'), + testId('topic'), + this._createCommentEntry(), + dom.onKeyDown({ + Escape: () => this.props.closeClicked?.(), + }) + ); + } + + private _createCommentEntry() { + return cssCommonPadding(dom.create(CommentEntry, { + mode: 'start', + text: this._newText, + onSave: () => this.props.onSave(this._newText.get()), + onCancel: () => this.props.closeClicked?.(), + editorArgs: [{placeholder: 'Write a comment'}], + mainButton: 'Comment', + buttons: ['Cancel'], + args: [testId('editor-start')] + })); + } +} + +/** + * Main component for displaying discussion on a popup. + */ +class CellWithCommentsView extends Disposable implements IDomComponent { + // Holder for a new comment text. + private _newText = Observable.create(this, ''); + // CommentList dom - used for scrolling. + private _discussionList!: HTMLDivElement; + // Currently edited comment. + private _commentInEdit = Observable.create(this, null); + // Helper variable to mitigate some flickering when closing editor. + // We hide the editor before resolving discussion or clearing discussion, as + // actions that create discussions and comments are asynchronous, so user can see + // that comments elements are removed. + private _closing = Observable.create(this, false); + private _comments: Observable; + private _commentsToRender: Observable; + private _truncated: Observable; + + constructor(public props: { + topic: ICellView, + readonly: Observable, + gristDoc: GristDoc, + panel?: boolean, + closeClicked?: () => void + }) { + super(); + if (props.panel) { + this._comments = Computed.create(this, use => + use(props.topic.comments).filter(ds => !use(ds.hidden) && use(ds.root))); + } else { + // Don't show resolved comments on a popup. + this._comments = Computed.create(this, + use => use(props.topic.comments).filter(ds => !use(ds.resolved) && !use(ds.hidden) && use(ds.root))); + } + this._commentsToRender = Computed.create(this, use => { + const sorted = use(this._comments).sort((a, b) => (use(a.timeCreated) ?? 0) - (use(b.timeCreated) ?? 0)); + const start = Math.max(0, sorted.length - COMMENTS_LIMIT); + return sorted.slice(start); + }); + this._truncated = Computed.create(this, use => use(this._comments).length > COMMENTS_LIMIT); + } + + public buildDom() { + return cssTopic( + dom.maybe(this._truncated, () => cssTruncate(`Showing last ${COMMENTS_LIMIT} comments`)), + cssTopic.cls('-panel', this.props.panel), + domOnCustom(CommentView.EDIT, (s: CommentView) => this._onEditComment(s)), + domOnCustom(CommentView.CANCEL, (s: CommentView) => this._onCancelEdit()), + dom.hide(this._closing), + testId('topic'), + testId('topic-filled'), + dom.maybe(!this.props.panel, () => + cssHeaderBox( + testId('topic-header'), + // NOT IMPLEMENTED YET + // cssHoverButton(cssRotate('Expand'), dom.on('click', () => this._remove()), {title: 'Previous discussion'}), + // cssHoverButton(icon('Expand'), dom.on('click', () => this._remove()), {title: 'Next discussion'}), + dom('div', dom.style('align-self', 'center'), "Comments"), + cssSpacer(), + // NOT IMPLEMENTED YET + // cssIconButton( + // icon('Dots'), + // menu(() => [], {placement: 'bottom-start'}), + // dom.on('click', stopPropagation) + // ), + cssHoverButton( + icon('Popup'), + testId('topic-button-panel'), + dom.on('click', () => this.props.gristDoc.showTool('discussion')), + {title: 'Open panel'} + ), + cssHeaderBox.cls("-border") + )), + this._discussionList = cssCommentList( + testId('topic-comments'), + dom.forEach(this._commentsToRender, comment => { + return cssDiscussionWrapper( + cssDiscussion( + cssDiscussion.cls("-resolved", use => Boolean(use(comment.resolved))), + dom.create(CommentView, { + ...this.props, + comment + }) + ) + ); + } + ) + ), + !this.props.panel ? this._createCommentEntry() : null, + dom.onKeyDown({ + Escape: () => this.props.closeClicked?.(), + }) + ); + } + + private _onCancelEdit() { + if (this._commentInEdit.get()) { + this._commentInEdit.get()?.isEditing.set(false); + } + this._commentInEdit.set(null); + } + + private _onEditComment(el: CommentView) { + if (this._commentInEdit.get()) { + this._commentInEdit.get()?.isEditing.set(false); + } + el.isEditing.set(true); + this._commentInEdit.set(el); + } + + private async _save() { + try { + return await this.props.topic.comment(this._newText.get().trim()); + } catch (err) { + return reportError(err); + } finally { + this._newText.set(''); + this._discussionList.scrollTo(0, 10000); + } + } + + private _createCommentEntry() { + return cssReplyBox(dom.create(CommentEntry, { + mode: 'comment', + text: this._newText, + onSave: () => this._save(), + onCancel: () => this.props.closeClicked?.(), + mainButton: 'Send', + editorArgs: [{placeholder: 'Comment'}], + args: [testId('editor-add')] + })); + } +} + +interface CommentProps { + comment: CellRec, + topic: ICellView, + gristDoc: GristDoc, + isReply?: boolean, + panel?: boolean, + args?: DomArg[] +} + +/** + * Component for displaying a single comment, either in popup or discussion panel. + */ +class CommentView extends Disposable { + // Public custom events. Those are propagated to the parent component (TopicView) to make + // sure only one comment is in edit mode at a time. + public static EDIT = 'comment-edit'; // comment is in edit mode + public static CANCEL = 'comment-cancel'; // edit mode was cancelled or turned off + public static SELECT = 'comment-select'; // comment was clicked + // Public modes that are modified by topic view. + public isEditing = Observable.create(this, false); + public replying = Observable.create(this, false); + private _replies: ObsArray; + private _hasReplies: Computed; + private _expanded = Observable.create(this, false); + private _resolved: Computed; + private _showReplies: Computed; + private _bodyDom: Element; + constructor( + public props: CommentProps) { + super(); + this._replies = createObsArray(this, props.comment.children()); + this._hasReplies = Computed.create(this, use => use(this._replies).length > 0); + this._resolved = Computed.create(this, use => Boolean(use(props.comment.resolved))); + this._showReplies = Computed.create(this, use => { + return !this.props.isReply && use(this._replies).length > 0 && + (use(this._expanded) || !use(this.props.comment.resolved)); + }); + } + public buildDom() { + const comment = this.props.comment; + const topic = this.props.topic; + const user = (c: CellRec) => + comment.hidden() ? null : commentAuthor(this.props.gristDoc, c.userRef(), c.userName()); + this._bodyDom = cssComment( + ...(this.props.args ?? []), + this.props.isReply ? testId('reply') : testId('comment'), + dom.on('click', () => { + if (this.props.isReply) { return; } + trigger(this._bodyDom, CommentView.SELECT, comment); + if (!this._resolved.get()) { return; } + this._expanded.set(!this._expanded.get()); + }), + dom.maybe(use => !use(comment.hidden), () => [ + cssColumns( + // 1. Column with avatar only + buildAvatar(user(comment), testId('comment-avatar')), + // 2. Column with nickname/date, menu and text + cssCommentHeader( + // User name date and buttons + cssCommentBodyHeader( + dom('div', + buildNick(user(comment), testId('comment-nick')), + dom.domComputed(use => cssTime( + formatTime(use(comment.timeUpdated) ?? use(comment.timeCreated) ?? 0), + testId('comment-time'), + )), + ), + cssSpacer(), + cssIconButton( + icon('Dots'), + testId('comment-menu'), + dom.style('margin-left', `3px`), + menu(() => this._menuItems(), {placement: 'bottom-start'}), + dom.on('click', stopPropagation) + ) + ), + ), + ), + // Comment text + dom.maybe(use => !use(this.isEditing), + () => dom.domComputed(comment.hidden, (hidden) => { + if (hidden) { + return dom('div', + "CENSORED", + {style: 'margin-top: 4px'}, + testId('comment-text'), + ); + } + return cssCommentPre( + dom.text(use => use(comment.text) ?? ''), + {style: 'margin-top: 4px'}, + testId('comment-text'), + ); + }) + ), + // Comment editor + dom.maybeOwned(this.isEditing, + (owner) => { + const text = Observable.create(owner, comment.text.peek() ?? ''); + return dom.create(CommentEntry, { + text, + mainButton: 'Save', + buttons: ['Cancel'], + onSave: async () => { + const value = text.get(); + text.set(""); + await topic.update(comment, value); + trigger(this._bodyDom, CommentView.CANCEL, this); + this.isEditing.set(false); + }, + onCancel: () => { + trigger(this._bodyDom, CommentView.CANCEL, this); + this.isEditing.set(false); + }, + mode: 'start', + args: [testId('editor-edit')] + }); + } + ), + dom.maybe(this._showReplies, () => + cssCommentReplyWrapper( + testId('replies'), + cssReplyList( + dom.forEach(this._replies, (commentReply) => { + return dom('div', + dom.create(CommentView, { + ...this.props, + comment: commentReply, + isReply: true, + args: [dom.style('padding-left', '0px'), dom.style('padding-right', '0px')], + }) + ); + }), + ) + ) + ), + // Reply editor or button + dom.maybe(use => !use(this.isEditing) && !this.props.isReply && !use(comment.resolved), + () => dom.domComputed(use => { + if (!use(this.replying)) { + return cssReplyButton(icon('Message'), 'Reply', + testId('comment-reply-button'), + dom.on('click', withStop(() => this.replying.set(true))), + dom.style('margin-left', use2 => use2(this._hasReplies) ? '16px' : '0px'), + ); + } else { + const text = Observable.create(null, ''); + return dom.create(CommentEntry, { + text, + args: [dom.style('margin-top', '8px'), testId('editor-reply')], + mainButton: 'Reply', + buttons: ['Cancel'], + onSave: async () => { + const value = text.get(); + this.replying.set(false); + await topic.reply(comment, value); + }, + onCancel: () => this.replying.set(false), + onClick: (button) => { + if (button === 'Cancel') { + this.replying.set(false); + } + }, + mode: 'reply' + }); + } + }) + ), + // Resolved marker + dom.domComputed((use) => { + if (!use(comment.resolved) || this.props.isReply) { return null; } + return cssResolvedBlock( + testId('comment-resolved'), + icon('FieldChoice'), + cssResolvedText(dom.text( + `Marked as resolved` + ))); + }), + ]), + ); + + return this._bodyDom; + } + private _menuItems() { + const currentUser = this.props.gristDoc.app.topAppModel.appObs.get()?.currentUser?.ref; + const canResolve = !this.props.comment.resolved() && !this.props.isReply; + const comment = this.props.comment; + return [ + !canResolve ? null : + menuItem( + () => this.props.topic.resolve(this.props.comment), + 'Resolve' + ), + !comment.resolved() ? null : + menuItem( + () => this.props.topic.open(comment), + 'Open' + ), + menuItem( + () => this.props.topic.remove(comment), + 'Remove', + dom.cls('disabled', use => { + return currentUser !== use(comment.userRef); + }) + ), + menuItem( + () => this._edit(), + 'Edit', + dom.cls('disabled', use => { + return currentUser !== use(comment.userRef); + }) + ), + ]; + } + + private _edit() { + trigger(this._bodyDom, CommentView.EDIT, this); + this.isEditing.set(true); + } +} + +/** + * Component for displaying input element for a comment (either for replying or starting a new discussion). + */ +class CommentEntry extends Disposable { + constructor(public props: { + text: Observable, + mode?: 'comment' | 'start' | 'reply', // inline for reply, full for new discussion + onClick?: (button: string) => void, + onSave?: () => Promise|void, + onCancel?: () => void, // On Escape + mainButton?: string, // Text for the main button (defaults to Send) + buttons?: string[], // Additional buttons to show. + editorArgs?: DomArg[] + args?: DomArg[] + }) { + super(); + } + + public buildDom() { + const text = this.props.text; + const clickBuilder = (button: string) => dom.on('click', () => { + if (button === "Cancel") { + this.props.onCancel?.(); + } else { + this.props.onClick?.(button); + } + }); + const onSave = async () => text.get() ? await this.props.onSave?.() : {}; + let textArea!: HTMLElement; + return cssCommentEntry( + ...(this.props.args ?? []), + cssCommentEntry.cls(`-${this.props.mode ?? 'comment'}`), + testId('comment-input'), + dom.on('click', stopPropagation), + textArea = buildTextEditor( + text, + cssCommentEntryText.cls(""), + cssTextArea.cls(`-${this.props.mode}`), + dom.onKeyDown({ + Enter$: async (e) => { + // Save on ctrl+enter + if (e.ctrlKey && text.get().trim()) { + await onSave?.(); + e.preventDefault(); + e.stopPropagation(); + return; + } + }, + Escape: (e) => { + this.props.onCancel?.(); + e.preventDefault(); + e.stopPropagation(); + }, + }), + ...(this.props.editorArgs || []), + testId('textarea'), + ), + elem => { + FocusLayer.create(this, { + defaultFocusElem: textArea, + allowFocus: (e) => (e !== document.body), + pauseMousetrap: true + }); + }, + cssCommentEntryButtons( + primaryButton( + this.props.mainButton ?? 'Send', + dom.prop('disabled', use => !use(text).trim()), + dom.on('click', withStop(onSave)), + testId('button-send'), + ), + dom.forEach(this.props.buttons || [], button => basicButton( + button, clickBuilder(button), testId(`button-${button}`) + )), + ) + ); + } +} + +/** + * Component that is rendered on the right drawer. It shows all discussions in the document or on the + * current page. By current page, we mean comments in all currently visible rows (that are not filtered out). + */ +export class DiscussionPanel extends Disposable implements IDomComponent { + // View mode - current page or whole document. + private _currentPage: Observable; + private _currentPageKo: ko.Observable; + private _onlyMine: Observable; + // Toggle to switch whether to show active discussions or all discussions (including resolved ones). + private _resolved: Observable; + private _length = Observable.create(this, 0); + + constructor(private _grist: GristDoc) { + super(); + const userId = _grist.app.topAppModel.appObs.get()?.currentUser?.id || 0; + // We store options in session storage, so that they are preserved across page reloads. + this._resolved = this.autoDispose(localStorageBoolObs(`u:${userId};showResolvedDiscussions`, false)); + this._onlyMine = this.autoDispose(localStorageBoolObs(`u:${userId};showMyDiscussions`, false)); + this._currentPage = this.autoDispose(localStorageBoolObs(`u:${userId};showCurrentPage`, true)); + this._currentPageKo = ko.observable(this._currentPage.get()); + this._currentPage.addListener(val => this._currentPageKo(val)); + } + + public buildDom(): DomContents { + const owner = new MultiHolder(); + + // Computed for all sections visible on the page. + const viewSections = Computed.create(owner, use => { + return use(use(this._grist.viewModel.viewSections).getObservable()); + }); + + // Based on the view, we get all tables or only visible ones. + const tables = Computed.create(owner, use => { + // Filter out those tables that are not available by ACL. + if (use(this._currentPageKo)) { + return [...new Set(use(viewSections).map(vs => use(vs.table)).filter(t => use(t.tableId)))]; + } else { + return use(this._grist.docModel.visibleTables.getObservable()).filter(t => use(t.tableId)); + } + }); + + // Column filter - only show discussions in this column (depending on the mode). + const columnFilter = Computed.create(owner, use => { + if (use(this._currentPageKo)) { + const fieldSet = new Set(); + use(viewSections).forEach(vs => { + use(use(vs.viewFields).getObservable()).forEach(vf => fieldSet.add(use(vf.colRef))); + }); + return (ds: CellRec) => { + return fieldSet.has(use(ds.colRef)); + }; + } else { + return () => true; + } + }); + + // Create a row filter based on user filters (rows that user actually see). + const watcher = RowWatcher.create(owner); + watcher.rowFilter.set(() => true); + // Now watch for viewSections (when they are changed, and then update watcher instance). + // Unfortunately, we can't use _viewSections here because GrainJS has a different + // behavior than ko when one observable changes during the evaluation. Here viewInstance + // will probably be set during computations. To fix this we need a ko.observable here. + const sources = owner.autoDispose(ko.computed(() => { + if (this._currentPageKo()) { + const list: RowSource[] = []; + for (const vs of this._grist.viewModel.viewSections().all()) { + const viewInstance = vs.viewInstance(); + if (viewInstance) { + list.push(viewInstance.rowSource); + } + } + return list; + } + return null; + })); + sources.peek()?.forEach(source => watcher.subscribeTo(source)); + owner.autoDispose(sources.subscribe(list => { + bundleChanges(() => { + watcher.clear(); + if (list) { + list.forEach(source => watcher.subscribeTo(source)); + } else { + // Page + watcher.rowFilter.set(() => true); + } + }); + })); + + const rowFilter = watcher.rowFilter; + + const discussionFilter = Computed.create(owner, use => { + const filterRow = use(rowFilter); + const filterCol = use(columnFilter); + const showAll = use(this._resolved); + const showAnyone = !use(this._onlyMine); + const currentUser = use(this._grist.app.topAppModel.appObs)?.currentUser?.email ?? ''; + const userFilter = (d: CellRec) => { + const replies = use(use(d.children).getObservable()); + return use(d.userRef) === currentUser || replies.some(c => use(c.userRef) === currentUser); + }; + return (ds: CellRec) => + !use(ds.hidden) // filter by ACL + && filterRow(use(ds.rowId)) + && filterCol(ds) + && (showAnyone || userFilter(ds)) + && (showAll || !use(ds.resolved)) + ; + }); + const allDiscussions = Computed.create(owner, use => { + const list = flatMap(flatMap(use(tables).map(t => { + const columns = use(use(t.columns).getObservable()); + const dList = columns.map(col => use(use(col.cells).getObservable()) + .filter(c => use(c.root) && use(c.type) === CellInfoType.COMMENT)); + return dList; + }))); + return list; + }); + const discussions = Computed.create(owner, use => { + const all = use(allDiscussions); + const filter = use(discussionFilter); + return all.filter(filter); + }); + const topic = CellWithComments.create(owner, this._grist); + topic.comments = discussions; + owner.autoDispose(discussions.addListener((d) => this._length.set(d.length))); + this._length.set(discussions.get().length); + // Selector for page all whole document. + return cssDiscussionPanel( + dom.autoDispose(owner), + testId('panel'), + // Discussion list - actually we are showing first comment of each discussion. + cssDiscussionPanelList( + dom.create(CellWithCommentsView, { + readonly: this._grist.isReadonly, + gristDoc: this._grist, + topic: topic, + panel: true + }) + ), + domOnCustom(CommentView.SELECT, (ds: CellRec) => { + this._navigate(ds).catch(() => {}); + }) + ); + } + + public buildMenu(): DomContents { + return cssPanelHeader( + dom('span', dom.text(use => `${use(this._length)} comments`), testId('comment-count')), + cssIconButtonMenu( + icon('Dots'), + testId('panel-menu'), + menu(() => { + return [cssDropdownMenu( + labeledSquareCheckbox(this._onlyMine, "Only my threads", testId('my-threads')), + labeledSquareCheckbox(this._currentPage, "Only current page", testId('only-page')), + labeledSquareCheckbox(this._resolved, "Show resolved comments", testId('show-resolved')), + )]; + }, {placement: 'bottom-start'}), + dom.on('click', stopPropagation) + ), + ); + } + + /** + * Navigates to cell on current page or opens discussion next to the panel. + */ + private async _navigate(discussion: CellRec) { + // Try to find the cell on the current page. + const rowId = discussion.rowId.peek(); + function findSection(viewSections: ViewSectionRec[]) { + const section = viewSections + .filter(s => s.tableRef.peek() === discussion.tableRef.peek()) + .filter(s => s.viewFields.peek().all().find(f => f.colRef.peek() === discussion.colRef.peek()))[0]; + const sectionId = section?.getRowId(); + const fieldIndex = section?.viewFields.peek().all() + .findIndex(f => f.colRef.peek() === discussion.colRef.peek()) ?? -1; + if (fieldIndex !== -1) { + return {sectionId, fieldIndex}; + } + return null; + } + let sectionId = 0; + let fieldIndex = -1; + const section = findSection(this._grist.viewModel.viewSections.peek().all()); + // If we haven't found the cell on the current page, try other pages. + if (!section) { + for (const pageId of this._grist.docModel.pages.getAllRows()) { + const page = this._grist.docModel.pages.getRowModel(pageId); + const vss = page.view.peek().viewSections.peek().all(); + const result = findSection(vss); + if (result) { + sectionId = result.sectionId; + fieldIndex = result.fieldIndex; + break; + } + } + } else { + sectionId = section.sectionId; + fieldIndex = section.fieldIndex; + } + + if (!sectionId) { + return; + } + + const currentPosition = this._grist.cursorPosition.get(); + + if (currentPosition?.sectionId === sectionId && + currentPosition.fieldIndex === fieldIndex && + currentPosition.rowId === rowId) { + return; + } + + // Navigate cursor to the cell. + const ok = await this._grist.recursiveMoveToCursorPos({ + rowId, + sectionId, + fieldIndex + }, true); + if (!ok) { + return; + } + } +} + +function buildTextEditor(text: Observable, ...args: DomArg[]) { + const textArea = cssTextArea( + bindProp(text), + autoFocus(), + autoGrow(text), + ...args + ); + return textArea; +} + + +function buildAvatar(user: FullUser | null, ...args: DomElementArg[]) { + return cssAvatar(user, 'small', ...args); +} + +function buildNick(user: {name: string} | null, ...args: DomArg[]) { + return cssNick(user?.name ?? 'Anonymous', ...args); +} + +function bindProp(text: Observable) { + return [ + dom.prop('value', text), + dom.on('input', (_, el: HTMLTextAreaElement) => text.set(el.value)), + ]; +} + +function autoFocus() { + return (el: HTMLElement) => void setTimeout(() => el.focus(), 10); +} + +function resize(el: HTMLTextAreaElement) { + el.style.height = '5px'; // hack for triggering style update. + const border = getComputedStyle(el, null).borderTopWidth || "0"; + el.style.height = `calc(${el.scrollHeight}px + 2 * ${border})`; +} + +function autoGrow(text: Observable) { + return (el: HTMLTextAreaElement) => { + el.addEventListener('input', () => resize(el)); + setTimeout(() => resize(el), 10); + dom.autoDisposeElem(el, text.addListener(val => { + // Changes to the text are not reflected by the input event (witch is used by the autoGrow) + // So we need to manually update the textarea when the text is cleared. + if (!val) { + el.style.height = '5px'; // there is a min-height css attribute, so this is only to trigger a style update. + } + })); + }; +} + +function buildPopup( + owner: Disposable, + cell: Element, + content: HTMLElement, + options: Partial, + closeClicked: () => void +) { + const popper = createPopper(cell, content, options); + owner.onDispose(() => popper.destroy()); + document.body.appendChild(content); + owner.onDispose(() => { dom.domDispose(content); content.remove(); }); + owner.autoDispose(onClickOutside(content, () => closeClicked())); +} + +// Helper binding function to handle click outside an element. Takes into account floating menus. +function onClickOutside(content: HTMLElement, click: () => void) { + const onClick = (evt: MouseEvent) => { + const target: Node | null = evt.target as Node; + if (target && !content.contains(target)) { + // Check if any parent of target has class grist-floating-menu, if so, don't close. + if (target.parentElement?.closest(".grist-floating-menu")) { + return; + } + click(); + } + }; + return dom.onElem(document, 'click', onClick, {useCapture: true}); +} + +// Display timestamp as a relative time ago using moment.js +function formatTime(timeStamp: number) { + const time = moment(timeStamp); + const now = moment(); + const diff = now.diff(time, 'days'); + if (diff < 1) { + return time.fromNow(); + } + return time.format('MMM D, YYYY'); +} + +function commentAuthor(grist: GristDoc, userRef?: string, userName?: string): FullUser | null { + if (!userRef) { + const loggedInUser = grist.app.topAppModel.appObs.get()?.currentValidUser; + if (!loggedInUser) { + return { + name: userName || '', + ref: userRef || '', + email: '', + id: 0 + }; + } + if (!loggedInUser.ref) { + throw new Error("User reference is not set"); + } + return loggedInUser; + } else { + if (typeof userName !== 'string') { + return null; + } + return { + name: userName, + ref: userRef || '', + email: '', + id: 0 + }; + } +} + +// Options for popper.js +const calcMaxSize = { + ...maxSize, + options: {padding: 4}, +}; +const applyMaxSize: any = { + name: 'applyMaxSize', + enabled: true, + phase: 'beforeWrite', + requires: ['maxSize'], + fn({state}: any) { + // The `maxSize` modifier provides this data + const {height} = state.modifiersData.maxSize; + Object.assign(state.styles.popper, { + maxHeight: `${Math.min(Math.max(250, height), 600)}px` + }); + } +}; +const cellPopperOptions: Partial = { + placement: 'bottom', + strategy: 'fixed', + modifiers: [ + calcMaxSize, + applyMaxSize, + { + name: 'offset', + options: { + offset: [0, 4], + }, + }, + {name: "computeStyles", options: {gpuAcceleration: false}}, + {name: 'eventListeners', enabled: false} + ], +}; + + +function stopPropagation(ev: Event) { + ev.stopPropagation(); +} + +function withStop(handler: () => any) { + return (ev: Event) => { + stopPropagation(ev); + handler(); + }; +} + +const cssAvatar = styled(createUserImage, ` + flex: none; + margin-top: 2px; +`); + + +const cssDiscussionPanel = styled('div', ` + display: flex; + flex-direction: column; + flex: 1; + overflow: auto; + padding: 8px; +`); + +const cssDiscussionPanelList = styled('div', ` + margin-bottom: 0px; +`); + +const cssCommonPadding = styled('div', ` + padding: 16px; +`); + +const cssPanelHeader = styled('div', ` + display: flex; + flex: 1; + align-items: center; + justify-content: space-between; +`); + +const cssDropdownMenu = styled('div', ` + display: flex; + padding: 12px; + padding-left: 16px; + padding-right: 16px; + gap: 10px; + flex-direction: column; +`); + +const cssReplyBox = styled(cssCommonPadding, ` + border-top: 1px solid ${colors.mediumGrey}; +`); + +const cssCommentEntry = styled('div', ` + display: grid; + &-comment { + grid-template-columns: 1fr auto; + grid-template-rows: 1fr; + gap: 8px; + grid-template-areas: "text buttons"; + } + &-start, &-reply { + grid-template-rows: 1fr auto; + grid-template-columns: 1fr; + gap: 8px; + grid-template-areas: "text" "buttons"; + } +`); + +const cssCommentEntryText = styled('div', ` + grid-area: text; +`); + +const cssCommentEntryButtons = styled('div', ` + grid-area: buttons; + display: flex; + align-items: flex-start; + + gap: 8px; +`); + +const cssTextArea = styled('textarea', ` + min-height: 5em; + border-radius: 3px; + padding: 4px 6px; + border: 1px solid ${colors.darkGrey}; + outline: none; + width: 100%; + resize: none; + max-height: 10em; + &-comment, &-reply { + min-height: 28px; + height: 28px; + } +`); + +const cssHeaderBox = styled('div', ` + background-color: ${colors.lightGrey}; + padding: 12px; /* 12px * 2 + 24px (size of the icon) == 48px in height */ + padding-right: 16px; + display: flex; + gap: 8px; + &-border { + border-bottom: 1px solid ${colors.mediumGrey}; + } +`); + +const cssTopic = styled('div', ` + position: relative; + display: flex; + flex-direction: column; + border: 1px solid #ccc; + border-radius: 4px; + background-color: ${colors.light}; + box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.2); + z-index: 100; + width: 325px; + overflow: hidden; + outline: none; + max-height: inherit; + &-disabled { + background-color: ${vars.labelActiveBg} + } + &-panel { + width: unset; + box-shadow: none; + border-radius: 0px; + background: unset; + border: 0px; + } +`); + +const cssDiscussionWrapper = styled('div', ` + border-bottom: 1px solid ${colors.darkGrey}; + &:last-child { + border-bottom: none; + } + .${cssTopic.className}-panel & { + border: 1px solid #ccc; + border-radius: 4px; + background-color: ${colors.light}; + margin-bottom: 4px; + } +`); + +const cssDiscussion = styled('div', ` + display: flex; + flex-direction: column; + padding: 16px; + &-resolved { + background-color: ${vars.labelActiveBg}; + cursor: pointer; + } + &-resolved * { + color: ${colors.slate} !important; + } +`); + +const cssCommentPre = styled('pre', ` + padding: 0px; + font-size: revert; + border: 0px; + background: inherit; + font-family: inherit; + margin: 0px; + white-space: break-spaces; + word-break: break-word; + word-wrap: break-word; +`); + +const cssCommentList = styled('div', ` + display: flex; + flex-direction: column; + overflow: auto; +`); + +const cssColumns = styled('div', ` + display: flex; + align-items: flex-start; + gap: 8px; +`); + +const cssCommentReplyWrapper = styled('div', ` + margin-top: 16px; +`); + +const cssComment = styled('div', ` + border-bottom: 1px solid ${colors.mediumGrey}; + .${cssCommentList.className} &:last-child { + border-bottom: 0px; + } +`); + +const cssReplyList = styled('div', ` + margin-left: 16px; + display: flex; + flex-direction: column; + gap: 20px; +`); + +const cssCommentHeader = styled('div', ` + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + overflow: hidden; +`); + +const cssCommentBodyHeader = styled('div', ` + display: flex; + align-items: baseline; + overflow: hidden; +`); + +const cssIconButton = styled('div', ` + flex: none; + margin: 0 4px 0 auto; + height: 24px; + width: 24px; + padding: 4px; + line-height: 0px; + border-radius: 3px; + cursor: pointer; + --icon-color: ${colors.slate}; + &:hover, &.weasel-popup-open { + background-color: ${colors.darkGrey}; + } +`); + +const cssIconButtonMenu = styled('div', ` + flex: none; + margin: 0 4px 0 auto; + height: 24px; + width: 24px; + padding: 4px; + line-height: 0px; + border-radius: 3px; + cursor: pointer; + --icon-color: ${colors.light}; + &:hover, &.weasel-popup-open { + background-color: ${colors.darkGreen}; + } +`); + +const cssReplyButton = styled(textButton, ` + align-self: flex-start; + display: flex; + gap: 4px; + margin-top: 16px; +`); + +const cssTime = styled('div', ` + color: ${colors.slate}; + font-size: ${vars.smallFontSize}; + text-overflow: ellipsis; + white-space: nowrap; + letter-spacing: 0.02em; + line-height: 16px; +`); + +const cssNick = styled('div', ` + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + color: ${colors.darkText}; + &-small { + font-size: 12px; + } +`); + +const cssSpacer = styled('div', ` + flex-grow: 1; +`); + +const cssCloseButton = styled('div', ` + padding: 4px; + border-radius: 4px; + line-height: 1px; + cursor: pointer; + --icon-color: ${colors.slate}; + &:hover { + background-color: ${colors.mediumGreyOpaque}; + } +`); + +const cssHoverButton = styled(cssCloseButton, ` + &:hover { + --icon-color: ${colors.lightGreen}; + } +`); + +// NOT IMPLEMENTED YET +// const cssRotate = styled(icon, ` +// transform: rotate(180deg); +// `); + +function domOnCustom(name: string, handler: (args: any, event: Event, element: Element) => void) { + return (el: Element) => { + dom.onElem(el, name, (ev, target) => { + const cv = ev as CustomEvent; + handler(cv.detail.args ?? {}, ev, target); + }); + }; +} + +function trigger(element: Element, name: string, args?: any) { + element.dispatchEvent(new CustomEvent(name, { + bubbles: true, + detail: {args} + })); +} + +const cssResolvedBlock = styled('div', ` + margin-top: 5px; + --icon-color: ${colors.dark}; +`); + +const cssResolvedText = styled('span', ` + color: ${colors.dark}; + font-size: ${vars.smallFontSize}; + margin-left: 5px; +`); + +const cssTruncate = styled('div', ` + position: absolute; + background: white; + inset: 0; + height: 2rem; + opacity: 57%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; +`); diff --git a/app/client/widgets/FieldBuilder.css b/app/client/widgets/FieldBuilder.css index 974bbfd1..52f88809 100644 --- a/app/client/widgets/FieldBuilder.css +++ b/app/client/widgets/FieldBuilder.css @@ -30,3 +30,18 @@ border-radius: 5px; background-color: var(--grist-theme-right-panel-field-settings-button-bg, lightgrey); } + +.field-comment-indicator { + display: none; +} + +.field-with-comments .field-comment-indicator { + display: block; + position: absolute; + top: 0; + right: 0; + width: 0; + height: 0; + border-top: 11px solid var(--grist-color-orange); + border-left: 11px solid transparent; +} diff --git a/app/client/widgets/FieldBuilder.ts b/app/client/widgets/FieldBuilder.ts index 4f9cafb5..dca47244 100644 --- a/app/client/widgets/FieldBuilder.ts +++ b/app/client/widgets/FieldBuilder.ts @@ -14,6 +14,7 @@ import { DataRowModel } from 'app/client/models/DataRowModel'; import { ColumnRec, DocModel, ViewFieldRec } from 'app/client/models/DocModel'; import { SaveableObjObservable, setSaveValue } from 'app/client/models/modelUtil'; import { CombinedStyle, Style } from 'app/client/models/Styles'; +import { COMMENTS } from 'app/client/models/features'; import { FieldSettingsMenu } from 'app/client/ui/FieldMenus'; import { cssBlockedCursor, cssLabel, cssRow } from 'app/client/ui/RightPanelStyles'; import { buttonSelect, cssButtonSelect } from 'app/client/ui2018/buttonSelect'; @@ -22,6 +23,7 @@ import { IOptionFull, menu, select } from 'app/client/ui2018/menus'; import { DiffBox } from 'app/client/widgets/DiffBox'; import { buildErrorDom } from 'app/client/widgets/ErrorDom'; import { FieldEditor, saveWithoutEditor, setupEditorCleanup } from 'app/client/widgets/FieldEditor'; +import { CellDiscussionPopup, EmptyCell } from 'app/client/widgets/DiscussionEditor'; import { openFormulaEditor } from 'app/client/widgets/FormulaEditor'; import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget'; import { NewBaseEditor } from "app/client/widgets/NewBaseEditor"; @@ -37,6 +39,8 @@ import * as _ from 'underscore'; const testId = makeTestId('test-fbuilder-'); + + // Creates a FieldBuilder object for each field in viewFields export function createAllFieldWidgets(gristDoc: GristDoc, viewFields: ko.Computed>, cursor: Cursor, options: { isPreview?: boolean } = {}) { @@ -99,6 +103,7 @@ export class FieldBuilder extends Disposable { private readonly _widgetCons: ko.Computed<{create: (...args: any[]) => NewAbstractWidget}>; private readonly _docModel: DocModel; private readonly _readonly: Computed; + private readonly _comments: ko.Computed; public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec, private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) { @@ -107,6 +112,7 @@ export class FieldBuilder extends Disposable { this._docModel = gristDoc.docModel; this.origColumn = field.column(); this.options = field.widgetOptionsJson; + this._comments = ko.pureComputed(() => toKo(ko, COMMENTS())()); this._readOnlyPureType = ko.pureComputed(() => this.field.column().pureType()); @@ -566,27 +572,47 @@ export class FieldBuilder extends Disposable { const errorInStyle = ko.pureComputed(() => Boolean(computedRule()?.error)); const cellText = ko.pureComputed(() => this.field.textColor() || ''); - const cllFill = ko.pureComputed(() => this.field.fillColor() || ''); + const cellFill = ko.pureComputed(() => this.field.fillColor() || ''); + + const hasComment = koUtil.withKoUtils(ko.computed(() => { + if (this.isDisposed()) { return false; } // Work around JS errors during field removal. + if (!this._comments()) { return false; } + if (this.gristDoc.isReadonlyKo()) { return false; } + const rowId = row.id(); + const discussion = this.field.column().cells().all() + .find(d => + d.rowId() === rowId + && !d.resolved() + && d.type() === gristTypes.CellInfoType.COMMENT + && !d.hidden() + && d.root()); + return Boolean(discussion); + }).extend({ deferred: true })).onlyNotifyUnequal(); + const domHolder = new MultiHolder(); + domHolder.autoDispose(hasComment); + domHolder.autoDispose(widgetObs); + domHolder.autoDispose(computedFlags); + domHolder.autoDispose(errorInStyle); + domHolder.autoDispose(cellText); + domHolder.autoDispose(cellFill); + domHolder.autoDispose(computedRule); + domHolder.autoDispose(fontBold); + domHolder.autoDispose(fontItalic); + domHolder.autoDispose(fontUnderline); + domHolder.autoDispose(fontStrikethrough); return (elem: Element) => { this._rowMap.set(row, elem); dom(elem, - dom.autoDispose(widgetObs), - dom.autoDispose(computedFlags), - dom.autoDispose(errorInStyle), - dom.autoDispose(ruleText), - dom.autoDispose(computedRule), - dom.autoDispose(ruleFill), - dom.autoDispose(fontBold), - dom.autoDispose(fontItalic), - dom.autoDispose(fontUnderline), - dom.autoDispose(fontStrikethrough), + dom.autoDispose(domHolder), kd.style('--grist-cell-color', cellText), - kd.style('--grist-cell-background-color', cllFill), + kd.style('--grist-cell-background-color', cellFill), kd.style('--grist-rule-color', ruleText), kd.style('--grist-column-rule-background-color', ruleFill), this._options.isPreview ? null : kd.cssClass(this.field.formulaCssClass), + kd.toggleClass('field-with-comments', hasComment), + kd.maybe(hasComment, () => dom('div.field-comment-indicator')), kd.toggleClass("readonly", toKo(ko, this._readonly)), kd.maybe(isSelected, () => dom('div.selected_cursor', kd.toggleClass('active_cursor', isActive) @@ -671,6 +697,43 @@ export class FieldBuilder extends Disposable { this.gristDoc.activeEditor.set(fieldEditor); } + public buildDiscussionPopup(editRow: DataRowModel, mainRowModel: DataRowModel, discussionId?: number) { + const owner = this.gristDoc.fieldEditorHolder; + const cellElem: Element = this._rowMap.get(mainRowModel)!; + if (this.columnTransform) { + this.columnTransform.finalize().catch(reportError); + return; + } + if (editRow._isAddRow.peek() || this._readonly.get()) { + return; + } + + const cell = editRow.cells[this.field.colId()]; + const value = cell && cell(); + if (gristTypes.isCensored(value)) { + this._fieldEditorHolder.clear(); + return; + } + + const tableRef = this.field.viewSection.peek()!.tableRef.peek()!; + + // Reuse fieldEditor holder to make sure only one popup/editor is attached to the cell. + const discussionHolder = MultiHolder.create(owner); + const discussions = EmptyCell.create(discussionHolder, { + gristDoc: this.gristDoc, + tableRef, + column: this.field.column.peek(), + rowId: editRow.id.peek(), + }); + CellDiscussionPopup.create(discussionHolder, { + domEl: cellElem, + topic: discussions, + discussionId, + gristDoc: this.gristDoc, + closeClicked: () => owner.clear() + }); + } + public isEditorActive() { return !this._fieldEditorHolder.isEmpty(); } diff --git a/app/client/widgets/FieldEditor.ts b/app/client/widgets/FieldEditor.ts index d22a59c1..68ced9c0 100644 --- a/app/client/widgets/FieldEditor.ts +++ b/app/client/widgets/FieldEditor.ts @@ -334,7 +334,7 @@ export class FieldEditor extends Disposable { let waitPromise: Promise|null = null; if (isFormula) { - const formula = editor.getCellValue(); + const formula = String(editor.getCellValue() ?? ''); // Bundle multiple changes so that we can undo them in one step. if (isFormula !== col.isFormula.peek() || formula !== col.formula.peek()) { waitPromise = this._gristDoc.docData.bundleActions(null, () => Promise.all([ diff --git a/app/client/widgets/FormulaEditor.ts b/app/client/widgets/FormulaEditor.ts index e7c1e28e..eb57cd50 100644 --- a/app/client/widgets/FormulaEditor.ts +++ b/app/client/widgets/FormulaEditor.ts @@ -298,10 +298,10 @@ export function openFormulaEditor(options: { // AsyncOnce ensures it's called once even if triggered multiple times. const saveEdit = asyncOnce(async () => { - const formula = editor.getCellValue(); + const formula = String(editor.getCellValue()); if (formula !== column.formula.peek()) { if (options.onSave) { - await options.onSave(column, formula as string); + await options.onSave(column, formula); } else { await column.updateColValues({formula}); } diff --git a/app/common/DocActions.ts b/app/common/DocActions.ts index 63d61c87..2f4bbdcc 100644 --- a/app/common/DocActions.ts +++ b/app/common/DocActions.ts @@ -79,7 +79,6 @@ export function isRenameTable(act: DocAction): act is RenameTable { return act[0 const SCHEMA_ACTIONS = new Set(['AddTable', 'RemoveTable', 'RenameTable', 'AddColumn', 'RemoveColumn', 'RenameColumn', 'ModifyColumn']); -// Maps each data action to whether it's a bulk action. const DATA_ACTIONS = new Set(['AddRecord', 'RemoveRecord', 'UpdateRecord', 'BulkAddRecord', 'BulkRemoveRecord', 'BulkUpdateRecord', 'ReplaceTableData', 'TableData']); diff --git a/app/common/GranularAccessClause.ts b/app/common/GranularAccessClause.ts index d2cc4aef..cff5b277 100644 --- a/app/common/GranularAccessClause.ts +++ b/app/common/GranularAccessClause.ts @@ -45,6 +45,7 @@ export interface UserInfo { Origin: string | null; LinkKey: Record; UserID: number | null; + UserRef: string | null; [attributes: string]: unknown; toJSON(): {[key: string]: any}; } diff --git a/app/common/UserAPI.ts b/app/common/UserAPI.ts index 40a8538a..d7664397 100644 --- a/app/common/UserAPI.ts +++ b/app/common/UserAPI.ts @@ -175,6 +175,7 @@ export interface UserAccessData { id: number; name: string; email: string; + ref?: string|null; picture?: string|null; // When present, a url to a public image of unspecified dimensions. // Represents the user's direct access to the resource of interest. Lack of access to a resource // is represented by a null value. diff --git a/app/common/gristTypes.ts b/app/common/gristTypes.ts index 5d5bf937..ebd73c09 100644 --- a/app/common/gristTypes.ts +++ b/app/common/gristTypes.ts @@ -344,3 +344,10 @@ export function isValidRuleValue(value: CellValue|undefined) { } export type RefListValue = [GristObjCode.List, ...number[]]|null; + +/** + * Type of cell metadata information. + */ +export enum CellInfoType { + COMMENT = 1, +} diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 5c911931..a8f0ba0b 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -568,6 +568,9 @@ export interface GristLoadConfig { // Loaded namespaces for translations. namespaces?: readonly string[]; + + // TODO: remove when comments will be released. + featureComments?: boolean; } export const HideableUiElements = StringUnion("helpCenter", "billing", "templates", "multiSite", "multiAccounts"); diff --git a/app/common/schema.ts b/app/common/schema.ts index ceeb2df3..d4564fcc 100644 --- a/app/common/schema.ts +++ b/app/common/schema.ts @@ -4,7 +4,7 @@ import { GristObjCode } from "app/plugin/GristData"; // tslint:disable:object-literal-key-quotes -export const SCHEMA_VERSION = 32; +export const SCHEMA_VERSION = 33; export const schema = { @@ -196,6 +196,17 @@ export const schema = { filter : "Text", }, + "_grist_Cells": { + tableRef : "Ref:_grist_Tables", + colRef : "Ref:_grist_Tables_column", + rowId : "Int", + root : "Bool", + parentId : "Ref:_grist_Cells", + type : "Int", + content : "Text", + userRef : "Text", + }, + }; export interface SchemaTypes { @@ -388,4 +399,15 @@ export interface SchemaTypes { filter: string; }; + "_grist_Cells": { + tableRef: number; + colRef: number; + rowId: number; + root: boolean; + parentId: number; + type: number; + content: string; + userRef: string; + }; + } diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index 1eab7005..3e570cf8 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -483,6 +483,7 @@ export class HomeDBManager extends EventEmitter { email: user.logins[0].displayEmail, name: user.name, picture: user.picture, + ref: user.ref, }; if (this.getAnonymousUserId() === user.id) { result.anonymous = true; diff --git a/app/server/lib/GranularAccess.ts b/app/server/lib/GranularAccess.ts index fd1cfdb7..5a303a6d 100644 --- a/app/server/lib/GranularAccess.ts +++ b/app/server/lib/GranularAccess.ts @@ -10,6 +10,11 @@ import { BulkColValues, BulkRemoveRecord, BulkUpdateRecord, + getColValues, + isBulkAddRecord, + isBulkRemoveRecord, + isBulkUpdateRecord, + isUpdateRecord, } from 'app/common/DocActions'; import { RemoveRecord, ReplaceTableData, UpdateRecord } from 'app/common/DocActions'; import { CellValue, ColValues, DocAction, getTableId, isSchemaAction } from 'app/common/DocActions'; @@ -23,7 +28,7 @@ import { AclMatchInput, InfoEditor, InfoView } from 'app/common/GranularAccessCl import { UserInfo } from 'app/common/GranularAccessClause'; import * as gristTypes from 'app/common/gristTypes'; import { getSetMapValue, isNonNullish, pruneArray } from 'app/common/gutil'; -import { SingleCell } from 'app/common/TableData'; +import { MetaRowRecord, SingleCell } from 'app/common/TableData'; import { canEdit, canView, isValidRole, Role } from 'app/common/roles'; import { FullUser, UserAccessData } from 'app/common/UserAPI'; import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager'; @@ -40,6 +45,7 @@ import { integerParam } from 'app/server/lib/requestUtils'; import { getRelatedRows, getRowIdsFromDocAction } from 'app/server/lib/RowAccess'; import cloneDeep = require('lodash/cloneDeep'); import fromPairs = require('lodash/fromPairs'); +import memoize = require('lodash/memoize'); import get = require('lodash/get'); // tslint:disable:no-bitwise @@ -208,7 +214,7 @@ export interface GranularAccessForBundle { * will be abandoned. * - appliedBundle(), called when DocActions have been applied to the DB, but before * those changes have been sent to clients. - * - sendDocUpdateforBundle() is called once a bundle has been applied, to notify + * - sendDocUpdateForBundle() is called once a bundle has been applied, to notify * client of changes. * - finishedBundle(), called when completely done with modification and any needed * client notifications, whether successful or failed. @@ -303,21 +309,44 @@ export class GranularAccess implements GranularAccessForBundle { } /** - * Get content of a given cell, if user has read access. + * Checks if user has read access to a cell. Optionally takes docData that will be used + * to retrieve the cell value instead of the current docData. + */ + public async hasCellAccess(docSession: OptDocSession, cell: SingleCell, docData?: DocData): Promise { + try { + await this.getCellValue(docSession, cell, docData); + return true; + } catch(err) { + if (err instanceof ErrorWithCode) { return false; } + throw err; + } + } + + /** + * Get content of a given cell, if user has read access. Optionally takes docData that will be used + * to retrieve the cell value instead of the current docData. * Throws if not. */ - public async getCellValue(docSession: OptDocSession, cell: SingleCell): Promise { + public async getCellValue(docSession: OptDocSession, cell: SingleCell, docData?: DocData): Promise { function fail(): never { throw new ErrorWithCode('ACL_DENY', 'Cannot access cell'); } - const pset = await this.getTableAccess(docSession, cell.tableId); - const tableAccess = this.getReadPermission(pset); - if (tableAccess === 'deny') { fail(); } - const rows = await this._fetchQueryFromDB({ - tableId: cell.tableId, - filters: { id: [cell.rowId] } - }); - if (!rows || rows[2].length === 0) { fail(); } + if (!await this.hasTableAccess(docSession, cell.tableId)) { fail(); } + let rows: TableDataAction|null = null; + if (docData) { + const record = docData.getTable(cell.tableId)?.getRecord(cell.rowId); + if (record) { + rows = ['TableData', cell.tableId, [cell.rowId], getColValues([record])]; + } + } else { + rows = await this._fetchQueryFromDB({ + tableId: cell.tableId, + filters: { id: [cell.rowId] } + }); + } + if (!rows || rows[2].length === 0) { + return fail(); + } const rec = new RecordView(rows, 0); const input: AclMatchInput = {user: await this._getUser(docSession), rec, newRec: rec}; const rowPermInfo = new PermissionInfo(this._ruler.ruleCollection, input); @@ -361,7 +390,9 @@ export class GranularAccess implements GranularAccessForBundle { public async canApplyBundle() { if (!this._activeBundle) { throw new Error('no active bundle'); } const {docActions, docSession, isDirect} = this._activeBundle; - if (this._activeBundle.hasDeliberateRuleChange && !await this.isOwner(docSession)) { + const currentUser = await this._getUser(docSession); + const userIsOwner = await this.isOwner(docSession); + if (this._activeBundle.hasDeliberateRuleChange && !userIsOwner) { throw new ErrorWithCode('ACL_DENY', 'Only owners can modify access rules'); } // Normally, viewer requests would never reach this point, but they can happen @@ -383,6 +414,8 @@ export class GranularAccess implements GranularAccessForBundle { })); } + await this._canApplyCellActions(currentUser, userIsOwner); + if (this._recoveryMode) { // Don't do any further checking in recovery mode. return; @@ -483,9 +516,12 @@ export class GranularAccess implements GranularAccessForBundle { const actions = await Promise.all( docActions.map((action, actionIdx) => this._filterOutgoingDocAction({docSession, action, actionIdx}))); - return ([] as DocAction[]).concat(...actions); + const result = ([] as DocAction[]).concat(...actions); + + return await this._filterOutgoingCellInfo(docSession, docActions, result); } + /** * Filter an ActionGroup to be sent to a client. */ @@ -762,9 +798,21 @@ export class GranularAccess implements GranularAccessForBundle { // If we are going to modify metadata, make a copy. tables = cloneDeep(tables); + // Prepare cell censorship information. + const cells = new CellData(this._docData).convertToCells(tables['_grist_Cells']); + let cellCensor: CellAccessHelper|undefined; + if (cells.length > 0) { + cellCensor = this._createCellAccess(docSession); + await cellCensor.calculate(cells); + } + const permInfo = await this._getAccess(docSession); const censor = new CensorshipInfo(permInfo, this._ruler.ruleCollection, tables, - await this.hasAccessRulesPermission(docSession)); + await this.hasAccessRulesPermission(docSession), + cellCensor); + if (cellCensor) { + censor.filter(tables["_grist_Cells"]); + } for (const tableId of STRUCTURAL_TABLES) { censor.apply(tables[tableId]); @@ -899,6 +947,38 @@ export class GranularAccess implements GranularAccessForBundle { return baseAccess; } + public async createSnapshotWithCells(docActions?: DocAction[]) { + if (!docActions) { + if (!this._activeBundle) { throw new Error('no active bundle'); } + if (this._activeBundle.applied) { + throw new Error("Can't calculate last state for cell metadata"); + } + docActions = this._activeBundle.docActions; + } + const rows = new Map(getRelatedRows(docActions)); + const cellData = new CellData(this._docData); + for(const action of docActions) { + for(const cell of cellData.convertToCells(action)) { + if (!rows.has(cell.tableId)) { rows.set(cell.tableId, new Set()); } + rows.get(cell.tableId)?.add(cell.rowId); + } + } + // Don't need to sync _grist_Cells table, since we already have it. + rows.delete('_grist_Cells'); + // Populate a minimal in-memory version of the database with these rows. + const docData = new DocData( + (tableId) => this._fetchQueryFromDB({tableId, filters: {id: [...rows.get(tableId)!]}}), { + _grist_Cells: this._docData.getMetaTable('_grist_Cells')!.getTableDataAction(), + // We need some basic table information to translate numeric ids to string ids (refs to ids). + _grist_Tables: this._docData.getMetaTable('_grist_Tables')!.getTableDataAction(), + _grist_Tables_column: this._docData.getMetaTable('_grist_Tables_column')!.getTableDataAction() + }, + ); + // Load pre-existing rows touched by the bundle. + await Promise.all([...rows.keys()].map(tableId => docData.syncTable(tableId))); + return docData; + } + /** * An optimization to catch obvious access problems for simple data * actions (such as UpdateRecord, BulkAddRecord, etc) early. Checks @@ -2067,9 +2147,9 @@ export class GranularAccess implements GranularAccessForBundle { return dummyAccessCheck; } const tableId = getTableId(a); - if (tableId.startsWith('_grist') && tableId !== '_grist_Attachments') { + if (tableId.startsWith('_grist') && tableId !== '_grist_Attachments' && tableId !== '_grist_Cells') { // Actions on any metadata table currently require the schemaEdit flag. - // Exception: the attachments table, which needs to be reworked to be compatible + // Exception: the attachments table and cell info table, which needs to be reworked to be compatible // with granular access. // Another exception: ensure owners always have full access to ACL tables, so they @@ -2088,6 +2168,105 @@ export class GranularAccess implements GranularAccessForBundle { return accessChecks[severity].schemaEdit; } } + + /** + * Filter outgoing actions and include or remove cell information from _grist_Cells. + */ + private async _filterOutgoingCellInfo(docSession: OptDocSession, before: DocAction[], after: DocAction[]) { + // Rewrite bundle, simplifying all actions that are touching cell metadata. + const cellView = new CellData(this._docData); + const patch = cellView.generatePatch(before); + + // If there is nothing to do, just return after state. + if (!patch) { return after; } + + // Now remove all action that modify cell metadata from after. + // We will use the patch to reconstruct the cell metadata. + const result = after.filter(action => !isCellDataAction(action)); + + // Prepare checker, we need to use checker from the last step. + const cursor = { + docSession, + action: before[before.length - 1], + actionIdx: before.length - 1 + }; + const ruler = await this._getRuler(cursor); + const permInfo = await ruler.getAccess(docSession); + const user = await this._getUser(docSession); + // Cache some data, as they are checked. + const readRows = memoize(this._fetchQueryFromDB.bind(this)); + const hasAccess = async (cell: SingleCell) => { + // First check table access, maybe table is hidden. + const tableAccess = permInfo.getTableAccess(cell.tableId); + const access = this.getReadPermission(tableAccess); + if (access === 'deny') { return false; } + + // Check, if table is fully allowed (no ACL column/rows rules). + if (access === 'allow') { return true; } + + // Maybe there are only rules that hides this column completely. + if (access === 'mixedColumns') { + const collAccess = this.getReadPermission(permInfo.getColumnAccess(cell.tableId, cell.colId)); + if (collAccess === 'deny') { return false; } + if (collAccess === 'allow') { return true; } + } + + // Probably there are rules at the cell level, check them. + const rows = await readRows({ + tableId: cell.tableId, + filters: { id: [cell.rowId] } + }); + // Make sure we have row. + if (!rows || rows[2].length === 0) { + if (cell.rowId) { + return false; + } + } + const rec = rows ? new RecordView(rows, 0) : undefined; + const input: AclMatchInput = {user, rec, newRec: rec}; + const rowPermInfo = new PermissionInfo(ruler.ruleCollection, input); + const rowAccess = rowPermInfo.getTableAccess(cell.tableId).perms.read; + if (rowAccess === 'deny') { return false; } + if (rowAccess !== 'allow') { + const colAccess = rowPermInfo.getColumnAccess(cell.tableId, cell.colId).perms.read; + if (colAccess === 'deny') { return false; } + } + return true; + }; + + // Now censor the patch, so it only contains cells content that user has access to. + await cellView.censorCells(patch, (cell) => hasAccess(cell)); + + // And append it to the result. + result.push(...patch); + + return result; + } + + /** + * Tests if the user can modify cell's data. + */ + private async _canApplyCellActions(currentUser: UserInfo, userIsOwner: boolean) { + // Owner can modify all comments, without exceptions. + if (userIsOwner) { + return; + } + if (!this._activeBundle) { throw new Error('no active bundle'); } + const {docActions, docSession} = this._activeBundle; + const snapShot = await this.createSnapshotWithCells(); + const cellView = new CellData(snapShot); + await cellView.applyAndCheck( + docActions, + userIsOwner, + this._ruler.haveRules(), + currentUser.UserRef || '', + (cell, state) => this.hasCellAccess(docSession, cell, state), + ); + } + + private _createCellAccess(docSession: OptDocSession, docData?: DocData) { + return new CellAccessHelper(this, this._ruler, docSession, this._fetchQueryFromDB, docData); + } } /** @@ -2308,6 +2487,84 @@ const dummyAccessCheck: IAccessCheck = { throwIfNotFullyAllowed() {} }; +/** + * Helper class to calculate access for a set of cells in bulk. Used for initial + * access check for a whole _grist_Cell table. Each cell can belong to a diffrent + * table and row, so here we will avoid loading rows multiple times and checking + * the table access multiple time. + */ +class CellAccessHelper { + private _tableAccess: Map = new Map(); + private _rowPermInfo: Map> = new Map(); + private _rows: Map = new Map(); + private _user!: UserInfo; + + constructor( + private _granular: GranularAccess, + private _ruler: Ruler, + private _docSession: OptDocSession, + private _fetchQueryFromDB?: (query: ServerQuery) => Promise, + 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) { + if (this._state) { + const rows = this._state.getTable(tableId)!.getTableDataAction(); + return rows; + } + if (this._fetchQueryFromDB) { + return await this._fetchQueryFromDB({ + tableId, + filters: { id: [...rowIds] } + }); + } + return ['TableData', tableId, [], {}] as TableDataAction; + } +} + /** * Manage censoring metadata. @@ -2325,23 +2582,27 @@ export class CensorshipInfo { public censoredViews = new Set(); public censoredColumns = new Set(); public censoredFields = new Set(); + public censoredComments = new Set(); public censored = { _grist_Tables: this.censoredTables, _grist_Tables_column: this.censoredColumns, _grist_Views: this.censoredViews, _grist_Views_section: this.censoredSections, _grist_Views_section_field: this.censoredFields, + _grist_Cells: this.censoredComments, }; public constructor(permInfo: PermissionInfo, ruleCollection: ACLRuleCollection, tables: {[key: string]: TableDataAction}, - private _canViewACLs: boolean) { + private _canViewACLs: boolean, + cellAccessInfo?: CellAccessHelper) { // Collect a list of censored columns (by " "). const columnCode = (tableRef: number, colId: string) => `${tableRef} ${colId}`; const censoredColumnCodes: Set = new Set(); const tableRefToTableId: Map = new Map(); const tableRefToIndex: Map = new Map(); + const columnRefToColId: Map = new Map(); const uncensoredTables: Set = new Set(); // Scan for forbidden tables. let rec = new RecordView(tables._grist_Tables, undefined); @@ -2365,10 +2626,12 @@ export class CensorshipInfo { for (let idx = 0; idx < ids.length; idx++) { rec.index = idx; const tableRef = rec.get('parentId') as number; + const colId = rec.get('colId') as string; + const colRef = ids[idx]; + columnRefToColId.set(colRef, colId); if (uncensoredTables.has(tableRef)) { continue; } const tableId = tableRefToTableId.get(tableRef); if (!tableId) { throw new Error('table not found'); } - const colId = rec.get('colId') as string; if (this.censoredTables.has(tableRef) || (colId !== 'manualSort' && permInfo.getColumnAccess(tableId, colId).perms.read === 'deny')) { censoredColumnCodes.add(columnCode(tableRef, colId)); @@ -2427,12 +2690,37 @@ export class CensorshipInfo { const rawViewSectionRef = rec.get('rawViewSectionRef') as number; this.censoredSections.delete(rawViewSectionRef); } + + // Collect a list of all cells metadata to which the user has no access. + rec = new RecordView(tables._grist_Cells, undefined); + ids = tables._grist_Cells ? getRowIdsFromDocAction(tables._grist_Cells) : []; + for (let idx = 0; idx < ids.length; idx++) { + rec.index = idx; + const isTableCensored = () => this.censoredTables.has(rec.get('tableRef') as number); + const isColumnCensored = () => this.censoredColumns.has(rec.get('colRef') as number); + const isCellCensored = () => { + if (!cellAccessInfo) { return false; } + const cell = { + tableId: tableRefToTableId.get(rec.get('tableRef') as number)!, + colId: columnRefToColId.get(rec.get('colRef') as number)!, + rowId: rec.get('rowId') as number + }; + return !cell.tableId || !cell.colId || cellAccessInfo.hasAccess(cell); + }; + if (isTableCensored() || isColumnCensored() || isCellCensored()) { + this.censoredComments.add(ids[idx]); + } + } } public apply(a: DataAction) { const tableId = getTableId(a); - const ids = getRowIdsFromDocAction(a); if (!STRUCTURAL_TABLES.has(tableId)) { return true; } + return this.filter(a); + } + + public filter(a: DataAction) { + const tableId = getTableId(a); if (!(tableId in this.censored)) { if (!this._canViewACLs && a[0] === 'TableData') { a[2] = []; @@ -2443,6 +2731,7 @@ export class CensorshipInfo { const rec = new RecordEditor(a, undefined, true); const method = getCensorMethod(getTableId(a)); const censoredRows = (this.censored as any)[tableId] as Set; + const ids = getRowIdsFromDocAction(a); for (const [index, id] of ids.entries()) { if (censoredRows.has(id)) { rec.index = index; @@ -2470,6 +2759,8 @@ function getCensorMethod(tableId: string): (rec: RecordEditor) => void { return rec => rec; case '_grist_ACLRules': return rec => rec; + case '_grist_Cells': + return rec => rec.set('content', [GristObjCode.Censored]).set('userRef', ''); default: throw new Error(`cannot censor ${tableId}`); } @@ -2645,3 +2936,464 @@ function actionHasRuleChange(a: DocAction): boolean { ) ); } + +interface SingleCellInfo extends SingleCell { + userRef: string; + id: number; +} + +/** + * Helper class that extends DocData with cell specific functions. + */ +export class CellData { + constructor(private _docData: DocData) { + + } + + public getCell(cellId: number) { + const row = this._docData.getMetaTable("_grist_Cells").getRecord(cellId); + return row ? this.convertToCellInfo(row) : null; + } + + public getCellRecord(cellId: number) { + const row = this._docData.getMetaTable("_grist_Cells").getRecord(cellId); + return row || null; + } + + /** + * Generates a patch for cell metadata. It assumes, that engine removes all + * cell metadata when cell (table/column/row) is removed and the bundle contains, + * all actions that are needed to remove the cell and cell metadata. + */ + public generatePatch(actions: DocAction[]) { + const removedCells: Set = new Set(); + const addedCells: Set = new Set(); + const updatedCells: Set = 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> = 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 + ) { + 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) { + 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 + ) { + // 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 = []; + // 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 { + 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; + } +} diff --git a/app/server/lib/RowAccess.ts b/app/server/lib/RowAccess.ts index 92975504..4cef855d 100644 --- a/app/server/lib/RowAccess.ts +++ b/app/server/lib/RowAccess.ts @@ -76,3 +76,15 @@ export function getRowIdsFromDocAction(docActions: RemoveRecord | BulkRemoveReco const ids = docActions[2]; return (typeof ids === 'number') ? [ids] : ids; } + +/** + * Tiny helper to get the row ids mentioned in a record-related DocAction as a list + * (even if the action is not a bulk action). When the action touches the whole row, + * it returns ["*"]. + */ +export function getColIdsFromDocAction(docActions: RemoveRecord | BulkRemoveRecord | AddRecord | + BulkAddRecord | UpdateRecord | BulkUpdateRecord | ReplaceTableData | + TableDataAction) { + if (docActions[3]) { return Object.keys(docActions[3]); } + return ['*']; +} diff --git a/app/server/lib/initialDocSql.ts b/app/server/lib/initialDocSql.ts index 1338daab..8c43cb89 100644 --- a/app/server/lib/initialDocSql.ts +++ b/app/server/lib/initialDocSql.ts @@ -6,7 +6,7 @@ export const GRIST_DOC_SQL = ` PRAGMA foreign_keys=OFF; BEGIN TRANSACTION; CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT ''); -INSERT INTO _grist_DocInfo VALUES(1,'','','',32,'',''); +INSERT INTO _grist_DocInfo VALUES(1,'','','',33,'',''); CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL); CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0); @@ -34,6 +34,7 @@ INSERT INTO _grist_ACLPrincipals VALUES(3,'group','','','Editors',''); INSERT INTO _grist_ACLPrincipals VALUES(4,'group','','','Viewers',''); CREATE TABLE IF NOT EXISTS "_grist_ACLMemberships" (id INTEGER PRIMARY KEY, "parent" INTEGER DEFAULT 0, "child" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Filters" (id INTEGER PRIMARY KEY, "viewSectionRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "filter" TEXT DEFAULT ''); +CREATE TABLE IF NOT EXISTS "_grist_Cells" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "rowId" INTEGER DEFAULT 0, "root" BOOLEAN DEFAULT 0, "parentId" INTEGER DEFAULT 0, "type" INTEGER DEFAULT 0, "content" TEXT DEFAULT '', "userRef" TEXT DEFAULT ''); CREATE INDEX _grist_Attachments_fileIdent ON _grist_Attachments(fileIdent); COMMIT; `; @@ -42,7 +43,7 @@ export const GRIST_DOC_WITH_TABLE1_SQL = ` PRAGMA foreign_keys=OFF; BEGIN TRANSACTION; CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT ''); -INSERT INTO _grist_DocInfo VALUES(1,'','','',32,'',''); +INSERT INTO _grist_DocInfo VALUES(1,'','','',33,'',''); CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0); INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2); CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL); @@ -86,6 +87,7 @@ INSERT INTO _grist_ACLPrincipals VALUES(3,'group','','','Editors',''); INSERT INTO _grist_ACLPrincipals VALUES(4,'group','','','Viewers',''); CREATE TABLE IF NOT EXISTS "_grist_ACLMemberships" (id INTEGER PRIMARY KEY, "parent" INTEGER DEFAULT 0, "child" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Filters" (id INTEGER PRIMARY KEY, "viewSectionRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "filter" TEXT DEFAULT ''); +CREATE TABLE IF NOT EXISTS "_grist_Cells" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "rowId" INTEGER DEFAULT 0, "root" BOOLEAN DEFAULT 0, "parentId" INTEGER DEFAULT 0, "type" INTEGER DEFAULT 0, "content" TEXT DEFAULT '', "userRef" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "Table1" (id INTEGER PRIMARY KEY, "manualSort" NUMERIC DEFAULT 1e999, "A" BLOB DEFAULT NULL, "B" BLOB DEFAULT NULL, "C" BLOB DEFAULT NULL); CREATE INDEX _grist_Attachments_fileIdent ON _grist_Attachments(fileIdent); COMMIT; diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts index e961b9e8..40b051a3 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -59,6 +59,7 @@ export function makeGristConfig(homeUrl: string|null, extra: Partial + + + + + \ No newline at end of file diff --git a/static/ui-icons/UI/Message.svg b/static/ui-icons/UI/Message.svg new file mode 100644 index 00000000..1f6a9264 --- /dev/null +++ b/static/ui-icons/UI/Message.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/ui-icons/UI/Popup.svg b/static/ui-icons/UI/Popup.svg new file mode 100644 index 00000000..4279a82a --- /dev/null +++ b/static/ui-icons/UI/Popup.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/fixtures/docs/Hello.grist b/test/fixtures/docs/Hello.grist index aea27d5a0004ab8ddd12d46c64fcbc3648565e69..63c17756e2be2ab3704a572c1b8db80d2e0b2482 100644 GIT binary patch delta 327 zcmZp;z})bFd4jZ{00RSq4G_bC&_o?$RRIP)`&G*FBCJ3-FiZC$yG37FD1TuOUT7XK6 zIXOTk0YS&+2`dWhH}SBra4<31GcZ>$*>4tPF=o*SlOBqv+0l3^{%FZWEDyir?RF+aaVNx{kA z-^bO_Pr=32%`wy`M8QBuL8&0IC^fGHtV)cfq_Q9ts9ek>IX|xiD6FIq;u;YGSFf%P U)KFTSS`?I;h9RXnxo1TO0BxCFE&u=k delta 97 zcmZp8z}#?wd4jYcKLZ1U6%fOK;6xo`prD@pO;(@~C-Y^Hg!W{fg51q_CB89jzRb)e q!l { // do rename - await clickPage(/People/) + await gu.openPage(/People/); await driver.findContent('.test-treeview-label', 'People').doClick(); await driver.find('.test-docpage-editor').sendKeys('PeopleRenamed', Key.ENTER); await gu.waitForServer(); @@ -214,7 +214,7 @@ describe('Pages', function() { } // goto page 'Interactions' - await clickPage(/Interactions/); + await gu.openPage(/Interactions/); // check selected page assert.match(await selectedPage(), /Interactions/); @@ -249,7 +249,7 @@ describe('Pages', function() { it('undo/redo should update url', async () => { // goto page 'Interactions' and send keys - await clickPage(/Interactions/); + await gu.openPage(/Interactions/); assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Interactions/); await driver.findContentWait('.gridview_data_row_num', /1/, 2000); await driver.sendKeys(Key.ENTER, 'Foo', Key.ENTER); @@ -257,7 +257,7 @@ describe('Pages', function() { assert.deepEqual(await gu.getVisibleGridCells(0, [1]), ['Foo']); // goto page 'People' and click undo - await clickPage(/People/); + await gu.openPage(/People/); await gu.waitForDocToLoad(); await gu.waitForUrl(/\/p\/2\b/); // check that url match p/2 @@ -277,7 +277,7 @@ describe('Pages', function() { it('Add new page should update url', async () => { // goto page 'Interactions' and check that url updated - await clickPage(/Interactions/); + await gu.openPage(/Interactions/); await gu.waitForUrl(/\/p\/1\b/); // Add new Page, check that url updated and page is selected @@ -286,7 +286,7 @@ describe('Pages', function() { assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Table1/); // goto page 'Interactions' and check that url updated and page selectd - await clickPage(/Interactions/); + await gu.openPage(/Interactions/); await gu.waitForUrl(/\/p\/1\b/); assert.match(await driver.find('.test-treeview-itemHeader.selected').getText(), /Interactions/); }); @@ -496,7 +496,3 @@ async function movePage(page: RegExp, target: {before: RegExp}|{after: RegExp}) }) .release()); } - -function clickPage(name: string|RegExp) { - return driver.findContent('.test-treeview-itemHeader', name).find(".test-docpage-initial").doClick(); -} diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index edd6790e..7856b132 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -860,6 +860,11 @@ export function getPageItem(pageName: string|RegExp): WebElementPromise { .findClosest('.test-treeview-itemHeaderWrapper'); } +export async function openPage(name: string|RegExp) { + await driver.findContentWait('.test-treeview-itemHeader', name, 500).find(".test-docpage-initial").doClick(); + await waitForServer(); // wait for table load +} + /** * Open the page menu for the specified page (by clicking the dots icon visible on hover). */ @@ -1082,9 +1087,9 @@ export async function renameColumn(col: IColHeader, newName: string) { } /** - * Removes a table using RAW data view. Return back a current url. + * Removes a table using RAW data view. Returns a current url. */ -export async function removeTable(tableId: string) { +export async function removeTable(tableId: string, goBack: boolean = false) { const back = await driver.getCurrentUrl(); await driver.find(".test-tools-raw").click(); const tableIdList = await driver.findAll('.test-raw-data-table-id', e => e.getText()); @@ -1096,6 +1101,10 @@ export async function removeTable(tableId: string) { await driver.find(".test-raw-data-menu-remove").click(); await driver.find(".test-modal-confirm").click(); await waitForServer(); + if (goBack) { + await driver.get(back); + await waitAppFocus(); + } return back; } @@ -1400,6 +1409,11 @@ export function openColumnMenu(col: IColHeader|string, option?: string): WebElem return new WebElementPromise(driver, openColumnMenuHelper(col, option)); } +export async function deleteColumn(col: IColHeader|string) { + await openColumnMenu(col, 'Delete column'); + await waitForServer(); +} + /** * Sets the type of the currently selected field to value. */ @@ -2554,13 +2568,30 @@ export async function changeBehavior(option: string|RegExp) { /** * Gets all available options in the behavior menu. */ - export async function availableBehaviorOptions() { +export async function availableBehaviorOptions() { await driver.find('.test-field-behaviour').click(); const list = await driver.findAll('.grist-floating-menu li', el => el.getText()); await driver.sendKeys(Key.ESCAPE); return list; } +export function withComments() { + let oldEnv: testUtils.EnvironmentSnapshot; + before(async () => { + if (process.env.COMMENTS !== 'true') { + oldEnv = new testUtils.EnvironmentSnapshot(); + process.env.COMMENTS = 'true'; + await server.restart(); + } + }); + after(async () => { + if (oldEnv) { + oldEnv.restore(); + await server.restart(); + } + }); +} + } // end of namespace gristUtils stackWrapOwnMethods(gristUtils);