(core) move client code to core

Summary:
This moves all client code to core, and makes minimal fix-ups to
get grist and grist-core to compile correctly.  The client works
in core, but I'm leaving clean-up around the build and bundles to
follow-up.

Test Plan: existing tests pass; server-dev bundle looks sane

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2627
This commit is contained in:
Paul Fitzpatrick
2020-10-02 11:10:00 -04:00
parent 5d60d51763
commit 1654a2681f
395 changed files with 52651 additions and 47 deletions

View File

@@ -0,0 +1,72 @@
var dispose = require('../lib/dispose');
const ko = require('knockout');
const {fromKo} = require('grainjs');
const ValueFormatter = require('app/common/ValueFormatter');
const {cssLabel, cssRow} = require('app/client/ui/RightPanel');
const {colorSelect} = require('app/client/ui2018/buttonSelect');
const {testId} = require('app/client/ui2018/cssVars');
const {cssHalfWidth, cssInlineLabel} = require('app/client/widgets/NewAbstractWidget');
/**
* AbstractWidget - The base of the inheritance tree for widgets.
* @param {Function} field - The RowModel for this view field.
*/
function AbstractWidget(field) {
this.field = field;
this.options = field.widgetOptionsJson;
this.valueFormatter = this.autoDispose(ko.computed(() => {
return ValueFormatter.createFormatter(field.displayColModel().type(), this.options());
}));
}
dispose.makeDisposable(AbstractWidget);
/**
* Builds the DOM showing configuration buttons and fields in the sidebar.
*/
AbstractWidget.prototype.buildConfigDom = function() {
throw new Error("Not Implemented");
};
/**
* Builds the transform prompt config DOM in the few cases where it is necessary.
* Child classes need not override this function if they do not require transform config options.
*/
AbstractWidget.prototype.buildTransformConfigDom = function() {
return null;
};
/**
* Builds the data cell DOM.
* @param {DataRowModel} row - The rowModel object.
*/
AbstractWidget.prototype.buildDom = function(row) {
throw new Error("Not Implemented");
};
AbstractWidget.prototype.buildColorConfigDom = function() {
return [
cssLabel('CELL COLOR'),
cssRow(
cssHalfWidth(
colorSelect(
fromKo(this.field.textColor),
(val) => this.field.textColor.saveOnly(val),
testId('text-color'),
),
cssInlineLabel('Text')
),
cssHalfWidth(
colorSelect(
fromKo(this.field.fillColor),
(val) => this.field.fillColor.saveOnly(val),
testId('fill-color'),
),
cssInlineLabel('Fill')
)
)
];
};
module.exports = AbstractWidget;

View File

@@ -0,0 +1,51 @@
/**
* Required parameters:
* @param {RowModel} options.field: ViewSectionField (i.e. column) being edited.
* @param {Object} options.cellValue: The value in the underlying cell being edited.
* @param {String} options.editValue: String to be edited, or undefined to use cellValue.
* @param {Number} options.cursorPos: The initial position where to place the cursor.
* @param {Object} options.commands: Object mapping command names to functions, to enable as part
* of the command group that should be activated while the editor exists.
*/
function BaseEditor(options) {
}
/**
* Called after the editor is instantiated to attach its DOM to the page.
* - cellRect: Bounding box of the element representing the cell that this editor should match
* in size and position. Used by derived classes, e.g. to construct an EditorPlacement object.
*/
BaseEditor.prototype.attach = function(cellRect) {
// No-op by default.
};
/**
* Called to get the value to save back to the cell.
*/
BaseEditor.prototype.getCellValue = function() {
throw new Error("Not Implemented");
};
/**
* Used if an editor needs preform any actions before a save
*/
BaseEditor.prototype.prepForSave = function() {
// No-op by default.
};
/**
* Called to get the text in the editor, used when switching between editing data and formula.
*/
BaseEditor.prototype.getTextValue = function() {
throw new Error("Not Implemented");
};
/**
* Called to get the position of the cursor in the editor. Used when switching between editing
* data and formula.
*/
BaseEditor.prototype.getCursorPos = function() {
throw new Error("Not Implemented");
};
module.exports = BaseEditor;

View File

@@ -0,0 +1,58 @@
.widget_checkbox {
position: relative;
margin: -1px auto;
width: 16px;
height: 16px;
}
.field_clip.has_cursor > .widget_checkbox {
cursor: pointer;
box-shadow: inset 0 0 0 1px #606060;
border-radius: 3px;
background: linear-gradient(to bottom, rgba(255,255,255,1) 0%, rgba(252,252,252,1) 29%, rgba(239,239,239,1) 50%, rgba(232,232,232,1) 50%, rgba(242,242,242,1) 100%);
}
.widget_checkbox:hover:not(.disabled) {
cursor: pointer;
box-shadow: inset 0 0 0 1px #606060;
border-radius: 3px;
background: linear-gradient(to bottom, rgba(255,255,255,1) 0%, rgba(252,252,252,1) 29%, rgba(239,239,239,1) 50%, rgba(232,232,232,1) 50%, rgba(242,242,242,1) 100%);
}
.widget_checkbox:active:not(.disabled) {
background: linear-gradient(to bottom, rgba(147,180,242,1) 0%, rgba(135,168,233,1) 10%, rgba(115,149,218,1) 25%, rgba(115,150,224,1) 37%, rgba(115,153,230,1) 50%, rgba(86,134,219,1) 51%, rgba(130,174,235,1) 83%, rgba(151,194,243,1) 100%);
}
.widget_checkbox:focus {
outline: none !important;
}
.widget_checkmark {
position: relative;
width: 6px;
height: 12px;
-ms-transform: rotate(40deg); /* IE 9 */
-webkit-transform: rotate(40deg); /* Chrome, Safari, Opera */
transform: rotate(40deg);
left: 4px;
top: 2px;
}
.checkmark_stem {
position: relative;
width: 3px;
height: 12px;
background-color: #606060;
border: 1px solid #606060;
left: 3px;
top: -5px;
}
.checkmark_kick {
position: relative;
width: 3px;
height: 3px;
background-color: #606060;
border: 1px solid #606060;
top: 7px;
}

View File

@@ -0,0 +1,44 @@
var dom = require('../lib/dom');
var dispose = require('../lib/dispose');
var _ = require('underscore');
var kd = require('../lib/koDom');
var AbstractWidget = require('./AbstractWidget');
/**
* CheckBox - A bi-state CheckBox widget
*/
function CheckBox(field) {
AbstractWidget.call(this, field);
}
dispose.makeDisposable(CheckBox);
_.extend(CheckBox.prototype, AbstractWidget.prototype);
CheckBox.prototype.buildConfigDom = function() {
return null;
};
CheckBox.prototype.buildDom = function(row) {
var value = row[this.field.colId()];
return dom('div.field_clip',
dom('div.widget_checkbox',
dom.on('click', () => {
if (!this.field.column().isRealFormula()) {
value.setAndSave(!value.peek());
}
}),
dom('div.widget_checkmark',
kd.show(value),
dom('div.checkmark_kick',
kd.style('background-color', this.field.textColor),
kd.style('border-color', this.field.textColor)
),
dom('div.checkmark_stem',
kd.style('background-color', this.field.textColor),
kd.style('border-color', this.field.textColor)
)
)
)
);
};
module.exports = CheckBox;

View File

@@ -0,0 +1,21 @@
var dispose = require('../lib/dispose');
var _ = require('underscore');
var TextEditor = require('./TextEditor');
function CheckBoxEditor(options) {
TextEditor.call(this, options);
}
dispose.makeDisposable(CheckBoxEditor);
_.extend(CheckBoxEditor.prototype, TextEditor.prototype);
// For documentation, see NewBaseEditor.ts
CheckBoxEditor.skipEditor = function(typedVal, cellVal) {
if (typedVal === ' ') {
// This is a special case when user hits <space>. We return the toggled value to save, and by
// this indicate that the editor should not open.
return !cellVal;
}
}
module.exports = CheckBoxEditor;

View File

@@ -0,0 +1,27 @@
var _ = require('underscore');
var dispose = require('../lib/dispose');
var TextEditor = require('./TextEditor');
const {autocomplete} = require('app/client/ui2018/menus');
/**
* ChoiceEditor - TextEditor with a dropdown for possible choices.
*/
function ChoiceEditor(options) {
TextEditor.call(this, options);
this.choices = options.field.widgetOptionsJson.peek().choices || [];
// Add autocomplete if there are any choices to select from
if (this.choices.length > 0) {
autocomplete(this.textInput, this.choices, {
allowNothingSelected: true,
onClick: () => this.options.commands.fieldEditSave(),
});
}
}
dispose.makeDisposable(ChoiceEditor);
_.extend(ChoiceEditor.prototype, TextEditor.prototype);
module.exports = ChoiceEditor;

View File

@@ -0,0 +1,96 @@
var commands = require('../components/commands');
var dispose = require('../lib/dispose');
var kd = require('../lib/koDom');
var _ = require('underscore');
var TextBox = require('./TextBox');
const {fromKoSave} = require('app/client/lib/fromKoSave');
const {ListEntry} = require('app/client/lib/listEntry');
const {alignmentSelect} = require('app/client/ui2018/buttonSelect');
const {colors, testId} = require('app/client/ui2018/cssVars');
const {icon} = require('app/client/ui2018/icons');
const {menu, menuItem} = require('app/client/ui2018/menus');
const {cssLabel, cssRow} = require('app/client/ui/RightPanel');
const {dom, Computed, styled} = require('grainjs');
/**
* ChoiceTextBox - A textbox for choice values.
*/
function ChoiceTextBox(field) {
TextBox.call(this, field);
this.docData = field._table.docModel.docData;
this.colId = field.column().colId;
this.tableId = field.column().table().tableId;
this.choices = this.options.prop('choices');
this.choiceValues = Computed.create(this, (use) => use(this.choices) || [])
}
dispose.makeDisposable(ChoiceTextBox);
_.extend(ChoiceTextBox.prototype, TextBox.prototype);
ChoiceTextBox.prototype.buildDom = function(row) {
let value = row[this.field.colId()];
return cssChoiceField(
cssChoiceText(
kd.style('text-align', this.alignment),
kd.text(() => row._isAddRow() ? '' : this.valueFormatter().format(value())),
),
cssDropdownIcon('Dropdown',
// When choices exist, click dropdown icon to open edit autocomplete.
dom.on('click', () => this._hasChoices() && commands.allCommands.editField.run()),
// When choices do not exist, open a single-item menu to open the sidepane choice option editor.
menu(() => [
menuItem(commands.allCommands.fieldTabOpen.run, 'Add Choice Options')
], {
trigger: [(elem, ctl) => {
// Only open this menu if there are no choices.
dom.onElem(elem, 'click', () => this._hasChoices() || ctl.open());
}]
}),
testId('choice-dropdown')
)
);
};
ChoiceTextBox.prototype.buildConfigDom = function() {
return [
cssRow(
alignmentSelect(fromKoSave(this.alignment))
),
cssLabel('OPTIONS'),
cssRow(
dom.create(ListEntry, this.choiceValues, (values) => this.choices.saveOnly(values))
)
];
};
ChoiceTextBox.prototype.buildTransformConfigDom = function() {
return this.buildConfigDom();
};
ChoiceTextBox.prototype._hasChoices = function() {
return this.choiceValues.get().length > 0;
};
const cssChoiceField = styled('div.field_clip', `
display: flex;
justify-content: space-between;
`);
const cssChoiceText = styled('div', `
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
`);
const cssDropdownIcon = styled(icon, `
cursor: pointer;
background-color: ${colors.lightGreen};
min-width: 16px;
width: 16px;
height: 16px;
`);
module.exports = ChoiceTextBox;

View File

@@ -0,0 +1,156 @@
/* global $, document */
const moment = require('moment-timezone');
const _ = require('underscore');
const gutil = require('app/common/gutil');
const commands = require('../components/commands');
const dispose = require('../lib/dispose');
const dom = require('../lib/dom');
const kd = require('../lib/koDom');
const TextEditor = require('./TextEditor');
const { parseDate } = require('app/common/parseDate');
// DatePicker unfortunately requires an <input> (not <textarea>). But textarea is better for us,
// because sometimes it's taller than a line, and an <input> looks worse. The following
// unconsionable hack tricks Datepicker into thinking anything it's attached to is an input.
// It's more reasonable to just modify boostrap-datepicker, but that has its own downside (with
// upgrading and minification). This hack, however, is simpler than other workarounds.
var Datepicker = $.fn.datepicker.Constructor;
// datepicker.isInput can now be set to anything, but when read, always returns true. Tricksy.
Object.defineProperty(Datepicker.prototype, 'isInput', {
get: function() { return true; },
set: function(v) {},
});
/**
* DateEditor - Editor for Date type. Includes a dropdown datepicker.
* See reference: http://bootstrap-datepicker.readthedocs.org/en/latest/index.html
*
* @param {String} options.timezone: Optional timezone to use instead of UTC.
*/
function DateEditor(options) {
// A string that is always `UTC` in the DateEditor, eases DateTimeEditor inheritance.
this.timezone = options.timezone || 'UTC';
this.dateFormat = options.field.widgetOptionsJson.peek().dateFormat;
// Strip moment format string to remove markers unsupported by the datepicker.
this.safeFormat = DateEditor.parseMomentToSafe(this.dateFormat);
// Use the default local timezone to format the placeholder date.
let defaultTimezone = moment.tz.guess();
let placeholder = moment.tz(defaultTimezone).format(this.safeFormat);
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.undefDefault(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);
}
});
// 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);
_.extend(DateEditor.prototype, TextEditor.prototype);
/** @inheritdoc */
DateEditor.prototype.getCellValue = function() {
let timestamp = parseDate(this.textInput.value, {
dateFormat: this.safeFormat,
timezone: this.timezone
});
return timestamp !== null ? timestamp : this.textInput.value;
};
// Helper to allow/disallow keyboard navigation within the datepicker.
DateEditor.prototype._allowKeyboardNav = function(bool) {
if (this._keyboardNav !== bool) {
this._keyboardNav = bool;
$(this.textInput).data().datepicker.o.keyboardNavigation = bool;
// Force parse must be turned on with keyboard navigation, since it forces the highlighted date
// to be used when enter is pressed. Otherwise, keyboard date selection will have no effect.
$(this.textInput).data().datepicker.o.forceParse = bool;
}
};
// Moment value formatting helper.
DateEditor.prototype.formatValue = function(value, formatString) {
if (_.isNumber(value) && formatString) {
return moment.tz(value*1000, this.timezone).format(formatString);
} else {
return "";
}
};
// Formats Moment string to remove markers unsupported by the datepicker.
// Moment reference: http://momentjs.com/docs/#/displaying/
DateEditor.parseMomentToSafe = function(mFormat) {
// Remove markers not representing year, month, or date, and also DDD, DDDo, DDDD, d, do,
// (and following whitespace/punctuation) since they are unsupported by the datepicker.
mFormat = mFormat.replace(/\b(?:[^DMY\W]+|D{3,4}o*)\b\W+/g, '');
// Convert other markers unsupported by the datepicker to similar supported markers.
mFormat = mFormat.replace(/\b([MD])o\b/g, '$1'); // Mo -> M, Do -> D
// Check which information the format contains. Format is only valid for editing if it
// contains day, month and year information.
var dayRe = /D{1,2}/g;
var monthRe = /M{1,4}/g;
var yearRe = /Y{2,4}/g;
var valid = dayRe.test(mFormat) && monthRe.test(mFormat) && yearRe.test(mFormat);
return valid ? mFormat : 'YYYY-MM-DD'; // Use basic format if given is invalid.
};
// Formats Moment string without datepicker unsupported markers for the datepicker.
// Datepicker reference: http://bootstrap-datepicker.readthedocs.org/en/latest/options.html#format
DateEditor.parseSafeToCalendar = function(sFormat) {
// M -> m, MM -> mm, D -> d, DD -> dd, YY -> yy, YYYY -> yyyy
sFormat = sFormat.replace(/\b(?:[MD]{1,2}|Y{2,4})\b/g, function(x) {
return x.toLowerCase();
});
sFormat = sFormat.replace(/\bM{2}(?=M{1,2}\b)/g, ''); // MMM -> M, MMMM -> MM
sFormat = sFormat.replace(/\bddd\b/g, 'D'); // ddd -> D
return sFormat.replace(/\bdddd\b/g, 'DD'); // dddd -> DD
};
module.exports = DateEditor;

View File

@@ -0,0 +1,89 @@
var _ = require('underscore');
var ko = require('knockout');
var dom = require('../lib/dom');
var dispose = require('../lib/dispose');
var kd = require('../lib/koDom');
var kf = require('../lib/koForm');
var AbstractWidget = require('./AbstractWidget');
const {fromKoSave} = require('app/client/lib/fromKoSave');
const {alignmentSelect} = require('app/client/ui2018/buttonSelect');
const {cssRow} = require('app/client/ui/RightPanel');
/**
* DateTextBox - The most basic widget for displaying simple date information.
*/
function DateTextBox(field) {
AbstractWidget.call(this, field);
this.alignment = this.options.prop('alignment');
this.dateFormat = this.options.prop('dateFormat');
this.isCustomDateFormat = this.options.prop('isCustomDateFormat');
this.dateFormatOptions = [
'YYYY-MM-DD',
'MM-DD-YYYY',
'MM/DD/YYYY',
'MM-DD-YY',
'MM/DD/YY',
'DD MMM YYYY',
'MMMM Do, YYYY',
'DD-MM-YYYY',
'Custom'
];
// Helper to set 'dateFormat' and 'isCustomDateFormat' from the set of default date format strings.
this.standardDateFormat = this.autoDispose(ko.computed({
owner: this,
read: function() { return this.isCustomDateFormat() ? 'Custom' : this.dateFormat(); },
write: function(val) {
if (val === 'Custom') { this.isCustomDateFormat.setAndSave(true); }
else {
this.options.update({isCustomDateFormat: false, dateFormat: val});
this.options.save();
}
}
}));
// An observable that always returns `UTC`, eases DateTimeEditor inheritance.
this.timezone = ko.observable('UTC');
}
dispose.makeDisposable(DateTextBox);
_.extend(DateTextBox.prototype, AbstractWidget.prototype);
DateTextBox.prototype.buildDateConfigDom = function() {
var self = this;
return dom('div',
kf.row(
1, dom('div.glyphicon.glyphicon-calendar.config_icon'),
8, kf.label('Date Format'),
9, dom(kf.select(self.standardDateFormat, self.dateFormatOptions), dom.testId("Widget_dateFormat"))
),
kd.maybe(self.isCustomDateFormat, function() {
return dom(kf.text(self.dateFormat), dom.testId("Widget_dateCustomFormat"));
})
);
};
DateTextBox.prototype.buildConfigDom = function() {
return dom('div',
this.buildDateConfigDom(),
cssRow(
alignmentSelect(fromKoSave(this.alignment))
)
);
};
DateTextBox.prototype.buildTransformConfigDom = function() {
return this.buildDateConfigDom();
};
DateTextBox.prototype.buildDom = function(row) {
let value = row[this.field.colId()];
return dom('div.field_clip',
kd.style('text-align', this.alignment),
kd.text(() => row._isAddRow() ? '' : this.valueFormatter().format(value()))
);
};
module.exports = DateTextBox;

