From 698c9d4e40b74b99199e03b76ec5f5abd82cb316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Thu, 17 Jun 2021 18:41:07 +0200 Subject: [PATCH] (core) Readonly editors Summary: Grist should not prevent read-only viewers from opening cell editors since they usually provide much more information than is visible in a cell. Every editor was enhanced with a read-only mode that provides the same information available for an editor but doesn't allow to change the underlying data. Test Plan: Browser tests Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2842 --- app/client/components/AceEditor.js | 6 ++ app/client/components/BaseView.js | 14 ++- app/client/components/EditorMonitor.ts | 3 + app/client/lib/TokenField.ts | 20 ++++- app/client/lib/koDom.js | 16 ++++ app/client/widgets/AttachmentsEditor.ts | 30 ++++--- app/client/widgets/CheckBoxEditor.js | 3 + app/client/widgets/ChoiceEditor.js | 2 +- app/client/widgets/ChoiceListEditor.ts | 8 ++ app/client/widgets/DateEditor.js | 111 ++++++++++++++---------- app/client/widgets/DateTimeEditor.js | 65 ++++++++++---- app/client/widgets/FieldBuilder.ts | 55 ++++++------ app/client/widgets/FieldEditor.ts | 66 +++++++++++--- app/client/widgets/FormulaEditor.ts | 27 ++++-- app/client/widgets/NTextEditor.ts | 13 ++- app/client/widgets/NewBaseEditor.ts | 8 ++ app/client/widgets/ReferenceEditor.ts | 10 ++- app/client/widgets/TextBox.css | 8 +- app/client/widgets/TextEditor.css | 26 ++++++ app/client/widgets/TextEditor.js | 4 +- test/nbrowser/gristUtils.ts | 12 +++ 21 files changed, 361 insertions(+), 146 deletions(-) diff --git a/app/client/components/AceEditor.js b/app/client/components/AceEditor.js index 815d9198..72d59576 100644 --- a/app/client/components/AceEditor.js +++ b/app/client/components/AceEditor.js @@ -26,6 +26,7 @@ function AceEditor(options) { this.calcSize = (options && options.calcSize) || ((elem, size) => size); this.gristDoc = (options && options.gristDoc) || null; this.editorState = (options && options.editorState) || null; + this._readonly = options.readonly || false; this.editor = null; this.editorDom = null; @@ -112,6 +113,11 @@ AceEditor.prototype.attachCommandGroup = function(commandGroup) { _.each(commandGroup.knownKeys, (command, key) => { this.editor.commands.addCommand({ name: command, + // We are setting readonly as true to enable all commands + // in a readonly mode. + // Because FieldEditor in readonly mode will rewire all commands that + // modify state, we are safe to enable them. + readOnly: this._readonly, bindKey: { win: key, mac: key, diff --git a/app/client/components/BaseView.js b/app/client/components/BaseView.js index deaa5d7a..2f7074ca 100644 --- a/app/client/components/BaseView.js +++ b/app/client/components/BaseView.js @@ -278,16 +278,12 @@ BaseView.prototype.activateEditorAtCursor = function(options) { // 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 (builder.field.disableEditData() || this.gristDoc.isReadonly.get()) { - builder.flashCursorReadOnly(lazyRow); - } else { - if (!lazyRow) { - // TODO scroll into view. For now, just don't activate the editor. - return; - } - this.editRowModel.assign(rowId); - builder.buildEditorDom(this.editRowModel, lazyRow, options || {}); + if (!lazyRow) { + // TODO scroll into view. For now, just don't activate the editor. + return; } + this.editRowModel.assign(rowId); + builder.buildEditorDom(this.editRowModel, lazyRow, options || {}); }; /** diff --git a/app/client/components/EditorMonitor.ts b/app/client/components/EditorMonitor.ts index 345b0bf3..dd1062f4 100644 --- a/app/client/components/EditorMonitor.ts +++ b/app/client/components/EditorMonitor.ts @@ -58,6 +58,9 @@ export class EditorMonitor extends Disposable { // will be invoked only once let executed = false; + // don't restore on readonly mode + if (doc.isReadonly.get()) { return; } + // on view shown this._currentViewListener.autoDispose(doc.currentView.addListener(async view => { if (executed) { diff --git a/app/client/lib/TokenField.ts b/app/client/lib/TokenField.ts index 7c2fcf68..c59531e2 100644 --- a/app/client/lib/TokenField.ts +++ b/app/client/lib/TokenField.ts @@ -35,6 +35,7 @@ export interface ITokenFieldOptions { acOptions?: IAutocompleteOptions; openAutocompleteOnFocus?: boolean; styles?: ITokenFieldStyles; + readonly?: boolean; // Allows overriding how tokens are copied to the clipboard, or retrieved from it. // By default, tokens are placed into clipboard as text/plain comma-separated token labels, with @@ -89,7 +90,13 @@ export class TokenField extends Disposable { this.autoDispose(this._tokens.addListener(this._recordUndo.bind(this))); // Use overridden styles if any were provided. - const {cssTokenField, cssToken, cssInputWrapper, cssTokenInput, cssDeleteButton, cssDeleteIcon} = + const { + cssTokenField, + cssToken, + cssInputWrapper, + cssTokenInput, + cssDeleteButton, + cssDeleteIcon} = {...tokenFieldStyles, ..._options.styles}; function stop(ev: Event) { @@ -101,15 +108,18 @@ export class TokenField extends Disposable { {tabIndex: '-1'}, dom.forEach(this._tokens, (t) => cssToken(this._options.renderToken(t.token), - cssDeleteButton(cssDeleteIcon('CrossSmall'), testId('tokenfield-delete')), dom.cls('selected', (use) => use(this._selection).has(t)), - dom.on('click', (ev) => this._onTokenClick(ev, t)), - dom.on('mousedown', (ev) => this._onMouseDown(ev, t)), + _options.readonly ? null : [ + cssDeleteButton(cssDeleteIcon('CrossSmall'), testId('tokenfield-delete')), + dom.on('click', (ev) => this._onTokenClick(ev, t)), + dom.on('mousedown', (ev) => this._onMouseDown(ev, t)) + ], testId('tokenfield-token') ), ), cssInputWrapper( this._textInput = cssTokenInput( + dom.boolAttr("readonly", this._options.readonly ?? false), dom.on('focus', this._onInputFocus.bind(this)), dom.on('blur', () => { this._acHolder.clear(); }), (this._acOptions ? @@ -174,6 +184,8 @@ export class TokenField extends Disposable { // Open the autocomplete dropdown, if autocomplete was configured in the options. private _openAutocomplete() { + // don't open dropdown in a readonly mode + if (this._options.readonly) { return; } if (this._acOptions && this._acHolder.isEmpty()) { Autocomplete.create(this._acHolder, this._textInput, this._acOptions); } diff --git a/app/client/lib/koDom.js b/app/client/lib/koDom.js index b566ccdf..50a5bf67 100644 --- a/app/client/lib/koDom.js +++ b/app/client/lib/koDom.js @@ -130,6 +130,22 @@ function attr(attrName, valueOrFunc) { } exports.attr = attr; +/** + * Sets or removes a boolean attribute of a DOM element. According to the spec, empty string is a + * valid true value for the attribute, and the false value is indicated by the attribute's absence. + * @param {String} attrName The name of the attribute to bind, e.g. 'href'. + * @param {Object} valueOrFunc An observable, a constant, or a function for a computed observable. + */ +function boolAttr(attrName, valueOrFunc) { + return makeBinding(valueOrFunc, function(elem, value) { + if (!value) { + elem.removeAttribute(attrName); + } else { + elem.setAttribute(attrName, ''); + } + }); +} +exports.boolAttr = boolAttr; /** * Keeps the style property `property` of a DOM element in sync with an observable value. diff --git a/app/client/widgets/AttachmentsEditor.ts b/app/client/widgets/AttachmentsEditor.ts index 94f8ea28..42873ef4 100644 --- a/app/client/widgets/AttachmentsEditor.ts +++ b/app/client/widgets/AttachmentsEditor.ts @@ -149,16 +149,18 @@ export class AttachmentsEditor extends NewBaseEditor { testId('pw-download') ), ), - cssButton(cssButtonIcon('FieldAttachment'), 'Add', - dom.on('click', () => this._select()), - testId('pw-add') - ), - dom.maybe(this._selected, selected => - cssButton(cssButtonIcon('Remove'), 'Delete', - dom.on('click', () => this._remove()), - testId('pw-remove') + this.options.readonly ? null : [ + cssButton(cssButtonIcon('FieldAttachment'), 'Add', + dom.on('click', () => this._select()), + testId('pw-add') ), - ) + dom.maybe(this._selected, () => + cssButton(cssButtonIcon('Remove'), 'Delete', + dom.on('click', () => this._remove()), + testId('pw-remove') + ), + ) + ] ), cssCloseButton(cssBigIcon('CrossBig'), dom.on('click', () => ctl.close()), testId('pw-close')), @@ -172,12 +174,12 @@ export class AttachmentsEditor extends NewBaseEditor { dom.hide(use => !use(this._attachments).length || use(this._index) === use(this._attachments).length - 1), dom.on('click', () => this._moveIndex(1)) ), - dom.domComputed(this._selected, selected => renderContent(selected)), + dom.domComputed(this._selected, selected => renderContent(selected, this.options.readonly)), // Drag-over logic (elem: HTMLElement) => dragOverClass(elem, cssDropping.className), - cssDragArea(cssWarning('Drop files here to attach')), - dom.on('drop', ev => this._upload(ev.dataTransfer!.files)), + cssDragArea(this.options.readonly ? null : cssWarning('Drop files here to attach')), + this.options.readonly ? null : dom.on('drop', ev => this._upload(ev.dataTransfer!.files)), testId('pw-modal') ]; } @@ -235,10 +237,10 @@ function isInEditor(ev: KeyboardEvent): boolean { return (ev.target as HTMLElement).tagName === 'INPUT'; } -function renderContent(att: Attachment|null): HTMLElement { +function renderContent(att: Attachment|null, readonly: boolean): HTMLElement { const commonArgs = [cssContent.cls(''), testId('pw-attachment-content')]; if (!att) { - return cssWarning('No attachments', cssDetails('Drop files here to attach.'), ...commonArgs); + return cssWarning('No attachments', readonly ? null : cssDetails('Drop files here to attach.'), ...commonArgs); } else if (att.hasPreview) { return dom('img', dom.attr('src', att.url), ...commonArgs); } else if (att.fileType.startsWith('video/')) { diff --git a/app/client/widgets/CheckBoxEditor.js b/app/client/widgets/CheckBoxEditor.js index c8cdb00c..9c7d6662 100644 --- a/app/client/widgets/CheckBoxEditor.js +++ b/app/client/widgets/CheckBoxEditor.js @@ -18,4 +18,7 @@ CheckBoxEditor.skipEditor = function(typedVal, cellVal) { } } +// For documentation, see NewBaseEditor.ts +CheckBoxEditor.supportsReadonly = function() { return false; } + module.exports = CheckBoxEditor; diff --git a/app/client/widgets/ChoiceEditor.js b/app/client/widgets/ChoiceEditor.js index 68c0967f..41af28f1 100644 --- a/app/client/widgets/ChoiceEditor.js +++ b/app/client/widgets/ChoiceEditor.js @@ -13,7 +13,7 @@ function ChoiceEditor(options) { this.choices = options.field.widgetOptionsJson.peek().choices || []; // Add autocomplete if there are any choices to select from - if (this.choices.length > 0) { + if (this.choices.length > 0 && !options.readonly) { autocomplete(this.textInput, this.choices, { allowNothingSelected: true, diff --git a/app/client/widgets/ChoiceListEditor.ts b/app/client/widgets/ChoiceListEditor.ts index ec7c6c37..e41240f6 100644 --- a/app/client/widgets/ChoiceListEditor.ts +++ b/app/client/widgets/ChoiceListEditor.ts @@ -70,10 +70,13 @@ export class ChoiceListEditor extends NewBaseEditor { createToken: label => new ChoiceItem(label, !choiceSet.has(label)), acOptions, openAutocompleteOnFocus: true, + readonly : options.readonly, styles: {cssTokenField, cssToken, cssDeleteButton, cssDeleteIcon}, }); this._dom = dom('div.default_editor', + dom.cls("readonly_editor", options.readonly), + dom.cls(cssReadonlyStyle.className, options.readonly), this.cellEditorDiv = cssCellEditor(testId('widget-text-editor'), this._contentSizer = cssContentSizer(), elem => this._tokenField.attach(elem), @@ -281,3 +284,8 @@ const cssChoiceList = styled('div', ` z-index: 1001; box-shadow: 0 0px 8px 0 rgba(38,38,51,0.6) `); + +const cssReadonlyStyle = styled('div', ` + padding-left: 16px; + background: white; +`); diff --git a/app/client/widgets/DateEditor.js b/app/client/widgets/DateEditor.js index 31c903aa..f1352ea6 100644 --- a/app/client/widgets/DateEditor.js +++ b/app/client/widgets/DateEditor.js @@ -35,60 +35,75 @@ function DateEditor(options) { // Strip moment format string to remove markers unsupported by the datepicker. this.safeFormat = DateEditor.parseMomentToSafe(this.dateFormat); + this._readonly = options.readonly; + // Use the default local timezone to format the placeholder date. let defaultTimezone = moment.tz.guess(); let placeholder = moment.tz(defaultTimezone).format(this.safeFormat); + if (options.readonly) { + // clear placeholder for readonly mode + placeholder = null; + } TextEditor.call(this, _.defaults(options, { placeholder: placeholder })); - // Set the edited value, if not explicitly given, to the formatted version of cellValue. - this.textInput.value = gutil.undef(options.state, options.editValue, - this.formatValue(options.cellValue, this.safeFormat)); - - // Indicates whether keyboard navigation is active for the datepicker. - this._keyboardNav = false; - - // Attach the datepicker. - this._datePickerWidget = $(this.textInput).datepicker({ - keyboardNavigation: false, - forceParse: false, - todayHighlight: true, - todayBtn: 'linked', - // Convert the stripped format string to one suitable for the datepicker. - format: DateEditor.parseSafeToCalendar(this.safeFormat) - }); - this.autoDisposeCallback(() => this._datePickerWidget.datepicker('remove')); - - // NOTE: Datepicker interferes with normal enter and escape functionality. Add an event handler - // to the DatePicker to prevent interference with normal behavior. - this._datePickerWidget.on('keydown', e => { - // If enter or escape is pressed, destroy the datepicker and re-dispatch the event. - if (e.keyCode === 13 || e.keyCode === 27) { - this._datePickerWidget.datepicker('remove'); - // The current target of the event will be the textarea. - setTimeout(() => e.currentTarget.dispatchEvent(e.originalEvent), 0); - } - }); + const isValid = _.isNumber(options.cellValue); + const formatted = this.formatValue(options.cellValue, this.safeFormat); + // Formatted value will be empty if a cell contains an error, + // but for a readonly mode we actually want to show what user typed + // into the cell. + const readonlyValue = isValid ? formatted : options.cellValue; + const cellValue = options.readonly ? readonlyValue : formatted; - // When the up/down arrow is pressed, modify the datepicker options to take control of - // the arrow keys for date selection. - let datepickerCommands = Object.assign({}, options.commands, { - datepickerFocus: () => { this._allowKeyboardNav(true); } - }); - this._datepickerCommands = this.autoDispose(commands.createGroup(datepickerCommands, this, true)); - - this._datePickerWidget.on('show', () => { - // A workaround to allow clicking in the datepicker without losing focus. - dom(document.querySelector('.datepicker'), - kd.attr('tabIndex', 0), // allows datepicker to gain focus - kd.toggleClass('clipboard_focus', true) // tells clipboard to not steal focus from us - ); - // Attach command group to the input to allow switching keyboard focus to the datepicker. - dom(this.textInput, - // If the user inputs text into the textbox, take keyboard focus from the datepicker. - dom.on('input', () => { this._allowKeyboardNav(false); }), - this._datepickerCommands.attach() - ); - }); + // Set the edited value, if not explicitly given, to the formatted version of cellValue. + this.textInput.value = gutil.undef(options.state, options.editValue, cellValue); + + if (!options.readonly) { + // Indicates whether keyboard navigation is active for the datepicker. + this._keyboardNav = false; + + // Attach the datepicker. + this._datePickerWidget = $(this.textInput).datepicker({ + keyboardNavigation: false, + forceParse: false, + todayHighlight: true, + todayBtn: 'linked', + // Convert the stripped format string to one suitable for the datepicker. + format: DateEditor.parseSafeToCalendar(this.safeFormat) + }); + this.autoDisposeCallback(() => this._datePickerWidget.datepicker('remove')); + + // NOTE: Datepicker interferes with normal enter and escape functionality. Add an event handler + // to the DatePicker to prevent interference with normal behavior. + this._datePickerWidget.on('keydown', e => { + // If enter or escape is pressed, destroy the datepicker and re-dispatch the event. + if (e.keyCode === 13 || e.keyCode === 27) { + this._datePickerWidget.datepicker('remove'); + // The current target of the event will be the textarea. + setTimeout(() => e.currentTarget.dispatchEvent(e.originalEvent), 0); + } + }); + + // When the up/down arrow is pressed, modify the datepicker options to take control of + // the arrow keys for date selection. + let datepickerCommands = Object.assign({}, options.commands, { + datepickerFocus: () => { this._allowKeyboardNav(true); } + }); + this._datepickerCommands = this.autoDispose(commands.createGroup(datepickerCommands, this, true)); + + this._datePickerWidget.on('show', () => { + // A workaround to allow clicking in the datepicker without losing focus. + dom(document.querySelector('.datepicker'), + kd.attr('tabIndex', 0), // allows datepicker to gain focus + kd.toggleClass('clipboard_focus', true) // tells clipboard to not steal focus from us + ); + // Attach command group to the input to allow switching keyboard focus to the datepicker. + dom(this.textInput, + // If the user inputs text into the textbox, take keyboard focus from the datepicker. + dom.on('input', () => { this._allowKeyboardNav(false); }), + this._datepickerCommands.attach() + ); + }); + } } dispose.makeDisposable(DateEditor); diff --git a/app/client/widgets/DateTimeEditor.js b/app/client/widgets/DateTimeEditor.js index 5607bb85..d4b6a92d 100644 --- a/app/client/widgets/DateTimeEditor.js +++ b/app/client/widgets/DateTimeEditor.js @@ -7,6 +7,7 @@ const kd = require('../lib/koDom'); const DateEditor = require('./DateEditor'); const gutil = require('app/common/gutil'); const { parseDate } = require('app/common/parseDate'); +const TextEditor = require('./TextEditor'); /** * DateTimeEditor - Editor for DateTime type. Includes a dropdown datepicker. @@ -18,10 +19,14 @@ function DateTimeEditor(options) { // Adjust the command group. var origCommands = options.commands; - options.commands = Object.assign({}, origCommands, { - prevField: () => this._focusIndex() === 1 ? this._setFocus(0) : origCommands.prevField(), - nextField: () => this._focusIndex() === 0 ? this._setFocus(1) : origCommands.nextField(), - }); + + // don't modify navigation for readonly mode + if (!options.readonly) { + options.commands = Object.assign({}, origCommands, { + prevField: () => this._focusIndex() === 1 ? this._setFocus(0) : origCommands.prevField(), + nextField: () => this._focusIndex() === 0 ? this._setFocus(1) : origCommands.nextField(), + }); + } // Call the superclass. DateEditor.call(this, options); @@ -32,20 +37,37 @@ function DateTimeEditor(options) { // modifies that to be two side-by-side textareas. this._dateSizer = this.contentSizer; // For consistency with _timeSizer and _timeInput. this._dateInput = this.textInput; - dom(this.dom, kd.toggleClass('celleditor_datetime', true)); - dom(this.dom.firstChild, kd.toggleClass('celleditor_datetime_editor', true)); - this.dom.appendChild( - dom('div.celleditor_cursor_editor.celleditor_datetime_editor', - this._timeSizer = dom('div.celleditor_content_measure'), - this._timeInput = dom('textarea.celleditor_text_editor', - // Use a placeholder of 12:00am, since that is the autofill time value. - kd.attr('placeholder', moment.tz('0', 'H', this.timezone).format(this._timeFormat)), - kd.value(this.formatValue(options.cellValue, this._timeFormat)), - this.commandGroup.attach(), - dom.on('input', () => this.onChange()) + + const isValid = _.isNumber(options.cellValue); + const formatted = this.formatValue(options.cellValue, this._timeFormat); + // Use a placeholder of 12:00am, since that is the autofill time value. + const placeholder = moment.tz('0', 'H', this.timezone).format(this._timeFormat); + + // for readonly + if (options.readonly) { + if (!isValid) { + // do nothing - DateEditor will show correct error + } else { + // append time format or a placeholder + const time = (formatted || placeholder); + const sep = time ? ' ' : ''; + this.textInput.value = this.textInput.value + sep + time; + } + } else { + dom(this.dom, kd.toggleClass('celleditor_datetime', true)); + dom(this.dom.firstChild, kd.toggleClass('celleditor_datetime_editor', true)); + this.dom.appendChild( + dom('div.celleditor_cursor_editor.celleditor_datetime_editor', + this._timeSizer = dom('div.celleditor_content_measure'), + this._timeInput = dom('textarea.celleditor_text_editor', + kd.attr('placeholder', placeholder), + kd.value(formatted), + this.commandGroup.attach(), + dom.on('input', () => this.onChange()) + ) ) - ) - ); + ); + } // If the edit value is encoded json, use those values as a starting point if (typeof options.state == 'string') { @@ -65,6 +87,9 @@ _.extend(DateTimeEditor.prototype, DateEditor.prototype); DateTimeEditor.prototype.setSizerLimits = function() { var maxSize = this.editorPlacement.calcSize({width: Infinity, height: Infinity}, {calcOnly: true}); + if (this.options.readonly) { + return; + } this._dateSizer.style.maxWidth = this._timeSizer.style.maxWidth = Math.ceil(maxSize.width / 2 - 6) + 'px'; }; @@ -118,6 +143,12 @@ DateTimeEditor.prototype.getCellValue = function() { * Overrides the resizing function in TextEditor. */ DateTimeEditor.prototype._resizeInput = function() { + + // for readonly field, we will use logic from a super class + if (this.options.readonly) { + TextEditor.prototype._resizeInput.call(this); + return; + } // Use the size calculation provided in options.calcSize (that takes into account cell size and // screen size), with both date and time parts as the input. The resulting size is applied to // the parent (containing date + time), with date and time each expanding or shrinking from the diff --git a/app/client/widgets/FieldBuilder.ts b/app/client/widgets/FieldBuilder.ts index 0b71472e..9f8cc0a4 100644 --- a/app/client/widgets/FieldBuilder.ts +++ b/app/client/widgets/FieldBuilder.ts @@ -21,14 +21,14 @@ import { DiffBox } from 'app/client/widgets/DiffBox'; import { buildErrorDom } from 'app/client/widgets/ErrorDom'; import { FieldEditor, openSideFormulaEditor, saveWithoutEditor } from 'app/client/widgets/FieldEditor'; import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget'; +import { NewBaseEditor } from "app/client/widgets/NewBaseEditor"; import * as UserType from 'app/client/widgets/UserType'; import * as UserTypeImpl from 'app/client/widgets/UserTypeImpl'; import * as gristTypes from 'app/common/gristTypes'; import * as gutil from 'app/common/gutil'; import { CellValue } from 'app/plugin/GristData'; -import { delay } from 'bluebird'; import { Computed, Disposable, fromKo, dom as grainjsDom, - Holder, IDisposable, makeTestId } from 'grainjs'; + Holder, IDisposable, makeTestId, toKo } from 'grainjs'; import * as ko from 'knockout'; import * as _ from 'underscore'; @@ -77,6 +77,7 @@ export class FieldBuilder extends Disposable { private readonly _fieldEditorHolder: Holder; private readonly _widgetCons: ko.Computed<{create: (...args: any[]) => NewAbstractWidget}>; private readonly _docModel: DocModel; + private readonly _readonly: Computed; public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec, private _cursor: Cursor) { @@ -88,6 +89,8 @@ export class FieldBuilder extends Disposable { this._readOnlyPureType = ko.pureComputed(() => this.field.column().pureType()); + this._readonly = Computed.create(this, (use) => use(gristDoc.isReadonly) || use(field.disableEditData)); + // Observable with a list of available types. this._availableTypes = Computed.create(this, (use) => { const isFormula = use(this.origColumn.isFormula); @@ -407,11 +410,13 @@ export class FieldBuilder extends Disposable { } }, this).extend({ deferred: true })).onlyNotifyUnequal(); + return (elem: Element) => { this._rowMap.set(row, elem); dom(elem, dom.autoDispose(widgetObs), kd.cssClass(this.field.formulaCssClass), + kd.toggleClass("readonly", toKo(ko, this._readonly)), kd.maybe(isSelected, () => dom('div.selected_cursor', kd.toggleClass('active_cursor', isActive) )), @@ -426,28 +431,6 @@ export class FieldBuilder extends Disposable { }; } - /** - * Flash the cursor in the given row briefly to indicate that editing in this cell is disabled. - */ - public async flashCursorReadOnly(mainRow: DataRowModel) { - const mainCell = this._rowMap.get(mainRow); - // Abort if a cell is not found (i.e. if this is a ChartView) - if (!mainCell) { return; } - const elem = mainCell.querySelector('.active_cursor'); - if (elem && !elem.classList.contains('cursor_read_only')) { - elem.classList.add('cursor_read_only'); - const div = elem.appendChild(dom('div.cursor_read_only_lock.glyphicon.glyphicon-lock')); - try { - await delay(200); - elem.classList.add('cursor_read_only_fade'); - await delay(400); - } finally { - elem.classList.remove('cursor_read_only', 'cursor_read_only_fade'); - elem.removeChild(div); - } - } - } - public buildEditorDom(editRow: DataRowModel, mainRowModel: DataRowModel, options: { init?: string, state?: any @@ -459,7 +442,16 @@ export class FieldBuilder extends Disposable { return; } - const editorCtor = UserTypeImpl.getEditorConstructor(this.options().widget, this._readOnlyPureType()); + // If this is censored value, don't open up the editor, unless it is a formula field. + const cell = editRow.cells[this.field.colId()]; + const value = cell && cell(); + if (gristTypes.isCensored(value) && !this.origColumn.isFormula.peek()) { + this._fieldEditorHolder.clear(); + return; + } + + const editorCtor: typeof NewBaseEditor = + UserTypeImpl.getEditorConstructor(this.options().widget, this._readOnlyPureType()); // constructor may be null for a read-only non-formula field, though not today. if (!editorCtor) { // Actually, we only expect buildEditorDom() to be called when isEditorActive() is false (i.e. @@ -468,7 +460,13 @@ export class FieldBuilder extends Disposable { return; } - if (saveWithoutEditor(editorCtor, editRow, this.field, options.init)) { + // if editor doesn't support readonly mode, don't show it + if (this._readonly.get() && editorCtor.supportsReadonly && !editorCtor.supportsReadonly()) { + this._fieldEditorHolder.clear(); + return; + } + + if (!this._readonly.get() && saveWithoutEditor(editorCtor, editRow, this.field, options.init)) { this._fieldEditorHolder.clear(); return; } @@ -483,8 +481,9 @@ export class FieldBuilder extends Disposable { editRow, cellElem, editorCtor, - startVal: options.init, - state : options.state + state: options.state, + startVal: this._readonly.get() ? undefined : options.init, // don't start with initial value + readonly: this._readonly.get() // readonly for editor will not be observable }); // Put the FieldEditor into a holder in GristDoc too. This way any existing FieldEditor (perhaps diff --git a/app/client/widgets/FieldEditor.ts b/app/client/widgets/FieldEditor.ts index e9709667..d2162b96 100644 --- a/app/client/widgets/FieldEditor.ts +++ b/app/client/widgets/FieldEditor.ts @@ -74,6 +74,7 @@ export class FieldEditor extends Disposable { private _saveEdit = asyncOnce(() => this._doSaveEdit()); private _editorHasChanged = false; private _isFormula = false; + private _readonly = false; constructor(options: { gristDoc: GristDoc, @@ -83,7 +84,8 @@ export class FieldEditor extends Disposable { cellElem: Element, editorCtor: IEditorConstructor, startVal?: string, - state?: any + state?: any, + readonly: boolean }) { super(); this._gristDoc = options.gristDoc; @@ -92,6 +94,7 @@ export class FieldEditor extends Disposable { this._editRow = options.editRow; this._editorCtor = options.editorCtor; this._cellElem = options.cellElem; + this._readonly = options.readonly; const startVal = options.startVal; let offerToMakeFormula = false; @@ -99,7 +102,7 @@ export class FieldEditor extends Disposable { const column = this._field.column(); this._isFormula = column.isRealFormula.peek(); let editValue: string|undefined = startVal; - if (startVal && gutil.startsWith(startVal, '=')) { + if (!options.readonly && startVal && gutil.startsWith(startVal, '=')) { if (this._isFormula || this._field.column().isEmpty()) { // If we typed '=' on an empty column, convert it to a formula. If on a formula column, // start editing ignoring the initial '='. @@ -131,9 +134,24 @@ export class FieldEditor extends Disposable { unmakeFormula: () => this._unmakeFormula(), }; - const state: any = options.state; + // for readonly editor rewire commands, most of this also could be + // done by just overriding the saveEdit method, but this is more clearer + if (options.readonly) { + this._editCommands.fieldEditSave = () => { + // those two lines are tightly coupled - without disposing first + // it will run itself in a loop. But this is needed for a GridView + // which navigates to the next row on save. + this._editCommands.fieldEditCancel(); + commands.allCommands.fieldEditSave.run(); + }; + this._editCommands.fieldEditSaveHere = this._editCommands.fieldEditCancel; + this._editCommands.prevField = () => { this._cancelEdit(); commands.allCommands.prevField.run(); }; + this._editCommands.nextField = () => { this._cancelEdit(); commands.allCommands.nextField.run(); }; + this._editCommands.makeFormula = () => true; /* don't stop propagation */ + this._editCommands.unmakeFormula = () => true; + } - this.rebuildEditor(editValue, Number.POSITIVE_INFINITY, state); + this.rebuildEditor(editValue, Number.POSITIVE_INFINITY, options.state); if (offerToMakeFormula) { this._offerToMakeFormula(); @@ -143,7 +161,12 @@ export class FieldEditor extends Disposable { // when user or server refreshes the browser this._gristDoc.editorMonitor.monitorEditor(this); - setupEditorCleanup(this, this._gristDoc, this._field, this._saveEdit); + // for readonly field we don't need to do anything special + if (!options.readonly) { + setupEditorCleanup(this, this._gristDoc, this._field, this._saveEdit); + } else { + setupReadonlyEditorCleanup(this, this._gristDoc, this._field, () => this._cancelEdit()); + } } // cursorPos refers to the position of the caret within the editor. @@ -166,10 +189,16 @@ export class FieldEditor extends Disposable { cellValue = cellCurrentValue; } - // Enter formula-editing mode (e.g. click-on-column inserts its ID) only if we are opening the - // editor by typing into it (and overriding previous formula). In other cases (e.g. double-click), - // we defer this mode until the user types something. - this._field.editingFormula(this._isFormula && editValue !== undefined); + const error = getFormulaError(this._gristDoc, this._editRow, column); + + // For readonly mode use the default behavior of Formula Editor + // TODO: cleanup this flag - it gets modified in too many places + if (!this._readonly){ + // Enter formula-editing mode (e.g. click-on-column inserts its ID) only if we are opening the + // editor by typing into it (and overriding previous formula). In other cases (e.g. double-click), + // we defer this mode until the user types something. + this._field.editingFormula(this._isFormula && editValue !== undefined); + } this._editorHasChanged = false; // Replace the item in the Holder with a new one, disposing the previous one. @@ -177,11 +206,12 @@ export class FieldEditor extends Disposable { gristDoc: this._gristDoc, field: this._field, cellValue, - formulaError: getFormulaError(this._gristDoc, this._editRow, column), + formulaError: error, editValue, cursorPos, state, commands: this._editCommands, + readonly : this._readonly })); // if editor supports live changes, connect it to the change emitter @@ -268,6 +298,7 @@ export class FieldEditor extends Disposable { // Cancels the edit private _cancelEdit() { + if (this.isDisposed()) { return; } const event: FieldEditorStateEvent = { position : this.cellPosition(), wasModified : this._editorHasChanged, @@ -380,6 +411,7 @@ export function openSideFormulaEditor(options: { cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor. commands: editCommands, cssClass: 'formula_editor_sidepane', + readonly : false }); editor.attach(refElem); @@ -389,6 +421,20 @@ export function openSideFormulaEditor(options: { return holder; } +/** + * For an readonly editor, set up its cleanup: + * - canceling on click-away (when focus returns to Grist "clipboard" element) + */ +function setupReadonlyEditorCleanup( + owner: MultiHolder, gristDoc: GristDoc, field: ViewFieldRec, cancelEdit: () => any +) { + // Whenever focus returns to the Clipboard component, close the editor by saving the value. + gristDoc.app.on('clipboard_focus', cancelEdit); + owner.onDispose(() => { + field.editingFormula(false); + gristDoc.app.off('clipboard_focus', cancelEdit); + }); +} /** * For an active editor, set up its cleanup: diff --git a/app/client/widgets/FormulaEditor.ts b/app/client/widgets/FormulaEditor.ts index 8d03baf5..2bd8b65e 100644 --- a/app/client/widgets/FormulaEditor.ts +++ b/app/client/widgets/FormulaEditor.ts @@ -47,11 +47,15 @@ export class FormulaEditor extends NewBaseEditor { // and _editorPlacement created. calcSize: this._calcSize.bind(this), gristDoc: options.gristDoc, - saveValueOnBlurEvent: true, - editorState : this.editorState + saveValueOnBlurEvent: !options.readonly, + editorState : this.editorState, + readonly: options.readonly }); - const allCommands = Object.assign({ setCursor: this._onSetCursor }, options.commands); + const allCommands = !options.readonly + ? Object.assign({ setCursor: this._onSetCursor }, options.commands) + // for readonly mode don't grab cursor when clicked away - just move the cursor + : options.commands; this._commandGroup = this.autoDispose(createGroup(allCommands, this, options.field.editingFormula)); const hideErrDetails = Observable.create(null, true); @@ -59,6 +63,8 @@ export class FormulaEditor extends NewBaseEditor { this.autoDispose(this._formulaEditor); this._dom = dom('div.default_editor', + // switch border shadow + dom.cls("readonly_editor", options.readonly), createMobileButtons(options.commands), options.cssClass ? dom.cls(options.cssClass) : null, @@ -70,7 +76,10 @@ export class FormulaEditor extends NewBaseEditor { dom('div.formula_editor.formula_field_edit', testId('formula-editor'), // We don't always enter editing mode immediately, e.g. not on double-clicking a cell. // In those cases, we'll switch as soon as the user types or clicks into the editor. - dom.on('mousedown', () => options.field.editingFormula(true)), + dom.on('mousedown', () => { + // but don't do it when this is a readonly mode + options.field.editingFormula(true); + }), this._formulaEditor.buildDom((aceObj: any) => { aceObj.setFontSize(11); aceObj.setHighlightActiveLine(false); @@ -82,10 +91,13 @@ export class FormulaEditor extends NewBaseEditor { this._formulaEditor.attachCommandGroup(this._commandGroup); // enable formula editing if state was passed - if (options.state) { + if (options.state || options.readonly) { options.field.editingFormula(true); } - + if (options.readonly) { + this._formulaEditor.enable(false); + aceObj.gotoLine(0, 0); // By moving, ace editor won't highlight anything + } // This catches any change to the value including e.g. via backspace or paste. aceObj.once("change", () => options.field.editingFormula(true)); }) @@ -147,8 +159,11 @@ export class FormulaEditor extends NewBaseEditor { // TODO: update regexes to unicode? private _onSetCursor(row: DataRowModel, col: ViewFieldRec) { + if (!col) { return; } // if clicked on row header, no col to insert + if (this.options.readonly) { return; } + const aceObj = this._formulaEditor.getEditor(); if (!aceObj.selection.isEmpty()) { // If text selected, replace whole selection diff --git a/app/client/widgets/NTextEditor.ts b/app/client/widgets/NTextEditor.ts index 56fbf234..fdc7d8a3 100644 --- a/app/client/widgets/NTextEditor.ts +++ b/app/client/widgets/NTextEditor.ts @@ -10,7 +10,6 @@ import {CellValue} from "app/common/DocActions"; import {undef} from 'app/common/gutil'; import {dom, Observable} from 'grainjs'; - export class NTextEditor extends NewBaseEditor { // Observable with current editor state (used by drafts or latest edit/position component) public readonly editorState: Observable; @@ -36,12 +35,18 @@ export class NTextEditor extends NewBaseEditor { this.commandGroup = this.autoDispose(createGroup(options.commands, null, true)); this._alignment = options.field.widgetOptionsJson.peek().alignment || 'left'; - this._dom = dom('div.default_editor', - this.cellEditorDiv = dom('div.celleditor_cursor_editor', testId('widget-text-editor'), + this._dom = + dom('div.default_editor', + // add readonly class + dom.cls("readonly_editor", options.readonly), + this.cellEditorDiv = dom('div.celleditor_cursor_editor', + testId('widget-text-editor'), this._contentSizer = dom('div.celleditor_content_measure'), - this.textInput = dom('textarea', dom.cls('celleditor_text_editor'), + this.textInput = dom('textarea', + dom.cls('celleditor_text_editor'), dom.style('text-align', this._alignment), dom.prop('value', initialValue), + dom.boolAttr('readonly', options.readonly), this.commandGroup.attach(), dom.on('input', () => this.onInput()) ) diff --git a/app/client/widgets/NewBaseEditor.ts b/app/client/widgets/NewBaseEditor.ts index b0552613..67c3b37b 100644 --- a/app/client/widgets/NewBaseEditor.ts +++ b/app/client/widgets/NewBaseEditor.ts @@ -22,6 +22,7 @@ export interface Options { cursorPos: number; commands: IEditorCommandGroup; state?: any; + readonly: boolean; } /** @@ -55,6 +56,13 @@ export abstract class NewBaseEditor extends Disposable { return undefined; } + /** + * Check if editor supports readonly mode (default: true) + */ + public static supportsReadonly(): boolean { + return true; + } + /** * Current state of the editor. Optional, not all editors will report theirs current state. */ diff --git a/app/client/widgets/ReferenceEditor.ts b/app/client/widgets/ReferenceEditor.ts index 8c8fa9b6..b56ac2bc 100644 --- a/app/client/widgets/ReferenceEditor.ts +++ b/app/client/widgets/ReferenceEditor.ts @@ -53,8 +53,12 @@ export class ReferenceEditor extends NTextEditor { this._visibleCol = vcol.colId() || 'id'; // Decorate the editor to look like a reference column value (with a "link" icon). - this.cellEditorDiv.classList.add(cssRefEditor.className); - this.cellEditorDiv.appendChild(cssRefEditIcon('FieldReference')); + // But not on readonly mode - here we will reuse default decoration + if (!options.readonly) { + this.cellEditorDiv.classList.add(cssRefEditor.className); + this.cellEditorDiv.appendChild(cssRefEditIcon('FieldReference')); + } + this.textInput.value = undef(options.state, options.editValue, this._idToText(options.cellValue)); const needReload = (options.editValue === undefined && !tableData.isLoaded); @@ -80,6 +84,8 @@ export class ReferenceEditor extends NTextEditor { public attach(cellElem: Element): void { super.attach(cellElem); + // don't create autocomplete for readonly mode + if (this.options.readonly) { return; } this._autocomplete = this.autoDispose(new Autocomplete(this.textInput, { menuCssClass: menuCssClass + ' ' + cssRefList.className, search: this._doSearch.bind(this), diff --git a/app/client/widgets/TextBox.css b/app/client/widgets/TextBox.css index e7971a8c..d9a4c376 100644 --- a/app/client/widgets/TextBox.css +++ b/app/client/widgets/TextBox.css @@ -31,10 +31,10 @@ cursor: pointer; } - .formula_field::before { + .formula_field::before, .formula_field_edit::before { background-color: #D0D0D0; } - .formula_field_edit::before { + .formula_field_edit:not(.readonly)::before { background-color: var(--grist-color-cursor); } .formula_field.invalid::before { @@ -45,6 +45,10 @@ background-color: var(--grist-color-cursor); color: #ffb6c1; } + + .readonly_editor .formula_field_edit::before { + display: none; + } } .invalid-text input { diff --git a/app/client/widgets/TextEditor.css b/app/client/widgets/TextEditor.css index d13e2f04..51bc61f2 100644 --- a/app/client/widgets/TextEditor.css +++ b/app/client/widgets/TextEditor.css @@ -7,6 +7,32 @@ box-shadow: 0 0 3px 2px var(--grist-color-cursor); } +.readonly_editor { + box-shadow: 0 0 3px 2px var(--grist-color-slate); +} + +/* make room for lock icon */ +.readonly_editor .celleditor_cursor_editor .celleditor_text_editor, +.readonly_editor .celleditor_cursor_editor .celleditor_content_measure { + padding-left: 18px; +} + +.readonly_editor::before { + content: ""; + position: absolute; + top: 0; + left: 0; + margin: 4px 3px 0 3px; + width: 13px; + height: 13px; + background-color: #D0D0D0; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + -webkit-mask-size: contain; + -webkit-mask-image: var(--icon-Lock); +} + + .formula_editor { background-color: white; padding: 4px 0 2px 21px; diff --git a/app/client/widgets/TextEditor.js b/app/client/widgets/TextEditor.js index fe984f41..0dee97d4 100644 --- a/app/client/widgets/TextEditor.js +++ b/app/client/widgets/TextEditor.js @@ -8,7 +8,7 @@ var commands = require('../components/commands'); const {testId} = require('app/client/ui2018/cssVars'); const {createMobileButtons, getButtonMargins} = require('app/client/widgets/EditorButtons'); const {EditorPlacement} = require('app/client/widgets/EditorPlacement'); -const { observable } = require('grainjs'); +const {observable} = require('grainjs'); /** * Required parameters: @@ -38,12 +38,14 @@ function TextEditor(options) { this.editorState = this.autoDispose(observable(initialValue)); this.dom = dom('div.default_editor', + kd.toggleClass("readonly_editor", options.readonly), dom('div.celleditor_cursor_editor', dom.testId('TextEditor_editor'), testId('widget-text-editor'), // new-style testId matches NTextEditor, for more uniform tests. this.contentSizer = dom('div.celleditor_content_measure'), this.textInput = dom('textarea.celleditor_text_editor', kd.attr('placeholder', options.placeholder || ''), kd.style('text-align', this._alignment), + kd.boolAttr('readonly', options.readonly), kd.value(initialValue), this.commandGroup.attach(), diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 461da4ca..ffbfe467 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -331,6 +331,12 @@ export async function getColumnNames() { .filter(name => name !== '+'); } +export async function getCardFieldLabels() { + const section = await driver.findWait('.active_section', 4000); + const labels = await section.findAll(".g_record_detail_label", el => el.getText()); + return labels; +} + /** * Resize the given grid column by a given number of pixels. */ @@ -371,6 +377,12 @@ export async function getCursorPosition() { // This must be a detail view, and we just got the info we need. return {rowNum: parseInt(rowNum, 10), col: colName}; } else { + // We might be on a single card record + const counter = await section.findAll(".grist-single-record__menu__count"); + if (counter.length) { + const cardRow = (await counter[0].getText()).split(' OF ')[0]; + return { rowNum : parseInt(cardRow), col: colName }; + } // Otherwise, it's a grid view, and we need to use indices to look up the info. const gridRows = await section.findAll('.gridview_data_row_num'); const gridRowNum = await gridRows[rowIndex].getText();