mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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
This commit is contained in:
parent
d3dc910784
commit
698c9d4e40
@ -26,6 +26,7 @@ function AceEditor(options) {
|
|||||||
this.calcSize = (options && options.calcSize) || ((elem, size) => size);
|
this.calcSize = (options && options.calcSize) || ((elem, size) => size);
|
||||||
this.gristDoc = (options && options.gristDoc) || null;
|
this.gristDoc = (options && options.gristDoc) || null;
|
||||||
this.editorState = (options && options.editorState) || null;
|
this.editorState = (options && options.editorState) || null;
|
||||||
|
this._readonly = options.readonly || false;
|
||||||
|
|
||||||
this.editor = null;
|
this.editor = null;
|
||||||
this.editorDom = null;
|
this.editorDom = null;
|
||||||
@ -112,6 +113,11 @@ AceEditor.prototype.attachCommandGroup = function(commandGroup) {
|
|||||||
_.each(commandGroup.knownKeys, (command, key) => {
|
_.each(commandGroup.knownKeys, (command, key) => {
|
||||||
this.editor.commands.addCommand({
|
this.editor.commands.addCommand({
|
||||||
name: command,
|
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: {
|
bindKey: {
|
||||||
win: key,
|
win: key,
|
||||||
mac: 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
|
// 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.
|
// it may be used as a key to retrieve the cell dom, which is useful for editor placement.
|
||||||
var lazyRow = this.getRenderedRowModel(rowId);
|
var lazyRow = this.getRenderedRowModel(rowId);
|
||||||
if (builder.field.disableEditData() || this.gristDoc.isReadonly.get()) {
|
if (!lazyRow) {
|
||||||
builder.flashCursorReadOnly(lazyRow);
|
// TODO scroll into view. For now, just don't activate the editor.
|
||||||
} else {
|
return;
|
||||||
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 || {});
|
|
||||||
}
|
}
|
||||||
|
this.editRowModel.assign(rowId);
|
||||||
|
builder.buildEditorDom(this.editRowModel, lazyRow, options || {});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -58,6 +58,9 @@ export class EditorMonitor extends Disposable {
|
|||||||
// will be invoked only once
|
// will be invoked only once
|
||||||
let executed = false;
|
let executed = false;
|
||||||
|
|
||||||
|
// don't restore on readonly mode
|
||||||
|
if (doc.isReadonly.get()) { return; }
|
||||||
|
|
||||||
// on view shown
|
// on view shown
|
||||||
this._currentViewListener.autoDispose(doc.currentView.addListener(async view => {
|
this._currentViewListener.autoDispose(doc.currentView.addListener(async view => {
|
||||||
if (executed) {
|
if (executed) {
|
||||||
|
@ -35,6 +35,7 @@ export interface ITokenFieldOptions {
|
|||||||
acOptions?: IAutocompleteOptions<IToken & ACItem>;
|
acOptions?: IAutocompleteOptions<IToken & ACItem>;
|
||||||
openAutocompleteOnFocus?: boolean;
|
openAutocompleteOnFocus?: boolean;
|
||||||
styles?: ITokenFieldStyles;
|
styles?: ITokenFieldStyles;
|
||||||
|
readonly?: boolean;
|
||||||
|
|
||||||
// Allows overriding how tokens are copied to the clipboard, or retrieved from it.
|
// 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
|
// 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)));
|
this.autoDispose(this._tokens.addListener(this._recordUndo.bind(this)));
|
||||||
|
|
||||||
// Use overridden styles if any were provided.
|
// Use overridden styles if any were provided.
|
||||||
const {cssTokenField, cssToken, cssInputWrapper, cssTokenInput, cssDeleteButton, cssDeleteIcon} =
|
const {
|
||||||
|
cssTokenField,
|
||||||
|
cssToken,
|
||||||
|
cssInputWrapper,
|
||||||
|
cssTokenInput,
|
||||||
|
cssDeleteButton,
|
||||||
|
cssDeleteIcon} =
|
||||||
{...tokenFieldStyles, ..._options.styles};
|
{...tokenFieldStyles, ..._options.styles};
|
||||||
|
|
||||||
function stop(ev: Event) {
|
function stop(ev: Event) {
|
||||||
@ -101,15 +108,18 @@ export class TokenField extends Disposable {
|
|||||||
{tabIndex: '-1'},
|
{tabIndex: '-1'},
|
||||||
dom.forEach(this._tokens, (t) =>
|
dom.forEach(this._tokens, (t) =>
|
||||||
cssToken(this._options.renderToken(t.token),
|
cssToken(this._options.renderToken(t.token),
|
||||||
cssDeleteButton(cssDeleteIcon('CrossSmall'), testId('tokenfield-delete')),
|
|
||||||
dom.cls('selected', (use) => use(this._selection).has(t)),
|
dom.cls('selected', (use) => use(this._selection).has(t)),
|
||||||
dom.on('click', (ev) => this._onTokenClick(ev, t)),
|
_options.readonly ? null : [
|
||||||
dom.on('mousedown', (ev) => this._onMouseDown(ev, t)),
|
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')
|
testId('tokenfield-token')
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
cssInputWrapper(
|
cssInputWrapper(
|
||||||
this._textInput = cssTokenInput(
|
this._textInput = cssTokenInput(
|
||||||
|
dom.boolAttr("readonly", this._options.readonly ?? false),
|
||||||
dom.on('focus', this._onInputFocus.bind(this)),
|
dom.on('focus', this._onInputFocus.bind(this)),
|
||||||
dom.on('blur', () => { this._acHolder.clear(); }),
|
dom.on('blur', () => { this._acHolder.clear(); }),
|
||||||
(this._acOptions ?
|
(this._acOptions ?
|
||||||
@ -174,6 +184,8 @@ export class TokenField extends Disposable {
|
|||||||
|
|
||||||
// Open the autocomplete dropdown, if autocomplete was configured in the options.
|
// Open the autocomplete dropdown, if autocomplete was configured in the options.
|
||||||
private _openAutocomplete() {
|
private _openAutocomplete() {
|
||||||
|
// don't open dropdown in a readonly mode
|
||||||
|
if (this._options.readonly) { return; }
|
||||||
if (this._acOptions && this._acHolder.isEmpty()) {
|
if (this._acOptions && this._acHolder.isEmpty()) {
|
||||||
Autocomplete.create(this._acHolder, this._textInput, this._acOptions);
|
Autocomplete.create(this._acHolder, this._textInput, this._acOptions);
|
||||||
}
|
}
|
||||||
|
@ -130,6 +130,22 @@ function attr(attrName, valueOrFunc) {
|
|||||||
}
|
}
|
||||||
exports.attr = attr;
|
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.
|
* 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')
|
testId('pw-download')
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
cssButton(cssButtonIcon('FieldAttachment'), 'Add',
|
this.options.readonly ? null : [
|
||||||
dom.on('click', () => this._select()),
|
cssButton(cssButtonIcon('FieldAttachment'), 'Add',
|
||||||
testId('pw-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')
|
|
||||||
),
|
),
|
||||||
)
|
dom.maybe(this._selected, () =>
|
||||||
|
cssButton(cssButtonIcon('Remove'), 'Delete',
|
||||||
|
dom.on('click', () => this._remove()),
|
||||||
|
testId('pw-remove')
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
),
|
),
|
||||||
cssCloseButton(cssBigIcon('CrossBig'), dom.on('click', () => ctl.close()),
|
cssCloseButton(cssBigIcon('CrossBig'), dom.on('click', () => ctl.close()),
|
||||||
testId('pw-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.hide(use => !use(this._attachments).length || use(this._index) === use(this._attachments).length - 1),
|
||||||
dom.on('click', () => this._moveIndex(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
|
// Drag-over logic
|
||||||
(elem: HTMLElement) => dragOverClass(elem, cssDropping.className),
|
(elem: HTMLElement) => dragOverClass(elem, cssDropping.className),
|
||||||
cssDragArea(cssWarning('Drop files here to attach')),
|
cssDragArea(this.options.readonly ? null : cssWarning('Drop files here to attach')),
|
||||||
dom.on('drop', ev => this._upload(ev.dataTransfer!.files)),
|
this.options.readonly ? null : dom.on('drop', ev => this._upload(ev.dataTransfer!.files)),
|
||||||
testId('pw-modal')
|
testId('pw-modal')
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -235,10 +237,10 @@ function isInEditor(ev: KeyboardEvent): boolean {
|
|||||||
return (ev.target as HTMLElement).tagName === 'INPUT';
|
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')];
|
const commonArgs = [cssContent.cls(''), testId('pw-attachment-content')];
|
||||||
if (!att) {
|
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) {
|
} else if (att.hasPreview) {
|
||||||
return dom('img', dom.attr('src', att.url), ...commonArgs);
|
return dom('img', dom.attr('src', att.url), ...commonArgs);
|
||||||
} else if (att.fileType.startsWith('video/')) {
|
} 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;
|
module.exports = CheckBoxEditor;
|
||||||
|
@ -13,7 +13,7 @@ function ChoiceEditor(options) {
|
|||||||
this.choices = options.field.widgetOptionsJson.peek().choices || [];
|
this.choices = options.field.widgetOptionsJson.peek().choices || [];
|
||||||
|
|
||||||
// Add autocomplete if there are any choices to select from
|
// 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, {
|
autocomplete(this.textInput, this.choices, {
|
||||||
allowNothingSelected: true,
|
allowNothingSelected: true,
|
||||||
|
@ -70,10 +70,13 @@ export class ChoiceListEditor extends NewBaseEditor {
|
|||||||
createToken: label => new ChoiceItem(label, !choiceSet.has(label)),
|
createToken: label => new ChoiceItem(label, !choiceSet.has(label)),
|
||||||
acOptions,
|
acOptions,
|
||||||
openAutocompleteOnFocus: true,
|
openAutocompleteOnFocus: true,
|
||||||
|
readonly : options.readonly,
|
||||||
styles: {cssTokenField, cssToken, cssDeleteButton, cssDeleteIcon},
|
styles: {cssTokenField, cssToken, cssDeleteButton, cssDeleteIcon},
|
||||||
});
|
});
|
||||||
|
|
||||||
this._dom = dom('div.default_editor',
|
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.cellEditorDiv = cssCellEditor(testId('widget-text-editor'),
|
||||||
this._contentSizer = cssContentSizer(),
|
this._contentSizer = cssContentSizer(),
|
||||||
elem => this._tokenField.attach(elem),
|
elem => this._tokenField.attach(elem),
|
||||||
@ -281,3 +284,8 @@ const cssChoiceList = styled('div', `
|
|||||||
z-index: 1001;
|
z-index: 1001;
|
||||||
box-shadow: 0 0px 8px 0 rgba(38,38,51,0.6)
|
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.
|
// Strip moment format string to remove markers unsupported by the datepicker.
|
||||||
this.safeFormat = DateEditor.parseMomentToSafe(this.dateFormat);
|
this.safeFormat = DateEditor.parseMomentToSafe(this.dateFormat);
|
||||||
|
|
||||||
|
this._readonly = options.readonly;
|
||||||
|
|
||||||
// Use the default local timezone to format the placeholder date.
|
// Use the default local timezone to format the placeholder date.
|
||||||
let defaultTimezone = moment.tz.guess();
|
let defaultTimezone = moment.tz.guess();
|
||||||
let placeholder = moment.tz(defaultTimezone).format(this.safeFormat);
|
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 }));
|
TextEditor.call(this, _.defaults(options, { placeholder: placeholder }));
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
// Set the edited value, if not explicitly given, to the formatted version of cellValue.
|
// Set the edited value, if not explicitly given, to the formatted version of cellValue.
|
||||||
this.textInput.value = gutil.undef(options.state, options.editValue,
|
this.textInput.value = gutil.undef(options.state, options.editValue, cellValue);
|
||||||
this.formatValue(options.cellValue, this.safeFormat));
|
|
||||||
|
|
||||||
// Indicates whether keyboard navigation is active for the datepicker.
|
if (!options.readonly) {
|
||||||
this._keyboardNav = false;
|
// Indicates whether keyboard navigation is active for the datepicker.
|
||||||
|
this._keyboardNav = false;
|
||||||
|
|
||||||
// Attach the datepicker.
|
// Attach the datepicker.
|
||||||
this._datePickerWidget = $(this.textInput).datepicker({
|
this._datePickerWidget = $(this.textInput).datepicker({
|
||||||
keyboardNavigation: false,
|
keyboardNavigation: false,
|
||||||
forceParse: false,
|
forceParse: false,
|
||||||
todayHighlight: true,
|
todayHighlight: true,
|
||||||
todayBtn: 'linked',
|
todayBtn: 'linked',
|
||||||
// Convert the stripped format string to one suitable for the datepicker.
|
// Convert the stripped format string to one suitable for the datepicker.
|
||||||
format: DateEditor.parseSafeToCalendar(this.safeFormat)
|
format: DateEditor.parseSafeToCalendar(this.safeFormat)
|
||||||
});
|
});
|
||||||
this.autoDisposeCallback(() => this._datePickerWidget.datepicker('remove'));
|
this.autoDisposeCallback(() => this._datePickerWidget.datepicker('remove'));
|
||||||
|
|
||||||
// NOTE: Datepicker interferes with normal enter and escape functionality. Add an event handler
|
// NOTE: Datepicker interferes with normal enter and escape functionality. Add an event handler
|
||||||
// to the DatePicker to prevent interference with normal behavior.
|
// to the DatePicker to prevent interference with normal behavior.
|
||||||
this._datePickerWidget.on('keydown', e => {
|
this._datePickerWidget.on('keydown', e => {
|
||||||
// If enter or escape is pressed, destroy the datepicker and re-dispatch the event.
|
// If enter or escape is pressed, destroy the datepicker and re-dispatch the event.
|
||||||
if (e.keyCode === 13 || e.keyCode === 27) {
|
if (e.keyCode === 13 || e.keyCode === 27) {
|
||||||
this._datePickerWidget.datepicker('remove');
|
this._datePickerWidget.datepicker('remove');
|
||||||
// The current target of the event will be the textarea.
|
// The current target of the event will be the textarea.
|
||||||
setTimeout(() => e.currentTarget.dispatchEvent(e.originalEvent), 0);
|
setTimeout(() => e.currentTarget.dispatchEvent(e.originalEvent), 0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// When the up/down arrow is pressed, modify the datepicker options to take control of
|
// When the up/down arrow is pressed, modify the datepicker options to take control of
|
||||||
// the arrow keys for date selection.
|
// the arrow keys for date selection.
|
||||||
let datepickerCommands = Object.assign({}, options.commands, {
|
let datepickerCommands = Object.assign({}, options.commands, {
|
||||||
datepickerFocus: () => { this._allowKeyboardNav(true); }
|
datepickerFocus: () => { this._allowKeyboardNav(true); }
|
||||||
});
|
});
|
||||||
this._datepickerCommands = this.autoDispose(commands.createGroup(datepickerCommands, this, true));
|
this._datepickerCommands = this.autoDispose(commands.createGroup(datepickerCommands, this, true));
|
||||||
|
|
||||||
this._datePickerWidget.on('show', () => {
|
this._datePickerWidget.on('show', () => {
|
||||||
// A workaround to allow clicking in the datepicker without losing focus.
|
// A workaround to allow clicking in the datepicker without losing focus.
|
||||||
dom(document.querySelector('.datepicker'),
|
dom(document.querySelector('.datepicker'),
|
||||||
kd.attr('tabIndex', 0), // allows datepicker to gain focus
|
kd.attr('tabIndex', 0), // allows datepicker to gain focus
|
||||||
kd.toggleClass('clipboard_focus', true) // tells clipboard to not steal focus from us
|
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.
|
// Attach command group to the input to allow switching keyboard focus to the datepicker.
|
||||||
dom(this.textInput,
|
dom(this.textInput,
|
||||||
// If the user inputs text into the textbox, take keyboard focus from the datepicker.
|
// If the user inputs text into the textbox, take keyboard focus from the datepicker.
|
||||||
dom.on('input', () => { this._allowKeyboardNav(false); }),
|
dom.on('input', () => { this._allowKeyboardNav(false); }),
|
||||||
this._datepickerCommands.attach()
|
this._datepickerCommands.attach()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose.makeDisposable(DateEditor);
|
dispose.makeDisposable(DateEditor);
|
||||||
|
@ -7,6 +7,7 @@ const kd = require('../lib/koDom');
|
|||||||
const DateEditor = require('./DateEditor');
|
const DateEditor = require('./DateEditor');
|
||||||
const gutil = require('app/common/gutil');
|
const gutil = require('app/common/gutil');
|
||||||
const { parseDate } = require('app/common/parseDate');
|
const { parseDate } = require('app/common/parseDate');
|
||||||
|
const TextEditor = require('./TextEditor');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DateTimeEditor - Editor for DateTime type. Includes a dropdown datepicker.
|
* DateTimeEditor - Editor for DateTime type. Includes a dropdown datepicker.
|
||||||
@ -18,10 +19,14 @@ function DateTimeEditor(options) {
|
|||||||
|
|
||||||
// Adjust the command group.
|
// Adjust the command group.
|
||||||
var origCommands = options.commands;
|
var origCommands = options.commands;
|
||||||
options.commands = Object.assign({}, origCommands, {
|
|
||||||
prevField: () => this._focusIndex() === 1 ? this._setFocus(0) : origCommands.prevField(),
|
// don't modify navigation for readonly mode
|
||||||
nextField: () => this._focusIndex() === 0 ? this._setFocus(1) : origCommands.nextField(),
|
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.
|
// Call the superclass.
|
||||||
DateEditor.call(this, options);
|
DateEditor.call(this, options);
|
||||||
@ -32,20 +37,37 @@ function DateTimeEditor(options) {
|
|||||||
// modifies that to be two side-by-side textareas.
|
// modifies that to be two side-by-side textareas.
|
||||||
this._dateSizer = this.contentSizer; // For consistency with _timeSizer and _timeInput.
|
this._dateSizer = this.contentSizer; // For consistency with _timeSizer and _timeInput.
|
||||||
this._dateInput = this.textInput;
|
this._dateInput = this.textInput;
|
||||||
dom(this.dom, kd.toggleClass('celleditor_datetime', true));
|
|
||||||
dom(this.dom.firstChild, kd.toggleClass('celleditor_datetime_editor', true));
|
const isValid = _.isNumber(options.cellValue);
|
||||||
this.dom.appendChild(
|
const formatted = this.formatValue(options.cellValue, this._timeFormat);
|
||||||
dom('div.celleditor_cursor_editor.celleditor_datetime_editor',
|
// Use a placeholder of 12:00am, since that is the autofill time value.
|
||||||
this._timeSizer = dom('div.celleditor_content_measure'),
|
const placeholder = moment.tz('0', 'H', this.timezone).format(this._timeFormat);
|
||||||
this._timeInput = dom('textarea.celleditor_text_editor',
|
|
||||||
// Use a placeholder of 12:00am, since that is the autofill time value.
|
// for readonly
|
||||||
kd.attr('placeholder', moment.tz('0', 'H', this.timezone).format(this._timeFormat)),
|
if (options.readonly) {
|
||||||
kd.value(this.formatValue(options.cellValue, this._timeFormat)),
|
if (!isValid) {
|
||||||
this.commandGroup.attach(),
|
// do nothing - DateEditor will show correct error
|
||||||
dom.on('input', () => this.onChange())
|
} 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 the edit value is encoded json, use those values as a starting point
|
||||||
if (typeof options.state == 'string') {
|
if (typeof options.state == 'string') {
|
||||||
@ -65,6 +87,9 @@ _.extend(DateTimeEditor.prototype, DateEditor.prototype);
|
|||||||
|
|
||||||
DateTimeEditor.prototype.setSizerLimits = function() {
|
DateTimeEditor.prototype.setSizerLimits = function() {
|
||||||
var maxSize = this.editorPlacement.calcSize({width: Infinity, height: Infinity}, {calcOnly: true});
|
var maxSize = this.editorPlacement.calcSize({width: Infinity, height: Infinity}, {calcOnly: true});
|
||||||
|
if (this.options.readonly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this._dateSizer.style.maxWidth =
|
this._dateSizer.style.maxWidth =
|
||||||
this._timeSizer.style.maxWidth = Math.ceil(maxSize.width / 2 - 6) + 'px';
|
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.
|
* Overrides the resizing function in TextEditor.
|
||||||
*/
|
*/
|
||||||
DateTimeEditor.prototype._resizeInput = function() {
|
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
|
// 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
|
// 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
|
// 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 { buildErrorDom } from 'app/client/widgets/ErrorDom';
|
||||||
import { FieldEditor, openSideFormulaEditor, saveWithoutEditor } from 'app/client/widgets/FieldEditor';
|
import { FieldEditor, openSideFormulaEditor, saveWithoutEditor } from 'app/client/widgets/FieldEditor';
|
||||||
import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget';
|
import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget';
|
||||||
|
import { NewBaseEditor } from "app/client/widgets/NewBaseEditor";
|
||||||
import * as UserType from 'app/client/widgets/UserType';
|
import * as UserType from 'app/client/widgets/UserType';
|
||||||
import * as UserTypeImpl from 'app/client/widgets/UserTypeImpl';
|
import * as UserTypeImpl from 'app/client/widgets/UserTypeImpl';
|
||||||
import * as gristTypes from 'app/common/gristTypes';
|
import * as gristTypes from 'app/common/gristTypes';
|
||||||
import * as gutil from 'app/common/gutil';
|
import * as gutil from 'app/common/gutil';
|
||||||
import { CellValue } from 'app/plugin/GristData';
|
import { CellValue } from 'app/plugin/GristData';
|
||||||
import { delay } from 'bluebird';
|
|
||||||
import { Computed, Disposable, fromKo, dom as grainjsDom,
|
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 ko from 'knockout';
|
||||||
import * as _ from 'underscore';
|
import * as _ from 'underscore';
|
||||||
|
|
||||||
@ -77,6 +77,7 @@ export class FieldBuilder extends Disposable {
|
|||||||
private readonly _fieldEditorHolder: Holder<IDisposable>;
|
private readonly _fieldEditorHolder: Holder<IDisposable>;
|
||||||
private readonly _widgetCons: ko.Computed<{create: (...args: any[]) => NewAbstractWidget}>;
|
private readonly _widgetCons: ko.Computed<{create: (...args: any[]) => NewAbstractWidget}>;
|
||||||
private readonly _docModel: DocModel;
|
private readonly _docModel: DocModel;
|
||||||
|
private readonly _readonly: Computed<boolean>;
|
||||||
|
|
||||||
public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec,
|
public constructor(public readonly gristDoc: GristDoc, public readonly field: ViewFieldRec,
|
||||||
private _cursor: Cursor) {
|
private _cursor: Cursor) {
|
||||||
@ -88,6 +89,8 @@ export class FieldBuilder extends Disposable {
|
|||||||
|
|
||||||
this._readOnlyPureType = ko.pureComputed(() => this.field.column().pureType());
|
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.
|
// Observable with a list of available types.
|
||||||
this._availableTypes = Computed.create(this, (use) => {
|
this._availableTypes = Computed.create(this, (use) => {
|
||||||
const isFormula = use(this.origColumn.isFormula);
|
const isFormula = use(this.origColumn.isFormula);
|
||||||
@ -407,11 +410,13 @@ export class FieldBuilder extends Disposable {
|
|||||||
}
|
}
|
||||||
}, this).extend({ deferred: true })).onlyNotifyUnequal();
|
}, this).extend({ deferred: true })).onlyNotifyUnequal();
|
||||||
|
|
||||||
|
|
||||||
return (elem: Element) => {
|
return (elem: Element) => {
|
||||||
this._rowMap.set(row, elem);
|
this._rowMap.set(row, elem);
|
||||||
dom(elem,
|
dom(elem,
|
||||||
dom.autoDispose(widgetObs),
|
dom.autoDispose(widgetObs),
|
||||||
kd.cssClass(this.field.formulaCssClass),
|
kd.cssClass(this.field.formulaCssClass),
|
||||||
|
kd.toggleClass("readonly", toKo(ko, this._readonly)),
|
||||||
kd.maybe(isSelected, () => dom('div.selected_cursor',
|
kd.maybe(isSelected, () => dom('div.selected_cursor',
|
||||||
kd.toggleClass('active_cursor', isActive)
|
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: {
|
public buildEditorDom(editRow: DataRowModel, mainRowModel: DataRowModel, options: {
|
||||||
init?: string,
|
init?: string,
|
||||||
state?: any
|
state?: any
|
||||||
@ -459,7 +442,16 @@ export class FieldBuilder extends Disposable {
|
|||||||
return;
|
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.
|
// constructor may be null for a read-only non-formula field, though not today.
|
||||||
if (!editorCtor) {
|
if (!editorCtor) {
|
||||||
// Actually, we only expect buildEditorDom() to be called when isEditorActive() is false (i.e.
|
// Actually, we only expect buildEditorDom() to be called when isEditorActive() is false (i.e.
|
||||||
@ -468,7 +460,13 @@ export class FieldBuilder extends Disposable {
|
|||||||
return;
|
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();
|
this._fieldEditorHolder.clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -483,8 +481,9 @@ export class FieldBuilder extends Disposable {
|
|||||||
editRow,
|
editRow,
|
||||||
cellElem,
|
cellElem,
|
||||||
editorCtor,
|
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
|
// 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 _saveEdit = asyncOnce(() => this._doSaveEdit());
|
||||||
private _editorHasChanged = false;
|
private _editorHasChanged = false;
|
||||||
private _isFormula = false;
|
private _isFormula = false;
|
||||||
|
private _readonly = false;
|
||||||
|
|
||||||
constructor(options: {
|
constructor(options: {
|
||||||
gristDoc: GristDoc,
|
gristDoc: GristDoc,
|
||||||
@ -83,7 +84,8 @@ export class FieldEditor extends Disposable {
|
|||||||
cellElem: Element,
|
cellElem: Element,
|
||||||
editorCtor: IEditorConstructor,
|
editorCtor: IEditorConstructor,
|
||||||
startVal?: string,
|
startVal?: string,
|
||||||
state?: any
|
state?: any,
|
||||||
|
readonly: boolean
|
||||||
}) {
|
}) {
|
||||||
super();
|
super();
|
||||||
this._gristDoc = options.gristDoc;
|
this._gristDoc = options.gristDoc;
|
||||||
@ -92,6 +94,7 @@ export class FieldEditor extends Disposable {
|
|||||||
this._editRow = options.editRow;
|
this._editRow = options.editRow;
|
||||||
this._editorCtor = options.editorCtor;
|
this._editorCtor = options.editorCtor;
|
||||||
this._cellElem = options.cellElem;
|
this._cellElem = options.cellElem;
|
||||||
|
this._readonly = options.readonly;
|
||||||
|
|
||||||
const startVal = options.startVal;
|
const startVal = options.startVal;
|
||||||
let offerToMakeFormula = false;
|
let offerToMakeFormula = false;
|
||||||
@ -99,7 +102,7 @@ export class FieldEditor extends Disposable {
|
|||||||
const column = this._field.column();
|
const column = this._field.column();
|
||||||
this._isFormula = column.isRealFormula.peek();
|
this._isFormula = column.isRealFormula.peek();
|
||||||
let editValue: string|undefined = startVal;
|
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 (this._isFormula || this._field.column().isEmpty()) {
|
||||||
// If we typed '=' on an empty column, convert it to a formula. If on a formula column,
|
// If we typed '=' on an empty column, convert it to a formula. If on a formula column,
|
||||||
// start editing ignoring the initial '='.
|
// start editing ignoring the initial '='.
|
||||||
@ -131,9 +134,24 @@ export class FieldEditor extends Disposable {
|
|||||||
unmakeFormula: () => this._unmakeFormula(),
|
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) {
|
if (offerToMakeFormula) {
|
||||||
this._offerToMakeFormula();
|
this._offerToMakeFormula();
|
||||||
@ -143,7 +161,12 @@ export class FieldEditor extends Disposable {
|
|||||||
// when user or server refreshes the browser
|
// when user or server refreshes the browser
|
||||||
this._gristDoc.editorMonitor.monitorEditor(this);
|
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.
|
// cursorPos refers to the position of the caret within the editor.
|
||||||
@ -166,10 +189,16 @@ export class FieldEditor extends Disposable {
|
|||||||
cellValue = cellCurrentValue;
|
cellValue = cellCurrentValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter formula-editing mode (e.g. click-on-column inserts its ID) only if we are opening the
|
const error = getFormulaError(this._gristDoc, this._editRow, column);
|
||||||
// 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.
|
// For readonly mode use the default behavior of Formula Editor
|
||||||
this._field.editingFormula(this._isFormula && editValue !== undefined);
|
// 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;
|
this._editorHasChanged = false;
|
||||||
// Replace the item in the Holder with a new one, disposing the previous one.
|
// 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,
|
gristDoc: this._gristDoc,
|
||||||
field: this._field,
|
field: this._field,
|
||||||
cellValue,
|
cellValue,
|
||||||
formulaError: getFormulaError(this._gristDoc, this._editRow, column),
|
formulaError: error,
|
||||||
editValue,
|
editValue,
|
||||||
cursorPos,
|
cursorPos,
|
||||||
state,
|
state,
|
||||||
commands: this._editCommands,
|
commands: this._editCommands,
|
||||||
|
readonly : this._readonly
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// if editor supports live changes, connect it to the change emitter
|
// if editor supports live changes, connect it to the change emitter
|
||||||
@ -268,6 +298,7 @@ export class FieldEditor extends Disposable {
|
|||||||
|
|
||||||
// Cancels the edit
|
// Cancels the edit
|
||||||
private _cancelEdit() {
|
private _cancelEdit() {
|
||||||
|
if (this.isDisposed()) { return; }
|
||||||
const event: FieldEditorStateEvent = {
|
const event: FieldEditorStateEvent = {
|
||||||
position : this.cellPosition(),
|
position : this.cellPosition(),
|
||||||
wasModified : this._editorHasChanged,
|
wasModified : this._editorHasChanged,
|
||||||
@ -380,6 +411,7 @@ export function openSideFormulaEditor(options: {
|
|||||||
cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor.
|
cursorPos: Number.POSITIVE_INFINITY, // Position of the caret within the editor.
|
||||||
commands: editCommands,
|
commands: editCommands,
|
||||||
cssClass: 'formula_editor_sidepane',
|
cssClass: 'formula_editor_sidepane',
|
||||||
|
readonly : false
|
||||||
});
|
});
|
||||||
editor.attach(refElem);
|
editor.attach(refElem);
|
||||||
|
|
||||||
@ -389,6 +421,20 @@ export function openSideFormulaEditor(options: {
|
|||||||
return holder;
|
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:
|
* For an active editor, set up its cleanup:
|
||||||
|
@ -47,11 +47,15 @@ export class FormulaEditor extends NewBaseEditor {
|
|||||||
// and _editorPlacement created.
|
// and _editorPlacement created.
|
||||||
calcSize: this._calcSize.bind(this),
|
calcSize: this._calcSize.bind(this),
|
||||||
gristDoc: options.gristDoc,
|
gristDoc: options.gristDoc,
|
||||||
saveValueOnBlurEvent: true,
|
saveValueOnBlurEvent: !options.readonly,
|
||||||
editorState : this.editorState
|
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));
|
this._commandGroup = this.autoDispose(createGroup(allCommands, this, options.field.editingFormula));
|
||||||
|
|
||||||
const hideErrDetails = Observable.create(null, true);
|
const hideErrDetails = Observable.create(null, true);
|
||||||
@ -59,6 +63,8 @@ export class FormulaEditor extends NewBaseEditor {
|
|||||||
|
|
||||||
this.autoDispose(this._formulaEditor);
|
this.autoDispose(this._formulaEditor);
|
||||||
this._dom = dom('div.default_editor',
|
this._dom = dom('div.default_editor',
|
||||||
|
// switch border shadow
|
||||||
|
dom.cls("readonly_editor", options.readonly),
|
||||||
createMobileButtons(options.commands),
|
createMobileButtons(options.commands),
|
||||||
options.cssClass ? dom.cls(options.cssClass) : null,
|
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'),
|
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.
|
// 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.
|
// 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) => {
|
this._formulaEditor.buildDom((aceObj: any) => {
|
||||||
aceObj.setFontSize(11);
|
aceObj.setFontSize(11);
|
||||||
aceObj.setHighlightActiveLine(false);
|
aceObj.setHighlightActiveLine(false);
|
||||||
@ -82,10 +91,13 @@ export class FormulaEditor extends NewBaseEditor {
|
|||||||
this._formulaEditor.attachCommandGroup(this._commandGroup);
|
this._formulaEditor.attachCommandGroup(this._commandGroup);
|
||||||
|
|
||||||
// enable formula editing if state was passed
|
// enable formula editing if state was passed
|
||||||
if (options.state) {
|
if (options.state || options.readonly) {
|
||||||
options.field.editingFormula(true);
|
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.
|
// This catches any change to the value including e.g. via backspace or paste.
|
||||||
aceObj.once("change", () => options.field.editingFormula(true));
|
aceObj.once("change", () => options.field.editingFormula(true));
|
||||||
})
|
})
|
||||||
@ -147,8 +159,11 @@ export class FormulaEditor extends NewBaseEditor {
|
|||||||
|
|
||||||
// TODO: update regexes to unicode?
|
// TODO: update regexes to unicode?
|
||||||
private _onSetCursor(row: DataRowModel, col: ViewFieldRec) {
|
private _onSetCursor(row: DataRowModel, col: ViewFieldRec) {
|
||||||
|
|
||||||
if (!col) { return; } // if clicked on row header, no col to insert
|
if (!col) { return; } // if clicked on row header, no col to insert
|
||||||
|
|
||||||
|
if (this.options.readonly) { return; }
|
||||||
|
|
||||||
const aceObj = this._formulaEditor.getEditor();
|
const aceObj = this._formulaEditor.getEditor();
|
||||||
|
|
||||||
if (!aceObj.selection.isEmpty()) { // If text selected, replace whole selection
|
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 {undef} from 'app/common/gutil';
|
||||||
import {dom, Observable} from 'grainjs';
|
import {dom, Observable} from 'grainjs';
|
||||||
|
|
||||||
|
|
||||||
export class NTextEditor extends NewBaseEditor {
|
export class NTextEditor extends NewBaseEditor {
|
||||||
// Observable with current editor state (used by drafts or latest edit/position component)
|
// Observable with current editor state (used by drafts or latest edit/position component)
|
||||||
public readonly editorState: Observable<string>;
|
public readonly editorState: Observable<string>;
|
||||||
@ -36,12 +35,18 @@ export class NTextEditor extends NewBaseEditor {
|
|||||||
|
|
||||||
this.commandGroup = this.autoDispose(createGroup(options.commands, null, true));
|
this.commandGroup = this.autoDispose(createGroup(options.commands, null, true));
|
||||||
this._alignment = options.field.widgetOptionsJson.peek().alignment || 'left';
|
this._alignment = options.field.widgetOptionsJson.peek().alignment || 'left';
|
||||||
this._dom = dom('div.default_editor',
|
this._dom =
|
||||||
this.cellEditorDiv = dom('div.celleditor_cursor_editor', testId('widget-text-editor'),
|
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._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.style('text-align', this._alignment),
|
||||||
dom.prop('value', initialValue),
|
dom.prop('value', initialValue),
|
||||||
|
dom.boolAttr('readonly', options.readonly),
|
||||||
this.commandGroup.attach(),
|
this.commandGroup.attach(),
|
||||||
dom.on('input', () => this.onInput())
|
dom.on('input', () => this.onInput())
|
||||||
)
|
)
|
||||||
|
@ -22,6 +22,7 @@ export interface Options {
|
|||||||
cursorPos: number;
|
cursorPos: number;
|
||||||
commands: IEditorCommandGroup;
|
commands: IEditorCommandGroup;
|
||||||
state?: any;
|
state?: any;
|
||||||
|
readonly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -55,6 +56,13 @@ export abstract class NewBaseEditor extends Disposable {
|
|||||||
return undefined;
|
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.
|
* 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';
|
this._visibleCol = vcol.colId() || 'id';
|
||||||
|
|
||||||
// Decorate the editor to look like a reference column value (with a "link" icon).
|
// Decorate the editor to look like a reference column value (with a "link" icon).
|
||||||
this.cellEditorDiv.classList.add(cssRefEditor.className);
|
// But not on readonly mode - here we will reuse default decoration
|
||||||
this.cellEditorDiv.appendChild(cssRefEditIcon('FieldReference'));
|
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));
|
this.textInput.value = undef(options.state, options.editValue, this._idToText(options.cellValue));
|
||||||
|
|
||||||
const needReload = (options.editValue === undefined && !tableData.isLoaded);
|
const needReload = (options.editValue === undefined && !tableData.isLoaded);
|
||||||
@ -80,6 +84,8 @@ export class ReferenceEditor extends NTextEditor {
|
|||||||
|
|
||||||
public attach(cellElem: Element): void {
|
public attach(cellElem: Element): void {
|
||||||
super.attach(cellElem);
|
super.attach(cellElem);
|
||||||
|
// don't create autocomplete for readonly mode
|
||||||
|
if (this.options.readonly) { return; }
|
||||||
this._autocomplete = this.autoDispose(new Autocomplete<ICellItem>(this.textInput, {
|
this._autocomplete = this.autoDispose(new Autocomplete<ICellItem>(this.textInput, {
|
||||||
menuCssClass: menuCssClass + ' ' + cssRefList.className,
|
menuCssClass: menuCssClass + ' ' + cssRefList.className,
|
||||||
search: this._doSearch.bind(this),
|
search: this._doSearch.bind(this),
|
||||||
|
@ -31,10 +31,10 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.formula_field::before {
|
.formula_field::before, .formula_field_edit::before {
|
||||||
background-color: #D0D0D0;
|
background-color: #D0D0D0;
|
||||||
}
|
}
|
||||||
.formula_field_edit::before {
|
.formula_field_edit:not(.readonly)::before {
|
||||||
background-color: var(--grist-color-cursor);
|
background-color: var(--grist-color-cursor);
|
||||||
}
|
}
|
||||||
.formula_field.invalid::before {
|
.formula_field.invalid::before {
|
||||||
@ -45,6 +45,10 @@
|
|||||||
background-color: var(--grist-color-cursor);
|
background-color: var(--grist-color-cursor);
|
||||||
color: #ffb6c1;
|
color: #ffb6c1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.readonly_editor .formula_field_edit::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.invalid-text input {
|
.invalid-text input {
|
||||||
|
@ -7,6 +7,32 @@
|
|||||||
box-shadow: 0 0 3px 2px var(--grist-color-cursor);
|
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 {
|
.formula_editor {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
padding: 4px 0 2px 21px;
|
padding: 4px 0 2px 21px;
|
||||||
|
@ -8,7 +8,7 @@ var commands = require('../components/commands');
|
|||||||
const {testId} = require('app/client/ui2018/cssVars');
|
const {testId} = require('app/client/ui2018/cssVars');
|
||||||
const {createMobileButtons, getButtonMargins} = require('app/client/widgets/EditorButtons');
|
const {createMobileButtons, getButtonMargins} = require('app/client/widgets/EditorButtons');
|
||||||
const {EditorPlacement} = require('app/client/widgets/EditorPlacement');
|
const {EditorPlacement} = require('app/client/widgets/EditorPlacement');
|
||||||
const { observable } = require('grainjs');
|
const {observable} = require('grainjs');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Required parameters:
|
* Required parameters:
|
||||||
@ -38,12 +38,14 @@ function TextEditor(options) {
|
|||||||
this.editorState = this.autoDispose(observable(initialValue));
|
this.editorState = this.autoDispose(observable(initialValue));
|
||||||
|
|
||||||
this.dom = dom('div.default_editor',
|
this.dom = dom('div.default_editor',
|
||||||
|
kd.toggleClass("readonly_editor", options.readonly),
|
||||||
dom('div.celleditor_cursor_editor', dom.testId('TextEditor_editor'),
|
dom('div.celleditor_cursor_editor', dom.testId('TextEditor_editor'),
|
||||||
testId('widget-text-editor'), // new-style testId matches NTextEditor, for more uniform tests.
|
testId('widget-text-editor'), // new-style testId matches NTextEditor, for more uniform tests.
|
||||||
this.contentSizer = dom('div.celleditor_content_measure'),
|
this.contentSizer = dom('div.celleditor_content_measure'),
|
||||||
this.textInput = dom('textarea.celleditor_text_editor',
|
this.textInput = dom('textarea.celleditor_text_editor',
|
||||||
kd.attr('placeholder', options.placeholder || ''),
|
kd.attr('placeholder', options.placeholder || ''),
|
||||||
kd.style('text-align', this._alignment),
|
kd.style('text-align', this._alignment),
|
||||||
|
kd.boolAttr('readonly', options.readonly),
|
||||||
kd.value(initialValue),
|
kd.value(initialValue),
|
||||||
this.commandGroup.attach(),
|
this.commandGroup.attach(),
|
||||||
|
|
||||||
|
@ -331,6 +331,12 @@ export async function getColumnNames() {
|
|||||||
.filter(name => name !== '+');
|
.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.
|
* 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.
|
// This must be a detail view, and we just got the info we need.
|
||||||
return {rowNum: parseInt(rowNum, 10), col: colName};
|
return {rowNum: parseInt(rowNum, 10), col: colName};
|
||||||
} else {
|
} 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.
|
// 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 gridRows = await section.findAll('.gridview_data_row_num');
|
||||||
const gridRowNum = await gridRows[rowIndex].getText();
|
const gridRowNum = await gridRows[rowIndex].getText();
|
||||||
|
Loading…
Reference in New Issue
Block a user