View File

@@ -0,0 +1,28 @@
.default_editor.celleditor_datetime {
box-shadow: none;
display: flex;
}
.celleditor_datetime_editor.celleditor_cursor_editor {
flex: auto;
min-width: 0;
overflow: hidden;
box-shadow: none;
z-index: 9;
outline: 1px solid var(--grist-color-cursor);
position: relative;
}
.celleditor_datetime_editor:focus-within {
box-shadow: 0 0 3px 2px var(--grist-color-cursor);
z-index: 10;
outline: none;
}
.celleditor_datetime_editor > .celleditor_text_editor {
width: 100%;
}
.datepicker {
outline: none;
}

View File

@@ -0,0 +1,118 @@
/* global document */
const moment = require('moment-timezone');
const _ = require('underscore');
const dom = require('../lib/dom');
const dispose = require('../lib/dispose');
const kd = require('../lib/koDom');
const DateEditor = require('./DateEditor');
const gutil = require('app/common/gutil');
const { parseDate } = require('app/common/parseDate');
/**
* DateTimeEditor - Editor for DateTime type. Includes a dropdown datepicker.
* See reference: http://bootstrap-datepicker.readthedocs.org/en/latest/index.html
*/
function DateTimeEditor(options) {
// Get the timezone from the end of the type string.
options.timezone = gutil.removePrefix(options.field.column().type(), "DateTime:");
// 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(),
});
// Call the superclass.
DateEditor.call(this, options);
this._timeFormat = options.field.widgetOptionsJson.peek().timeFormat;
// To reuse code, this knows all about the DOM that DateEditor builds (using TextEditor), and
// 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._resizeInput())
)
)
);
}
dispose.makeDisposable(DateTimeEditor);
_.extend(DateTimeEditor.prototype, DateEditor.prototype);
DateTimeEditor.prototype.setSizerLimits = function() {
var maxSize = this.editorPlacement.calcSize({width: Infinity, height: Infinity}, {calcOnly: true});
this._dateSizer.style.maxWidth =
this._timeSizer.style.maxWidth = Math.ceil(maxSize.width / 2 - 6) + 'px';
};
/**
* Returns which element has focus: 0 if date, 1 if time, null if neither.
*/
DateTimeEditor.prototype._focusIndex = function() {
return document.activeElement === this._dateInput ? 0 :
(document.activeElement === this._timeInput ? 1 : null);
};
/**
* Sets focus to date if index is 0, or time if index is 1.
*/
DateTimeEditor.prototype._setFocus = function(index) {
var elem = (index === 0 ? this._dateInput : (index === 1 ? this._timeInput : null));
if (elem) {
elem.focus();
elem.selectionStart = 0;
elem.selectionEnd = elem.value.length;
}
};
DateTimeEditor.prototype.getCellValue = function() {
let date = this._dateInput.value;
let time = this._timeInput.value;
let timestamp = parseDate(date, {
dateFormat: this.safeFormat,
time: time,
timeFormat: this._timeFormat,
timezone: this.timezone
});
return timestamp !== null ? timestamp :
(date && time ? `${date} ${time}` : date || time);
};
/**
* Overrides the resizing function in TextEditor.
*/
DateTimeEditor.prototype._resizeInput = function() {
// 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
// measured sizes using flexbox logic.
this._dateSizer.textContent = this._dateInput.value;
this._timeSizer.textContent = this._timeInput.value;
var dateRect = this._dateSizer.getBoundingClientRect();
var timeRect = this._timeSizer.getBoundingClientRect();
// Textboxes get 3px of padding on left/right/top (see TextEditor.css); we specify it manually
// since editorPlacement can't do a good job figuring it out with the flexbox arrangement.
var size = this.editorPlacement.calcSize({
width: dateRect.width + timeRect.width + 12,
height: Math.max(dateRect.height, timeRect.height) + 3
});
this.dom.style.width = size.width + 'px';
this._dateInput.parentNode.style.flexBasis = (dateRect.width + 6) + 'px';
this._timeInput.parentNode.style.flexBasis = (timeRect.width + 6) + 'px';
this._dateInput.style.height = Math.ceil(size.height - 3) + 'px';
this._timeInput.style.height = Math.ceil(size.height - 3) + 'px';
};
module.exports = DateTimeEditor;

View File

@@ -0,0 +1,120 @@
/* globals $ */
var _ = require('underscore');
var ko = require('knockout');
var moment = require('moment-timezone');
var dom = require('../lib/dom');
var dispose = require('../lib/dispose');
var kd = require('../lib/koDom');
var kf = require('../lib/koForm');
var DateTextBox = require('./DateTextBox');
var gutil = require('app/common/gutil');
const {fromKoSave} = require('app/client/lib/fromKoSave');
const {alignmentSelect} = require('app/client/ui2018/buttonSelect');
const {testId} = require('app/client/ui2018/cssVars');
const {cssRow} = require('app/client/ui/RightPanel');
/**
* DateTimeTextBox - The most basic widget for displaying date and time information.
*/
function DateTimeTextBox(field) {
DateTextBox.call(this, field);
this.timezoneOptions = moment.tz.names();
this.isInvalidTimezone = ko.observable(false);
// Returns the timezone from the end of the type string
this.timezone = this.autoDispose(ko.computed({
owner: this,
read: function() {
return gutil.removePrefix(field.column().type(), "DateTime:");
},
write: function(val) {
if (_.contains(this.timezoneOptions, val)) {
field.column().type.setAndSave('DateTime:' + val);
this.isInvalidTimezone(false);
} else {
this.isInvalidTimezone(true);
}
}
}));
this.timeFormat = this.options.prop('timeFormat');
this.isCustomTimeFormat = this.options.prop('isCustomTimeFormat');
this.timeFormatOptions = [
'h:mma',
'h:mma z',
'HH:mm',
'HH:mm z',
'HH:mm:ss',
'HH:mm:ss z',
'Custom'
];
// Helper to set 'timeFormat' and 'isCustomTimeFormat' from the set of default time format strings.
this.standardTimeFormat = this.autoDispose(ko.computed({
owner: this,
read: function() { return this.isCustomTimeFormat() ? 'Custom' : this.timeFormat(); },
write: function(val) {
if (val === 'Custom') { this.isCustomTimeFormat.setAndSave(true); }
else {
this.isCustomTimeFormat.setAndSave(false);
this.timeFormat.setAndSave(val);
}
}
}));
}
dispose.makeDisposable(DateTimeTextBox);
_.extend(DateTimeTextBox.prototype, DateTextBox.prototype);
/**
* Builds the config dom for the DateTime TextBox. If isTransformConfig is true,
* builds only the necessary dom for the transform config menu.
*/
DateTimeTextBox.prototype.buildConfigDom = function(isTransformConfig) {
var self = this;
// Set up autocomplete for the timezone entry.
var textDom = kf.text(self.timezone);
var tzInput = textDom.querySelector('input');
$(tzInput).autocomplete({
source: self.timezoneOptions,
minLength: 1,
delay: 10,
select: function(event, ui) {
self.timezone(ui.item.value);
return false;
}
});
return dom('div',
kf.row(
1, dom('div.glyphicon.glyphicon-globe.config_icon'),
8, kf.label('Timezone'),
9, dom(textDom,
kd.toggleClass('invalid-text', this.isInvalidTimezone),
dom.testId("Widget_tz"),
testId('widget-tz'))
),
self.buildDateConfigDom(),
kf.row(
1, dom('div.glyphicon.glyphicon-dashboard.config_icon'),
8, kf.label('Time Format'),
9, dom(kf.select(self.standardTimeFormat, self.timeFormatOptions), dom.testId("Widget_timeFormat"))
),
kd.maybe(self.isCustomTimeFormat, function() {
return dom(kf.text(self.timeFormat), dom.testId("Widget_timeCustomFormat"));
}),
isTransformConfig ? null : cssRow(
alignmentSelect(fromKoSave(this.alignment))
)
);
};
DateTimeTextBox.prototype.buildTransformConfigDom = function() {
return this.buildConfigDom(true);
};
module.exports = DateTimeTextBox;

View File

@@ -0,0 +1,128 @@
import { DataRowModel } from 'app/client/models/DataRowModel';
import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget';
import { CellValue } from 'app/common/DocActions';
import { isVersions } from 'app/common/gristTypes';
import { BaseFormatter } from 'app/common/ValueFormatter';
import { Diff, DIFF_DELETE, DIFF_INSERT, diff_match_patch as DiffMatchPatch, DIFF_EQUAL } from 'diff-match-patch';
import { Computed, dom } from 'grainjs';
/**
*
* A special widget used for rendering cell-level comparisons and conflicts.
*
*/
export class DiffBox extends NewAbstractWidget {
private _diffTool = new DiffMatchPatch();
public buildConfigDom() {
return dom('div');
}
/**
* Render a cell-level diff as a series of styled spans.
*/
public buildDom(row: DataRowModel) {
const formattedValue = Computed.create(null, (use) => {
if (use(row._isAddRow) || this.isDisposed() || use(this.field.displayColModel).isDisposed()) {
// Work around JS errors during certain changes, following code in Reference.js
return [] as Diff[];
}
const value = use(row.cells[use(use(this.field.displayColModel).colId)]);
const formatter = use(this.valueFormatter);
return this._prepareCellDiff(value, formatter);
});
return dom(
'div.field_clip',
dom.autoDispose(formattedValue),
dom.style('text-align', this.options.prop('alignment')),
dom.cls('text_wrapping', (use) => Boolean(use(this.options.prop('wrap')))),
dom.forEach(formattedValue, ([code, txt]) => {
if (code === DIFF_DELETE) {
return dom("span.diff-parent", txt);
} else if (code === DIFF_INSERT) {
return dom("span.diff-remote", txt);
} else if (code === DIFF_LOCAL) {
return dom("span.diff-local", txt);
} else {
return dom("span.diff-common", txt);
}
}),
);
}
/**
* Given the cell value and the formatter, construct a list of fragments in
* diff-match-patch format expressing the difference between versions.
* The format is a list of [CODE, STRING] pairs, where the possible values of
* CODE are:
* -1 -- meaning DELETION of the parent value.
* 0 -- meaning text common to all versions.
* 1 -- meaning INSERTION of the remote value.
* 2 -- meaning INSERTION of the local value.
*
* When a change is made only locally or remotely, then the list returned may
* include common text, deletions and insertions in any order.
*
* When a change is made both locally and remotely, the list returned does not
* include any common text, but just reports the parent value, then the local value,
* then the remote value. This may be optimized in future.
*/
private _prepareCellDiff(value: CellValue, formatter: BaseFormatter): Diff[] {
if (!isVersions(value)) {
// This can happen for reference columns, where the diff widget is
// selected on the basis of one column, but we are displaying the
// content of another. We have more version information for the
// reference column than for its display column.
return [[DIFF_EQUAL, formatter.format(value)]];
}
const versions = value[1];
if (!('local' in versions)) {
// Change was made remotely only.
return this._prepareTextDiff(formatter.format(versions.parent),
formatter.format(versions.remote));
} else if (!('remote' in versions)) {
// Change was made locally only.
return this._prepareTextDiff(formatter.format(versions.parent),
formatter.format(versions.local))
.map(([code, txt]) => [code === DIFF_INSERT ? DIFF_LOCAL : code, txt]);
}
// Change was made both locally and remotely.
return [[DIFF_DELETE, formatter.format(versions.parent)],
[DIFF_LOCAL, formatter.format(versions.local)],
[DIFF_INSERT, formatter.format(versions.remote)]];
}
// Run diff-match-patch on the text, do its cleanup, and then some extra
// ad-hoc cleanup of our own. Diffs are hard to read if they are too
// "choppy".
private _prepareTextDiff(txt1: string, txt2: string): Diff[] {
const diffs = this._diffTool.diff_main(txt1, txt2);
this._diffTool.diff_cleanupSemantic(diffs);
if (diffs.length > 2 && this._notDiffWorthy(txt1, diffs.length) &&
this._notDiffWorthy(txt2, diffs.length)) {
return [[DIFF_DELETE, txt1], [DIFF_INSERT, txt2]];
}
if (diffs.length === 1 && diffs[0][0] === DIFF_DELETE) {
// Add an empty set symbol, since otherwise it will be ambiguous
// whether the deletion was done locally or remotely.
diffs.push([1, '\u2205']);
}
return diffs;
}
// Heuristic for whether to show common parts of versions, or to treat them
// as entirely distinct.
private _notDiffWorthy(txt: string, parts: number) {
return txt.length < 5 * parts || this._isMostlyNumeric(txt);
}
// Check is text has a lot of numeric content.
private _isMostlyNumeric(txt: string) {
return [...txt].filter(c => c >= '0' && c <= '9').length > txt.length / 2;
}
}
// A constant marking text fragments present locally but not in parent (or remote).
// Must be distinct from DiffMatchPatch.DIFF_* constants (-1, 0, 1).
const DIFF_LOCAL = 2;

View File

@@ -0,0 +1,104 @@
import {Disposable, dom} from 'grainjs';
export interface ISize {
width: number;
height: number;
}
interface ISizeOpts {
// Don't reposition the editor as part of the size calculation.
calcOnly?: boolean;
}
// edgeMargin is how many pixels to leave before the edge of the browser window.
const edgeMargin = 12;
// How large the editor can get when it needs to shift to the left or upwards.
const maxShiftWidth = 560;
const maxShiftHeight = 400;
/**
* This class implements the placement and sizing of the cell editor, such as TextEditor and
* FormulaEditor. These try to match the size and position of the cell being edited, expanding
* when needed.
*
* This class also takes care of attaching the editor DOM and destroying it on disposal.
*/
export class EditorPlacement extends Disposable {
private _editorRoot: HTMLElement;
// - editorDom is the DOM to attach. It gets destroyed when EditorPlacement is disposed.
// - cellRect is the bounding box of the cell being mirrored by the editor; the editor generally
// expands to match the size of the cell.
constructor(editorDom: HTMLElement, private _cellRect: ClientRect|DOMRect) {
super();
const editorRoot = this._editorRoot = dom('div.cell_editor', editorDom);
// To hide from the user the incorrectly-sized element, we set visibility to hidden, and
// reset it in _calcEditorSize() as soon as we have the sizes.
editorRoot.style.visibility = 'hidden';
document.body.appendChild(editorRoot);
this.onDispose(() => {
// When the editor is destroyed, destroy and remove its DOM.
dom.domDispose(editorRoot);
editorRoot.remove();
});
}
/**
* Calculate the size of the full editor and shift the editor if needed to give it more space.
* The position and size are applied to the editor unless {calcOnly: true} option is given.
*/
public calcSize(desiredSize: ISize, options: ISizeOpts = {}): ISize {
const maxRect = document.body.getBoundingClientRect();
const noShiftMaxWidth = maxRect.right - edgeMargin - this._cellRect.left;
const maxWidth = Math.min(maxRect.width - 2 * edgeMargin, Math.max(maxShiftWidth, noShiftMaxWidth));
const width = Math.min(maxWidth, Math.max(this._cellRect.width, desiredSize.width));
const left = Math.max(edgeMargin, Math.min(this._cellRect.left - maxRect.left, maxRect.width - edgeMargin - width));
const noShiftMaxHeight = maxRect.bottom - edgeMargin - this._cellRect.top;
const maxHeight = Math.min(maxRect.height - 2 * edgeMargin, Math.max(maxShiftHeight, noShiftMaxHeight));
const height = Math.min(maxHeight, Math.max(this._cellRect.height, desiredSize.height));
const top = Math.max(edgeMargin, Math.min(this._cellRect.top - maxRect.top, maxRect.height - edgeMargin - height));
// To hide from the user the split second before things are sized correctly, we set visibility
// to hidden until we can get the sizes. As soon as sizes are available, restore visibility.
if (!options.calcOnly) {
Object.assign(this._editorRoot.style, {
visibility: 'visible',
left: left + 'px',
top: top + 'px',
// Set the width (but not the height) of the outer container explicitly to accommodate the
// particular setup where a formula may include error details below -- these should
// stretch to the calculated width (so need an explicit value), but may be dynamic in
// height. (This feels hacky, but solves the problem.)
width: width + 'px',
});
}
return {width, height};
}
/**
* Calculate the size for the editable part of the editor, given in elem. This assumes that the
* size of the full editor differs from the editable part only in constant padding. The full
* editor may be shifted as part of this call.
*/
public calcSizeWithPadding(elem: HTMLElement, desiredElemSize: ISize, options: ISizeOpts = {}): ISize {
const rootRect = this._editorRoot.getBoundingClientRect();
const elemRect = elem.getBoundingClientRect();
const heightDelta = rootRect.height - elemRect.height;
const widthDelta = rootRect.width - elemRect.width;
const {width, height} = this.calcSize({
width: desiredElemSize.width + widthDelta,
height: desiredElemSize.height + heightDelta,
}, options);
return {
width: width - widthDelta,
height: height - heightDelta,
};
}
}

