mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Comments
Summary: First iteration for comments system for Grist. - Comments are stored in a generic metatable `_grist_Cells` - Each comment is connected to a particular cell (hence the generic name of the table) - Access level works naturally for records stored in this table -- User can add/read comments for cells he can see -- User can't update/remove comments that he doesn't own, but he can delete them by removing cells (rows/columns) -- Anonymous users can't see comments at all. - Each comment can have replies (but replies can't have more replies) Comments are hidden by default, they can be enabled by COMMENTS=true env variable. Some things for follow-up - Avatars, currently the user's profile image is not shown or retrieved from the server - Virtual rendering for comments list in creator panel. Currently, there is a limit of 200 comments. Test Plan: New and existing tests Reviewers: georgegevoian, paulfitz Reviewed By: georgegevoian Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D3509
This commit is contained in:
@@ -37,21 +37,21 @@ import {createValidationRec, ValidationRec} from 'app/client/models/entities/Val
|
||||
import {createViewFieldRec, ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {createViewRec, ViewRec} from 'app/client/models/entities/ViewRec';
|
||||
import {createViewSectionRec, ViewSectionRec} from 'app/client/models/entities/ViewSectionRec';
|
||||
import {CellRec, createCellRec} from 'app/client/models/entities/CellRec';
|
||||
import {RefListValue} from 'app/common/gristTypes';
|
||||
import {decodeObject} from 'app/plugin/objtypes';
|
||||
|
||||
// Re-export all the entity types available. The recommended usage is like this:
|
||||
// import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel';
|
||||
export type {ColumnRec, DocInfoRec, FilterRec, PageRec, TabBarRec, TableRec, ValidationRec,
|
||||
ViewFieldRec, ViewRec, ViewSectionRec};
|
||||
|
||||
ViewFieldRec, ViewRec, ViewSectionRec, CellRec};
|
||||
|
||||
/**
|
||||
* Creates the type for a MetaRowModel containing a KoSaveableObservable for each field listed in
|
||||
* the auto-generated app/common/schema.ts. It represents the metadata record in the database.
|
||||
* Particular DocModel entities derive from this, and add other helpful computed values.
|
||||
*/
|
||||
export type IRowModel<TName extends keyof SchemaTypes> = MetaRowModel & {
|
||||
export type IRowModel<TName extends keyof SchemaTypes> = MetaRowModel<TName> & {
|
||||
[ColId in keyof SchemaTypes[TName]]: KoSaveableObservable<SchemaTypes[TName][ColId]>;
|
||||
};
|
||||
|
||||
@@ -124,6 +124,7 @@ export class DocModel {
|
||||
public pages: MTM<PageRec> = this._metaTableModel("_grist_Pages", createPageRec);
|
||||
public rules: MTM<ACLRuleRec> = this._metaTableModel("_grist_ACLRules", createACLRuleRec);
|
||||
public filters: MTM<FilterRec> = this._metaTableModel("_grist_Filters", createFilterRec);
|
||||
public cells: MTM<CellRec> = this._metaTableModel("_grist_Cells", createCellRec);
|
||||
|
||||
public docInfoRow: DocInfoRec;
|
||||
|
||||
|
||||
43
app/client/models/entities/CellRec.ts
Normal file
43
app/client/models/entities/CellRec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {isCensored} from 'app/common/gristTypes';
|
||||
import * as ko from 'knockout';
|
||||
import {KoArray} from 'app/client/lib/koArray';
|
||||
import {jsonObservable} from 'app/client/models/modelUtil';
|
||||
import * as modelUtil from 'app/client/models/modelUtil';
|
||||
import {ColumnRec, DocModel, IRowModel, recordSet, refRecord, TableRec} from 'app/client/models/DocModel';
|
||||
|
||||
|
||||
export interface CellRec extends IRowModel<"_grist_Cells"> {
|
||||
column: ko.Computed<ColumnRec>;
|
||||
table: ko.Computed<TableRec>;
|
||||
children: ko.Computed<KoArray<CellRec>>;
|
||||
hidden: ko.Computed<boolean>;
|
||||
parent: ko.Computed<CellRec>;
|
||||
|
||||
text: modelUtil.KoSaveableObservable<string|undefined>;
|
||||
userName: modelUtil.KoSaveableObservable<string|undefined>;
|
||||
timeCreated: modelUtil.KoSaveableObservable<number|undefined>;
|
||||
timeUpdated: modelUtil.KoSaveableObservable<number|undefined>;
|
||||
resolved: modelUtil.KoSaveableObservable<boolean|undefined>;
|
||||
resolvedBy: modelUtil.KoSaveableObservable<string|undefined>;
|
||||
}
|
||||
|
||||
export function createCellRec(this: CellRec, docModel: DocModel): void {
|
||||
this.hidden = ko.pureComputed(() => isCensored(this.content()));
|
||||
this.column = refRecord(docModel.columns, this.colRef);
|
||||
this.table = refRecord(docModel.tables, this.tableRef);
|
||||
this.parent = refRecord(docModel.cells, this.parentId);
|
||||
this.children = recordSet(this, docModel.cells, 'parentId');
|
||||
const properContent = modelUtil.savingComputed({
|
||||
read: () => this.hidden() ? '{}' : this.content(),
|
||||
write: (setter, val) => setter(this.content, val)
|
||||
});
|
||||
const optionJson = jsonObservable(properContent);
|
||||
|
||||
// Comments:
|
||||
this.text = optionJson.prop('text');
|
||||
this.userName = optionJson.prop('userName');
|
||||
this.timeCreated = optionJson.prop('timeCreated');
|
||||
this.timeUpdated = optionJson.prop('timeUpdated');
|
||||
this.resolved = optionJson.prop('resolved');
|
||||
this.resolvedBy = optionJson.prop('resolvedBy');
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {KoArray} from 'app/client/lib/koArray';
|
||||
import {DocModel, IRowModel, recordSet, refRecord, TableRec, ViewFieldRec} from 'app/client/models/DocModel';
|
||||
import {CellRec, DocModel, IRowModel, recordSet,
|
||||
refRecord, TableRec, ViewFieldRec} from 'app/client/models/DocModel';
|
||||
import {jsonObservable, ObjObservable} from 'app/client/models/modelUtil';
|
||||
import * as gristTypes from 'app/common/gristTypes';
|
||||
import {getReferencedTableId} from 'app/common/gristTypes';
|
||||
@@ -55,7 +56,7 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> {
|
||||
visibleColModel: ko.Computed<ColumnRec>;
|
||||
|
||||
disableModifyBase: ko.Computed<boolean>; // True if column config can't be modified (name, type, etc.)
|
||||
disableModify: ko.Computed<boolean>; // True if column can't be modified or is being transformed.
|
||||
disableModify: ko.Computed<boolean>; // True if column can't be modified (is summary) or is being transformed.
|
||||
disableEditData: ko.Computed<boolean>; // True to disable editing of the data in this column.
|
||||
|
||||
isHiddenCol: ko.Computed<boolean>;
|
||||
@@ -73,6 +74,7 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> {
|
||||
// (i.e. they aren't actually referenced but they exist in the visible column and are relevant to e.g. autocomplete)
|
||||
// `formatter` formats actual cell values, e.g. a whole list from the display column.
|
||||
formatter: ko.Computed<BaseFormatter>;
|
||||
cells: ko.Computed<KoArray<CellRec>>;
|
||||
|
||||
// Helper which adds/removes/updates column's displayCol to match the formula.
|
||||
saveDisplayFormula(formula: string): Promise<void>|undefined;
|
||||
@@ -83,6 +85,7 @@ export function createColumnRec(this: ColumnRec, docModel: DocModel): void {
|
||||
this.widgetOptionsJson = jsonObservable(this.widgetOptions);
|
||||
this.viewFields = recordSet(this, docModel.viewFields, 'colRef');
|
||||
this.summarySource = refRecord(docModel.columns, this.summarySourceCol);
|
||||
this.cells = recordSet(this, docModel.cells, 'colRef');
|
||||
|
||||
// Is this an empty column (undecided if formula or data); denoted by an empty formula.
|
||||
this.isEmpty = ko.pureComputed(() => this.isFormula() && this.formula() === '');
|
||||
|
||||
12
app/client/models/features.ts
Normal file
12
app/client/models/features.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
|
||||
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
|
||||
import {Observable} from 'grainjs';
|
||||
|
||||
export function COMMENTS(): Observable<boolean> {
|
||||
const G = getBrowserGlobals('document', 'window');
|
||||
if (!G.window.COMMENTS) {
|
||||
G.window.COMMENTS = localStorageBoolObs('feature-comments', Boolean(getGristConfig().featureComments));
|
||||
}
|
||||
return G.window.COMMENTS;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
||||
import {CompareFunc, sortedIndex} from 'app/common/gutil';
|
||||
import {SkippableRows} from 'app/common/TableData';
|
||||
import {RowFilterFunc} from "app/common/RowFilterFunc";
|
||||
import {Observable} from 'grainjs';
|
||||
|
||||
/**
|
||||
* Special constant value that can be used for the `rows` array for the 'rowNotify'
|
||||
@@ -390,7 +391,10 @@ class RowGroupHelper<Value> extends RowSource {
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Helper function that does map.get(key).push(r), creating an Array for the given key if
|
||||
* necessary.
|
||||
*/
|
||||
function _addToMapOfArrays<K, V>(map: Map<K, V[]>, key: K, r: V): void {
|
||||
let arr = map.get(key);
|
||||
if (!arr) { map.set(key, arr = []); }
|
||||
@@ -437,11 +441,6 @@ export class RowGrouping<Value> extends RowListener {
|
||||
|
||||
// Implementation of the RowListener interface.
|
||||
|
||||
/**
|
||||
* Helper function that does map.get(key).push(r), creating an Array for the given key if
|
||||
* necessary.
|
||||
*/
|
||||
|
||||
public onAddRows(rows: RowList) {
|
||||
const groupedRows = new Map();
|
||||
for (const r of rows) {
|
||||
@@ -707,6 +706,41 @@ export class SortedRowSet extends RowListener {
|
||||
}
|
||||
}
|
||||
|
||||
type RowTester = (rowId: RowId) => boolean;
|
||||
/**
|
||||
* RowWatcher is a RowListener that maintains an observable function that checks whether a row
|
||||
* is in the connected RowSource.
|
||||
*/
|
||||
export class RowWatcher extends RowListener {
|
||||
/**
|
||||
* Observable function that returns true if the row is in the connected RowSource.
|
||||
*/
|
||||
public rowFilter: Observable<RowTester> = Observable.create(this, () => false);
|
||||
// We count the number of times the row is added or removed from the source.
|
||||
// In most cases row is added and removed only once.
|
||||
private _rowCounter: Map<RowId, number> = new Map();
|
||||
|
||||
public clear() {
|
||||
this._rowCounter.clear();
|
||||
this.rowFilter.set(() => false);
|
||||
this.stopListening();
|
||||
}
|
||||
|
||||
protected onAddRows(rows: RowList) {
|
||||
for (const r of rows) {
|
||||
this._rowCounter.set(r, (this._rowCounter.get(r) || 0) + 1);
|
||||
}
|
||||
this.rowFilter.set((row) => (this._rowCounter.get(row) ?? 0) > 0);
|
||||
}
|
||||
|
||||
protected onRemoveRows(rows: RowList) {
|
||||
for (const r of rows) {
|
||||
this._rowCounter.set(r, (this._rowCounter.get(r) || 0) - 1);
|
||||
}
|
||||
this.rowFilter.set((row) => (this._rowCounter.get(row) ?? 0) > 0);
|
||||
}
|
||||
}
|
||||
|
||||
function isSmallChange(rows: RowList) {
|
||||
return Array.isArray(rows) && rows.length <= 2;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user