(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:
Alex Hall 2023-12-18 19:50:57 +02:00
parent 2f0dbb7d25
commit bd52665f96
13 changed files with 237 additions and 104 deletions

View File

@ -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;
}) })

View File

@ -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);
}

View File

@ -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);

View File

@ -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)));
} }
} }

View 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);
}
}

View File

@ -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());
}
}

View File

@ -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

View File

@ -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',

View File

@ -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),

View File

@ -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]]);
}); });
}); });

Binary file not shown.

View File

@ -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,

View File

@ -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
],
); );
}) })
); );