View File

@@ -0,0 +1,19 @@
import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {getObjCode} from 'app/common/gristTypes';
import {formatUnknown} from 'app/common/ValueFormatter';
import {dom} from 'grainjs';
export function buildErrorDom(row: DataRowModel, field: ViewFieldRec) {
const value = row.cells[field.colId.peek()];
const options = field.widgetOptionsJson;
// The "invalid" class sets the pink background, as long as the error text is non-empty.
return dom('div.field_clip.invalid',
// Sets CSS class field-error-P, field-error-U, etc.
dom.clsPrefix('field-error-', (use) => getObjCode(use(value)) || ''),
dom.style('text-align', options.prop('alignment')),
dom.cls('text_wrapping', (use) => Boolean(use(options.prop('wrap')))),
dom.text((use) => formatUnknown(value ? use(value) : '???'))
);
}

View File

@@ -0,0 +1,32 @@
.transform_editor {
width: 90%;
margin: 5px auto;
border: 1px solid #DDDDDD;
}
.transform_menu {
padding: 5px 0;
margin: 5px;
border-top: 1px solid rgba(200, 200, 200, .3);
border-bottom: 1px solid rgba(200, 200, 200, .3);
}
.fieldbuilder_settings {
background-color: #e8e8e8;
margin: 1rem -1px -4px -1px;
padding-bottom: 1px;
}
.fieldbuilder_settings_header {
height: 2rem;
margin-top: 0px;
margin-bottom: 0px;
}
.fieldbuilder_settings_button {
display: inline-block;
float: right;
padding: 0 1rem;
border-radius: 5px;
background-color: lightgrey;
}

View File

