(core) Comments

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

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

Test Plan: New and existing tests

Reviewers: georgegevoian, paulfitz

Reviewed By: georgegevoian

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D3509
This commit is contained in:
Jarosław Sadziński
2022-10-17 11:47:16 +02:00
parent 8be920dd25
commit bfd7243fe2
41 changed files with 2621 additions and 77 deletions

View File

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

View File

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

View File

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

View File

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