mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Allow adding rows to widgets filtered by a link using a formula column
Summary: When a widget `A` is selected by a widget `B` so that `A` is filtered, adding a new row to `A` uses the values in the selected row of `B` and the columns relevant to the linking as default values for the new row. This ensures that the new row matches the current linking filter and remains visible. However this would previously cause a sandbox error when one of the linking columns was a formula column, which doesn't allow setting values. This diff ignores formula columns when picking default values. Since the value of the formula column in the new row typically won't match the linking filter, extra measures are needed to avoid the new row immediately disappearing. Regular filters already have a mechanism for this, but I didn't manage to extend it to also work for linking. Thanks @dsagal for creating `UnionRowSource` (originally in D4017) which is now used as the solution for temporarily exempting rows from both kinds of filtering. While testing, I also came across another bug in linking summary tables that caused incorrect filtering, which I fixed with some changes to `DynamicQuerySet`. Test Plan: Extended an nbrowser test, which both tests for the main change as well as the secondary bugfix. Reviewers: georgegevoian Reviewed By: georgegevoian Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D4135
This commit is contained in:
parent
2f0dbb7d25
commit
bd52665f96
@ -13,6 +13,7 @@ const {DynamicQuerySet} = require('../models/QuerySet');
|
|||||||
const {SortFunc} = require('app/common/SortFunc');
|
const {SortFunc} = require('app/common/SortFunc');
|
||||||
const rowset = require('../models/rowset');
|
const rowset = require('../models/rowset');
|
||||||
const Base = require('./Base');
|
const Base = require('./Base');
|
||||||
|
const {getDefaultColValues} = require("./BaseView2");
|
||||||
const {Cursor} = require('./Cursor');
|
const {Cursor} = require('./Cursor');
|
||||||
const FieldBuilder = require('../widgets/FieldBuilder');
|
const FieldBuilder = require('../widgets/FieldBuilder');
|
||||||
const commands = require('./commands');
|
const commands = require('./commands');
|
||||||
@ -21,6 +22,7 @@ const {ClientColumnGetters} = require('app/client/models/ClientColumnGetters');
|
|||||||
const {reportError, reportSuccess} = require('app/client/models/errors');
|
const {reportError, reportSuccess} = require('app/client/models/errors');
|
||||||
const {urlState} = require('app/client/models/gristUrlState');
|
const {urlState} = require('app/client/models/gristUrlState');
|
||||||
const {SectionFilter} = require('app/client/models/SectionFilter');
|
const {SectionFilter} = require('app/client/models/SectionFilter');
|
||||||
|
const {UnionRowSource} = require('app/client/models/UnionRowSource');
|
||||||
const {copyToClipboard} = require('app/client/lib/clipboardUtils');
|
const {copyToClipboard} = require('app/client/lib/clipboardUtils');
|
||||||
const {setTestState} = require('app/client/lib/testState');
|
const {setTestState} = require('app/client/lib/testState');
|
||||||
const {ExtraRows} = require('app/client/models/DataTableModelWithDiff');
|
const {ExtraRows} = require('app/client/models/DataTableModelWithDiff');
|
||||||
@ -70,17 +72,24 @@ function BaseView(gristDoc, viewSectionModel, options) {
|
|||||||
this._mainRowSource = rowset.ExtendedRowSource.create(this, this._mainRowSource, extraRowIds);
|
this._mainRowSource = rowset.ExtendedRowSource.create(this, this._mainRowSource, extraRowIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rows that should temporarily be visible even if they don't match filters.
|
||||||
|
// This is so that a newly added row doesn't immediately disappear, which would be confusing.
|
||||||
|
this._exemptFromFilterRows = rowset.ExemptFromFilterRowSource.create(this);
|
||||||
|
this._exemptFromFilterRows.subscribeTo(this.tableModel);
|
||||||
|
|
||||||
// Create a section filter and a filtered row source that subscribes to its changes.
|
// 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,
|
// `sectionFilter` also provides `setFilterOverride()` to allow controlling a filter from a column menu.
|
||||||
// and `setFilterOverride()` to allow controlling a filter from a column menu.
|
// Whenever changes are made to filters, exempt rows are reset.
|
||||||
this._sectionFilter = SectionFilter.create(this, this.viewSection, this.tableModel.tableData);
|
this._sectionFilter = SectionFilter.create(
|
||||||
|
this, this.viewSection, this.tableModel.tableData, () => this._exemptFromFilterRows.reset()
|
||||||
|
);
|
||||||
this._filteredRowSource = rowset.FilteredRowSource.create(this, this._sectionFilter.sectionFilterFunc.get());
|
this._filteredRowSource = rowset.FilteredRowSource.create(this, this._sectionFilter.sectionFilterFunc.get());
|
||||||
this._filteredRowSource.subscribeTo(this._mainRowSource);
|
this._filteredRowSource.subscribeTo(this._mainRowSource);
|
||||||
this.autoDispose(this._sectionFilter.sectionFilterFunc.addListener(filterFunc => {
|
this.autoDispose(this._sectionFilter.sectionFilterFunc.addListener(filterFunc => {
|
||||||
this._filteredRowSource.updateFilter(filterFunc);
|
this._filteredRowSource.updateFilter(filterFunc);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.rowSource = this._filteredRowSource;
|
this.rowSource = UnionRowSource.create(this, [this._filteredRowSource, this._exemptFromFilterRows]);
|
||||||
|
|
||||||
// Sorted collection of all rows to show in this view.
|
// Sorted collection of all rows to show in this view.
|
||||||
this.sortedRows = rowset.SortedRowSet.create(this, null, this.tableModel.tableData);
|
this.sortedRows = rowset.SortedRowSet.create(this, null, this.tableModel.tableData);
|
||||||
@ -96,7 +105,7 @@ function BaseView(gristDoc, viewSectionModel, options) {
|
|||||||
}, this));
|
}, this));
|
||||||
|
|
||||||
// Here we are subscribed to the bulk of the data (main table, possibly filtered).
|
// Here we are subscribed to the bulk of the data (main table, possibly filtered).
|
||||||
this.sortedRows.subscribeTo(this._filteredRowSource);
|
this.sortedRows.subscribeTo(this.rowSource);
|
||||||
|
|
||||||
// We create a special one-row RowSource for the "Add new" row, in case we need it.
|
// 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 = rowset.RowSource.create(this);
|
||||||
@ -201,6 +210,7 @@ function BaseView(gristDoc, viewSectionModel, options) {
|
|||||||
this._queryRowSource.makeQuery(linkingFilter.filters, linkingFilter.operations, (err) => {
|
this._queryRowSource.makeQuery(linkingFilter.filters, linkingFilter.operations, (err) => {
|
||||||
if (this.isDisposed()) { return; }
|
if (this.isDisposed()) { return; }
|
||||||
if (err) { reportError(err); }
|
if (err) { reportError(err); }
|
||||||
|
this._exemptFromFilterRows.reset();
|
||||||
this.onTableLoaded();
|
this.onTableLoaded();
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
@ -442,7 +452,7 @@ BaseView.prototype.insertRow = function(index) {
|
|||||||
return this.sendTableAction(['AddRecord', null, { 'manualSort': insertPos }])
|
return this.sendTableAction(['AddRecord', null, { 'manualSort': insertPos }])
|
||||||
.then(rowId => {
|
.then(rowId => {
|
||||||
if (!this.isDisposed()) {
|
if (!this.isDisposed()) {
|
||||||
this._sectionFilter.addTemporaryRow(rowId);
|
this._exemptFromFilterRows.addExemptRow(rowId);
|
||||||
this.setCursorPos({rowId});
|
this.setCursorPos({rowId});
|
||||||
}
|
}
|
||||||
return rowId;
|
return rowId;
|
||||||
@ -450,11 +460,7 @@ BaseView.prototype.insertRow = function(index) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
BaseView.prototype._getDefaultColValues = function() {
|
BaseView.prototype._getDefaultColValues = function() {
|
||||||
const linkingState = this.viewSection.linkingState.peek();
|
return getDefaultColValues(this.viewSection);
|
||||||
if (!linkingState) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return linkingState.getDefaultColValues();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -562,7 +568,7 @@ BaseView.prototype._saveEditRowField = function(editRowModel, colName, value) {
|
|||||||
// Once we know the new row's rowId, add it to column filters to make sure it's displayed.
|
// Once we know the new row's rowId, add it to column filters to make sure it's displayed.
|
||||||
.then(rowId => {
|
.then(rowId => {
|
||||||
if (!this.isDisposed()) {
|
if (!this.isDisposed()) {
|
||||||
this._sectionFilter.addTemporaryRow(rowId);
|
this._exemptFromFilterRows.addExemptRow(rowId);
|
||||||
this.setCursorPos({rowId});
|
this.setCursorPos({rowId});
|
||||||
}
|
}
|
||||||
return rowId;
|
return rowId;
|
||||||
@ -581,7 +587,7 @@ BaseView.prototype._saveEditRowField = function(editRowModel, colName, value) {
|
|||||||
// unless the filter is on a Bool column
|
// unless the filter is on a Bool column
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (!this.isDisposed() && this.currentColumn().pureType() !== 'Bool') {
|
if (!this.isDisposed() && this.currentColumn().pureType() !== 'Bool') {
|
||||||
this._sectionFilter.addTemporaryRow(rowId);
|
this._exemptFromFilterRows.addExemptRow(rowId);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
})
|
})
|
||||||
|
@ -9,7 +9,9 @@ import {UserAction} from 'app/common/DocActions';
|
|||||||
import {isFullReferencingType} from 'app/common/gristTypes';
|
import {isFullReferencingType} from 'app/common/gristTypes';
|
||||||
import {SchemaTypes} from 'app/common/schema';
|
import {SchemaTypes} from 'app/common/schema';
|
||||||
import {BulkColValues} from 'app/plugin/GristData';
|
import {BulkColValues} from 'app/plugin/GristData';
|
||||||
|
import {ViewSectionRec} from "app/client/models/entities/ViewSectionRec";
|
||||||
import omit = require('lodash/omit');
|
import omit = require('lodash/omit');
|
||||||
|
import pick = require('lodash/pick');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a 2-d paste column-oriented paste data and target cols, transform the data to omit
|
* Given a 2-d paste column-oriented paste data and target cols, transform the data to omit
|
||||||
@ -87,3 +89,18 @@ export async function parsePasteForView(
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default values for a new record so that it continues to satisfy the current linking filters.
|
||||||
|
* Exclude formula columns since we can't set their values.
|
||||||
|
*/
|
||||||
|
export function getDefaultColValues(viewSection: ViewSectionRec): Record<string, any> {
|
||||||
|
const linkingState = viewSection.linkingState.peek();
|
||||||
|
if (!linkingState) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const dataColIds = viewSection.columns.peek()
|
||||||
|
.filter(col => !col.isRealFormula.peek())
|
||||||
|
.map(col => col.colId.peek());
|
||||||
|
return pick(linkingState.getDefaultColValues(), dataColIds);
|
||||||
|
}
|
||||||
|
@ -117,6 +117,22 @@ export class QuerySetManager extends Disposable {
|
|||||||
* RowSource.
|
* RowSource.
|
||||||
*/
|
*/
|
||||||
export class DynamicQuerySet extends RowSource {
|
export class DynamicQuerySet extends RowSource {
|
||||||
|
/**
|
||||||
|
* Replace the query represented by this DynamicQuerySet. If multiple makeQuery() calls are made
|
||||||
|
* quickly (while waiting for the server), cb() may only be called for the latest one.
|
||||||
|
*
|
||||||
|
* If there is an error fetching data, cb(err) will be called with that error. The second
|
||||||
|
* argument to cb() is true if any data was changed, and false if not. Note that for a series of
|
||||||
|
* makeQuery() calls, cb() is always called at least once, and always asynchronously.
|
||||||
|
*
|
||||||
|
* It's possible for this to be called very quickly in succession,
|
||||||
|
* e.g. when selecting another row of a linked summary table grouped by multiple columns,
|
||||||
|
* as an observable gets triggered for each one.
|
||||||
|
* We only want to keep the last call, and _updateQuerySetDebounced may not be called
|
||||||
|
* in the correct order, so we first debounce here.
|
||||||
|
*/
|
||||||
|
public readonly makeQuery = debounce(tbind(this._makeQuery, this), 0);
|
||||||
|
|
||||||
// Holds a reference to the currently active QuerySet.
|
// Holds a reference to the currently active QuerySet.
|
||||||
private _holder = Holder.create<IRefCountSub<QuerySet>>(this);
|
private _holder = Holder.create<IRefCountSub<QuerySet>>(this);
|
||||||
|
|
||||||
@ -147,17 +163,13 @@ export class DynamicQuerySet extends RowSource {
|
|||||||
return this._querySet ? this._querySet.isTruncated : false;
|
return this._querySet ? this._querySet.isTruncated : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private _makeQuery(filters: { [colId: string]: any[] },
|
||||||
* Replace the query represented by this DynamicQuerySet. If multiple makeQuery() calls are made
|
operations: {[colId: string]: QueryOperation},
|
||||||
* quickly (while waiting for the server), cb() may only be called for the latest one.
|
cb: (err: Error|null, changed: boolean) => void): void {
|
||||||
*
|
if (this.isDisposed()) {
|
||||||
* If there is an error fetching data, cb(err) will be called with that error. The second
|
cb(new Error('Disposed'), false);
|
||||||
* argument to cb() is true if any data was changed, and false if not. Note that for a series of
|
return;
|
||||||
* makeQuery() calls, cb() is always called at least once, and always asynchronously.
|
}
|
||||||
*/
|
|
||||||
public makeQuery(filters: {[colId: string]: any[]},
|
|
||||||
operations: {[colId: string]: QueryOperation},
|
|
||||||
cb: (err: Error|null, changed: boolean) => void): void {
|
|
||||||
const query: ClientQuery = {tableId: this._tableModel.tableData.tableId, filters, operations};
|
const query: ClientQuery = {tableId: this._tableModel.tableData.tableId, filters, operations};
|
||||||
const newQuerySet = this._querySetManager.useQuerySet(this._holder, query);
|
const newQuerySet = this._querySetManager.useQuerySet(this._holder, query);
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ import {TableData} from 'app/client/models/TableData';
|
|||||||
import {buildColFilter, ColumnFilterFunc} from 'app/common/ColumnFilterFunc';
|
import {buildColFilter, ColumnFilterFunc} from 'app/common/ColumnFilterFunc';
|
||||||
import {buildRowFilter, RowFilterFunc, RowValueFunc } from 'app/common/RowFilterFunc';
|
import {buildRowFilter, RowFilterFunc, RowValueFunc } from 'app/common/RowFilterFunc';
|
||||||
import {UIRowId} from 'app/plugin/GristAPI';
|
import {UIRowId} from 'app/plugin/GristAPI';
|
||||||
import {Computed, Disposable, MutableObsArray, obsArray, Observable, UseCB} from 'grainjs';
|
import {Computed, Disposable, Observable, UseCB} from 'grainjs';
|
||||||
|
|
||||||
export type {ColumnFilterFunc};
|
export type {ColumnFilterFunc};
|
||||||
|
|
||||||
@ -21,20 +21,21 @@ type ColFilterCB = (fieldOrColumn: ViewFieldRec|ColumnRec, colFilter: ColumnFilt
|
|||||||
* subscribe to in order to update their FilteredRowSource.
|
* subscribe to in order to update their FilteredRowSource.
|
||||||
*
|
*
|
||||||
* Additionally, `setFilterOverride()` provides a way to override the current filter for a given colRef,
|
* Additionally, `setFilterOverride()` provides a way to override the current filter for a given colRef,
|
||||||
* to reflect the changes in an open filter dialog. Also, `addTemporaryRow()` allows to add a rowId
|
* to reflect the changes in an open filter dialog.
|
||||||
* that should be present regardless of filters. These rows are removed automatically when an update to the filter
|
|
||||||
* results in their being displayed (obviating the need to maintain their rowId explicitly).
|
|
||||||
*/
|
*/
|
||||||
export class SectionFilter extends Disposable {
|
export class SectionFilter extends Disposable {
|
||||||
public readonly sectionFilterFunc: Observable<RowFilterFunc<UIRowId>>;
|
public readonly sectionFilterFunc: Observable<RowFilterFunc<UIRowId>>;
|
||||||
|
|
||||||
private _openFilterOverride: Observable<OpenColumnFilter|null> = Observable.create(this, null);
|
private _openFilterOverride: Observable<OpenColumnFilter|null> = Observable.create(this, null);
|
||||||
private _tempRows: MutableObsArray<UIRowId> = obsArray();
|
|
||||||
|
|
||||||
constructor(public viewSection: ViewSectionRec, private _tableData: TableData) {
|
constructor(
|
||||||
|
public viewSection: ViewSectionRec,
|
||||||
|
private _tableData: TableData,
|
||||||
|
private _resetExemptRows: () => void,
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
const columnFilterFunc = Computed.create(this, this._openFilterOverride, (use, openFilter) => {
|
this.sectionFilterFunc = Computed.create(this, this._openFilterOverride, (use, openFilter) => {
|
||||||
const openFilterFilterFunc = openFilter && use(openFilter.colFilter.filterFunc);
|
const openFilterFilterFunc = openFilter && use(openFilter.colFilter.filterFunc);
|
||||||
function getFilterFunc(fieldOrColumn: ViewFieldRec|ColumnRec, colFilter: ColumnFilterFunc|null) {
|
function getFilterFunc(fieldOrColumn: ViewFieldRec|ColumnRec, colFilter: ColumnFilterFunc|null) {
|
||||||
if (openFilter?.colRef === fieldOrColumn.origCol().getRowId()) {
|
if (openFilter?.colRef === fieldOrColumn.origCol().getRowId()) {
|
||||||
@ -42,16 +43,8 @@ export class SectionFilter extends Disposable {
|
|||||||
}
|
}
|
||||||
return colFilter;
|
return colFilter;
|
||||||
}
|
}
|
||||||
return this._buildPlainFilterFunc(getFilterFunc, use);
|
return this.buildFilterFunc(getFilterFunc, use);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sectionFilterFunc = Computed.create(this, columnFilterFunc, this._tempRows,
|
|
||||||
(_use, filterFunc, tempRows) => this._addRowsToFilter(filterFunc, tempRows));
|
|
||||||
|
|
||||||
// Prune temporary rowIds that are no longer being filtered out.
|
|
||||||
this.autoDispose(columnFilterFunc.addListener(f => {
|
|
||||||
this._tempRows.set(this._tempRows.get().filter(rowId => !f(rowId)));
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -68,37 +61,14 @@ export class SectionFilter extends Disposable {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public addTemporaryRow(rowId: number) {
|
|
||||||
// Only add the rowId if it would otherwise be filtered out
|
|
||||||
if (!this.sectionFilterFunc.get()(rowId)) {
|
|
||||||
this._tempRows.push(rowId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public resetTemporaryRows() {
|
|
||||||
this._tempRows.set([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a filter function that combines the filter function of all the columns. You can use
|
* Builds a filter function that combines the filter function of all the columns. You can use
|
||||||
* `getFilterFunc(column, colFilter)` to customize the filter func for each columns. It calls
|
* `getFilterFunc(column, colFilter)` to customize the filter func for each column. It calls
|
||||||
* `getFilterFunc` right away. Also, all the rows that were added with `addTemporaryRow()` bypass
|
* `getFilterFunc` right away.
|
||||||
* the filter.
|
* This also immediately resets rows that were temporarily exempted from filtering.
|
||||||
*/
|
*/
|
||||||
public buildFilterFunc(getFilterFunc: ColFilterCB, use: UseCB) {
|
public buildFilterFunc(getFilterFunc: ColFilterCB, use: UseCB) {
|
||||||
return this._addRowsToFilter(this._buildPlainFilterFunc(getFilterFunc, use), this._tempRows.get());
|
this._resetExemptRows();
|
||||||
}
|
|
||||||
|
|
||||||
private _addRowsToFilter(filterFunc: RowFilterFunc<UIRowId>, rows: UIRowId[]) {
|
|
||||||
return (rowId: UIRowId) => rows.includes(rowId) || (typeof rowId !== 'number') || filterFunc(rowId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal that helps build a filter function that combines the filter function of all
|
|
||||||
* columns. You can use `getFilterFunc(column, colFilter)` to customize the filter func for each
|
|
||||||
* column. It calls `getFilterFunc` right away.
|
|
||||||
*/
|
|
||||||
private _buildPlainFilterFunc(getFilterFunc: ColFilterCB, use: UseCB): RowFilterFunc<UIRowId> {
|
|
||||||
const filters = use(this.viewSection.filters);
|
const filters = use(this.viewSection.filters);
|
||||||
const funcs: Array<RowFilterFunc<UIRowId> | null> = filters.map(({filter, fieldOrColumn}) => {
|
const funcs: Array<RowFilterFunc<UIRowId> | null> = filters.map(({filter, fieldOrColumn}) => {
|
||||||
const colFilter = buildColFilter(use(filter), use(use(fieldOrColumn.origCol).type));
|
const colFilter = buildColFilter(use(filter), use(use(fieldOrColumn.origCol).type));
|
||||||
@ -111,6 +81,6 @@ export class SectionFilter extends Disposable {
|
|||||||
return buildRowFilter(getter as RowValueFunc<UIRowId>, filterFunc);
|
return buildRowFilter(getter as RowValueFunc<UIRowId>, filterFunc);
|
||||||
}).filter(f => f !== null); // Filter out columns that don't have a filter
|
}).filter(f => f !== null); // Filter out columns that don't have a filter
|
||||||
|
|
||||||
return (rowId: UIRowId) => funcs.every(f => Boolean(f && f(rowId)));
|
return (rowId: UIRowId) => rowId === 'new' || funcs.every(f => Boolean(f && f(rowId)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
59
app/client/models/UnionRowSource.ts
Normal file
59
app/client/models/UnionRowSource.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import {RowList, RowListener, RowSource} from 'app/client/models/rowset';
|
||||||
|
import {UIRowId} from "app/plugin/GristAPI";
|
||||||
|
|
||||||
|
export class UnionRowSource extends RowListener implements RowSource {
|
||||||
|
protected _allRows = new Map<UIRowId, Set<RowSource>>();
|
||||||
|
|
||||||
|
constructor(parentRowSources: RowSource[]) {
|
||||||
|
super();
|
||||||
|
for (const p of parentRowSources) {
|
||||||
|
this.subscribeTo(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAllRows(): RowList {
|
||||||
|
return this._allRows.keys();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getNumRows(): number {
|
||||||
|
return this._allRows.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onAddRows(rows: RowList, rowSource: RowSource) {
|
||||||
|
const outputRows = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
let sources = this._allRows.get(r);
|
||||||
|
if (!sources) {
|
||||||
|
sources = new Set();
|
||||||
|
this._allRows.set(r, sources);
|
||||||
|
outputRows.push(r);
|
||||||
|
}
|
||||||
|
sources.add(rowSource);
|
||||||
|
}
|
||||||
|
if (outputRows.length > 0) {
|
||||||
|
this.trigger('rowChange', 'add', outputRows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onRemoveRows(rows: RowList, rowSource: RowSource) {
|
||||||
|
const outputRows = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
const sources = this._allRows.get(r);
|
||||||
|
if (!sources) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sources.delete(rowSource);
|
||||||
|
if (sources.size === 0) {
|
||||||
|
outputRows.push(r);
|
||||||
|
this._allRows.delete(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (outputRows.length > 0) {
|
||||||
|
this.trigger('rowChange', 'remove', outputRows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onUpdateRows(rows: RowList) {
|
||||||
|
this.trigger('rowChange', 'update', rows);
|
||||||
|
}
|
||||||
|
}
|
@ -82,10 +82,10 @@ export class RowListener extends DisposableWithEvents {
|
|||||||
* Subscribes to the given rowSource and adds the rows currently in it.
|
* Subscribes to the given rowSource and adds the rows currently in it.
|
||||||
*/
|
*/
|
||||||
public subscribeTo(rowSource: RowSource): void {
|
public subscribeTo(rowSource: RowSource): void {
|
||||||
this.onAddRows(rowSource.getAllRows());
|
this.onAddRows(rowSource.getAllRows(), rowSource);
|
||||||
this.listenTo(rowSource, 'rowChange', (changeType: ChangeType, rows: RowList) => {
|
this.listenTo(rowSource, 'rowChange', (changeType: ChangeType, rows: RowList) => {
|
||||||
const method: ChangeMethod = _changeTypes[changeType];
|
const method: ChangeMethod = _changeTypes[changeType];
|
||||||
this[method](rows);
|
this[method](rows, rowSource);
|
||||||
});
|
});
|
||||||
this.listenTo(rowSource, 'rowNotify', this.onRowNotify);
|
this.listenTo(rowSource, 'rowNotify', this.onRowNotify);
|
||||||
}
|
}
|
||||||
@ -103,17 +103,17 @@ export class RowListener extends DisposableWithEvents {
|
|||||||
/**
|
/**
|
||||||
* Process row additions. To be implemented by derived classes.
|
* Process row additions. To be implemented by derived classes.
|
||||||
*/
|
*/
|
||||||
protected onAddRows(rows: RowList) { /* no-op */ }
|
protected onAddRows(rows: RowList, rowSource?: RowSource) { /* no-op */ }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process row removals. To be implemented by derived classes.
|
* Process row removals. To be implemented by derived classes.
|
||||||
*/
|
*/
|
||||||
protected onRemoveRows(rows: RowList) { /* no-op */ }
|
protected onRemoveRows(rows: RowList, rowSource?: RowSource) { /* no-op */ }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process row updates. To be implemented by derived classes.
|
* Process row updates. To be implemented by derived classes.
|
||||||
*/
|
*/
|
||||||
protected onUpdateRows(rows: RowList) { /* no-op */ }
|
protected onUpdateRows(rows: RowList, rowSource?: RowSource) { /* no-op */ }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derived classes may override this event to handle row notifications. By default, it re-triggers
|
* Derived classes may override this event to handle row notifications. By default, it re-triggers
|
||||||
@ -128,15 +128,6 @@ export class RowListener extends DisposableWithEvents {
|
|||||||
// MappedRowSource
|
// MappedRowSource
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
|
||||||
* A trivial RowSource returning a fixed list of rows.
|
|
||||||
*/
|
|
||||||
export abstract class ArrayRowSource extends RowSource {
|
|
||||||
constructor(private _rows: UIRowId[]) { super(); }
|
|
||||||
public getAllRows(): RowList { return this._rows; }
|
|
||||||
public getNumRows(): number { return this._rows.length; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MappedRowSource wraps any other RowSource, and passes through all rows, replacing each row
|
* MappedRowSource wraps any other RowSource, and passes through all rows, replacing each row
|
||||||
* identifier with the result of mapperFunc(row) call.
|
* identifier with the result of mapperFunc(row) call.
|
||||||
@ -773,3 +764,42 @@ function _allRowsSorted<T>(array: T[], allRows: Set<T>, sortedRows: Iterable<T>,
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track rows that should temporarily be visible even if they don't match filters.
|
||||||
|
* This is so that a newly added row doesn't immediately disappear, which would be confusing.
|
||||||
|
* This doesn't have much to do with BaseFilteredRowSource, it's just reusing some implementation.
|
||||||
|
*/
|
||||||
|
export class ExemptFromFilterRowSource extends BaseFilteredRowSource {
|
||||||
|
public constructor() {
|
||||||
|
super(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call this when one or more new rows are added to keep them temporarily visible.
|
||||||
|
*/
|
||||||
|
public addExemptRows(rows: RowList) {
|
||||||
|
const newRows = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!this._matchingRows.has(r)) {
|
||||||
|
this._matchingRows.add(r);
|
||||||
|
newRows.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newRows.length > 0) {
|
||||||
|
this.trigger('rowChange', 'add', newRows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public addExemptRow(rowId: number) {
|
||||||
|
this.addExemptRows([rowId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call this when linking or filters change to clear out the temporary rows.
|
||||||
|
*/
|
||||||
|
public reset() {
|
||||||
|
this.onRemoveRows(this.getAllRows());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -54,7 +54,7 @@ export interface IFilterMenuOptions {
|
|||||||
rangeInputOptions?: IRangeInputOptions;
|
rangeInputOptions?: IRangeInputOptions;
|
||||||
showAllFiltersButton?: boolean;
|
showAllFiltersButton?: boolean;
|
||||||
doCancel(): void;
|
doCancel(): void;
|
||||||
doSave(reset: boolean): void;
|
doSave(): void;
|
||||||
renderValue(key: CellValue, value: IFilterCount): DomElementArg;
|
renderValue(key: CellValue, value: IFilterCount): DomElementArg;
|
||||||
onClose(): void;
|
onClose(): void;
|
||||||
valueParser?(val: string): any;
|
valueParser?(val: string): any;
|
||||||
@ -104,7 +104,6 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
|
|||||||
|
|
||||||
let searchInput: HTMLInputElement;
|
let searchInput: HTMLInputElement;
|
||||||
let cancel = false;
|
let cancel = false;
|
||||||
let reset = false;
|
|
||||||
|
|
||||||
const filterMenu: HTMLElement = cssMenu(
|
const filterMenu: HTMLElement = cssMenu(
|
||||||
{ tabindex: '-1' }, // Allow menu to be focused
|
{ tabindex: '-1' }, // Allow menu to be focused
|
||||||
@ -128,7 +127,7 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
|
|||||||
dom.cls(menuCssClass),
|
dom.cls(menuCssClass),
|
||||||
dom.autoDispose(filterListener),
|
dom.autoDispose(filterListener),
|
||||||
// Save or cancel on disposal, which should always happen as part of closing.
|
// Save or cancel on disposal, which should always happen as part of closing.
|
||||||
dom.onDispose(() => cancel ? doCancel() : doSave(reset)),
|
dom.onDispose(() => cancel ? doCancel() : doSave()),
|
||||||
dom.onKeyDown({
|
dom.onKeyDown({
|
||||||
Enter: () => onClose(),
|
Enter: () => onClose(),
|
||||||
Escape: () => onClose(),
|
Escape: () => onClose(),
|
||||||
@ -327,7 +326,6 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
|
|||||||
dom('div',
|
dom('div',
|
||||||
cssPrimaryButton('Close', testId('apply-btn'),
|
cssPrimaryButton('Close', testId('apply-btn'),
|
||||||
dom.on('click', () => {
|
dom.on('click', () => {
|
||||||
reset = true;
|
|
||||||
onClose();
|
onClose();
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@ -696,16 +694,13 @@ export function createFilterMenu(params: ICreateFilterMenuParams) {
|
|||||||
model,
|
model,
|
||||||
valueCounts,
|
valueCounts,
|
||||||
onClose: () => { openCtl.close(); onClose(); },
|
onClose: () => { openCtl.close(); onClose(); },
|
||||||
doSave: (reset: boolean = false) => {
|
doSave: () => {
|
||||||
const spec = columnFilter.makeFilterJson();
|
const spec = columnFilter.makeFilterJson();
|
||||||
const {viewSection} = sectionFilter;
|
const {viewSection} = sectionFilter;
|
||||||
viewSection.setFilter(
|
viewSection.setFilter(
|
||||||
fieldOrColumn.origCol().origColRef(),
|
fieldOrColumn.origCol().origColRef(),
|
||||||
{filter: spec}
|
{filter: spec}
|
||||||
);
|
);
|
||||||
if (reset) {
|
|
||||||
sectionFilter.resetTemporaryRows();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the save was for a new filter, and if that new filter was pinned. If it was, and
|
// Check if the save was for a new filter, and if that new filter was pinned. If it was, and
|
||||||
// it is the second pinned filter in the section, trigger a tip that explains how multiple
|
// it is the second pinned filter in the section, trigger a tip that explains how multiple
|
||||||
|
@ -7,6 +7,7 @@ import {dom} from 'grainjs';
|
|||||||
|
|
||||||
export function buildErrorDom(row: DataRowModel, field: ViewFieldRec) {
|
export function buildErrorDom(row: DataRowModel, field: ViewFieldRec) {
|
||||||
const value = row.cells[field.colId.peek()];
|
const value = row.cells[field.colId.peek()];
|
||||||
|
if (value === undefined) { return null; } // Work around JS errors during field removal.
|
||||||
const options = field.widgetOptionsJson;
|
const options = field.widgetOptionsJson;
|
||||||
// The "invalid" class sets the pink background, as long as the error text is non-empty.
|
// The "invalid" class sets the pink background, as long as the error text is non-empty.
|
||||||
return dom('div.field_clip.invalid',
|
return dom('div.field_clip.invalid',
|
||||||
|
@ -685,6 +685,7 @@ export class FieldBuilder extends Disposable {
|
|||||||
kd.scope(widgetObs, (widget: NewAbstractWidget) => {
|
kd.scope(widgetObs, (widget: NewAbstractWidget) => {
|
||||||
if (this.isDisposed()) { return null; } // Work around JS errors during field removal.
|
if (this.isDisposed()) { return null; } // Work around JS errors during field removal.
|
||||||
const cellDom = widget ? widget.buildDom(row) : buildErrorDom(row, this.field);
|
const cellDom = widget ? widget.buildDom(row) : buildErrorDom(row, this.field);
|
||||||
|
if (cellDom === null) { return null; }
|
||||||
return dom(cellDom, kd.toggleClass('has_cursor', isActive),
|
return dom(cellDom, kd.toggleClass('has_cursor', isActive),
|
||||||
kd.toggleClass('field-error-from-style', errorInStyle),
|
kd.toggleClass('field-error-from-style', errorInStyle),
|
||||||
kd.toggleClass('font-bold', fontBold),
|
kd.toggleClass('font-bold', fontBold),
|
||||||
|
@ -15,15 +15,15 @@ describe('rowset', function() {
|
|||||||
sinon.spy(lis, "onUpdateRows");
|
sinon.spy(lis, "onUpdateRows");
|
||||||
|
|
||||||
lis.subscribeTo(src);
|
lis.subscribeTo(src);
|
||||||
assert.deepEqual(lis.onAddRows.args, [[[1, 2, 3]]]);
|
assert.deepEqual(lis.onAddRows.args, [[[1, 2, 3], src]]);
|
||||||
lis.onAddRows.resetHistory();
|
lis.onAddRows.resetHistory();
|
||||||
|
|
||||||
src.trigger('rowChange', 'add', [5, 6]);
|
src.trigger('rowChange', 'add', [5, 6]);
|
||||||
src.trigger('rowChange', 'remove', [6, 1]);
|
src.trigger('rowChange', 'remove', [6, 1]);
|
||||||
src.trigger('rowChange', 'update', [3, 5]);
|
src.trigger('rowChange', 'update', [3, 5]);
|
||||||
assert.deepEqual(lis.onAddRows.args, [[[5, 6]]]);
|
assert.deepEqual(lis.onAddRows.args, [[[5, 6], src]]);
|
||||||
assert.deepEqual(lis.onRemoveRows.args, [[[6, 1]]]);
|
assert.deepEqual(lis.onRemoveRows.args, [[[6, 1], src]]);
|
||||||
assert.deepEqual(lis.onUpdateRows.args, [[[3, 5]]]);
|
assert.deepEqual(lis.onUpdateRows.args, [[[3, 5], src]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should support subscribing to multiple sources', function() {
|
it('should support subscribing to multiple sources', function() {
|
||||||
@ -40,18 +40,18 @@ describe('rowset', function() {
|
|||||||
|
|
||||||
lis.subscribeTo(src1);
|
lis.subscribeTo(src1);
|
||||||
lis.subscribeTo(src2);
|
lis.subscribeTo(src2);
|
||||||
assert.deepEqual(lis.onAddRows.args, [[[1, 2, 3]], [["a", "b", "c"]]]);
|
assert.deepEqual(lis.onAddRows.args, [[[1, 2, 3], src1], [["a", "b", "c"], src2]]);
|
||||||
|
|
||||||
src1.trigger('rowChange', 'update', [2, 3]);
|
src1.trigger('rowChange', 'update', [2, 3]);
|
||||||
src2.trigger('rowChange', 'remove', ["b"]);
|
src2.trigger('rowChange', 'remove', ["b"]);
|
||||||
assert.deepEqual(lis.onUpdateRows.args, [[[2, 3]]]);
|
assert.deepEqual(lis.onUpdateRows.args, [[[2, 3], src1]]);
|
||||||
assert.deepEqual(lis.onRemoveRows.args, [[["b"]]]);
|
assert.deepEqual(lis.onRemoveRows.args, [[["b"], src2]]);
|
||||||
|
|
||||||
lis.onAddRows.resetHistory();
|
lis.onAddRows.resetHistory();
|
||||||
lis.unsubscribeFrom(src1);
|
lis.unsubscribeFrom(src1);
|
||||||
src1.trigger('rowChange', 'add', [4]);
|
src1.trigger('rowChange', 'add', [4]);
|
||||||
src2.trigger('rowChange', 'add', ["d"]);
|
src2.trigger('rowChange', 'add', ["d"]);
|
||||||
assert.deepEqual(lis.onAddRows.args, [[["d"]]]);
|
assert.deepEqual(lis.onAddRows.args, [[["d"], src2]]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
BIN
test/fixtures/docs/SelectBySummary.grist
vendored
BIN
test/fixtures/docs/SelectBySummary.grist
vendored
Binary file not shown.
@ -106,10 +106,9 @@ describe('SelectByRefList', function() {
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
// LINKTARGET is being filtered by the `id` column
|
// LINKTARGET is being filtered by the `id` column
|
||||||
// There's no column to set a default value for that would help
|
// There's no column to set a default value for.
|
||||||
// The newly added row disappears immediately
|
|
||||||
// TODO should we be appending the new row ID to the reflist in the source table?
|
// TODO should we be appending the new row ID to the reflist in the source table?
|
||||||
newRow = ['', '', ''];
|
newRow = ['99', '', ''];
|
||||||
await checkSelectingRecords('REFLISTS • LinkTarget_reflist', sourceData, newRow);
|
await checkSelectingRecords('REFLISTS • LinkTarget_reflist', sourceData, newRow);
|
||||||
|
|
||||||
// Similar to the above but indirect. We connect LINKTARGET.ref and REFLISTS.reflist,
|
// Similar to the above but indirect. We connect LINKTARGET.ref and REFLISTS.reflist,
|
||||||
|
@ -118,17 +118,60 @@ describe('SelectBySummary', function() {
|
|||||||
// so those values will be used as defaults in the source table
|
// so those values will be used as defaults in the source table
|
||||||
await gu.getCell({section: 'TABLE1 [by onetwo, choices]', col: 'rownum', rowNum: 2}).click();
|
await gu.getCell({section: 'TABLE1 [by onetwo, choices]', col: 'rownum', rowNum: 2}).click();
|
||||||
|
|
||||||
// Create a new record with rownum=99
|
// Create new records with rownum = 99 and 100
|
||||||
await gu.getCell({section: 'TABLE1', col: 'rownum', rowNum: 3}).click();
|
await gu.getCell({section: 'TABLE1', col: 'rownum', rowNum: 3}).click();
|
||||||
await gu.enterCell('99');
|
await gu.enterCell('99');
|
||||||
|
await gu.enterCell('100');
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
await gu.getVisibleGridCells({
|
await gu.getVisibleGridCells({
|
||||||
section: 'TABLE1',
|
section: 'TABLE1',
|
||||||
cols: ['onetwo', 'choices', 'rownum'],
|
cols: ['onetwo', 'choices', 'rownum'],
|
||||||
rowNums: [3],
|
rowNums: [1, 2, 3, 4, 5],
|
||||||
}),
|
}),
|
||||||
['2', 'a', '99'],
|
[
|
||||||
|
'2', 'a', '2',
|
||||||
|
'2', 'a\nb', '6',
|
||||||
|
// The two rows we just added.
|
||||||
|
// The filter link sets the default value 'a'.
|
||||||
|
// It can't set a default value for 'onetwo' because that's a formula column.
|
||||||
|
// This first row doesn't match the filter link, but it still shows temporarily.
|
||||||
|
'1', 'a', '99',
|
||||||
|
'2', 'a', '100',
|
||||||
|
'', '', '', // new row
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Select a different record in the summary table, sanity check the linked table.
|
||||||
|
await gu.getCell({section: 'TABLE1 [by onetwo, choices]', col: 'rownum', rowNum: 3}).click();
|
||||||
|
assert.deepEqual(
|
||||||
|
await gu.getVisibleGridCells({
|
||||||
|
section: 'TABLE1',
|
||||||
|
cols: ['onetwo', 'choices', 'rownum'],
|
||||||
|
rowNums: [1, 2, 3],
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
'1', 'b', '3',
|
||||||
|
'1', 'a\nb', '5',
|
||||||
|
'', '', '', // new row
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now go back to the previously selected summary table row.
|
||||||
|
await gu.getCell({section: 'TABLE1 [by onetwo, choices]', col: 'rownum', rowNum: 2}).click();
|
||||||
|
assert.deepEqual(
|
||||||
|
await gu.getVisibleGridCells({
|
||||||
|
section: 'TABLE1',
|
||||||
|
cols: ['onetwo', 'choices', 'rownum'],
|
||||||
|
rowNums: [1, 2, 3, 4],
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
'2', 'a', '2',
|
||||||
|
'2', 'a\nb', '6',
|
||||||
|
// The row ['1', 'a', '99'] is now filtered out as normal.
|
||||||
|
'2', 'a', '100',
|
||||||
|
'', '', '', // new row
|
||||||
|
],
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
Loading…
Reference in New Issue
Block a user