@@ -0,0 +1,498 @@
import { ColumnTransform } from 'app/client/components/ColumnTransform';
import { Cursor } from 'app/client/components/Cursor';
import { FormulaTransform } from 'app/client/components/FormulaTransform';
import { GristDoc } from 'app/client/components/GristDoc';
import { addColTypeSuffix } from 'app/client/components/TypeConversion';
import { TypeTransform } from 'app/client/components/TypeTransform';
import * as dom from 'app/client/lib/dom';
import { KoArray } from 'app/client/lib/koArray';
import * as kd from 'app/client/lib/koDom';
import * as kf from 'app/client/lib/koForm';
import * as koUtil from 'app/client/lib/koUtil';
import { reportError } from 'app/client/models/AppModel';
import { DataRowModel } from 'app/client/models/DataRowModel';
import { ColumnRec, DocModel, ViewFieldRec } from 'app/client/models/DocModel';
import { SaveableObjObservable, setSaveValue } from 'app/client/models/modelUtil';
import { FieldSettingsMenu } from 'app/client/ui/FieldMenus';
import { cssLabel, cssRow } from 'app/client/ui/RightPanel';
import { buttonSelect } from 'app/client/ui2018/buttonSelect';
import { IOptionFull, menu, select } from 'app/client/ui2018/menus';
import { DiffBox } from 'app/client/widgets/DiffBox';
import { buildErrorDom } from 'app/client/widgets/ErrorDom';
import { FieldEditor, saveWithoutEditor } from 'app/client/widgets/FieldEditor';
import { NewAbstractWidget } from 'app/client/widgets/NewAbstractWidget';
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, dom as grainjsDom, fromKo, Holder, IDisposable, makeTestId } from 'grainjs';
import * as ko from 'knockout';
import * as _ from 'underscore';
const testId = makeTestId('test-fbuilder-');
// Creates a FieldBuilder object for each field in viewFields
export function createAllFieldWidgets(gristDoc: GristDoc, viewFields: ko.Computed<KoArray<ViewFieldRec>>,
cursor: Cursor) {
// TODO: Handle disposal from the map when fields are removed.
return viewFields().map(function(field) {
return new FieldBuilder(gristDoc, field, cursor);
}).setAutoDisposeValues();
}
/**
* Returns the appropriate object from UserType.typeDefs, defaulting to Text for unknown types.
*/
function getTypeDefinition(type: string | false) {
if (!type) { return UserType.typeDefs.Text; }
return UserType.typeDefs[type] || UserType.typeDefs.Text;
}
/**
* Creates an instance of FieldBuilder. Used to create all column configuration DOMs, cell DOMs,
* and cell editor DOMs for all Grist Types.
* @param {Object} field - The field for which the DOMs are to be created.
* @param {Object} cursor - The cursor object, used to get the cursor position while saving values.
*/
export class FieldBuilder extends Disposable {
public columnTransform: ColumnTransform | null;
public readonly origColumn: ColumnRec;
public readonly options: SaveableObjObservable<any>;
public readonly widget: ko.PureComputed<any>;
public readonly isCallPending: ko.Observable<boolean>;
public readonly widgetImpl: ko.Computed<NewAbstractWidget>;
public readonly diffImpl: NewAbstractWidget;
private readonly availableTypes: Computed<Array<IOptionFull<string>>>;
private readonly readOnlyPureType: ko.PureComputed<string>;
private readonly isRightType: ko.PureComputed<(value: CellValue, options?: any) => boolean>;
private readonly refTableId: ko.Computed<string | null>;
private readonly isRef: ko.Computed<boolean>;
private readonly _rowMap: Map<DataRowModel, Element>;
private readonly isTransformingFormula: ko.Computed<boolean>;
private readonly isTransformingType: ko.Computed<boolean>;
private readonly _fieldEditorHolder: Holder<IDisposable>;
private readonly widgetCons: ko.Computed<{create: (...args: any[]) => NewAbstractWidget}>;
private readonly docModel: DocModel;
public constructor(readonly gristDoc: GristDoc, readonly field: ViewFieldRec,
private _cursor: Cursor) {
super();
this.docModel = gristDoc.docModel;
this.origColumn = field.column();
this.options = field.widgetOptionsJson;
this.readOnlyPureType = ko.pureComputed(() => this.field.column().pureType());
// Observable with a list of available types.
this.availableTypes = Computed.create(this, (use) => {
const isFormula = use(this.origColumn.isFormula);
const types: Array<IOptionFull<string>> = [];
_.each(UserType.typeDefs, (def: any, key: string|number) => {
const o: IOptionFull<string> = {
value: key as string,
label: def.label,
icon: def.icon
};
if (key === 'Any') {
// User is unable to select the Any type in non-formula columns.
o.disabled = !isFormula;
}
types.push(o);
});
return types;
});
// Observable which evaluates to a *function* that decides if a value is valid.
this.isRightType = ko.pureComputed(function() {
return gristTypes.isRightType(this.readOnlyPureType()) || _.constant(false);
}, this);
// Returns a boolean indicating whether the column is type Reference.
this.isRef = this.autoDispose(ko.computed(() => {
return gutil.startsWith(this.field.column().type(), 'Ref:');
}));
// Gives the table ID to which the reference points.
this.refTableId = this.autoDispose(ko.computed({
read: () => gutil.removePrefix(this.field.column().type(), "Ref:"),
write: val => this._setType(`Ref:${val}`)
}));
this.widget = ko.pureComputed({
owner: this,
read() { return this.options().widget; },
write(widget) {
// Reset the entire JSON, so that all options revert to their defaults.
this.options.setAndSave({
widget,
// persists color settings across widgets
fillColor: field.fillColor.peek(),
textColor: field.textColor.peek()
}).catch(reportError);
}
});
// Whether there is a pending call that transforms column.
this.isCallPending = ko.observable(false);
// Maintains an instance of the transform object if the field is currently being transformed,
// and null if not. Gets disposed along with the transform menu dom.
this.columnTransform = null;
// Returns a boolean indicating whether a formula transform is in progress.
this.isTransformingFormula = this.autoDispose(ko.computed(() => {
return this.field.column().isTransforming() && this.columnTransform instanceof FormulaTransform;
}));
// Returns a boolean indicating whether a type transform is in progress.
this.isTransformingType = this.autoDispose(ko.computed(() => {
return (this.field.column().isTransforming() || this.isCallPending()) &&
(this.columnTransform instanceof TypeTransform);
}));
// This holds a single FieldEditor. When a new FieldEditor is created (on edit), it replaces the
// previous one if any.
this._fieldEditorHolder = Holder.create(this);
// Map from rowModel to cell dom for the field to which this fieldBuilder applies.
this._rowMap = new Map();
// Returns the constructor for the widget, and only notifies subscribers on changes.
this.widgetCons = this.autoDispose(koUtil.withKoUtils(ko.computed(function() {
return UserTypeImpl.getWidgetConstructor(this.options().widget,
this.readOnlyPureType());
}, this)).onlyNotifyUnequal());
// Computed builder for the widget.
this.widgetImpl = this.autoDispose(koUtil.computedBuilder(() => {
const cons = this.widgetCons();
// Must subscribe to `colId` so that field.colId is rechecked on transform.
return cons.create.bind(cons, this.field, this.field.colId());
}, this).extend({ deferred: true }));
this.diffImpl = this.autoDispose(DiffBox.create(this.field));
}
// dispose.makeDisposable(FieldBuilder);
public buildSelectWidgetDom() {
return grainjsDom.maybe((use) => !use(this.isTransformingType) && use(this.readOnlyPureType), type => {
const typeWidgets = getTypeDefinition(type).widgets;
const widgetOptions = Object.keys(typeWidgets).map(label => ({
label,
value: label,
icon: typeWidgets[label].icon
}));
return widgetOptions.length <= 1 ? null : [
cssLabel('CELL FORMAT'),
cssRow(
widgetOptions.length <= 2 ? buttonSelect(fromKo(this.widget), widgetOptions) :
select(fromKo(this.widget), widgetOptions),
testId('widget-select')
)
];
});
}
/**
* Build the type change dom.
*/
public buildSelectTypeDom() {
const selectType = Computed.create(null, (use) => use(fromKo(this.readOnlyPureType)));
selectType.onWrite(newType => newType === this.readOnlyPureType.peek() || this._setType(newType));
const onDispose = () => (this.isDisposed() || selectType.set(this.field.column().pureType()));
return [
cssRow(
grainjsDom.autoDispose(selectType),
select(selectType, this.availableTypes, {
disabled: (use) => use(this.isTransformingFormula) || use(this.origColumn.disableModify) ||
use(this.isCallPending)
}),
testId('type-select')
),
grainjsDom.maybe((use) => use(this.isRef) && !use(this.isTransformingType), () => this._buildRefTableSelect()),
grainjsDom.maybe(this.isTransformingType, () => {
// Editor dom must be built before preparing transform.
return dom('div.type_transform_prompt',
kf.prompt(
dom('div',
grainjsDom.maybe(this.isRef, () => this._buildRefTableSelect()),
grainjsDom.maybe((use) => use(this.field.column().isTransforming),
() => this.columnTransform!.buildDom())
)
),
grainjsDom.onDispose(onDispose)
);
})
];
}
// Helper function to set the column type to newType.
public _setType(newType: string): Promise<unknown>|undefined {
if (this.origColumn.isFormula()) {
// Do not type transform a new/empty column or a formula column. Just make a best guess for
// the full type, and set it.
const column = this.field.column();
column.type.setAndSave(addColTypeSuffix(newType, column, this.docModel)).catch(reportError);
} else if (!this.columnTransform) {
this.columnTransform = TypeTransform.create(null, this.gristDoc, this);
return this.columnTransform.prepare(newType);
} else {
if (this.columnTransform instanceof TypeTransform) {
return this.columnTransform.setType(newType);
}
}
}
// Builds the reference type table selector. Built when the column is type reference.
public _buildRefTableSelect() {
const allTables = Computed.create(null, (use) =>
use(this.docModel.allTableIds.getObservable()).map(tableId => ({
value: tableId,
label: tableId,
icon: 'FieldTable' as const
}))
);
return [
cssLabel('DATA FROM TABLE'),
cssRow(
dom.autoDispose(allTables),
select(fromKo(this.refTableId), allTables),
testId('ref-table-select')
)
];
}
/**
* Build the formula transform dom
*/
public buildTransformDom() {
const transformButton = ko.computed({
read: () => this.field.column().isTransforming(),
write: val => {
if (val) {
this.columnTransform = FormulaTransform.create(null, this.gristDoc, this);
return this.columnTransform.prepare();
} else {
return this.columnTransform && this.columnTransform.cancel();
}
}
});
return dom('div',
dom.autoDispose(transformButton),
dom.onDispose(() => {
// When losing focus, if there's an active column transform, finalize it.
if (this.columnTransform) {
this.columnTransform.finalize();
}
}),
kf.row(
15, kf.label('Apply Formula to Data'),
3, kf.buttonGroup(
kf.checkButton(transformButton,
dom('span.glyphicon.glyphicon-flash'),
dom.testId("FieldBuilder_editTransform"),
kd.toggleClass('disabled', () => this.isTransformingType() || this.origColumn.isFormula() ||
this.origColumn.disableModify())
)
)
),
kd.maybe(this.isTransformingFormula, () => {
return this.columnTransform!.buildDom();
})
);
}
/**
* Builds the FieldBuilder Options Config DOM. Calls the buildConfigDom function of its widgetImpl.
*/
public buildConfigDom() {
// NOTE: adding a grainjsDom .maybe here causes the disposable order of the widgetImpl and
// the dom created by the widgetImpl to get out of sync.
return dom('div',
kd.maybe(() => !this.isTransformingType() && this.widgetImpl(), (widget: NewAbstractWidget) =>
dom('div',
widget.buildConfigDom(),
widget.buildColorConfigDom(),
// If there is more than one field for this column (i.e. present in multiple views).
kd.maybe(() => this.origColumn.viewFields().all().length > 1, () =>
dom('div.fieldbuilder_settings',
kf.row(
kd.toggleClass('fieldbuilder_settings_header', true),
kf.label(
dom('div.fieldbuilder_settings_button',
dom.testId('FieldBuilder_settings'),
kd.text(() => this.field.useColOptions() ? 'Common' : 'Separate'), ' ▾',
menu(ctl => FieldSettingsMenu(this.field.useColOptions(), {
useSeparate: () => this.fieldSettingsUseSeparate(),
saveAsCommon: () => this.fieldSettingsSaveAsCommon(),
revertToCommon: () => this.fieldSettingsRevertToCommon()
}))
),
'Field in ',
kd.text(() => this.origColumn.viewFields().all().length),
' views'
)
)
)
)
)
)
);
}
public fieldSettingsUseSeparate() {
return this.gristDoc.docData.bundleActions(
`Use separate field settings for ${this.origColumn.colId()}`, () => {
return Promise.all([
setSaveValue(this.field.widgetOptions, this.field.column().widgetOptions()),
setSaveValue(this.field.visibleCol, this.field.column().visibleCol()),
this.field.saveDisplayFormula(this.field.column()._displayColModel().formula() || '')
]);
}
);
}
public fieldSettingsSaveAsCommon() {
return this.gristDoc.docData.bundleActions(
`Save field settings for ${this.origColumn.colId()} as common`, () => {
return Promise.all([
setSaveValue(this.field.column().widgetOptions, this.field.widgetOptions()),
setSaveValue(this.field.column().visibleCol, this.field.visibleCol()),
this.field.column().saveDisplayFormula(this.field._displayColModel().formula() || ''),
setSaveValue(this.field.widgetOptions, ''),
setSaveValue(this.field.visibleCol, 0),
this.field.saveDisplayFormula('')
]);
}
);
}
public fieldSettingsRevertToCommon() {
return this.gristDoc.docData.bundleActions(
`Revert field settings for ${this.origColumn.colId()} to common`, () => {
return Promise.all([
setSaveValue(this.field.widgetOptions, ''),
setSaveValue(this.field.visibleCol, 0),
this.field.saveDisplayFormula('')
]);
}
);
}
/**
* Builds the cell and editor DOM for the chosen UserType. Calls the buildDom and
* buildEditorDom functions of its widgetImpl.
*/
public buildDomWithCursor(row: DataRowModel, isActive: boolean, isSelected: boolean) {
const widgetObs = koUtil.withKoUtils(ko.computed(function() {
// TODO: Accessing row values like this doesn't always work (row and field might not be updated
// simultaneously).
if (this.isDisposed()) { return null; } // Work around JS errors during field removal.
const value = row.cells[this.field.colId()];
const cell = value && value();
if (value && this.isRightType()(cell, this.options) || row._isAddRow.peek()) {
return this.widgetImpl();
} else if (gristTypes.isVersions(cell)) {
return this.diffImpl;
} else {
return null;
}
}, this).extend({ deferred: true })).onlyNotifyUnequal();
return (elem: Element) => {
this._rowMap.set(row, elem);
dom(elem,
dom.autoDispose(widgetObs),
kd.cssClass(this.field.formulaCssClass),
kd.maybe(isSelected, () => dom('div.selected_cursor',
kd.toggleClass('active_cursor', isActive)
)),
kd.scope(widgetObs, (widget: NewAbstractWidget) => {
if (this.isDisposed()) { return null; } // Work around JS errors during field removal.
const cellDom = widget ? widget.buildDom(row) : buildErrorDom(row, this.field);
return dom(cellDom, kd.toggleClass('has_cursor', isActive));
})
);
};
}
/**
* 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
}) {
// If the user attempts to edit a value during transform, finalize (i.e. cancel or execute)
// the transform.
if (this.columnTransform) {
this.columnTransform.finalize();
return;
}
const editorCtor = 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.
// _fieldEditorHolder is already clear), but clear here explicitly for clarity.
this._fieldEditorHolder.clear();
return;
}
if (saveWithoutEditor(editorCtor, editRow, this.field, options.init)) {
this._fieldEditorHolder.clear();
return;
}
const cellElem = this._rowMap.get(mainRowModel)!;
// The editor may dispose itself; the Holder will know to clear itself in this case.
const fieldEditor = FieldEditor.create(this._fieldEditorHolder, {
gristDoc: this.gristDoc,
field: this.field,
cursor: this._cursor,
editRow,
cellElem,
editorCtor,
startVal: options.init,
});
// Put the FieldEditor into a holder in GristDoc too. This way any existing FieldEditor (perhaps
// for another field, or for another BaseView) will get disposed at this time. The reason to
// still maintain a Holder in this FieldBuilder is mainly to match older behavior; changing that
// will entail a number of other tweaks related to the order of creating and disposal.
this.gristDoc.fieldEditorHolder.autoDispose(fieldEditor);
}
public isEditorActive() {
return !this._fieldEditorHolder.isEmpty();
}
}

View File

@@ -0,0 +1,267 @@
import * as commands from 'app/client/components/commands';
import {Cursor} from 'app/client/components/Cursor';
import {GristDoc} from 'app/client/components/GristDoc';
import {UnsavedChange} from 'app/client/components/UnsavedChanges';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {reportError} from 'app/client/models/errors';
import {FormulaEditor} from 'app/client/widgets/FormulaEditor';
import {NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
import {CellValue} from "app/common/DocActions";
import {isRaisedException} from 'app/common/gristTypes';
import * as gutil from 'app/common/gutil';
import {Disposable, Holder, Observable} from 'grainjs';
type IEditorConstructor = typeof NewBaseEditor;
interface ICommandGroup { [cmd: string]: () => void; }
/**
* Check if the typed-in value should change the cell without opening the cell editor, and if so,
* saves and returns true. E.g. on typing space, CheckBoxEditor toggles the cell without opening.
*/
export function saveWithoutEditor(
editorCtor: IEditorConstructor, editRow: DataRowModel, field: ViewFieldRec, typedVal: string|undefined
): boolean {
// Never skip the editor if editing a formula. Also, check that skipEditor static function
// exists (we don't bother adding it on old-style JS editors that don't need it).
if (!field.column.peek().isRealFormula.peek() && editorCtor.skipEditor) {
const origVal = editRow.cells[field.colId()].peek();
const skipEditorValue = editorCtor.skipEditor(typedVal, origVal);
if (skipEditorValue !== undefined) {
setAndSave(editRow, field, skipEditorValue).catch(reportError);
return true;
}
}
return false;
}
// Set the given field of editRow to value, only if different from the current value of the cell.
export async function setAndSave(editRow: DataRowModel, field: ViewFieldRec, value: CellValue): Promise<void> {
const obs = editRow.cells[field.colId()];
if (value !== obs.peek()) {
return obs.setAndSave(value);
}
}
export class FieldEditor extends Disposable {
private _gristDoc: GristDoc;
private _field: ViewFieldRec;
private _cursor: Cursor;
private _editRow: DataRowModel;
private _cellRect: ClientRect|DOMRect;
private _editCommands: ICommandGroup;
private _editorCtor: IEditorConstructor;
private _editorHolder: Holder<NewBaseEditor> = Holder.create(this);
private _saveEditPromise: Promise<boolean>|null = null;
constructor(options: {
gristDoc: GristDoc,
field: ViewFieldRec,
cursor: Cursor,
editRow: DataRowModel,
cellElem: Element,
editorCtor: IEditorConstructor,
startVal?: string,
}) {
super();
this._gristDoc = options.gristDoc;
this._field = options.field;
this._cursor = options.cursor;
this._editRow = options.editRow;
this._editorCtor = options.editorCtor;
this._cellRect = rectWithoutBorders(options.cellElem);
const startVal = options.startVal;
const column = this._field.column();
let isFormula: boolean, editValue: string|undefined;
if (startVal && gutil.startsWith(startVal, '=')) {
// If we entered the cell by typing '=', we immediately convert to formula.
isFormula = true;
editValue = gutil.removePrefix(startVal, '=') as string;
} else {
// Initially, we mark the field as editing formula if it's a non-empty formula field. This can
// be changed by typing "=", but the field won't be an actual formula field until saved.
isFormula = column.isRealFormula.peek();
editValue = startVal;
}
// These are the commands for while the editor is active.
this._editCommands = {
// _saveEdit disables this command group, so when we run fieldEditSave again, it triggers
// another registered group, if any. E.g. GridView listens to it to move the cursor down.
fieldEditSave: () => {
this._saveEdit().then((jumped: boolean) => {
// To avoid confusing cursor movement, do not increment the rowIndex if the row
// was re-sorted after editing.
if (!jumped) { commands.allCommands.fieldEditSave.run(); }
})
.catch(reportError);
},
fieldEditSaveHere: () => { this._saveEdit().catch(reportError); },
fieldEditCancel: () => { this.dispose(); },
prevField: () => { this._saveEdit().then(commands.allCommands.prevField.run).catch(reportError); },
nextField: () => { this._saveEdit().then(commands.allCommands.nextField.run).catch(reportError); },
makeFormula: () => this._makeFormula(),
unmakeFormula: () => this._unmakeFormula(),
};
this.rebuildEditor(isFormula, editValue, Number.POSITIVE_INFINITY);
// Whenever focus returns to the Clipboard component, close the editor by saving the value.
this._gristDoc.app.on('clipboard_focus', this._saveEdit, this);
// TODO: This should ideally include a callback that returns true only when the editor value
// has changed. Currently an open editor is considered unsaved even when unchanged.
UnsavedChange.create(this, async () => { await this._saveEdit(); });
this.onDispose(() => {
this._gristDoc.app.off('clipboard_focus', this._saveEdit, this);
// Unset field.editingFormula flag when the editor closes.
this._field.editingFormula(false);
});
}
// cursorPos refers to the position of the caret within the editor.
public rebuildEditor(isFormula: boolean, editValue: string|undefined, cursorPos: number) {
const editorCtor: IEditorConstructor = isFormula ? FormulaEditor : this._editorCtor;
const column = this._field.column();
const cellCurrentValue = this._editRow.cells[this._field.colId()].peek();
const cellValue = column.isFormula() ? column.formula() : 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(isFormula && editValue !== undefined);
let formulaError: Observable<CellValue>|undefined;
if (column.isFormula() && isRaisedException(cellCurrentValue)) {
const fv = formulaError = Observable.create(null, cellCurrentValue);
this._gristDoc.docData.getFormulaError(column.table().tableId(),
this._field.colId(),
this._editRow.getRowId()
)
.then(value => { fv.set(value); })
.catch(reportError);
}
// Replace the item in the Holder with a new one, disposing the previous one.
const editor = this._editorHolder.autoDispose(editorCtor.create({
gristDoc: this._gristDoc,
field: this._field,
cellValue,
formulaError,
editValue,
cursorPos,
commands: this._editCommands,
}));
editor.attach(this._cellRect);
}
private _makeFormula() {
const editor = this._editorHolder.get();
// On keyPress of "=" on textInput, turn the value into a formula.
if (editor && !this._field.editingFormula.peek() && editor.getCursorPos() === 0) {
this.rebuildEditor(true, editor.getTextValue(), 0);
return false;
}
return true; // don't stop propagation.
}
private _unmakeFormula() {
const editor = this._editorHolder.get();
// Only convert to data if we are undoing a to-formula conversion. To convert formula to
// data, delete the formula first (which makes the column "empty").
if (editor && this._field.editingFormula.peek() && editor.getCursorPos() === 0 &&
!this._field.column().isRealFormula()) {
// Restore a plain '=' character. This gives a way to enter "=" at the start if line. The
// second backspace will delete it.
this.rebuildEditor(false, '=' + editor.getTextValue(), 1);
return false;
}
return true; // don't stop propagation.
}
private async _saveEdit() {
return this._saveEditPromise || (this._saveEditPromise = this._doSaveEdit());
}
// Returns whether the cursor jumped, i.e. current record got reordered.
private async _doSaveEdit(): Promise<boolean> {
const editor = this._editorHolder.get();
if (!editor) { return false; }
// Make sure the editor is save ready
const saveIndex = this._cursor.rowIndex();
await editor.prepForSave();
if (this.isDisposed()) {
// We shouldn't normally get disposed here, but if we do, avoid confusing JS errors.
console.warn("Unable to finish saving edited cell"); // tslint:disable-line:no-console
return false;
}
// Then save the value the appropriate way
// TODO: this isFormula value doesn't actually reflect if editing the formula, since
// editingFormula() is used for toggling column headers, and this is deferred to start of
// typing (a double-click or Enter) does not immediately set it. (This can cause a
// console.warn below, although harmless.)
let isFormula = this._field.editingFormula();
const col = this._field.column();
let waitPromise: Promise<unknown>|null = null;
if (isFormula) {
const formula = editor.getCellValue();
if (col.isRealFormula() && formula === "") {
// A somewhat surprising feature: deleting the formula converts the column to data, keeping
// the values. To clear the column, enter an empty formula again (now into a data column).
// TODO: this should probably be made more intuitive.
isFormula = false;
}
// Bundle multiple changes so that we can undo them in one step.
if (isFormula !== col.isFormula.peek() || formula !== col.formula.peek()) {
waitPromise = this._gristDoc.docData.bundleActions(null, () => Promise.all([
col.updateColValues({isFormula, formula}),
// If we're saving a non-empty formula, then also add an empty record to the table
// so that the formula calculation is visible to the user.
(this._editRow._isAddRow.peek() && formula !== "" ?
this._editRow.updateColValues({}) : undefined),
]));
}
} else {
const value = editor.getCellValue();
if (col.isRealFormula()) {
// tslint:disable-next-line:no-console
console.warn("It should be impossible to save a plain data value into a formula column");
} else {
// This could still be an isFormula column if it's empty (isEmpty is true), but we don't
// need to toggle isFormula in that case, since the data engine takes care of that.
waitPromise = setAndSave(this._editRow, this._field, value);
}
}
const cursor = this._cursor;
// Deactivate the editor. We are careful to avoid using `this` afterwards.
this.dispose();
await waitPromise;
return (saveIndex !== cursor.rowIndex());
}
}
// Get the bounding rect of elem excluding borders. This allows the editor to match cellElem more
// closely which is more visible in case of DetailView.
function rectWithoutBorders(elem: Element): ClientRect {
const rect = elem.getBoundingClientRect();
const style = getComputedStyle(elem, null);
const bTop = parseFloat(style.getPropertyValue('border-top-width'));
const bRight = parseFloat(style.getPropertyValue('border-right-width'));
const bBottom = parseFloat(style.getPropertyValue('border-bottom-width'));
const bLeft = parseFloat(style.getPropertyValue('border-left-width'));
return {
width: rect.width - bLeft - bRight,
height: rect.height - bTop - bBottom,
top: rect.top + bTop,
bottom: rect.bottom - bBottom,
left: rect.left + bLeft,
right: rect.right - bRight,
};
}

View File

@@ -0,0 +1,175 @@
import * as AceEditor from 'app/client/components/AceEditor';
import {createGroup} from 'app/client/components/commands';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {colors, testId} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {EditorPlacement, ISize} from 'app/client/widgets/EditorPlacement';
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
import {undefDefault} from 'app/common/gutil';
import {dom, Observable, styled} from 'grainjs';
// How wide to expand the FormulaEditor when an error is shown in it.
const minFormulaErrorWidth = 400;
/**
* Required parameters:
* @param {RowModel} options.field: ViewSectionField (i.e. column) being edited.
* @param {Object} options.cellValue: The value in the underlying cell being edited.
* @param {String} options.editValue: String to be edited.
* @param {Number} options.cursorPos: The initial position where to place the cursor.
* @param {Object} options.commands: Object mapping command names to functions, to enable as part
* of the command group that should be activated while the editor exists.
* @param {Boolean} options.omitBlurEventForObservableMode: Flag to indicate whether ace editor
* should save the value on `blur` event.
*/
export class FormulaEditor extends NewBaseEditor {
private _formulaEditor: any;
private _commandGroup: any;
private _dom: HTMLElement;
private _editorPlacement: EditorPlacement;
constructor(options: Options) {
super(options);
this._formulaEditor = AceEditor.create({
// A bit awkward, but we need to assume calcSize is not used until attach() has been called
// and _editorPlacement created.
calcSize: this._calcSize.bind(this),
gristDoc: options.gristDoc,
saveValueOnBlurEvent: true,
});
const allCommands = Object.assign({ setCursor: this._onSetCursor }, options.commands);
this._commandGroup = this.autoDispose(createGroup(allCommands, this, options.field.editingFormula));
const hideErrDetails = Observable.create(null, true);
const formulaError = options.formulaError;
this.autoDispose(this._formulaEditor);
this._dom = dom('div.default_editor',
// This shouldn't be needed, but needed for tests.
dom.on('mousedown', (ev) => {
ev.preventDefault();
this._formulaEditor.getEditor().focus();
}),
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)),
this._formulaEditor.buildDom((aceObj: any) => {
aceObj.setFontSize(11);
aceObj.setHighlightActiveLine(false);
aceObj.getSession().setUseWrapMode(false);
aceObj.renderer.setPadding(0);
const val = undefDefault(options.editValue, String(options.cellValue));
const pos = Math.min(options.cursorPos, val.length);
this._formulaEditor.setValue(val, pos);
this._formulaEditor.attachCommandGroup(this._commandGroup);
// This catches any change to the value including e.g. via backspace or paste.
aceObj.once("change", () => options.field.editingFormula(true));
})
),
(formulaError ?
dom('div.error_box',
dom('div.error_msg', testId('formula-error-msg'),
dom.on('click', () => {
hideErrDetails.set(!hideErrDetails.get());
this._formulaEditor.resize();
}),
dom.domComputed(hideErrDetails, (hide) => cssCollapseIcon(hide ? 'Expand' : 'Collapse')),
dom.text((use) => { const f = use(formulaError) as string[]; return `${f[1]}: ${f[2]}`; }),
),
dom('div.error_details',
dom.hide(hideErrDetails),
dom.text((use) => (use(formulaError) as string[])[3]),
testId('formula-error-details'),
),
) : null
)
);
}
public attach(cellRect: ClientRect|DOMRect): void {
this._editorPlacement = EditorPlacement.create(this, this._dom, cellRect);
this._formulaEditor.onAttach();
this._formulaEditor.editor.focus();
}
public getCellValue() {
return this._formulaEditor.getValue();
}
public getTextValue() {
return this._formulaEditor.getValue();
}
public getCursorPos() {
const aceObj = this._formulaEditor.getEditor();
return aceObj.getSession().getDocument().positionToIndex(aceObj.getCursorPosition());
}
private _calcSize(elem: HTMLElement, desiredElemSize: ISize) {
// If we have an error to show, ask for a larger size for formulaEditor.
const desiredSize = {
width: Math.max(desiredElemSize.width, (this.options.formulaError ? minFormulaErrorWidth : 0)),
height: desiredElemSize.height,
};
return this._editorPlacement.calcSizeWithPadding(elem, desiredSize);
}
// TODO: update regexes to unicode?
private _onSetCursor(row: DataRowModel, col: ViewFieldRec) {
if (!col) { return; } // if clicked on row header, no col to insert
const aceObj = this._formulaEditor.getEditor();
if (!aceObj.selection.isEmpty()) { // If text selected, replace whole selection
aceObj.session.replace(aceObj.selection.getRange(), '$' + col.colId());
} else { // Not a selection, gotta figure out what to replace
const pos = aceObj.getCursorPosition();
const line = aceObj.session.getLine(pos.row);
const result = _isInIdentifier(line, pos.column); // returns {start, end, id} | null
if (!result) { // Not touching an identifier, insert colId as normal
aceObj.insert("$" + col.colId());
// We are touching an identifier
} else if (result.ident.startsWith("$")) { // If ident is a colId, replace it
const idRange = AceEditor.makeRange(pos.row, result.start, pos.row, result.end);
aceObj.session.replace(idRange, "$" + col.colId());
}
// Else touching a normal identifier, dont mangle it
}
// Resize editor in case it is needed.
this._formulaEditor.resize();
aceObj.focus();
}
}
// returns whether the column in that line is inside or adjacent to an identifier
// if yes, returns {start, end, ident}, else null
function _isInIdentifier(line: string, column: number) {
// If cursor is in or after an identifier, scoot back to the start of it
const prefix = line.slice(0, column);
let startOfIdent = prefix.search(/[$A-Za-z0-9_]+$/);
if (startOfIdent < 0) { startOfIdent = column; } // if no match, maybe we're right before it
// We're either before an ident or nowhere near one. Try to match to its end
const match = line.slice(startOfIdent).match(/^[$a-zA-Z0-9_]+/);
if (match) {
const ident = match[0];
return { ident, start: startOfIdent, end: startOfIdent + ident.length};
} else {
return null;
}
}
const cssCollapseIcon = styled(icon, `
margin: -3px 4px 0 4px;
--icon-color: ${colors.slate};
`);

View File

@@ -0,0 +1,13 @@
import {Options} from 'app/client/widgets/NewBaseEditor';
import {NTextEditor} from 'app/client/widgets/NTextEditor';
/**
* HyperLinkEditor - Is the same NTextEditor but with some placeholder text to help explain
* to the user how links should be formatted.
*/
export class HyperLinkEditor extends NTextEditor {
constructor(options: Options) {
super(options);
this.textInput.setAttribute('placeholder', '[link label] url');
}
}

View File

