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:
1382
app/client/widgets/DiscussionEditor.ts
Normal file
1382
app/client/widgets/DiscussionEditor.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -30,3 +30,18 @@
|
||||
border-radius: 5px;
|
||||
background-color: var(--grist-theme-right-panel-field-settings-button-bg, lightgrey);
|
||||
}
|
||||
|
||||
.field-comment-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.field-with-comments .field-comment-indicator {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 11px solid var(--grist-color-orange);
|
||||
border-left: 11px solid transparent;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { DataRowModel } from 'app/client/models/DataRowModel';
|
||||
import { ColumnRec, DocModel, ViewFieldRec } from 'app/client/models/DocModel';
|
||||
import { SaveableObjObservable, setSaveValue } from 'app/client/models/modelUtil';
|
||||
import { CombinedStyle, Style } from 'app/client/models/Styles';
|
||||
import { COMMENTS } from 'app/client/models/features';
|
||||
import { FieldSettingsMenu } from 'app/client/ui/FieldMenus';
|
||||
import { cssBlockedCursor, cssLabel, cssRow } from 'app/client/ui/RightPanelStyles';
|
||||
import { buttonSelect, cssButtonSelect } from 'app/client/ui2018/buttonSelect';
|
||||
@@ -22,6 +23,7 @@ import { IOptionFull, menu, select } from 'app/client/ui2018/menus';
|
||||
import { DiffBox } from 'app/client/widgets/DiffBox';
|
||||
import { buildErrorDom } from 'app/client/widgets/ErrorDom';
|
||||
import { FieldEditor, saveWithoutEditor, setupEditorCleanup } from 'app/client/widgets/FieldEditor';
|
||||
import { CellDiscussionPopup, EmptyCell } from 'app/client/widgets/DiscussionEditor';
|
||||
import { openFormulaEditor } from 'app/client/widgets/FormulaEditor';
|
||||
import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget';
|
||||
import { NewBaseEditor } from "app/client/widgets/NewBaseEditor";
|
||||
@@ -37,6 +39,8 @@ import * as _ from 'underscore';
|
||||
|
||||
const testId = makeTestId('test-fbuilder-');
|
||||
|
||||
|
||||
|
||||
// Creates a FieldBuilder object for each field in viewFields
|
||||
export function createAllFieldWidgets(gristDoc: GristDoc, viewFields: ko.Computed<KoArray<ViewFieldRec>>,
|
||||
cursor: Cursor, options: { isPreview?: boolean } = {}) {
|
||||
@@ -99,6 +103,7 @@ export class FieldBuilder extends Disposable {
|
||||
private readonly _widgetCons: ko.Computed<{create: (...args: any[]) => NewAbstractWidget}>;
|
||||
private readonly _docModel: DocModel;
|
||||
private readonly _readonly: Computed<boolean>;
|
||||
private readonly _comments: ko.Computed<boolean>;
|
||||
|
||||
public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec,
|
||||
private _cursor: Cursor, private _options: { isPreview?: boolean } = {}) {
|
||||
@@ -107,6 +112,7 @@ export class FieldBuilder extends Disposable {
|
||||
this._docModel = gristDoc.docModel;
|
||||
this.origColumn = field.column();
|
||||
this.options = field.widgetOptionsJson;
|
||||
this._comments = ko.pureComputed(() => toKo(ko, COMMENTS())());
|
||||
|
||||
this._readOnlyPureType = ko.pureComputed(() => this.field.column().pureType());
|
||||
|
||||
@@ -566,27 +572,47 @@ export class FieldBuilder extends Disposable {
|
||||
const errorInStyle = ko.pureComputed(() => Boolean(computedRule()?.error));
|
||||
|
||||
const cellText = ko.pureComputed(() => this.field.textColor() || '');
|
||||
const cllFill = ko.pureComputed(() => this.field.fillColor() || '');
|
||||
const cellFill = ko.pureComputed(() => this.field.fillColor() || '');
|
||||
|
||||
const hasComment = koUtil.withKoUtils(ko.computed(() => {
|
||||
if (this.isDisposed()) { return false; } // Work around JS errors during field removal.
|
||||
if (!this._comments()) { return false; }
|
||||
if (this.gristDoc.isReadonlyKo()) { return false; }
|
||||
const rowId = row.id();
|
||||
const discussion = this.field.column().cells().all()
|
||||
.find(d =>
|
||||
d.rowId() === rowId
|
||||
&& !d.resolved()
|
||||
&& d.type() === gristTypes.CellInfoType.COMMENT
|
||||
&& !d.hidden()
|
||||
&& d.root());
|
||||
return Boolean(discussion);
|
||||
}).extend({ deferred: true })).onlyNotifyUnequal();
|
||||
|
||||
const domHolder = new MultiHolder();
|
||||
domHolder.autoDispose(hasComment);
|
||||
domHolder.autoDispose(widgetObs);
|
||||
domHolder.autoDispose(computedFlags);
|
||||
domHolder.autoDispose(errorInStyle);
|
||||
domHolder.autoDispose(cellText);
|
||||
domHolder.autoDispose(cellFill);
|
||||
domHolder.autoDispose(computedRule);
|
||||
domHolder.autoDispose(fontBold);
|
||||
domHolder.autoDispose(fontItalic);
|
||||
domHolder.autoDispose(fontUnderline);
|
||||
domHolder.autoDispose(fontStrikethrough);
|
||||
|
||||
return (elem: Element) => {
|
||||
this._rowMap.set(row, elem);
|
||||
dom(elem,
|
||||
dom.autoDispose(widgetObs),
|
||||
dom.autoDispose(computedFlags),
|
||||
dom.autoDispose(errorInStyle),
|
||||
dom.autoDispose(ruleText),
|
||||
dom.autoDispose(computedRule),
|
||||
dom.autoDispose(ruleFill),
|
||||
dom.autoDispose(fontBold),
|
||||
dom.autoDispose(fontItalic),
|
||||
dom.autoDispose(fontUnderline),
|
||||
dom.autoDispose(fontStrikethrough),
|
||||
dom.autoDispose(domHolder),
|
||||
kd.style('--grist-cell-color', cellText),
|
||||
kd.style('--grist-cell-background-color', cllFill),
|
||||
kd.style('--grist-cell-background-color', cellFill),
|
||||
kd.style('--grist-rule-color', ruleText),
|
||||
kd.style('--grist-column-rule-background-color', ruleFill),
|
||||
this._options.isPreview ? null : kd.cssClass(this.field.formulaCssClass),
|
||||
kd.toggleClass('field-with-comments', hasComment),
|
||||
kd.maybe(hasComment, () => dom('div.field-comment-indicator')),
|
||||
kd.toggleClass("readonly", toKo(ko, this._readonly)),
|
||||
kd.maybe(isSelected, () => dom('div.selected_cursor',
|
||||
kd.toggleClass('active_cursor', isActive)
|
||||
@@ -671,6 +697,43 @@ export class FieldBuilder extends Disposable {
|
||||
this.gristDoc.activeEditor.set(fieldEditor);
|
||||
}
|
||||
|
||||
public buildDiscussionPopup(editRow: DataRowModel, mainRowModel: DataRowModel, discussionId?: number) {
|
||||
const owner = this.gristDoc.fieldEditorHolder;
|
||||
const cellElem: Element = this._rowMap.get(mainRowModel)!;
|
||||
if (this.columnTransform) {
|
||||
this.columnTransform.finalize().catch(reportError);
|
||||
return;
|
||||
}
|
||||
if (editRow._isAddRow.peek() || this._readonly.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cell = editRow.cells[this.field.colId()];
|
||||
const value = cell && cell();
|
||||
if (gristTypes.isCensored(value)) {
|
||||
this._fieldEditorHolder.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const tableRef = this.field.viewSection.peek()!.tableRef.peek()!;
|
||||
|
||||
// Reuse fieldEditor holder to make sure only one popup/editor is attached to the cell.
|
||||
const discussionHolder = MultiHolder.create(owner);
|
||||
const discussions = EmptyCell.create(discussionHolder, {
|
||||
gristDoc: this.gristDoc,
|
||||
tableRef,
|
||||
column: this.field.column.peek(),
|
||||
rowId: editRow.id.peek(),
|
||||
});
|
||||
CellDiscussionPopup.create(discussionHolder, {
|
||||
domEl: cellElem,
|
||||
topic: discussions,
|
||||
discussionId,
|
||||
gristDoc: this.gristDoc,
|
||||
closeClicked: () => owner.clear()
|
||||
});
|
||||
}
|
||||
|
||||
public isEditorActive() {
|
||||
return !this._fieldEditorHolder.isEmpty();
|
||||
}
|
||||
|
||||
@@ -334,7 +334,7 @@ export class FieldEditor extends Disposable {
|
||||
let waitPromise: Promise<unknown>|null = null;
|
||||
|
||||
if (isFormula) {
|
||||
const formula = editor.getCellValue();
|
||||
const formula = String(editor.getCellValue() ?? '');
|
||||
// Bundle multiple changes so that we can undo them in one step.
|
||||
if (isFormula !== col.isFormula.peek() || formula !== col.formula.peek()) {
|
||||
waitPromise = this._gristDoc.docData.bundleActions(null, () => Promise.all([
|
||||
|
||||
@@ -298,10 +298,10 @@ export function openFormulaEditor(options: {
|
||||
|
||||
// AsyncOnce ensures it's called once even if triggered multiple times.
|
||||
const saveEdit = asyncOnce(async () => {
|
||||
const formula = editor.getCellValue();
|
||||
const formula = String(editor.getCellValue());
|
||||
if (formula !== column.formula.peek()) {
|
||||
if (options.onSave) {
|
||||
await options.onSave(column, formula as string);
|
||||
await options.onSave(column, formula);
|
||||
} else {
|
||||
await column.updateColValues({formula});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user