(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
pull/9/head
Jarosław Sadziński 3 years ago
parent d3dc910784
commit 698c9d4e40

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

@ -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 || {});
};
/**

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

@ -35,6 +35,7 @@ export interface ITokenFieldOptions {
acOptions?: IAutocompleteOptions<IToken & ACItem>;
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);
}

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

@ -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/')) {

@ -18,4 +18,7 @@ CheckBoxEditor.skipEditor = function(typedVal, cellVal) {
}
}
// For documentation, see NewBaseEditor.ts
CheckBoxEditor.supportsReadonly = function() { return false; }
module.exports = CheckBoxEditor;

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

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

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

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

@ -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<IDisposable>;
private readonly _widgetCons: ko.Computed<{create: (...args: any[]) => NewAbstractWidget}>;
private readonly _docModel: DocModel;
private readonly _readonly: Computed<boolean>;
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

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

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

@ -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<string>;
@ -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())
)

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

@ -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<ICellItem>(this.textInput, {
menuCssClass: menuCssClass + ' ' + cssRefList.className,
search: this._doSearch.bind(this),

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

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

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

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

Loading…
Cancel
Save