@@ -0,0 +1,78 @@
var _ = require('underscore');
var dom = require('../lib/dom');
var dispose = require('../lib/dispose');
var kd = require('../lib/koDom');
var TextBox = require('./TextBox');
const G = require('../lib/browserGlobals').get('URL');
const {colors, testId} = require('app/client/ui2018/cssVars');
const {icon} = require('app/client/ui2018/icons');
const {styled} = require('grainjs');
/**
* Creates a widget for displaying links. Links can entered directly or following a title.
* The last entry following a space is used as the url.
* ie 'google https://www.google.com' would apears as 'google' to the user but link to the url.
*/
function HyperLinkTextBox(field) {
TextBox.call(this, field);
}
dispose.makeDisposable(HyperLinkTextBox);
_.extend(HyperLinkTextBox.prototype, TextBox.prototype);
HyperLinkTextBox.prototype.buildDom = function(row) {
var value = row[this.field.colId()];
return cssFieldClip(
kd.style('text-align', this.alignment),
kd.toggleClass('text_wrapping', this.wrapping),
kd.maybe(value, () =>
dom('a',
kd.attr('href', () => _constructUrl(value())),
kd.attr('target', '_blank'),
// As per Google and Mozilla recommendations to prevent opened links
// from running on the same process as Grist:
// https://developers.google.com/web/tools/lighthouse/audits/noopener
kd.attr('rel', 'noopener noreferrer'),
cssLinkIcon('FieldLink'),
testId('tb-link')
)
),
kd.text(() => _formatValue(value()))
);
};
/**
* Formats value like `foo bar baz` by discarding `baz` and returning `foo bar`.
*/
function _formatValue(value) {
// value might be null, at least transiently. Handle it to avoid an exception.
if (typeof value !== 'string') { return value; }
const index = value.lastIndexOf(' ');
return index >= 0 ? value.slice(0, index) : value;
}
/**
* Given value like `foo bar baz`, constructs URL by checking if `baz` is a valid URL and,
* if not, prepending `http://`.
*/
function _constructUrl(value) {
const url = value.slice(value.lastIndexOf(' ') + 1);
try {
// Try to construct a valid URL
return (new G.URL(url)).toString();
} catch (e) {
// Not a valid URL, so try to prefix it with http
return 'http://' + url;
}
}
const cssFieldClip = styled('div.field_clip', `
color: ${colors.lightGreen};
`);
const cssLinkIcon = styled(icon, `
background-color: ${colors.lightGreen};
margin: -1px 2px 2px 0;
`);
module.exports = HyperLinkTextBox;

View File

@@ -0,0 +1,66 @@
import {fromKoSave} from 'app/client/lib/fromKoSave';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {cssRow} from 'app/client/ui/RightPanel';
import {alignmentSelect, makeButtonSelect} from 'app/client/ui2018/buttonSelect';
import {testId} from 'app/client/ui2018/cssVars';
import {NewAbstractWidget} from 'app/client/widgets/NewAbstractWidget';
import {Computed, dom, Observable} from 'grainjs';
/**
* TextBox - The most basic widget for displaying text information.
*/
export class NTextBox extends NewAbstractWidget {
protected alignment: Observable<string>;
protected wrapping: Observable<boolean>;
constructor(field: ViewFieldRec) {
super(field);
this.alignment = fromKoSave<string>(this.options.prop('alignment'));
const wrap = this.options.prop('wrap');
this.wrapping = Computed.create(this, (use) => {
const w = use(wrap);
if (w === null || w === undefined) {
// When user has yet to specify a desired wrapping state, GridView and DetailView have
// different default states. GridView defaults to wrapping disabled, while DetailView
// defaults to wrapping enabled.
return (this.field.viewSection().parentKey() === 'record') ? false : true;
} else {
return w;
}
});
this.autoDispose(this.wrapping.addListener(() => {
this.field.viewSection().events.trigger('rowHeightChange');
}));
}
public buildConfigDom() {
return dom('div',
cssRow(
alignmentSelect(this.alignment),
dom('div', {style: 'margin-left: 8px;'},
makeButtonSelect(this.wrapping, [{value: true, icon: 'Wrap'}], this._toggleWrap.bind(this), {}),
testId('tb-wrap-text')
)
)
);
}
public buildDom(row: DataRowModel) {
const value = row.cells[this.field.colId.peek()];
return dom('div.field_clip',
dom.style('text-align', this.alignment),
dom.cls('text_wrapping', this.wrapping),
dom.text((use) => use(row._isAddRow) ? '' : use(this.valueFormatter).format(use(value))),
);
}
private _toggleWrap(value: boolean) {
const newValue = !this.wrapping.get();
this.options.update({wrap: newValue});
(this.options as any).save();
}
}

View File

@@ -0,0 +1,98 @@
/**
* This is a copy of TextEditor.js, converted to typescript.
*/
import {createGroup} from 'app/client/components/commands';
import {testId} from 'app/client/ui2018/cssVars';
import {EditorPlacement} from 'app/client/widgets/EditorPlacement';
import {NewBaseEditor, Options} from 'app/client/widgets/NewBaseEditor';
import {CellValue} from "app/common/DocActions";
import {undefDefault} from 'app/common/gutil';
import {dom} from 'grainjs';
export class NTextEditor extends NewBaseEditor {
protected cellEditorDiv: HTMLElement;
protected textInput: HTMLTextAreaElement;
protected commandGroup: any;
private _dom: HTMLElement;
private _editorPlacement: EditorPlacement;
private _contentSizer: HTMLElement;
private _alignment: string;
// Note: TextEditor supports also options.placeholder for use by derived classes, but this is
// easy to apply to this.textInput without needing a separate option.
constructor(options: Options) {
super(options);
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._contentSizer = dom('div.celleditor_content_measure'),
this.textInput = dom('textarea', dom.cls('celleditor_text_editor'),
dom.style('text-align', this._alignment),
dom.prop('value', undefDefault(options.editValue, String(options.cellValue))),
this.commandGroup.attach(),
// Resize the textbox whenever user types in it.
dom.on('input', () => this.resizeInput())
)
)
);
}
public attach(cellRect: ClientRect|DOMRect): void {
// Attach the editor dom to page DOM.
this._editorPlacement = EditorPlacement.create(this, this._dom, cellRect);
this.setSizerLimits();
// Once the editor is attached to DOM, resize it to content, focus, and set cursor.
this.resizeInput();
this.textInput.focus();
const pos = Math.min(this.options.cursorPos, this.textInput.value.length);
this.textInput.setSelectionRange(pos, pos);
}
public getCellValue(): CellValue {
return this.textInput.value;
}
public getTextValue() {
return this.textInput.value;
}
public getCursorPos() {
return this.textInput.selectionStart;
}
public setSizerLimits() {
// Set the max width of the sizer to the max we could possibly grow to, so that it knows to wrap
// once we reach it.
const maxSize = this._editorPlacement.calcSizeWithPadding(this.textInput,
{width: Infinity, height: Infinity}, {calcOnly: true});
this._contentSizer.style.maxWidth = Math.ceil(maxSize.width) + 'px';
}
/**
* Helper which resizes textInput to match its content. It relies on having a contentSizer element
* with the same font/size settings as the textInput, and on having `calcSize` helper,
* which is provided by the EditorPlacement class.
*/
protected resizeInput() {
const textInput = this.textInput;
// \u200B is a zero-width space; it is used so the textbox will expand vertically
// on newlines, but it does not add any width.
this._contentSizer.textContent = textInput.value + '\u200B';
const rect = this._contentSizer.getBoundingClientRect();
// Allow for a bit of extra space after the cursor (only desirable when text is left-aligned).
if (this._alignment === "left") {
// Modifiable in modern browsers: https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect
rect.width += 16;
}
const size = this._editorPlacement.calcSizeWithPadding(textInput, rect);
textInput.style.width = size.width + 'px';
textInput.style.height = size.height + 'px';
}
}

View File

@@ -0,0 +1,107 @@
/**
* NewAbstractWidget is equivalent to AbstractWidget for outside code, but is in typescript, and
* so is friendlier and clearer to derive TypeScript classes from.
*/
import {DocComm} from 'app/client/components/DocComm';
import {DocData} from 'app/client/models/DocData';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {SaveableObjObservable} from 'app/client/models/modelUtil';
import {cssLabel, cssRow} from 'app/client/ui/RightPanel';
import {colorSelect} from 'app/client/ui2018/buttonSelect';
import {colors, testId} from 'app/client/ui2018/cssVars';
import {BaseFormatter, createFormatter} from 'app/common/ValueFormatter';
import {Disposable, fromKo, Observable, styled} from 'grainjs';
import * as ko from 'knockout';
/**
* NewAbstractWidget - The base of the inheritance tree for widgets.
* @param {Function} field - The RowModel for this view field.
*/
export abstract class NewAbstractWidget extends Disposable {
/**
* Override the create() method to match the parameters of create() expected by FieldBuilder.
*/
public static create(field: ViewFieldRec) {
return Disposable.create.call(this as any, null, field);
}
protected options: SaveableObjObservable<any>;
protected valueFormatter: Observable<BaseFormatter>;
constructor(protected field: ViewFieldRec) {
super();
this.options = field.widgetOptionsJson;
// Note that its easier to create a knockout computed from the several knockout observables,
// but then we turn it into a grainjs observable.
const formatter = this.autoDispose(ko.computed(() =>
createFormatter(field.displayColModel().type(), this.options())));
this.valueFormatter = fromKo(formatter);
}
/**
* Builds the DOM showing configuration buttons and fields in the sidebar.
*/
public buildConfigDom(): Element|null { return null; }
/**
* Builds the transform prompt config DOM in the few cases where it is necessary.
* Child classes need not override this function if they do not require transform config options.
*/
public buildTransformConfigDom(): Element|null { return null; }
public buildColorConfigDom(): Element[] {
return [
cssLabel('CELL COLOR'),
cssRow(
cssHalfWidth(
colorSelect(
fromKo(this.field.textColor) as Observable<string>,
(val) => this.field.textColor.saveOnly(val),
testId('text-color')
),
cssInlineLabel('Text'),
),
cssHalfWidth(
colorSelect(
fromKo(this.field.fillColor) as Observable<string>,
(val) => this.field.fillColor.saveOnly(val),
testId('fill-color')
),
cssInlineLabel('Fill')
)
)
];
}
/**
* Builds the data cell DOM.
* @param {DataRowModel} row - The rowModel object.
*/
public abstract buildDom(row: any): Element;
/**
* Returns the DocData object to which this field belongs.
*/
protected _getDocData(): DocData {
// TODO: There should be a better way to access docData and docComm, or better yet GristDoc.
return this.field._table.tableData.docData;
}
/**
* Returns the docComm object for communicating with the server.
*/
protected _getDocComm(): DocComm { return this._getDocData().docComm; }
}
export const cssHalfWidth = styled('div', `
display: flex;
flex: 1 1 50%;
align-items: center;
`);
export const cssInlineLabel = styled('span', `
margin: 0 8px;
color: ${colors.dark};
`);

View File

@@ -0,0 +1,86 @@
/**
* NewBaseEditor is equivalent to BaseEditor for outside code, but is in typescript, and
* so is friendlier and clearer to derive TypeScript classes from.
*/
import {GristDoc} from 'app/client/components/GristDoc';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {CellValue} from "app/common/DocActions";
import {Disposable, IDisposableOwner, Observable} from 'grainjs';
export interface Options {
gristDoc: GristDoc;
field: ViewFieldRec;
cellValue: CellValue;
formulaError?: Observable<CellValue>;
editValue?: string;
cursorPos: number;
commands: {[cmd: string]: () => void};
}
/**
* Required parameters:
* @param {RowModel} options.field: ViewSectionField (i.e. column) being edited.
* @param {String} options.cellValue: The value in the underlying cell being edited.
* @param {String} options.editValue: String to be edited, or undefined to use cellValue.
* @param {Number} options.cursorPos: The initial position where to place the cursor.
* @param {Element} option.cellRect: Bounding box of the element representing the cell that this
* editor should match in size and position.
* @param {Object} options.commands: Object mapping command names to functions, to enable as part
* of the command group that should be activated while the editor exists.
*/
export abstract class NewBaseEditor extends Disposable {
/**
* Override the create() method to allow the parameters of create() expected by old-style
* Editors and provided by FieldBuilder. TODO: remove this method once all editors have been
* updated to new-style Disposables.
*/
public static create(owner: IDisposableOwner|null, options: Options): NewBaseEditor;
public static create(options: Options): NewBaseEditor;
public static create(ownerOrOptions: any, options?: any): NewBaseEditor {
return options ?
Disposable.create.call(this as any, ownerOrOptions, options) :
Disposable.create.call(this as any, null, ownerOrOptions);
}
/**
* Check if the typed-in value should change the cell without opening the editor, and if so,
* returns the value to save. E.g. on typing " ", CheckBoxEditor toggles value without opening.
*/
public static skipEditor(typedVal: string|undefined, origVal: CellValue): CellValue|undefined {
return undefined;
}
constructor(protected options: Options) {
super();
}
/**
* Called after the editor is instantiated to attach its DOM to the page.
* - cellRect: Bounding box of the element representing the cell that this editor should match
* in size and position. Used by derived classes, e.g. to construct an EditorPlacement object.
*/
public abstract attach(cellRect: ClientRect|DOMRect): void;
/**
* Called to get the value to save back to the cell.
*/
public abstract getCellValue(): CellValue;
/**
* Used if an editor needs preform any actions before a save
*/
public prepForSave(): void | Promise<void> {
// No-op by default.
}
/**
* Called to get the text in the editor, used when switching between editing data and formula.
*/
public abstract getTextValue(): string;
/**
* Called to get the position of the cursor in the editor. Used when switching between editing
* data and formula.
*/
public abstract getCursorPos(): number;
}

View File

@@ -0,0 +1,200 @@
/**
* See app/common/NumberFormat for description of options we support.
*/
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {reportError} from 'app/client/models/errors';
import {cssLabel, cssRow} from 'app/client/ui/RightPanel';
import {ISelectorOption, makeButtonSelect} from 'app/client/ui2018/buttonSelect';
import {colors, testId} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {NTextBox} from 'app/client/widgets/NTextBox';
import {clamp} from 'app/common/gutil';
import {buildNumberFormat, NumberFormatOptions, NumMode, NumSign} from 'app/common/NumberFormat';
import {Computed, dom, DomElementArg, fromKo, MultiHolder, Observable, styled} from 'grainjs';
const modeOptions: Array<ISelectorOption<NumMode>> = [
{value: 'currency', label: '$'},
{value: 'decimal', label: ','},
{value: 'percent', label: '%'},
{value: 'scientific', label: 'Exp'}
];
const signOptions: Array<ISelectorOption<NumSign>> = [
{value: 'parens', label: '(-)'},
];
/**
* NumericTextBox - The most basic widget for displaying numeric information.
*/
export class NumericTextBox extends NTextBox {
constructor(field: ViewFieldRec) {
super(field);
}
public buildConfigDom() {
// Holder for all computeds created here. It gets disposed with the returned DOM element.
const holder = new MultiHolder();
// Resolved options, to show default min/max decimals, which change depending on numMode.
const resolved = Computed.create<Intl.ResolvedNumberFormatOptions>(holder, (use) =>
buildNumberFormat({numMode: use(this.options).numMode}).resolvedOptions());
// Prepare various observables that reflect the options in the UI.
const options = fromKo(this.options);
const numMode = Computed.create(holder, options, (use, opts) => opts.numMode || null);
const numSign = Computed.create(holder, options, (use, opts) => opts.numSign || null);
const minDecimals = Computed.create(holder, options, (use, opts) => numberOrDefault(opts.decimals, ''));
const maxDecimals = Computed.create(holder, options, (use, opts) => numberOrDefault(opts.maxDecimals, ''));
const defaultMin = Computed.create(holder, resolved, (use, res) => res.minimumFractionDigits);
const defaultMax = Computed.create(holder, resolved, (use, res) => res.maximumFractionDigits);
// Save a value as the given property in this.options() observable. Set it, save, and revert
// on save error. This is similar to what modelUtil.setSaveValue() does.
const setSave = (prop: keyof NumberFormatOptions, value: unknown) => {
const orig = {...this.options.peek()};
if (value !== orig[prop]) {
this.options({...orig, [prop]: value, ...updateOptions(prop, value)});
this.options.save().catch((err) => { reportError(err); this.options(orig); });
}
};
// Prepare setters for the UI elements.
// Min/max fraction digits may range from 0 to 20; other values are invalid.
const setMinDecimals = (val?: number) => setSave('decimals', val && clamp(val, 0, 20));
const setMaxDecimals = (val?: number) => setSave('maxDecimals', val && clamp(val, 0, 20));
// Mode and Sign behave as toggles: clicking a selected on deselects it.
const setMode = (val: NumMode) => setSave('numMode', val !== numMode.get() ? val : undefined);
const setSign = (val: NumSign) => setSave('numSign', val !== numSign.get() ? val : undefined);
return dom.update(super.buildConfigDom(),
dom.autoDispose(holder),
cssLabel('Number Format'),
cssRow(
makeButtonSelect(numMode, modeOptions, setMode, {}, cssModeSelect.cls(''), testId('numeric-mode')),
makeButtonSelect(numSign, signOptions, setSign, {}, cssSignSelect.cls(''), testId('numeric-sign')),
),
cssLabel('Decimals'),
cssRow(
decimals('min', minDecimals, defaultMin, setMinDecimals, testId('numeric-min-decimals')),
decimals('max', maxDecimals, defaultMax, setMaxDecimals, testId('numeric-max-decimals')),
),
);
}
}
function numberOrDefault<T>(value: unknown, def: T): number|T {
return typeof value !== 'undefined' ? Number(value) : def;
}
// Helper used by setSave() above to reset some properties when switching modes.
function updateOptions(prop: keyof NumberFormatOptions, value: unknown): NumberFormatOptions {
// Reset the numSign to default when toggling mode to percent or scientific.
if (prop === 'numMode' && (!value || value === 'scientific' || value === 'percent')) {
return {numSign: undefined};
}
return {};
}
function decimals(
label: string,
value: Observable<number|''>,
defaultValue: Observable<number>,
setFunc: (val?: number) => void, ...args: DomElementArg[]
) {
return cssDecimalsBox(
cssNumLabel(label),
cssNumInput({type: 'text', size: '2', min: '0'},
dom.prop('value', value),
dom.prop('placeholder', defaultValue),
dom.on('change', (ev, elem) => {
const newVal = parseInt(elem.value, 10);
// Set value explicitly before its updated via setFunc; this way the value reflects the
// observable in the case the observable is left unchanged (e.g. because of clamping).
elem.value = String(value.get());
setFunc(Number.isNaN(newVal) ? undefined : newVal);
elem.blur();
}),
dom.on('focus', (ev, elem) => elem.select()),
),
cssSpinner(
cssSpinnerBtn(cssSpinnerTop('DropdownUp'),
dom.on('click', () => setFunc(numberOrDefault(value.get(), defaultValue.get()) + 1))),
cssSpinnerBtn(cssSpinnerBottom('Dropdown'),
dom.on('click', () => setFunc(numberOrDefault(value.get(), defaultValue.get()) - 1))),
),
...args
);
}
const cssDecimalsBox = styled('div', `
position: relative;
flex: auto;
--icon-color: ${colors.slate};
color: ${colors.slate};
font-weight: normal;
display: flex;
align-items: center;
&:first-child {
margin-right: 16px;
}
`);
const cssNumLabel = styled('div', `
position: absolute;
padding-left: 8px;
pointer-events: none;
`);
const cssNumInput = styled('input', `
padding: 4px 32px 4px 40px;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
color: ${colors.dark};
width: 100%;
text-align: right;
appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
`);
const cssSpinner = styled('div', `
position: absolute;
right: 8px;
width: 16px;
height: 100%;
display: flex;
flex-direction: column;
`);
const cssSpinnerBtn = styled('div', `
flex: 1 1 0px;
min-height: 0px;
position: relative;
cursor: pointer;
overflow: hidden;
&:hover {
--icon-color: ${colors.dark};
}
`);
const cssSpinnerTop = styled(icon, `
position: absolute;
top: 0px;
`);
const cssSpinnerBottom = styled(icon, `
position: absolute;
bottom: 0px;
`);
const cssModeSelect = styled(makeButtonSelect, `
flex: 4 4 0px;
background-color: white;
`);
const cssSignSelect = styled(makeButtonSelect, `
flex: 1 1 0px;
background-color: white;
margin-left: 16px;
`);

