gristlabs_grist-core/app/client/components/BaseView.js
Dmitry S 48e90c4998 (core) Change how formula columns can be converted to data.
Summary:
- No longer convert data columns to formula by typing a leading "=". Instead,
  show a tooltip with a link to click if the conversion was intended.
- No longer convert a formula column to data by deleting its formula. Leave the
  column empty instead.
- Offer the option "Convert formula to data" in column menu for formulas.
- Offer the option to "Clear column"
- If a subset of rows is shown, offer "Clear values" and "Clear entire column".

- Add logic to detect when a view shows a subset of all rows.
- Factor out showTooltip() from showTransientTooltip().

- Add a bunch of test cases to cover various combinations (there are small
  variations in options depending on whether all rows are shown, on whether
  multiple columns are selected, and whether columns include data columns).

Test Plan: Added a bunch of test cases.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2746
2021-03-05 12:42:57 -05:00

653 lines
26 KiB
JavaScript

/* global window */
var _ = require('underscore');
var ko = require('knockout');
var moment = require('moment-timezone');
var {getSelectionDesc} = require('app/common/DocActions');
var {nativeCompare, roundDownToMultiple, waitObs} = require('app/common/gutil');
var gristTypes = require('app/common/gristTypes');
var koUtil = require('../lib/koUtil');
var tableUtil = require('../lib/tableUtil');
var {DataRowModel} = require('../models/DataRowModel');
var {DynamicQuerySet} = require('../models/QuerySet');
var {SortFunc} = require('app/common/SortFunc');
var rowset = require('../models/rowset');
var Base = require('./Base');
var {Cursor} = require('./Cursor');
var FieldBuilder = require('../widgets/FieldBuilder');
var commands = require('./commands');
var LinkingState = require('./LinkingState');
var BackboneEvents = require('backbone').Events;
const {ClientColumnGetters} = require('app/client/models/ClientColumnGetters');
const {reportError, UserError} = require('app/client/models/errors');
const {urlState} = require('app/client/models/gristUrlState');
const {SectionFilter} = require('app/client/models/SectionFilter');
const {copyToClipboard} = require('app/client/lib/copyToClipboard');
const {setTestState} = require('app/client/lib/testState');
const {ExtraRows} = require('app/client/models/DataTableModelWithDiff');
const {createFilterMenu} = require('app/client/ui/ColumnFilterMenu');
/**
* BaseView forms the basis for ViewSection classes.
* @param {Object} viewSectionModel - The model for the viewSection represented.
* @param {Boolean} options.addNewRow - Whether to include an add row in the model.
*/
function BaseView(gristDoc, viewSectionModel, options) {
Base.call(this, gristDoc);
this.options = options || {};
this.viewSection = viewSectionModel;
this._name = this.viewSection.titleDef.peek();
//--------------------------------------------------
// Observable models mapped to the document
// Instantiate the models for the view metadata and for the data itself.
// The table should never change for a given view, so no need to watch the table() observable.
this.schemaModel = this.viewSection.table();
// Check if we are making a comparison with another document.
this.comparison = this.gristDoc.comparison;
// TODO: but accessing by tableId identifier may be problematic when the table is renamed.
this.tableModel = this.gristDoc.getTableModelMaybeWithDiff(this.schemaModel.tableId());
this.extraRows = new ExtraRows(this.schemaModel.tableId(), this.comparison && this.comparison.details);
// We use a DynamicQuerySet as the underlying RowSource, with ColumnFilters applies on top of
// it. It filters based on section linking, re-querying as needed in case of onDemand tables.
this._queryRowSource = DynamicQuerySet.create(this, gristDoc.querySetManager, this.tableModel);
// When we have a summary table, filter out rows corresponding to empty groups.
// (TODO this may be better implemented by deleting empty groups in the data engine.)
if (this.viewSection.table().summarySourceTable()) {
const groupGetter = this.tableModel.tableData.getRowPropFunc('group');
this._mainRowSource = rowset.BaseFilteredRowSource.create(this,
rowId => !gristTypes.isEmptyList(groupGetter(rowId)));
this._mainRowSource.subscribeTo(this._queryRowSource);
} else {
this._mainRowSource = this._queryRowSource;
}
if (this.comparison) {
// Assign extra row ids for any rows added in the remote (right) table or removed in the
// local (left) table.
const extraRowIds = this.extraRows.getExtraRows();
this._mainRowSource = rowset.ExtendedRowSource.create(this, this._mainRowSource, extraRowIds);
}
// Create a section filter and a filtered row source that subscribes to its changes.
// `sectionFilter` also provides an `addTemporaryRow()` to allow views to display newly inserted rows,
// and `setFilterOverride()` to allow controlling a filter from a column menu.
this._sectionFilter = SectionFilter.create(this, this.viewSection.viewFields, this.tableModel.tableData);
this._filteredRowSource = rowset.FilteredRowSource.create(this, this._sectionFilter.sectionFilterFunc.get());
this._filteredRowSource.subscribeTo(this._mainRowSource);
this.autoDispose(this._sectionFilter.sectionFilterFunc.addListener(filterFunc => {
this._filteredRowSource.updateFilter(filterFunc);
}));
// Sorted collection of all rows to show in this view.
this.sortedRows = rowset.SortedRowSet.create(this, null, this.tableModel.tableData);
// Re-sort when sortSpec changes.
this.sortFunc = new SortFunc(new ClientColumnGetters(this.tableModel, {unversioned: true}));
this.autoDispose(this.viewSection.activeDisplaySortSpec.subscribeInit(function(spec) {
this.sortFunc.updateSpec(spec);
this.sortedRows.updateSort((rowId1, rowId2) => {
var value = nativeCompare(rowId1 === "new", rowId2 === "new");
return value || this.sortFunc.compare(rowId1, rowId2);
});
}, this));
// Here we are subscribed to the bulk of the data (main table, possibly filtered).
this.sortedRows.subscribeTo(this._filteredRowSource);
// We create a special one-row RowSource for the "Add new" row, in case we need it.
this.newRowSource = rowset.RowSource.create(this);
this.newRowSource.getAllRows = function() { return ['new']; };
// This is the LazyArrayModel containing DataRowModels, for rendering, e.g. with scrolly.
this.viewData = this.autoDispose(this.tableModel.createLazyRowsModel(this.sortedRows));
// Floating row model that is not destroyed when the row is scrolled out of view. It must be
// assigned manually to a rowId. Additionally, we override the saving of field values with a
// custom method that handles better positioning of cursor on adding a new row.
this.editRowModel = this.autoDispose(this.tableModel.createFloatingRowModel());
this.editRowModel._saveField =
(colName, value) => this._saveEditRowField(this.editRowModel, colName, value);
// Reset heights of rows when there is an action that affects them.
this.listenTo(this.viewData, 'rowModelNotify', rowModels => this.onRowResize(rowModels));
this.listenTo(this.viewSection.events, 'rowHeightChange', this.onResize );
// Create a command group for keyboard shortcuts common to all views.
this.autoDispose(commands.createGroup(BaseView.commonCommands, this, this.viewSection.hasFocus));
//--------------------------------------------------
// Prepare logic for linking with other sections.
// Linking state maintains .filterFunc and .cursorPos observables which we use for
// auto-scrolling and filtering.
this._linkingState = this.autoDispose(koUtil.computedBuilder(() => {
let v = this.viewSection;
let src = v.linkSrcSection();
const filterByAllShown = v.optionsObj.prop('filterByAllShown');
return src.getRowId() ?
LinkingState.create.bind(LinkingState, this.gristDoc,
src, v.linkSrcCol().colId(), v, v.linkTargetCol().colId(), filterByAllShown()) :
null;
}));
this._linkingFilter = this.autoDispose(ko.computed(() => {
const linking = this._linkingState();
return linking && linking.filterColValues ? linking.filterColValues() : {};
}));
// A computed for the rowId of the row selected by section linking.
this.linkedRowId = this.autoDispose(ko.computed(() => {
let linking = this._linkingState();
return linking && linking.cursorPos ? linking.cursorPos() : null;
}).extend({deferred: true}));
// Update the cursor whenever linkedRowId() changes.
this.autoDispose(this.linkedRowId.subscribe(rowId => this.setCursorPos({rowId})));
// Indicated whether editing the section should be disabled given the current linking state.
this.disableEditing = this.autoDispose(ko.computed(() => {
const linking = this._linkingState();
return linking && linking.disableEditing();
}));
this.enableAddRow = this.autoDispose(ko.computed(() => this.options.addNewRow &&
!this.viewSection.disableAddRemoveRows() && !this.disableEditing()));
// Hide the add row if editing is disabled via filter linking.
this.autoDispose(this.enableAddRow.subscribeInit(_enableAddRow => {
if (_enableAddRow) {
this.sortedRows.subscribeTo(this.newRowSource);
} else {
this.sortedRows.unsubscribeFrom(this.newRowSource);
}
}));
//--------------------------------------------------
// Observables local to this view
this._isLoading = ko.observable(true);
this._pendingCursorPos = this.viewSection.lastCursorPos;
// Initialize the cursor with the previous cursor position indicies, if they exist.
console.log("%s BaseView viewSection %s (%s) lastCursorPos %s", this._debugName, this.viewSection.getRowId(),
this.viewSection.table().tableId(), JSON.stringify(this.viewSection.lastCursorPos));
this.cursor = this.autoDispose(Cursor.create(null, this, this.viewSection.lastCursorPos));
this.currentColumn = this.autoDispose(ko.pureComputed(() =>
this.viewSection.viewFields().at(this.cursor.fieldIndex()).column()
).extend({rateLimit: 0})); // TODO Test this without the rateLimit
this.currentEditingColumnIndex = ko.observable(-1);
// A koArray of FieldBuilder objects, one for each view-section field.
this.fieldBuilders = this.autoDispose(
FieldBuilder.createAllFieldWidgets(this.gristDoc, this.viewSection.viewFields, this.cursor)
);
// An observable evaluating to the FieldBuilder for the field where the cursor is.
this.activeFieldBuilder = this.autoDispose(ko.pureComputed(() =>
this.fieldBuilders.at(this.cursor.fieldIndex())
));
// Observable for whether the data in this view is truncated, i.e. not all rows are included
// (this can only be true for on-demand tables).
this.isTruncated = ko.observable(false);
// This computed's purpose is the side-effect of calling makeQuery() initially and when any
// dependency changes.
this.autoDispose(ko.computed(() => {
this._isLoading(true);
this._queryRowSource.makeQuery(this._linkingFilter(), (err) => {
if (this.isDisposed()) { return; }
if (err) { window.gristNotify(`Query error: ${err.message}`); }
this.onTableLoaded();
});
}));
// Reset cursor to the first row when filtering changes.
this.autoDispose(this._linkingFilter.subscribe((x) => this.onLinkFilterChange()));
// When sorting changes, reset the cursor to the first row. (The alternative of moving the
// cursor to stay at the same record is sometimes better, but sometimes more annoying.)
this.autoDispose(this.viewSection.activeSortSpec.subscribe(() => this.setCursorPos({rowIndex: 0})));
this.copySelection = ko.observable(null);
// Whether parts needed for printing should be rendered now.
this._isPrinting = ko.observable(false);
}
Base.setBaseFor(BaseView);
_.extend(Base.prototype, BackboneEvents);
/**
* These commands are common to GridView and DetailView.
*/
BaseView.commonCommands = {
input: function(input) { this.activateEditorAtCursor(input); },
editField: function() { this.activateEditorAtCursor(); },
insertRecordBefore: function() { this.insertRow(this.cursor.rowIndex()); },
insertRecordAfter: function() { this.insertRow(this.cursor.rowIndex() + 1); },
insertCurrentDate: function() { this.insertCurrentDate(false); },
insertCurrentDateTime: function() { this.insertCurrentDate(true); },
copyLink: function() { this.copyLink().catch(reportError); },
};
/**
* Sets the cursor to the given position, deferring if necessary until the current query finishes
* loading.
*/
BaseView.prototype.setCursorPos = function(cursorPos) {
if (!this._isLoading.peek()) {
this.cursor.setCursorPos(cursorPos);
} else {
// This is the first step; the second happens in onTableLoaded.
this._pendingCursorPos = cursorPos;
this.cursor.setLive(false);
}
};
/**
* Returns a promise that's resolved when the query being loaded finishes loading.
* If no query is being loaded, it will resolve immediately.
*/
BaseView.prototype.getLoadingDonePromise = function() {
return waitObs(this._isLoading, (value) => !value);
};
/**
* Start editing the selected cell.
* @param {String} input: If given, initialize the editor with the given input (rather than the
* original content of the cell).
*/
BaseView.prototype.activateEditorAtCursor = function(input) {
var builder = this.activeFieldBuilder();
if (builder.isEditorActive()) {
return;
}
var rowId = this.viewData.getRowId(this.cursor.rowIndex());
// LazyArrayModel row model which is also used to build the cell dom. Needed since
// it may be used as a key to retrieve the cell dom, which is useful for editor placement.
var lazyRow = this.getRenderedRowModel(rowId);
if (builder.field.disableEditData() || this.gristDoc.isReadonly.get()) {
builder.flashCursorReadOnly(lazyRow);
} else {
if (!lazyRow) {
// TODO scroll into view. For now, just don't activate the editor.
return;
}
this.editRowModel.assign(rowId);
builder.buildEditorDom(this.editRowModel, lazyRow, { 'init': input });
}
};
// Copy an anchor link for the current row to the clipboard.
BaseView.prototype.copyLink = async function() {
const rowId = this.viewData.getRowId(this.cursor.rowIndex());
const colRef = this.viewSection.viewFields().peek()[this.cursor.fieldIndex()].colRef();
const sectionId = this.viewSection.getRowId();
try {
const link = urlState().makeUrl({ hash: { sectionId, rowId, colRef } });
await copyToClipboard(link);
setTestState({clipboard: link});
reportError(new UserError('Link copied to clipboard', {key: 'clipboard'}));
} catch (e) {
throw new Error('cannot copy to clipboard');
}
};
/**
* Insert a new row immediately before the row at the given index if given an Integer. Otherwise
* insert a new row at the end.
*/
BaseView.prototype.insertRow = function(index) {
if (this.viewSection.disableAddRemoveRows() || this.disableEditing()) {
return;
}
var rowId = this.viewData.getRowId(index);
var insertPos = Number.isInteger(rowId) ?
this.tableModel.tableData.getValue(rowId, 'manualSort') : null;
return this.sendTableAction(['AddRecord', null, { 'manualSort': insertPos }])
.then(rowId => {
if (!this.isDisposed()) {
this._sectionFilter.addTemporaryRow(rowId);
this.setCursorPos({rowId});
}
return rowId;
});
};
/**
* Given a 2-d paste column-oriented paste data and target cols, transform the data to omit
* fields that shouldn't be pasted over and extract rich paste data if available.
* @param {Array<Array<(RichPasteObject|string)>>} data - Column-oriented 2-d array of either
* plain strings or rich paste data returned by `tableUtil.parsePasteHtml` with `displayValue`
* and, optionally, `colType` and `rawValue` attributes.
* @param {Array<MetaRowModel>} cols - Array of target column objects
* @returns {Object} - Object mapping colId to array of column values, suitable for use in Bulk
* actions.
*/
BaseView.prototype._parsePasteForView = function(data, cols) {
let updateCols = cols.map(col => {
if (col && !col.isRealFormula() && !col.disableEditData()) {
return col;
} else {
return null; // Don't include formulas and missing columns
}
});
let updateColIds = updateCols.map(c => c && c.colId());
let updateColTypes = updateCols.map(c => c && c.type());
let richData = data;
if (data.length > 0 && data[0].length > 0 &&
_.isObject(data[0][0]) && data[0][0].hasOwnProperty('displayValue')) {
richData = data.map((col, idx) => {
if (col[0].colType === updateColTypes[idx]) {
return col.map(v => v.hasOwnProperty('rawValue') ? v.rawValue : v.displayValue);
} else {
return col.map(v => v.displayValue);
}
});
}
return _.omit(_.object(updateColIds, richData), null);
};
BaseView.prototype._getDefaultColValues = function() {
const filterValues = this._linkingFilter.peek();
return _.mapObject(_.pick(filterValues, v => (v.length > 0)), v => v[0]);
};
/**
* Enhances [Bulk]AddRecord actions to include the default values determined by the current
* section-linking filter.
*/
BaseView.prototype._enhanceAction = function(action) {
if (action[0] === 'AddRecord' || action[0] === 'BulkAddRecord') {
let colValues = this._getDefaultColValues();
let rowIds = action[1];
if (action[0] === 'BulkAddRecord') {
colValues = _.mapObject(colValues, v => rowIds.map(() => v));
}
Object.assign(colValues, action[2]);
return [action[0], rowIds, colValues];
} else {
return action;
}
};
/**
* Enhances a list of table actions and turns them from implicit-table actions into
* proper actions.
*/
BaseView.prototype.prepTableActions = function(actions) {
actions = actions.map(a => this._enhanceAction(a));
actions.forEach(action_ => {
action_.splice(1, 0, this.tableModel.tableData.tableId);
});
return actions;
};
/**
* Shortcut for `.tableModel.tableData.sendTableActions`, which also sets default values
* determined by the current section-linking filter, if any.
*/
BaseView.prototype.sendTableActions = function(actions, optDesc) {
return this.tableModel.sendTableActions(actions.map(a => this._enhanceAction(a)), optDesc);
};
/**
* Shortcut for `.tableModel.tableData.sendTableAction`, which also sets default values
* determined by the current section-linking filter, if any.
*/
BaseView.prototype.sendTableAction = function(action, optDesc) {
return action ? this.tableModel.sendTableAction(this._enhanceAction(action), optDesc) : null;
};
/**
* Inserts the current date/time into the selected cell if the cell is of a compatible type
* (Text/Date/DateTime/Any).
* @param {Boolean} withTime: Whether to include the time in addition to the date. This is ignored
* for Date columns (assumed false) and for DateTime (assumed true).
*/
BaseView.prototype.insertCurrentDate = function(withTime) {
let column = this.currentColumn();
if (column.isRealFormula()) {
// Ignore the shortcut when in a formula column.
return;
}
let type = column.pureType();
let value, now = Date.now();
const docTimezone = this.gristDoc.docInfo.timezone.peek();
if (type === 'Text' || type === 'Any') {
// Use document timezone. Don't forget to use uppercase HH for 24-hour time.
value = moment.tz(now, docTimezone).format('YYYY-MM-DD' + (withTime ? ' HH:mm:ss' : ''));
} else if (type === 'Date') {
// Get UTC midnight for the current date (as seen in docTimezone). This is a bit confusing. If
// it's "2019-11-14 23:30 -05:00", then it's "2019-11-15 04:30" in UTC. Since we measure time
// from Epoch UTC, we want the UTC time to have the correct date, so need to add the offset
// (-05:00) to get "2019-11-14 23:30" in UTC, and then round down to midnight.
const offsetMinutes = moment.tz(now, docTimezone).utcOffset();
value = roundDownToMultiple(now / 1000 + offsetMinutes * 60, 24*3600);
} else if (type === 'DateTime') {
value = now / 1000;
} else {
// Ignore the shortcut when in a column of an inappropriate type.
return;
}
var rowId = this.viewData.getRowId(this.cursor.rowIndex());
this.editRowModel.assign(rowId);
this.editRowModel[column.colId()].setAndSave(value);
};
/**
* Override the saving of field values to add some extra processing:
* - If a new row is saved, then we may need to adjust the row where the cursor is.
* - We add the edited or added row to ensure it's displayed regardless of current columnFilters.
* - We change the main view's row observables to see the new value immediately.
* TODO: When saving a formula in the addRow, the cursor moves down instead of staying in place.
* To fix that behavior, propose to factor out the `isAddRow` overrides from here
* into a `setNewRowColValues` on the editRowModel and have `FieldBuilder._saveEdit` call
* that instead of `updateColValues`.
*/
BaseView.prototype._saveEditRowField = function(editRowModel, colName, value) {
if (editRowModel._isAddRow.peek()) {
this.cursor.setLive(false);
const colValues = this._getDefaultColValues();
colValues[colName] = value;
return editRowModel.updateColValues(colValues)
// Once we know the new row's rowId, add it to column filters to make sure it's displayed.
.then(rowId => {
if (!this.isDisposed()) {
this._sectionFilter.addTemporaryRow(rowId);
this.setCursorPos({rowId});
}
return rowId;
})
.finally(() => !this.isDisposed() && this.cursor.setLive(true));
} else {
var rowId = editRowModel.getRowId();
// We are editing the floating "edit" rowModel, but to ensure that we see data in the main view
// (when the editor closes), we immediately update the main view's rowModel, if such exists.
var mainRowModel = this.getRenderedRowModel(rowId);
if (mainRowModel) {
mainRowModel[colName](value);
}
const ret = DataRowModel.prototype._saveField.call(editRowModel, colName, value)
// Display this rowId, even if it doesn't match the filter
.then((result) => {
if (!this.isDisposed()) {
this._sectionFilter.addTemporaryRow(rowId);
}
return result;
})
.finally(() => !this.isDisposed() && mainRowModel && mainRowModel._assignColumn(colName));
return this.viewSection.isSorted() ? ret : null;
// Do not return the saveField call in the case that the column is unsorted: in this case,
// we assumes optimistically that the action is successful and browser events can
// continue being processed immediately without waiting.
// When sorted, we wait on the saveField call so we may determine where the row ends
// up for cursor movement purposes.
}
};
/**
* Uses the current cursor selection to return a rich paste object with a reference to the data,
* and the selection ranges. See CopySelection.js
*
* @returns {pasteObj} - Paste object
*/
BaseView.prototype.copy = function(selection) {
this.copySelection(selection);
return {
data: this.tableModel.tableData,
selection: selection
};
};
/**
* Uses the current cursor selection to return a rich paste object with a reference to the data,
* the selection ranges and a callback that when called performs all of the actions needed for a cut.
*
* @returns {pasteObj} - Paste object
*/
BaseView.prototype.cut = function(selection) {
this.copySelection(selection);
return {
data: this.tableModel.tableData,
selection: selection,
cutCallback: () => tableUtil.makeDeleteAction(selection)
};
};
/**
* Helper to send paste actions from the cutCallback and a list of paste actions.
*/
BaseView.prototype.sendPasteActions = function(cutCallback, actions) {
let cutAction = null;
// If this is a cut -> paste, add the cut action and a description.
if (cutCallback) {
cutAction = cutCallback();
// If the cut occurs on an edit restricted cell, there may be no cut action.
if (cutAction) { actions.unshift(cutAction); }
}
return this.gristDoc.docData.sendActions(actions,
this._getPasteDesc(actions[actions.length - 1], cutAction));
};
/**
* Returns a string which describes a cut/copy action.
*/
BaseView.prototype._getPasteDesc = function(pasteAction, optCutAction) {
if (optCutAction) {
return `Moved ${getSelectionDesc(optCutAction, true)} to ` +
`${getSelectionDesc(pasteAction, true)}.`;
} else {
return `Pasted data to ${getSelectionDesc(pasteAction, true)}.`;
}
};
BaseView.prototype.buildDom = function() {
throw new Error("Not Implemented");
};
/**
* Called by ViewLayout to return view-specific controls to add into its ViewSection's title bar.
* By default builds nothing. Derived views may override.
*/
BaseView.prototype.buildTitleControls = function() {
return null;
};
/**
* Called when table data gets loaded (if already loaded, then called immediately after the
* constructor). Derived views may override.
*/
BaseView.prototype.onTableLoaded = function() {
// Complete the setting of a pending cursor position (see setCursorPos() for the first half).
if (this._pendingCursorPos) {
this.cursor.setCursorPos(this._pendingCursorPos);
this._pendingCursorPos = null;
}
this._isLoading(false);
this.isTruncated(this._queryRowSource.isTruncated);
this.cursor.setLive(true);
};
/**
* Called when view gets resized. Derived views may override.
*/
BaseView.prototype.onResize = function() {
};
/**
* Called when rows have changed and may potentially need resizing. Derived views may override.
* @param {Array<DataRowModel>} rowModels: Array of row models whose size may have changed.
*/
BaseView.prototype.onRowResize = function(rowModels) {
};
/**
* Called when user selects a different row which drives the link-filtering of this section.
*/
BaseView.prototype.onLinkFilterChange = function(rowId) {
this.setCursorPos({rowIndex: 0});
};
/**
* Called before and after printing this section.
*/
BaseView.prototype.prepareToPrint = function(onOff) {
this._isPrinting(onOff);
};
/**
* Called to obtain the rowModel for the given rowId. Returns a rowModel if it belongs to the
* section and is rendered, otherwise returns null.
* Useful to tie a rendered row to the row being edited. Derived views may override.
*/
BaseView.prototype.getRenderedRowModel = function(rowId) {
return this.viewData.getRowModel(rowId);
};
/**
* Returns the index of the last non-AddNew row in the grid.
*/
BaseView.prototype.getLastDataRowIndex = function() {
let last = this.viewData.peekLength - 1;
return (last >= 0 && this.viewData.getRowId(last) === 'new') ? last - 1 : last;
};
/**
* Creates and opens ColumnFilterMenu for a given field, and returns its PopupControl.
*/
BaseView.prototype.createFilterMenu = function(openCtl, field) {
return createFilterMenu(openCtl, this._sectionFilter, field, this._filteredRowSource, this.tableModel.tableData);
};
/**
* Whether the rows shown by this view are a proper subset of all rows in the table.
*/
BaseView.prototype.isFiltered = function() {
return this._filteredRowSource.getNumRows() < this.tableModel.tableData.numRecords();
};
module.exports = BaseView;