2020-10-02 15:10:00 +00:00
|
|
|
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');
|
2020-11-12 15:35:15 +00:00
|
|
|
var tableUtil = require('../lib/tableUtil');
|
2020-10-02 15:10:00 +00:00
|
|
|
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 BackboneEvents = require('backbone').Events;
|
2021-08-26 11:39:17 +00:00
|
|
|
const {LinkingState} = require('./LinkingState');
|
2020-10-02 15:10:00 +00:00
|
|
|
const {ClientColumnGetters} = require('app/client/models/ClientColumnGetters');
|
2021-10-01 19:38:58 +00:00
|
|
|
const {reportError, reportSuccess} = require('app/client/models/errors');
|
2020-10-02 15:10:00 +00:00
|
|
|
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');
|
2020-11-11 20:25:37 +00:00
|
|
|
const {ExtraRows} = require('app/client/models/DataTableModelWithDiff');
|
2020-10-02 15:10:00 +00:00
|
|
|
const {createFilterMenu} = require('app/client/ui/ColumnFilterMenu');
|
(core) When changing a table for a page widget, unset widget-linking to avoid invalid values.
Summary:
Previously, using "Change Widget" allowed one to change the underlying table,
but would keep the linking settings. This could allow invalid settings which
would sometimes lead to JS errors. These manifested in production as
"UserError: Query error: n is not a function".
- Unset linking settings in this case, to avoid invalid values.
- In case invalid values are encountered (e.g. saved previously), treat them as
unset, to avoid JS errors.
- If an error does occur, report it with a stack trace.
Also, for testing, added 'selectBy' option to gristUtils helpers for using page-widget-picker.
Test Plan: Added test cases for resetting linking, and for ignoring invalid link settings.
Reviewers: alexmojaki
Reviewed By: alexmojaki
Differential Revision: https://phab.getgrist.com/D2993
2021-08-24 03:28:07 +00:00
|
|
|
const {LinkConfig} = require('app/client/ui/selectBy');
|
2021-08-10 18:21:03 +00:00
|
|
|
const {encodeObject} = require("app/plugin/objtypes");
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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());
|
2020-11-11 20:25:37 +00:00
|
|
|
this.extraRows = new ExtraRows(this.schemaModel.tableId(), this.comparison && this.comparison.details);
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
// 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) {
|
2020-11-11 20:25:37 +00:00
|
|
|
// 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();
|
2020-10-02 15:10:00 +00:00
|
|
|
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.
|
2020-11-18 15:54:23 +00:00
|
|
|
this.sortedRows = rowset.SortedRowSet.create(this, null, this.tableModel.tableData);
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
// Re-sort when sortSpec changes.
|
2020-11-18 15:54:23 +00:00
|
|
|
this.sortFunc = new SortFunc(new ClientColumnGetters(this.tableModel, {unversioned: true}));
|
2020-10-02 15:10:00 +00:00
|
|
|
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();
|
(core) When changing a table for a page widget, unset widget-linking to avoid invalid values.
Summary:
Previously, using "Change Widget" allowed one to change the underlying table,
but would keep the linking settings. This could allow invalid settings which
would sometimes lead to JS errors. These manifested in production as
"UserError: Query error: n is not a function".
- Unset linking settings in this case, to avoid invalid values.
- In case invalid values are encountered (e.g. saved previously), treat them as
unset, to avoid JS errors.
- If an error does occur, report it with a stack trace.
Also, for testing, added 'selectBy' option to gristUtils helpers for using page-widget-picker.
Test Plan: Added test cases for resetting linking, and for ignoring invalid link settings.
Reviewers: alexmojaki
Reviewed By: alexmojaki
Differential Revision: https://phab.getgrist.com/D2993
2021-08-24 03:28:07 +00:00
|
|
|
if (!src.getRowId()) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
try {
|
|
|
|
const config = new LinkConfig(v);
|
2021-08-26 11:39:17 +00:00
|
|
|
return LinkingState.create.bind(LinkingState, null, this.gristDoc, config);
|
(core) When changing a table for a page widget, unset widget-linking to avoid invalid values.
Summary:
Previously, using "Change Widget" allowed one to change the underlying table,
but would keep the linking settings. This could allow invalid settings which
would sometimes lead to JS errors. These manifested in production as
"UserError: Query error: n is not a function".
- Unset linking settings in this case, to avoid invalid values.
- In case invalid values are encountered (e.g. saved previously), treat them as
unset, to avoid JS errors.
- If an error does occur, report it with a stack trace.
Also, for testing, added 'selectBy' option to gristUtils helpers for using page-widget-picker.
Test Plan: Added test cases for resetting linking, and for ignoring invalid link settings.
Reviewers: alexmojaki
Reviewed By: alexmojaki
Differential Revision: https://phab.getgrist.com/D2993
2021-08-24 03:28:07 +00:00
|
|
|
} catch (err) {
|
|
|
|
console.warn(`Can't create LinkingState: ${err.message}`);
|
|
|
|
return null;
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
}));
|
|
|
|
|
|
|
|
this._linkingFilter = this.autoDispose(ko.computed(() => {
|
|
|
|
const linking = this._linkingState();
|
2021-08-10 18:21:03 +00:00
|
|
|
const result = linking && linking.filterColValues ? linking.filterColValues() : {filters: {}};
|
|
|
|
result.operations = result.operations || {};
|
|
|
|
for (const key in result.filters) {
|
|
|
|
result.operations[key] = result.operations[key] || 'in';
|
|
|
|
}
|
|
|
|
return result;
|
2020-10-02 15:10:00 +00:00
|
|
|
}));
|
|
|
|
|
|
|
|
// 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);
|
2021-08-10 18:21:03 +00:00
|
|
|
const linkingFilter = this._linkingFilter();
|
|
|
|
this._queryRowSource.makeQuery(linkingFilter.filters, linkingFilter.operations, (err) => {
|
2020-10-02 15:10:00 +00:00
|
|
|
if (this.isDisposed()) { return; }
|
(core) When changing a table for a page widget, unset widget-linking to avoid invalid values.
Summary:
Previously, using "Change Widget" allowed one to change the underlying table,
but would keep the linking settings. This could allow invalid settings which
would sometimes lead to JS errors. These manifested in production as
"UserError: Query error: n is not a function".
- Unset linking settings in this case, to avoid invalid values.
- In case invalid values are encountered (e.g. saved previously), treat them as
unset, to avoid JS errors.
- If an error does occur, report it with a stack trace.
Also, for testing, added 'selectBy' option to gristUtils helpers for using page-widget-picker.
Test Plan: Added test cases for resetting linking, and for ignoring invalid link settings.
Reviewers: alexmojaki
Reviewed By: alexmojaki
Differential Revision: https://phab.getgrist.com/D2993
2021-08-24 03:28:07 +00:00
|
|
|
if (err) { reportError(err); }
|
2020-10-02 15:10:00 +00:00
|
|
|
this.onTableLoaded();
|
|
|
|
});
|
|
|
|
}));
|
|
|
|
|
|
|
|
// Reset cursor to the first row when filtering changes.
|
2020-12-15 23:52:21 +00:00
|
|
|
this.autoDispose(this._linkingFilter.subscribe((x) => this.onLinkFilterChange()));
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
// 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);
|
2020-10-09 21:39:13 +00:00
|
|
|
|
|
|
|
// Whether parts needed for printing should be rendered now.
|
|
|
|
this._isPrinting = ko.observable(false);
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
Base.setBaseFor(BaseView);
|
|
|
|
_.extend(Base.prototype, BackboneEvents);
|
|
|
|
|
|
|
|
/**
|
|
|
|
* These commands are common to GridView and DetailView.
|
|
|
|
*/
|
|
|
|
BaseView.commonCommands = {
|
2021-05-17 14:05:49 +00:00
|
|
|
input: function(input) { this.activateEditorAtCursor({init: input}); },
|
2020-10-02 15:10:00 +00:00
|
|
|
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).
|
|
|
|
*/
|
2021-05-17 14:05:49 +00:00
|
|
|
BaseView.prototype.activateEditorAtCursor = function(options) {
|
2020-10-02 15:10:00 +00:00
|
|
|
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);
|
2021-06-17 16:41:07 +00:00
|
|
|
if (!lazyRow) {
|
|
|
|
// TODO scroll into view. For now, just don't activate the editor.
|
|
|
|
return;
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
2021-06-17 16:41:07 +00:00
|
|
|
this.editRowModel.assign(rowId);
|
|
|
|
builder.buildEditorDom(this.editRowModel, lazyRow, options || {});
|
2020-10-02 15:10:00 +00:00
|
|
|
};
|
|
|
|
|
2021-03-17 03:45:44 +00:00
|
|
|
/**
|
|
|
|
* Move the floating RowModel for editing to the current cursor position, and return it.
|
|
|
|
*
|
|
|
|
* This is used for opening the formula editor in the side panel; the current row is used to get
|
|
|
|
* possible exception info from the formula.
|
|
|
|
*/
|
|
|
|
BaseView.prototype.moveEditRowToCursor = function() {
|
|
|
|
var rowId = this.viewData.getRowId(this.cursor.rowIndex());
|
|
|
|
this.editRowModel.assign(rowId);
|
|
|
|
return this.editRowModel;
|
|
|
|
};
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
// 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});
|
2021-10-01 19:38:58 +00:00
|
|
|
reportSuccess('Link copied to clipboard', {key: 'clipboard'});
|
2020-10-02 15:10:00 +00:00
|
|
|
} 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.
|
|
|
|
*/
|
2021-10-21 18:50:49 +00:00
|
|
|
BaseView.prototype._parsePasteForView = function(data, fields) {
|
|
|
|
const updateCols = fields.map(field => {
|
|
|
|
const col = field && field.column();
|
2020-10-02 15:10:00 +00:00
|
|
|
if (col && !col.isRealFormula() && !col.disableEditData()) {
|
|
|
|
return col;
|
|
|
|
} else {
|
|
|
|
return null; // Don't include formulas and missing columns
|
|
|
|
}
|
|
|
|
});
|
2021-10-21 18:50:49 +00:00
|
|
|
const updateColIds = updateCols.map(c => c && c.colId());
|
|
|
|
const updateColTypes = updateCols.map(c => c && c.type());
|
|
|
|
const parsers = fields.map(field => field && field.valueParser() || (x => x));
|
|
|
|
|
|
|
|
const richData = data.map((col, idx) => {
|
|
|
|
if (!col.length) {
|
|
|
|
return col;
|
|
|
|
}
|
2021-11-03 23:54:46 +00:00
|
|
|
const typeMatches = col[0] && col[0].colType === updateColTypes[idx];
|
2021-10-21 18:50:49 +00:00
|
|
|
const parser = parsers[idx];
|
|
|
|
return col.map(v => {
|
|
|
|
if (v) {
|
|
|
|
if (typeMatches && v.hasOwnProperty('rawValue')) {
|
|
|
|
return v.rawValue;
|
|
|
|
}
|
|
|
|
if (v.hasOwnProperty('displayValue')) {
|
|
|
|
return parser(v.displayValue);
|
|
|
|
}
|
|
|
|
if (typeof v === "string") {
|
|
|
|
return parser(v);
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
2021-10-21 18:50:49 +00:00
|
|
|
return v;
|
2020-10-02 15:10:00 +00:00
|
|
|
});
|
2021-10-21 18:50:49 +00:00
|
|
|
});
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
return _.omit(_.object(updateColIds, richData), null);
|
|
|
|
};
|
|
|
|
|
|
|
|
BaseView.prototype._getDefaultColValues = function() {
|
2021-08-10 18:21:03 +00:00
|
|
|
const {filters, operations} = this._linkingFilter.peek();
|
|
|
|
return _.mapObject(
|
(core) Add other direction of linking by reflist
Summary:
Allows selecting by a reflist in another table. This generalises cursor-linking with a ref column, but now it's filter linking.
Added another case to LinkingState where the source column is a reflist to the target table, filtering by the id column.
Updated convertQueryFromRefs and related functions to handle this since the id column has no column ref. In this case the string 'id' is used instead of a number.
LinkingState also checks if the source value is a reflist and uses that as the list of filter values instead of a single-element list of the cell value.
Indirect linking also works, where the source and target columns both are both references to the same table. This was the plan for a source reflist and target ref column.
I was surprised to see it also works perfectly when both columns are reflists, and it filters rows where there's an intersection!
Adding rows to the target section using the selected source record for default values is iffy. When filtering by row ID, there's no column for defaults, so the new row disappears.
For a source reflist and target ref, the first value of the reflist is the default, which is okayish. When both are reflists, the full source reflist is the default for the target column.
This seems like a bit much but just using the first value seems a bit arbitrary when there's room for all of them?
While doing all this I noticed an unrelated bug which I fixed as I was refactoring. Previously cursor linking based on a reference column did not update the cursor in the link target
when the value of the selected reference cell changed. Now cursor linking uses a floating row model like most other cases to observe the value correctly.
Test Plan: Extended SelectByRefList test and fixture, added previously failing test to RightPanelSelectBy.
Reviewers: dsagal
Reviewed By: dsagal
Differential Revision: https://phab.getgrist.com/D3004
2021-08-30 13:29:39 +00:00
|
|
|
_.pick(filters, (value, key) => value.length > 0 && key !== "id"),
|
2021-08-10 18:21:03 +00:00
|
|
|
(value, key) => operations[key] === "intersects" ? encodeObject(value) : value[0]
|
|
|
|
);
|
2020-10-02 15:10:00 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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) {
|
|
|
|
};
|
|
|
|
|
2020-12-15 23:52:21 +00:00
|
|
|
/**
|
|
|
|
* Called when user selects a different row which drives the link-filtering of this section.
|
|
|
|
*/
|
|
|
|
BaseView.prototype.onLinkFilterChange = function(rowId) {
|
|
|
|
this.setCursorPos({rowIndex: 0});
|
|
|
|
};
|
|
|
|
|
2020-10-09 21:39:13 +00:00
|
|
|
/**
|
|
|
|
* Called before and after printing this section.
|
|
|
|
*/
|
|
|
|
BaseView.prototype.prepareToPrint = function(onOff) {
|
|
|
|
this._isPrinting(onOff);
|
|
|
|
};
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*/
|
2021-05-06 12:23:50 +00:00
|
|
|
BaseView.prototype.createFilterMenu = function(openCtl, field, onClose) {
|
2021-06-17 15:26:43 +00:00
|
|
|
return createFilterMenu(openCtl, this._sectionFilter, field, this._mainRowSource, this.tableModel.tableData, onClose);
|
2020-10-02 15:10:00 +00:00
|
|
|
};
|
|
|
|
|
2021-03-05 15:17:07 +00:00
|
|
|
/**
|
|
|
|
* 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();
|
|
|
|
};
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
module.exports = BaseView;
|