View File

@@ -0,0 +1,405 @@
// External dependencies
import {computed, Computed, computedArray, Disposable} from 'grainjs';
import {MutableObsArray, obsArray, ObsArray, observable, Observable} from 'grainjs';
import {dom, input, LiveIndex, makeLiveIndex, noTestId, styled, TestId} from 'grainjs';
import noop = require('lodash/noop');
// Grist client libs
import {DocComm} from 'app/client/components/DocComm';
import {dragOverClass} from 'app/client/lib/dom';
import {selectFiles, uploadFiles} from 'app/client/lib/uploads';
import {DocData} from 'app/client/models/DocData';
import {TableData} from 'app/client/models/TableData';
import {button1Small, button1SmallBright} from 'app/client/ui/buttons';
import {cssModalBody, cssModalTitle, IModalControl, modal} from 'app/client/ui2018/modals';
import {encodeQueryParams, mod} from 'app/common/gutil';
import {UploadResult} from 'app/common/uploads';
const modalPreview = styled('div', `
position: relative;
max-width: 80%;
margin: 0 auto;
background-color: white;
border: 1px solid #bbb;
`);
const modalPreviewImage = styled('img', `
display: block;
max-width: 100%;
max-height: 400px;
`);
const noPreview = styled('div', `
margin: 40% 0;
text-align: center;
vertical-align: top;
color: #bbb;
`);
const thumbnail = styled('div', `
position: relative;
height: 100%;
margin: 0 5px;
cursor: pointer;
`);
const thumbnailOuterRow = styled('div', `
display: flex;
position: relative;
height: 60px;
margin: 0 10px;
justify-content: center;
`);
const thumbnailInnerRow = styled('div', `
display: inline-flex;
height: 100%;
padding: 10px 0;
overflow-x: auto;
`);
const addButton = styled('div.glyphicon.glyphicon-paperclip', `
position: relative;
flex: 0 0 40px;
height: 40px;
width: 40px;
margin: 9px 10px;
padding: 12px 6px;
text-align: center;
vertical-align: middle;
border: 1px dashed #bbbbbb;
cursor: pointer;
font-size: 12pt;
`);
const addButtonPlus = styled('div.glyphicon.glyphicon-plus', `
position: absolute;
left: 9px;
top: 9px;
font-size: 5pt;
`);
const selectedOverlay = styled('div', `
position: absolute;
top: 0;
width: 100%;
height: 100%;
background-color: #3390bf80;
`);
const noAttachments = styled('div', `
width: 100%;
height: 300px;
padding: 125px 0;
text-align: center;
border: 2px dashed #bbbbbb;
font-size: 12pt;
cursor: pointer;
`);
const menuBtnStyle = `
float: right;
margin: 0 5px;
cursor: pointer;
color: black;
text-decoration: none;
`;
const navArrowStyle = `
width: 10%;
height: 60px;
padding: 20px 0;
text-align: center;
cursor: pointer;
font-size: 14pt;
`;
interface Attachment {
rowId: number;
fileIdent: string;
filename: Observable<string>;
hasPreview: boolean;
url: Observable<string>;
}
// Used as a placeholder for the currently selected attachment if all attachments are removed.
const nullAttachment: Attachment = {
rowId: 0,
fileIdent: '',
filename: observable(''),
hasPreview: false,
url: observable('')
};
/**
* PreviewModal - Modal showing an attachment and options to rename, download or remove the
* attachment from the cell.
*/
export class PreviewModal extends Disposable {
private _attachmentsTable: TableData;
private _docComm: DocComm;
private _isEditingName: Observable<boolean> = observable(false);
private _newName: Observable<string> = observable('');
private _isRenameValid: Computed<boolean>;
private _rowIds: MutableObsArray<number>;
private _attachments: ObsArray<Attachment>;
private _index: LiveIndex;
private _selected: Computed<Attachment>;
constructor(
docData: DocData,
cellValue: number[],
testId: TestId = noTestId,
initRowId?: number,
onClose: (cellValue: any) => void = noop,
) {
super();
this._attachmentsTable = docData.getTable('_grist_Attachments')!;
this._docComm = docData.docComm;
this._rowIds = obsArray(Array.isArray(cellValue) ? cellValue.slice(1) : []);
this._attachments = computedArray(this._rowIds, (val: number): Attachment => {
const fileIdent: string = this._attachmentsTable.getValue(val, 'fileIdent') as string;
const filename: Observable<string> =
observable(this._attachmentsTable.getValue(val, 'fileName') as string);
return {
rowId: val,
fileIdent,
filename,
hasPreview: Boolean(this._attachmentsTable.getValue(val, 'imageHeight')),
url: computed((use) => this._getUrl(fileIdent, use(filename)))
};
});
this._index = makeLiveIndex(this, this._attachments,
initRowId ? this._rowIds.get().indexOf(initRowId) : 0);
this._selected = this.autoDispose(computed((use) => {
const index = use(this._index);
return index === null ? nullAttachment : use(this._attachments)[index];
}));
this._isRenameValid = this.autoDispose(computed((use) =>
Boolean(use(this._newName) && (use(this._newName) !== use(use(this._selected).filename)))
));
modal((ctl, owner) => {
owner.onDispose(() => { onClose(this.getCellValue()); });
return [
dom.style('padding', '16px'), // To match the previous style more closely.
cssModalTitle(
dom('span',
dom.text((use) => use(use(this._selected).filename) || 'No attachments'),
testId('modal-title'),
),
// This is a bootstrap-styled button, for now only to match previous style more closely.
dom('button', dom.cls('close'), '×', testId('modal-close-x'),
dom.on('click', () => ctl.close()),
)
),
cssModalBody(this._buildDom(testId, ctl)),
];
});
this.autoDispose(this._selected.addListener(att => { this._scrollToThumbnail(att.rowId); }));
// Initialize with the selected attachment's thumbnail in view.
if (initRowId) {
this._scrollToThumbnail(initRowId);
}
}
public getCellValue() {
const rowIds = this._rowIds.get() as any[];
return rowIds.length > 0 ? ["L"].concat(this._rowIds.get() as any[]) : '';
}
// Builds the attachment preview modal.
private _buildDom(testId: TestId, ctl: IModalControl): Element {
return dom('div',
// Prevent focus from switching away from the modal on mousedown.
dom.on('mousedown', (e) => e.preventDefault()),
dom.domComputed((use) =>
use(this._rowIds).length > 0 ? this._buildPreviewNav(testId, ctl) : this._buildEmptyMenu(testId)),
// Drag-over logic
dragOverClass('attachment_drag_over'),
dom.on('drop', ev => this._upload(ev.dataTransfer!.files)),
testId('modal')
);
}
private _buildPreviewNav(testId: TestId, ctl: IModalControl) {
const modalPrev = (selected: Attachment) => modalPreviewImage(
dom.attr('src', (use) => use(selected.url)),
testId('image')
);
const noPrev = noPreview({style: 'padding: 60px;'},
dom('div.glyphicon.glyphicon-file'),
dom('div', 'Preview not available')
);
return [
// Preview and left/right nav arrows
dom('div', {style: 'display: flex; align-items: center; height: 400px;'},
dom('div.glyphicon.glyphicon-chevron-left', {style: navArrowStyle},
dom.on('click', () => this._moveIndex(-1)),
testId('left')
),
modalPreview(
dom.maybe(this._selected, (selected) =>
dom.domComputed((use) => selected.hasPreview ? modalPrev(selected) : noPrev)
),
dom.attr('title', (use) => use(use(this._selected).filename)),
),
dom('div.glyphicon.glyphicon-chevron-right', {style: navArrowStyle},
dom.on('click', () => this._moveIndex(1)),
testId('right')
)
),
// Nav thumbnails
thumbnailOuterRow(
thumbnailInnerRow(
dom.forEach(this._attachments, (a: Attachment) => this._buildThumbnail(a)),
testId('thumbnails')
),
addButton(
addButtonPlus(),
dom.on('click', () => { this._select(); }), // tslint:disable-line:no-floating-promises TODO
testId('add')
)
),
// Menu buttons
dom('div', {style: 'height: 10px; margin: 10px;'},
dom('div.glyphicon.glyphicon-trash', {style: menuBtnStyle},
dom.on('click', () => this._remove()),
testId('remove')
),
dom('a.glyphicon.glyphicon-download-alt', {style: menuBtnStyle},
dom.attr('href', (use) => use(use(this._selected).url)),
dom.attr('target', '_blank'),
dom.attr('download', (use) => use(use(this._selected).filename)),
testId('download')
),
dom('div.glyphicon.glyphicon-pencil', {style: menuBtnStyle},
dom.on('click', () => this._isEditingName.set(!this._isEditingName.get())),
testId('rename')
)
),
// Rename menu
dom.maybe(this._isEditingName, () => {
this._newName.set(this._selected.get().filename.get());
return dom('div', {style: 'display: flex; margin: 20px 0 0 0;'},
input(this._newName, {onInput: true},
{style: 'flex: 4 1 0; margin: 0 5px;', placeholder: 'Rename file...'},
// Allow the input element to gain focus.
dom.on('mousedown', (e) => e.stopPropagation()),
dom.onKeyPress({Enter: () => this._renameFile()}),
// Prevent the dialog from losing focus and disposing on input completion.
dom.on('blur', (ev) => ctl.focus()),
dom.onDispose(() => ctl.focus()),
testId('rename-input')
),
button1Small('Cancel', {style: 'flex: 1 1 0; margin: 0 5px;'},
dom.on('click', () => this._isEditingName.set(false)),
testId('rename-cancel')
),
button1SmallBright('Save', {style: 'flex: 1 1 0; margin: 0 5px;'},
dom.on('click', () => this._renameFile()),
dom.boolAttr('disabled', (use) => !use(this._isRenameValid)),
testId('rename-save')
)
);
})
];
}
private _buildEmptyMenu(testId: TestId): Element {
return noAttachments('Click or drag to add attachments',
addButton({style: 'border: none; margin: 2px;'},
addButtonPlus(),
),
dom.on('click', () => { this._select(); }), // tslint:disable-line:no-floating-promises TODO
testId('empty-add')
);
}
private _buildThumbnail(att: Attachment): Element {
const isSelected: Computed<boolean> = computed((use) =>
att.rowId === use(this._selected).rowId);
return thumbnail({id: `thumbnail-${att.rowId}`, style: att.hasPreview ? '' : 'width: 30px;'},
dom.autoDispose(isSelected),
// TODO: Update to legitimately determine whether a file preview exists.
att.hasPreview ? dom('img', {style: 'height: 100%; vertical-align: top;'},
dom.attr('src', (use) => this._getUrl(att.fileIdent, use(att.filename)))
) : noPreview({style: 'width: 30px;'},
dom('div.glyphicon.glyphicon-file')
),
dom.maybe(isSelected, () => selectedOverlay()),
// Add a filename tooltip to the thumbnails.
dom.attr('title', (use) => use(att.filename)),
dom.style('border', (use) => use(isSelected) ? '1px solid #317193' : '1px solid #bbb'),
dom.on('click', () => {
this._index.set(this._attachments.get().findIndex(a => a.rowId === att.rowId));
})
);
}
private _getUrl(fileIdent: string, filename: string): string {
return this._docComm.docUrl('attachment') + '?' + encodeQueryParams({
...this._docComm.getUrlParams(),
ident: fileIdent,
name: filename
});
}
private _renameFile(): void {
const val = this._newName.get();
if (this._isRenameValid.get()) {
this._selected.get().filename.set(val);
const rowId = this._selected.get().rowId;
this._attachmentsTable.sendTableAction(['UpdateRecord', rowId, {fileName: val}]);
this._isEditingName.set(false);
}
}
private _moveIndex(dir: -1|1): void {
const len = this._attachments.get().length;
this._index.set(mod(this._index.get()! + dir, len));
}
private _scrollToThumbnail(rowId: number): void {
const tn = document.getElementById(`thumbnail-${rowId}`);
if (tn) {
tn.scrollIntoView();
}
}
// Removes the attachment being previewed from the cell (but not the document).
private _remove(): void {
this._rowIds.splice(this._index.get()!, 1);
}
private async _select(): Promise<void> {
const uploadResult = await selectFiles({docWorkerUrl: this._docComm.docWorkerUrl,
multiple: true, sizeLimit: 'attachment'});
return this._add(uploadResult);
}
private async _upload(files: FileList): Promise<void> {
const uploadResult = await uploadFiles(Array.from(files),
{docWorkerUrl: this._docComm.docWorkerUrl,
sizeLimit: 'attachment'});
return this._add(uploadResult);
}
private async _add(uploadResult: UploadResult|null): Promise<void> {
if (!uploadResult) { return; }
const rowIds = await this._docComm.addAttachments(uploadResult.uploadId);
const len = this._rowIds.get().length;
if (rowIds.length > 0) {
this._rowIds.push(...rowIds);
this._index.set(len);
}
}
}

View File

@@ -0,0 +1,12 @@
.attachment_hover_icon {
display: none;
}
.attachment_widget:hover .attachment_hover_icon {
display: inline;
}
.attachment_drag_over {
outline: 2px dashed #ff9a00;
outline-offset: -2px;
}

View File

@@ -0,0 +1,247 @@
import {Computed, dom, fromKo, input, makeTestId, onElem, styled, TestId} from 'grainjs';
import {dragOverClass} from 'app/client/lib/dom';
import {selectFiles, uploadFiles} from 'app/client/lib/uploads';
import {cssRow} from 'app/client/ui/RightPanel';
import {colors} from 'app/client/ui2018/cssVars';
import {NewAbstractWidget} from 'app/client/widgets/NewAbstractWidget';
import {NewBaseEditor} from 'app/client/widgets/NewBaseEditor';
import {PreviewModal} from 'app/client/widgets/PreviewModal';
import {encodeQueryParams} from 'app/common/gutil';
import {TableData} from 'app/common/TableData';
import {UploadResult} from 'app/common/uploads';
const testId: TestId = makeTestId('test-pw-');
const attachmentWidget = styled('div.attachment_widget.field_clip', `
display: flex;
flex-wrap: wrap;
white-space: pre-wrap;
`);
const attachmentIcon = styled('div.attachment_icon.glyphicon.glyphicon-paperclip', `
position: absolute;
top: 2px;
left: 2px;
padding: 2px;
background-color: #D0D0D0;
color: white;
border-radius: 2px;
border: none;
cursor: pointer;
box-shadow: 0 0 0 1px white;
z-index: 1;
&:hover {
background-color: #3290BF;
}
`);
const attachmentPreview = styled('div', `
color: black;
background-color: white;
border: 1px solid #bbb;
margin: 0 2px 2px 0;
position: relative;
vertical-align: top;
z-index: 0;
&:hover > .show_preview {
display: block;
}
`);
const showPreview = styled('div.glyphicon.glyphicon-eye-open.show_preview', `
position: absolute;
display: none;
right: -2px;
top: -2px;
width: 12px;
height: 12px;
border-radius: 2px;
background-color: #888;
color: white;
font-size: 6pt;
padding: 2px;
cursor: pointer;
&:hover {
display: block;
}
`);
const noPreviewIcon = styled('div.glyphicon.glyphicon-file', `
min-width: 100%;
color: #bbb;
text-align: center;
margin: calc(50% * 1.33 - 6px) 0;
font-size: 6pt;
vertical-align: top;
`);
const sizeLabel = styled('div', `
color: ${colors.slate};
margin-right: 9px;
`);
export interface SavingObservable<T> extends ko.Observable<T> {
setAndSave(value: T): void;
}
/**
* PreviewsWidget - A widget for displaying attachments as image previews.
*/
export class PreviewsWidget extends NewAbstractWidget {
private _attachmentsTable: TableData;
private _height: SavingObservable<string>;
constructor(field: any) {
super(field);
// TODO: the Attachments table currently treated as metadata, and loaded on open,
// but should probably be loaded on demand as it contains user data, which may be large.
this._attachmentsTable = this._getDocData().getTable('_grist_Attachments')!;
this._height = this.options.prop('height') as SavingObservable<string>;
this.autoDispose(this._height.subscribe(() => {
this.field.viewSection().events.trigger('rowHeightChange');
}));
}
public buildDom(_row: any): Element {
// NOTE: A cellValue of the correct type includes the list encoding designator 'L' as the
// first element.
const cellValue: SavingObservable<number[]> = _row[this.field.colId()];
const values = Computed.create(null, fromKo(cellValue), (use, _cellValue) =>
Array.isArray(_cellValue) ? _cellValue.slice(1) : []);
return attachmentWidget(
dom.autoDispose(values),
dragOverClass('attachment_drag_over'),
attachmentIcon(
dom.cls('attachment_hover_icon', (use) => use(values).length > 0),
dom.on('click', () => this._selectAndSave(cellValue))
),
dom.forEach(values, (value: number) =>
isNaN(value) ? null : this._buildAttachment(value, cellValue)
),
dom.on('drop', ev => this._uploadAndSave(cellValue, ev.dataTransfer!.files))
);
}
public buildConfigDom(): Element {
const inputRange = input(fromKo(this._height), {onInput: true}, {
style: 'margin: 0 5px;',
type: 'range',
min: '16',
max: '96',
value: '36'
}, testId('thumbnail-size'));
// Save the height on change event (when the user releases the drag button)
onElem(inputRange, 'change', (ev: any) => { this._height.setAndSave(ev.target.value); });
return cssRow(
sizeLabel('Size'),
inputRange
);
}
protected _buildAttachment(value: number, cellValue: SavingObservable<number[]>): Element {
const filename: string = this._attachmentsTable.getValue(value, 'fileName') as string;
const height: number = this._attachmentsTable.getValue(value, 'imageHeight') as number;
const width: number = this._attachmentsTable.getValue(value, 'imageWidth') as number;
const hasPreview: boolean = Boolean(height);
const ratio: number = hasPreview ? (width / height) : .75;
return attachmentPreview({title: filename}, // Add a filename tooltip to the previews.
dom.style('height', (use) => `${use(this._height)}px`),
dom.style('width', (use) => `${parseInt(use(this._height), 10) * ratio}px`),
// TODO: Update to legitimately determine whether a file preview exists.
hasPreview ? dom('img', {style: 'height: 100%; min-width: 100%; vertical-align: top;'},
dom.attr('src', this._getUrl(value))
) : noPreviewIcon(),
dom.cls('no_preview', () => !hasPreview),
showPreview(
dom.on('click', () => this._showPreview(value, cellValue)),
testId(`preview-${value}`)
),
testId(String(value))
);
}
// Returns the attachment download url.
private _getUrl(rowId: number): string {
const ident = this._attachmentsTable.getValue(rowId, 'fileIdent') as string;
if (!ident) {
return '';
} else {
const docComm = this._getDocComm();
return docComm.docUrl('attachment') + '?' + encodeQueryParams({
...docComm.getUrlParams(),
ident,
name: this._attachmentsTable.getValue(rowId, 'fileName') as string
});
}
}
private async _selectAndSave(value: SavingObservable<number[]>): Promise<void> {
const uploadResult = await selectFiles({docWorkerUrl: this._getDocComm().docWorkerUrl,
multiple: true, sizeLimit: 'attachment'});
return this._save(value, uploadResult);
}
private async _uploadAndSave(value: SavingObservable<number[]>, files: FileList): Promise<void> {
const uploadResult = await uploadFiles(Array.from(files),
{docWorkerUrl: this._getDocComm().docWorkerUrl,
sizeLimit: 'attachment'});
return this._save(value, uploadResult);
}
private async _save(value: SavingObservable<number[]>, uploadResult: UploadResult|null): Promise<void> {
if (!uploadResult) { return; }
const rowIds = await this._getDocComm().addAttachments(uploadResult.uploadId);
// Values should be saved with a leading "L" to fit Grist's list value encoding.
const formatted: any[] = value() ? value() : ["L"];
value.setAndSave(formatted.concat(rowIds));
// Trigger a row height change in case the added attachment wraps to the next line.
this.field.viewSection().events.trigger('rowHeightChange');
}
// Show a preview for the attachment with the given rowId value in the attachments table.
private _showPreview(value: number, cellValue: SavingObservable<number[]>): void {
// Modal should be disposed on close.
const onClose = (_cellValue: number[]) => {
cellValue.setAndSave(_cellValue);
modal.dispose();
};
const modal = PreviewModal.create(this, this._getDocData(), cellValue.peek(), testId, value,
onClose);
}
}
/**
* A PreviewsEditor is just the PreviewModal, which allows adding and removing attachments.
*/
export class PreviewsEditor extends NewBaseEditor {
private _modal: any;
public attach(cellRect: ClientRect|DOMRect) {
const docData = this.options.gristDoc.docData;
// Disposal on close is handled by the FieldBuilder editor logic.
this._modal = PreviewModal.create(this, docData, this.options.cellValue as number[], testId);
}
public getCellValue(): any {
return this._modal.getCellValue();
}
public getCursorPos(): number {
return 0;
}
public getTextValue(): string {
return '';
}
}

View File

@@ -0,0 +1,4 @@
.cell_icon {
margin-right: 3px;
color: #808080;
}

View File

@@ -0,0 +1,112 @@
var _ = require('underscore');
var ko = require('knockout');
var gutil = require('app/common/gutil');
var dispose = require('../lib/dispose');
var dom = require('../lib/dom');
var kd = require('../lib/koDom');
var TextBox = require('./TextBox');
const {colors, testId} = require('app/client/ui2018/cssVars');
const {icon} = require('app/client/ui2018/icons');
const {select} = require('app/client/ui2018/menus');
const {cssLabel, cssRow} = require('app/client/ui/RightPanel');
const {isVersions} = require('app/common/gristTypes');
const {Computed, styled} = require('grainjs');
/**
* Reference - The widget for displaying references to another table's records.
*/
function Reference(field) {
TextBox.call(this, field);
var col = field.column();
this.field = field;
this.colId = col.colId;
this.docModel = this.field._table.docModel;
this._refValueFormatter = this.autoDispose(ko.computed(() =>
field.createVisibleColFormatter()));
this._visibleColRef = Computed.create(this, (use) => use(this.field.visibleColRef));
// Note that saveOnly is used here to prevent display value flickering on visible col change.
this._visibleColRef.onWrite((val) => this.field.visibleColRef.saveOnly(val));
this._validCols = Computed.create(this, (use) => {
const refTable = use(use(this.field.column).refTable);
if (!refTable) { return []; }
return use(use(refTable.columns))
.filter(col => !use(col.isHiddenCol))
.map(col => ({
label: use(col.label),
value: col.getRowId(),
icon: 'FieldColumn',
disabled: gutil.startsWith(use(col.type), 'Ref:') || use(col.isTransforming)
}))
.concat([{label: 'Row ID', value: 0, icon: 'FieldColumn'}]);
});
// Computed returns a function that formats cell values.
this._formatValue = this.autoDispose(ko.computed(() => {
// If the field is pulling values from a display column, use a general-purpose formatter.
if (this.field.displayColRef() !== this.field.colRef()) {
const fmt = this._refValueFormatter();
return (val) => fmt.formatAny(val);
} else {
const refTable = this.field.column().refTable();
const refTableId = refTable ? refTable.tableId() : "";
return (val) => val > 0 ? `${refTableId}[${val}]` : "";
}
}));
}
dispose.makeDisposable(Reference);
_.extend(Reference.prototype, TextBox.prototype);
Reference.prototype.buildConfigDom = function() {
return [
cssLabel('SHOW COLUMN'),
cssRow(
select(this._visibleColRef, this._validCols),
testId('fbuilder-ref-col-select')
)
];
};
Reference.prototype.buildTransformConfigDom = function() {
return this.buildConfigDom();
};
Reference.prototype.buildDom = function(row, options) {
const formattedValue = ko.computed(() => {
if (row._isAddRow() || this.isDisposed() || this.field.displayColModel().isDisposed()) {
// Work around JS errors during certain changes (noticed when visibleCol field gets removed
// for a column using per-field settings).
return "";
}
const value = row.cells[this.field.displayColModel().colId()];
if (!value) { return ""; }
const content = value();
if (isVersions(content)) {
// We can arrive here if the reference value is unchanged (viewed as a foreign key)
// but the content of its displayCol has changed. Postponing doing anything about
// this until we have three-way information for computed columns. For now,
// just showing one version of the cell. TODO: elaborate.
return this._formatValue()(content[1].local || content[1].parent);
}
return this._formatValue()(content);
});
return dom('div.field_clip',
dom.autoDispose(formattedValue),
cssRefIcon('FieldReference',
testId('ref-link-icon')
),
kd.text(formattedValue));
};
const cssRefIcon = styled(icon, `
background-color: ${colors.slate};
margin: -1px 2px 2px 0;
`);
module.exports = Reference;

View File

@@ -0,0 +1,247 @@
import {ACResults, buildHighlightedDom, HighlightFunc} from 'app/client/lib/ACIndex';
import {Autocomplete} from 'app/client/lib/autocomplete';
import {ICellItem} from 'app/client/models/ColumnACIndexes';
import {reportError} from 'app/client/models/errors';
import {TableData} from 'app/client/models/TableData';
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menuCssClass} from 'app/client/ui2018/menus';
import {Options} from 'app/client/widgets/NewBaseEditor';
import {NTextEditor} from 'app/client/widgets/NTextEditor';
import {CellValue} from 'app/common/DocActions';
import {removePrefix, undefDefault} from 'app/common/gutil';
import {BaseFormatter} from 'app/common/ValueFormatter';
import {styled} from 'grainjs';
/**
* A ReferenceEditor offers an autocomplete of choices from the referenced table.
*/
export class ReferenceEditor extends NTextEditor {
private _tableData: TableData;
private _formatter: BaseFormatter;
private _enableAddNew: boolean;
private _showAddNew: boolean = false;
private _visibleCol: string;
private _autocomplete?: Autocomplete<ICellItem>;
constructor(options: Options) {
super(options);
const field = options.field;
// Get the table ID to which the reference points.
const refTableId = removePrefix(field.column().type(), "Ref:");
if (!refTableId) {
throw new Error("ReferenceEditor used for non-Reference column");
}
const docData = options.gristDoc.docData;
const tableData = docData.getTable(refTableId);
if (!tableData) {
throw new Error("ReferenceEditor: invalid referenced table");
}
this._tableData = tableData;
// Construct the formatter for the displayed values using the options from the target column.
this._formatter = field.createVisibleColFormatter();
// Whether we should enable the "Add New" entry to allow adding new items to the target table.
const vcol = field.visibleColModel();
this._enableAddNew = vcol && !vcol.isRealFormula();
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'));
this.textInput.value = undefDefault(options.editValue, this._idToText(options.cellValue));
const needReload = (options.editValue === undefined && !tableData.isLoaded);
// The referenced table has probably already been fetched (because there must already be a
// Reference widget instantiated), but it's better to avoid this assumption.
docData.fetchTable(refTableId).then(() => {
if (this.isDisposed()) { return; }
if (needReload && this.textInput.value === '') {
this.textInput.value = undefDefault(options.editValue, this._idToText(options.cellValue));
this.resizeInput();
}
if (this._autocomplete) {
if (options.editValue === undefined) {
this._autocomplete.search((items) => items.findIndex((item) => item.rowId === options.cellValue));
} else {
this._autocomplete.search();
}
}
})
.catch(reportError);
}
public attach(cellRect: ClientRect|DOMRect): void {
super.attach(cellRect);
this._autocomplete = this.autoDispose(new Autocomplete<ICellItem>(this.textInput, {
menuCssClass: menuCssClass + ' ' + cssRefList.className,
search: this._doSearch.bind(this),
renderItem: this._renderItem.bind(this),
getItemText: (item) => item.text,
onClick: () => this.options.commands.fieldEditSaveHere(),
}));
}
/**
* If the 'new' item is saved, add it to the referenced table first. See _buildSourceList
*/
public async prepForSave() {
const selectedItem = this._autocomplete && this._autocomplete.getSelectedItem();
if (selectedItem &&
selectedItem.rowId === 'new' &&
selectedItem.text === this.textInput.value) {
const colInfo = {[this._visibleCol]: this.textInput.value};
selectedItem.rowId = await this._tableData.sendTableAction(["AddRecord", null, colInfo]);
}
}
public getCellValue() {
const selectedItem = this._autocomplete && this._autocomplete.getSelectedItem();
if (selectedItem) {
// Selected from the autocomplete dropdown; so we know the *value* (i.e. rowId).
return selectedItem.rowId;
} else if (nocaseEqual(this.textInput.value, this._idToText(this.options.cellValue))) {
// Unchanged from what's already in the cell.
return this.options.cellValue;
}
// Search for textInput's value, or else use the typed value itself (as alttext).
if (this.textInput.value === '') {
return 0; // This is the default value for a reference column.
}
const searchFunc = (value: any) => nocaseEqual(value, this.textInput.value);
const matches = this._tableData.columnSearch(this._visibleCol, this._formatter, searchFunc, 1);
if (matches.length > 0) {
return matches[0].value;
} else {
return this.textInput.value;
}
}
private _idToText(value: CellValue) {
if (typeof value === 'number') {
return this._formatter.formatAny(this._tableData.getValue(value, this._visibleCol));
}
return String(value || '');
}
private async _doSearch(text: string): Promise<ACResults<ICellItem>> {
const acIndex = this._tableData.columnACIndexes.getColACIndex(this._visibleCol, this._formatter);
const result = acIndex.search(text);
// If the search text does not match anything exactly, add 'new' item for it. See also prepForSave.
this._showAddNew = false;
if (this._enableAddNew && text) {
const cleanText = text.trim().toLowerCase();
if (!result.items.find((item) => item.cleanText === cleanText)) {
result.items.push({rowId: 'new', text, cleanText});
this._showAddNew = true;
}
}
return result;
}
private _renderItem(item: ICellItem, highlightFunc: HighlightFunc) {
if (item.rowId === 'new') {
return cssRefItem(cssRefItem.cls('-new'),
cssPlusButton(cssPlusIcon('Plus')), item.text,
testId('ref-editor-item'), testId('ref-editor-new-item'),
);
}
return cssRefItem(cssRefItem.cls('-with-new', this._showAddNew),
buildHighlightedDom(item.text, highlightFunc, cssMatchText),
testId('ref-editor-item'),
);
}
}
function nocaseEqual(a: string, b: string) {
return a.trim().toLowerCase() === b.trim().toLowerCase();
}
const cssRefEditor = styled('div', `
& > .celleditor_text_editor, & > .celleditor_content_measure {
padding-left: 21px;
}
`);
const cssRefList = styled('div', `
overflow-y: auto;
padding: 8px 0 0 0;
--weaseljs-menu-item-padding: 8px 16px;
`);
// We need to now the height of the sticky "+" element.
const addNewHeight = '37px';
const cssRefItem = styled('li', `
display: block;
font-family: ${vars.fontFamily};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
outline: none;
padding: var(--weaseljs-menu-item-padding, 8px 24px);
cursor: pointer;
&.selected {
background-color: var(--weaseljs-selected-background-color, #5AC09C);
color: var(--weaseljs-selected-color, white);
}
&-with-new {
scroll-margin-bottom: ${addNewHeight};
}
&-new {
color: ${colors.slate};
position: sticky;
bottom: 0px;
height: ${addNewHeight};
background-color: white;
border-top: 1px solid ${colors.mediumGrey};
scroll-margin-bottom: initial;
}
&-new.selected {
color: ${colors.lightGrey};
}
`);
const cssPlusButton = styled('div', `
display: inline-block;
width: 20px;
height: 20px;
border-radius: 20px;
margin-right: 8px;
text-align: center;
background-color: ${colors.lightGreen};
color: ${colors.light};
.selected > & {
background-color: ${colors.darkGreen};
}
`);
const cssPlusIcon = styled(icon, `
background-color: ${colors.light};
`);
const cssRefEditIcon = styled(icon, `
background-color: ${colors.slate};
position: absolute;
top: 0;
left: 0;
margin: 2px 3px 0 3px;
`);
const cssMatchText = styled('span', `
color: ${colors.lightGreen};
.selected > & {
color: ${colors.lighterGreen};
}
`);

View File

@@ -0,0 +1,3 @@
.widget_spinner {
padding-right: 15px;
}

View File

@@ -0,0 +1,32 @@
import * as kf from 'app/client/lib/koForm';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {NumericTextBox} from 'app/client/widgets/NumericTextBox';
import {buildNumberFormat} from 'app/common/NumberFormat';
import {dom} from 'grainjs';
import * as ko from 'knockout';
/**
* Spinner - A widget with a text field and spinner.
*/
export class Spinner extends NumericTextBox {
private _stepSize: ko.Computed<number>;
constructor(field: ViewFieldRec) {
super(field);
const resolved = this.autoDispose(ko.computed(() =>
buildNumberFormat({numMode: this.options().numMode}).resolvedOptions()));
this._stepSize = this.autoDispose(ko.computed(() => {
const extraScaling = (this.options().numMode === 'percent') ? 2 : 0;
return Math.pow(10, -(this.options().decimals || resolved().minimumFractionDigits) - extraScaling);
}));
}
public buildDom(row: DataRowModel) {
const value = row.cells[this.field.colId.peek()];
return dom.update(super.buildDom(row),
dom.cls('widget_spinner'),
kf.spinner(value, this._stepSize)
);
}
}

View File

@@ -0,0 +1,48 @@
.widget_switch {
position: relative;
margin: -1px auto;
width: 30px;
height: 17px;
}
.switch_slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
border-radius: 17px;
}
.switch_slider:hover {
box-shadow: 0 0 1px #2196F3;
}
.switch_circle {
position: absolute;
cursor: pointer;
content: "";
height: 13px;
width: 13px;
left: 2px;
bottom: 2px;
background-color: white;
border-radius: 17px;
}
.switch_on > .switch_slider {
background-color: #2CB0AF;
}
.switch_on > .switch_circle {
-webkit-transform: translateX(13px);
-ms-transform: translateX(13px);
transform: translateX(13px);
}
.switch_transition > .switch_slider, .switch_transition > .switch_circle {
-webkit-transition: .4s;
transition: .4s;
}

View File

@@ -0,0 +1,37 @@
var dom = require('../lib/dom');
var dispose = require('../lib/dispose');
var _ = require('underscore');
var kd = require('../lib/koDom');
var AbstractWidget = require('./AbstractWidget');
/**
* Switch - A bi-state Switch widget
*/
function Switch(field) {
AbstractWidget.call(this, field);
}
dispose.makeDisposable(Switch);
_.extend(Switch.prototype, AbstractWidget.prototype);
Switch.prototype.buildConfigDom = function() {
return null;
};
Switch.prototype.buildDom = function(row) {
var value = row[this.field.colId()];
return dom('div.field_clip',
dom('div.widget_switch',
kd.toggleClass('switch_on', value),
kd.toggleClass('switch_transition', row._isRealChange),
dom('div.switch_slider'),
dom('div.switch_circle'),
dom.on('click', () => {
if (!this.field.column().isRealFormula()) {
value.setAndSave(!value.peek());
}
})
)
);
};
module.exports = Switch;

View File

@@ -0,0 +1,51 @@
.record-add .field_clip {
background-color: inherit;
}
.transform_field {
background-color: #FEFFE8;
}
@media not print {
.formula_field, .formula_field_edit {
padding-left: 18px;
}
.formula_field_edit {
color: #D0D0D0;
}
.formula_field::before, .formula_field_edit::before {
content: '=';
position: absolute;
left: 2px;
top: 4px;
width: 12px;
height: 12px;
border-radius: 2px;
line-height: 12px;
font-family: sans-serif;
font-size: 14px;
text-align: center;
font-weight: bold;
cursor: pointer;
color: white;
}
.formula_field::before {
background-color: #D0D0D0;
}
.formula_field_edit::before {
background-color: var(--grist-color-cursor);
}
.formula_field.invalid::before {
background-color: white;
color: #ffb6c1;
}
.formula_field_edit.invalid::before {
background-color: var(--grist-color-cursor);
color: #ffb6c1;
}
}
.invalid-text input {
background-color: #ffb6c1;
}

View File

@@ -0,0 +1,71 @@
var _ = require('underscore');
var ko = require('knockout');
var dom = require('../lib/dom');
var dispose = require('../lib/dispose');
var kd = require('../lib/koDom');
var AbstractWidget = require('./AbstractWidget');
var modelUtil = require('../models/modelUtil');
const {fromKoSave} = require('app/client/lib/fromKoSave');
const {alignmentSelect, buttonToggleSelect} = require('app/client/ui2018/buttonSelect');
const {testId} = require('app/client/ui2018/cssVars');
const {cssRow} = require('app/client/ui/RightPanel');
const {Computed} = require('grainjs');
/**
* TextBox - The most basic widget for displaying text information.
*/
function TextBox(field) {
AbstractWidget.call(this, field);
this.alignment = this.options.prop('alignment');
let wrap = this.options.prop('wrap');
this.wrapping = this.autoDispose(ko.computed({
read: () => {
let w = wrap();
if (w === null || w === undefined) {
// When user has yet to specify a desired wrapping state, GridView and DetailView have
// different default states. GridView defaults to wrapping disabled, while DetailView
// defaults to wrapping enabled.
return (this.field.viewSection().parentKey() === 'record') ? false : true;
} else {
return w;
}
},
write: val => wrap(val)
}));
modelUtil.addSaveInterface(this.wrapping, val => wrap.saveOnly(val));
this.autoDispose(this.wrapping.subscribe(() =>
this.field.viewSection().events.trigger('rowHeightChange')
));
}
dispose.makeDisposable(TextBox);
_.extend(TextBox.prototype, AbstractWidget.prototype);
TextBox.prototype.buildConfigDom = function() {
const wrapping = Computed.create(null, use => use(this.wrapping));
wrapping.onWrite((val) => modelUtil.setSaveValue(this.wrapping, Boolean(val)));
return dom('div',
cssRow(
dom.autoDispose(wrapping),
alignmentSelect(fromKoSave(this.alignment)),
dom('div', {style: 'margin-left: 8px;'},
buttonToggleSelect(wrapping, [{value: true, icon: 'Wrap'}]),
testId('tb-wrap-text')
)
)
);
};
TextBox.prototype.buildDom = function(row) {
let value = row[this.field.colId()];
return dom('div.field_clip',
kd.style('text-align', this.alignment),
kd.toggleClass('text_wrapping', this.wrapping),
kd.text(() => row._isAddRow() ? '' : this.valueFormatter().format(value()))
);
};
module.exports = TextBox;

View File

@@ -0,0 +1,80 @@
.cell_editor {
position: absolute;
z-index: 1000; /* make it higher than popper's 999 */
}
.default_editor {
box-shadow: 0 0 3px 2px var(--grist-color-cursor);
}
.formula_editor {
background-color: white;
padding: 2px 0 2px 18px;
z-index: 10;
}
.celleditor_cursor_editor {
background-color: white;
/* the following are copied from .field_clip */
padding: 3px 3px 0px 3px;
font-family: var(--grist-font-family-data);
font-size: var(--grist-medium-font-size);
line-height: 18px;
white-space: pre-wrap;
overflow-wrap: break-word;
}
.celleditor_text_editor {
display: block;
outline: none;
padding: 0px;
border: none;
resize: none;
z-index: 10;
color: black;
/* Inherit styles, same as for .celleditor_content_measure, to ensure that sizes correspond. */
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
.celleditor_content_measure {
position: absolute;
left: 0;
top: 0;
border: none;
visibility: hidden;
overflow: visible;
/* with 'pre-wrap', this lets the editor gets as wide as needed before wrapping; */
/* width is limited only by max-width (which is set in JS code). */
width: max-content;
/* Inherit styles, same as for .celleditor_text_editor, to ensure that sizes correspond. */
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
.error_msg {
color: black;
cursor: default;
margin: 4px;
}
.error_details {
padding: 2px 2px 2px 2px;
background-color: #F8ECEA;
margin: 0 0 -2px 0;
}
.error_box {
background-color: #ffb6c1;
padding: 2px 0px 2px 0px;
white-space: pre-wrap;
}
.kf_collapser {
height: 1.2rem;
}

View File

@@ -0,0 +1,108 @@
var _ = require('underscore');
var gutil = require('app/common/gutil');
var dom = require('../lib/dom');
var kd = require('../lib/koDom');
var dispose = require('../lib/dispose');
var BaseEditor = require('./BaseEditor');
var commands = require('../components/commands');
const {testId} = require('app/client/ui2018/cssVars');
const {EditorPlacement} = require('app/client/widgets/EditorPlacement');
/**
* Required parameters:
* @param {RowModel} options.field: ViewSectionField (i.e. column) being edited.
* @param {Object} options.cellValue: The value in the underlying cell being edited.
* @param {String} options.editValue: String to be edited, or undefined to use cellValue.
* @param {Number} options.cursorPos: The initial position where to place the cursor.
* @param {Object} options.commands: Object mapping command names to functions, to enable as part
* of the command group that should be activated while the editor exists.
*
* Optional parameters:
* @param {String} options.placeholder: Optional placeholder for the textarea.
*
* TextEditor exposes the following members, which derived classes may use:
* @member {Object} this.options: Options as passed into the constructor.
* @member {Node} this.dom: The DOM element for the editor.
* @member {Node} this.textInput: The textarea element of the editor (contained within this.dom).
* @member {Object} this.commandGroup: The CommandGroup created from options.commands.
*/
function TextEditor(options) {
this.options = options;
this.commandGroup = this.autoDispose(commands.createGroup(options.commands, null, true));
this._alignment = options.field.widgetOptionsJson.peek().alignment || 'left';
this.dom = dom('div.default_editor',
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.value(gutil.undefDefault(options.editValue, String(options.cellValue))),
this.commandGroup.attach(),
// Resize the textbox whenever user types in it.
dom.on('input', () => this._resizeInput())
)
)
);
}
dispose.makeDisposable(TextEditor);
_.extend(TextEditor.prototype, BaseEditor.prototype);
TextEditor.prototype.attach = function(cellRect) {
// Attach the editor dom to page DOM.
this.editorPlacement = EditorPlacement.create(this, this.dom, cellRect);
this.setSizerLimits();
// Once the editor is attached to DOM, resize it to content, focus, and set cursor.
this._resizeInput();
this.textInput.focus();
var pos = Math.min(this.options.cursorPos, this.textInput.value.length);
this.textInput.setSelectionRange(pos, pos);
};
TextEditor.prototype.setSizerLimits = function() {
// Set the max width of the sizer to the max we could possibly grow to, so that it knows to wrap
// once we reach it.
const maxSize = this.editorPlacement.calcSizeWithPadding(this.textInput,
{width: Infinity, height: Infinity}, {calcOnly: true});
this.contentSizer.style.maxWidth = Math.ceil(maxSize.width) + 'px';
};
TextEditor.prototype.getCellValue = function() {
return this.textInput.value;
};
TextEditor.prototype.getTextValue = function() {
return this.textInput.value;
};
TextEditor.prototype.getCursorPos = function() {
return this.textInput.selectionStart;
};
/**
* Helper which resizes textInput to match its content. It relies on having a contentSizer element
* with the same font/size settings as the textInput, and on having `calcSize` helper,
* which is provided by the EditorPlacement class.
*/
TextEditor.prototype._resizeInput = function() {
var textInput = this.textInput;
// \u200B is a zero-width space; it is used so the textbox will expand vertically
// on newlines, but it does not add any width.
this.contentSizer.textContent = textInput.value + '\u200B';
var rect = this.contentSizer.getBoundingClientRect();
// Allow for a bit of extra space after the cursor (only desirable when text is left-aligned).
if (this._alignment === 'left') {
rect.width += 16;
}
var size = this.editorPlacement.calcSizeWithPadding(textInput, rect);
textInput.style.width = size.width + 'px';
textInput.style.height = size.height + 'px';
};
module.exports = TextEditor;

View File

@@ -0,0 +1,236 @@
var _ = require('underscore');
/**
* Given a widget name and a type, return the name of the widget that would
* actually be used for that type (hopefully the same, unless falling back
* on a default if widget name is unlisted), and all default configuration
* information for that widget/type combination.
* Returns something of form:
* {
* name:"WidgetName",
* config: {
* cons: "NameOfWidgetClass",
* editCons: "NameOfEditorClass",
* options: { ... default options for widget ... }
* }
* }
*/
function getWidgetConfiguration(widgetName, type) {
const oneTypeDef = typeDefs[type] || typeDefs.Text;
if (!(widgetName in oneTypeDef.widgets)) {
widgetName = oneTypeDef.default;
}
return {
name: widgetName,
config: oneTypeDef.widgets[widgetName]
};
}
exports.getWidgetConfiguration = getWidgetConfiguration;
function mergeOptions(options, type) {
const {name, config} = getWidgetConfiguration(options.widget, type);
return _.defaults({widget: name}, options, config.options);
}
exports.mergeOptions = mergeOptions;
// Contains the list of types with their storage types, possible widgets, default widgets,
// and defaults for all widget settings
// The names of widgets are used, instead of the actual classes needed, in order to limit
// the spread of dependencies. See ./UserTypeImpl for actual classes.
var typeDefs = {
Any: {
label: 'Any',
icon: 'FieldAny',
widgets: {
TextBox: {
cons: 'TextBox',
editCons: 'TextEditor',
icon: 'FieldTextbox',
options: {
alignment: 'left'
}
}
},
default: 'TextBox'
},
Text: {
label: 'Text',
icon: 'FieldText',
widgets: {
TextBox: {
cons: 'TextBox',
editCons: 'TextEditor',
icon: 'FieldTextbox',
options: {
alignment: 'left',
}
},
HyperLink: {
cons: 'HyperLinkTextBox',
editCons: 'HyperLinkEditor',
icon: 'FieldLink',
options: {
alignment: 'left',
}
}
},
default: 'TextBox'
},
Numeric: {
label: 'Numeric',
icon: 'FieldNumeric',
widgets: {
TextBox: {
cons: 'NumericTextBox',
editCons: 'TextEditor',
icon: 'FieldTextbox',
options: {
alignment: 'right'
}
},
Spinner: {
cons: 'Spinner',
editCons: 'TextEditor',
icon: 'FieldSpinner',
options: {
alignment: 'right'
}
}
},
default: 'TextBox'
},
Int: {
label: 'Integer',
icon: 'FieldInteger',
widgets: {
TextBox: {
cons: 'NumericTextBox',
editCons: 'TextEditor',
icon: 'FieldTextbox',
options: {
decimals: 0,
alignment: 'right'
}
},
Spinner: {
cons: 'Spinner',
editCons: 'TextEditor',
icon: 'FieldSpinner',
options: {
decimals: 0,
alignment: 'right'
}
}
},
default: 'TextBox'
},
Bool: {
label: 'Toggle',
icon: 'FieldToggle',
widgets: {
TextBox: {
cons: 'TextBox',
editCons: 'TextEditor',
icon: 'FieldTextbox',
options: {
alignment: 'center'
}
},
CheckBox: {
cons: 'CheckBox',
editCons: 'CheckBoxEditor',
icon: 'FieldCheckbox',
options: {}
},
Switch: {
cons: 'Switch',
editCons: 'CheckBoxEditor',
icon: 'FieldSwitcher',
options: {}
}
},
default: 'CheckBox'
},
Date: {
label: 'Date',
icon: 'FieldDate',
widgets: {
TextBox: {
cons: 'DateTextBox',
editCons: 'DateEditor',
icon: 'FieldTextbox',
options: {
dateFormat: 'YYYY-MM-DD',
isCustomDateFormat: false,
alignment: 'left'
}
}
},
default: 'TextBox'
},
DateTime: {
label: 'DateTime',
icon: 'FieldDateTime',
widgets: {
TextBox: {
cons: 'DateTimeTextBox',
editCons: 'DateTimeEditor',
icon: 'FieldTextbox',
options: {
dateFormat: 'YYYY-MM-DD', // Default to ISO standard: https://xkcd.com/1179/
timeFormat: 'h:mma',
isCustomDateFormat: false,
isCustomTimeFormat: false,
alignment: 'left'
}
}
},
default: 'TextBox'
},
Choice: {
label: 'Choice',
icon: 'FieldChoice',
widgets: {
TextBox: {
cons: 'ChoiceTextBox',
editCons: 'ChoiceEditor',
icon: 'FieldTextbox',
options: {
alignment: 'left',
choices: null
}
}
},
default: 'TextBox'
},
Ref: {
label: 'Reference',
icon: 'FieldReference',
widgets: {
Reference: {
cons: 'Reference',
editCons: 'ReferenceEditor',
icon: 'FieldReference',
options: {}
}
},
default: 'Reference'
},
Attachments: {
label: 'Attachment',
icon: 'FieldAttachment',
widgets: {
Previews: {
cons: 'PreviewsWidget',
editCons: 'PreviewsEditor',
icon: 'FieldAttachment',
options: {
height: '36'
}
}
},
default: 'Previews'
}
};
exports.typeDefs = typeDefs;

View File

@@ -0,0 +1,49 @@
const {NTextBox} = require('./NTextBox');
const {NumericTextBox} = require('./NumericTextBox');
const {Spinner} = require('./Spinner');
const {PreviewsWidget, PreviewsEditor} = require('./PreviewsWidget');
const UserType = require('./UserType');
const {HyperLinkEditor} = require('./HyperLinkEditor');
const {NTextEditor} = require('./NTextEditor');
const {ReferenceEditor} = require('./ReferenceEditor');
/**
* Convert the name of a widget to its implementation.
*/
const nameToWidget = {
'TextBox': NTextBox,
'TextEditor': NTextEditor,
'NumericTextBox': NumericTextBox,
'HyperLinkTextBox': require('./HyperLinkTextBox'),
'HyperLinkEditor': HyperLinkEditor,
'Spinner': Spinner,
'CheckBox': require('./CheckBox'),
'CheckBoxEditor': require('./CheckBoxEditor'),
'Reference': require('./Reference'),
'Switch': require('./Switch'),
'ReferenceEditor': ReferenceEditor,
'ChoiceTextBox': require('./ChoiceTextBox'),
'ChoiceEditor': require('./ChoiceEditor'),
'DateTimeTextBox': require('./DateTimeTextBox'),
'DateTextBox': require('./DateTextBox'),
'DateEditor': require('./DateEditor'),
'PreviewsWidget': PreviewsWidget,
'PreviewsEditor': PreviewsEditor,
'DateTimeEditor': require('./DateTimeEditor'),
};
exports.nameToWidget = nameToWidget;
/** return a good class to instantiate for viewing a widget/type combination */
function getWidgetConstructor(widget, type) {
const {config} = UserType.getWidgetConfiguration(widget, type);
return nameToWidget[config.cons];
}
exports.getWidgetConstructor = getWidgetConstructor;
/** return a good class to instantiate for editing a widget/type combination */
function getEditorConstructor(widget, type) {
const {config} = UserType.getWidgetConfiguration(widget, type);
return nameToWidget[config.editCons];
}
exports.getEditorConstructor = getEditorConstructor;