(core) Allow filtering hidden columns

Summary:
Existing filters are now moved out of fields
and into a new metadata table for filters, and the
client is updated to retrieve/update/save filters from
the new table. This enables storing of filters for
columns that don't have fields (notably, hidden columns).

Test Plan: Browser and server tests.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D3138
This commit is contained in:
George Gevoian
2021-11-19 12:30:11 -08:00
parent 0d460ac2d4
commit 7fe4423a6f
20 changed files with 431 additions and 165 deletions

View File

@@ -28,6 +28,7 @@ import {schema, SchemaTypes} from 'app/common/schema';
import {ACLRuleRec, createACLRuleRec} from 'app/client/models/entities/ACLRuleRec';
import {ColumnRec, createColumnRec} from 'app/client/models/entities/ColumnRec';
import {createDocInfoRec, DocInfoRec} from 'app/client/models/entities/DocInfoRec';
import {createFilterRec, FilterRec} from 'app/client/models/entities/FilterRec';
import {createPageRec, PageRec} from 'app/client/models/entities/PageRec';
import {createTabBarRec, TabBarRec} from 'app/client/models/entities/TabBarRec';
import {createTableRec, TableRec} from 'app/client/models/entities/TableRec';
@@ -41,6 +42,7 @@ import {createViewSectionRec, ViewSectionRec} from 'app/client/models/entities/V
// import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel';
export {ColumnRec} from 'app/client/models/entities/ColumnRec';
export {DocInfoRec} from 'app/client/models/entities/DocInfoRec';
export {FilterRec} from 'app/client/models/entities/FilterRec';
export {PageRec} from 'app/client/models/entities/PageRec';
export {TabBarRec} from 'app/client/models/entities/TabBarRec';
export {TableRec} from 'app/client/models/entities/TableRec';
@@ -111,6 +113,7 @@ export class DocModel {
public validations: MTM<ValidationRec> = this._metaTableModel("_grist_Validations", createValidationRec);
public pages: MTM<PageRec> = this._metaTableModel("_grist_Pages", createPageRec);
public rules: MTM<ACLRuleRec> = this._metaTableModel("_grist_ACLRules", createACLRuleRec);
public filters: MTM<FilterRec> = this._metaTableModel("_grist_Filters", createFilterRec);
public docInfoRow: DocInfoRec;

View File

@@ -1,6 +1,5 @@
import {KoArray} from 'app/client/lib/koArray';
import {ColumnFilter} from 'app/client/models/ColumnFilter';
import {ViewFieldRec} from 'app/client/models/DocModel';
import {ColumnRec, ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
import {RowId} from 'app/client/models/rowset';
import {TableData} from 'app/client/models/TableData';
import {buildColFilter, ColumnFilterFunc} from 'app/common/ColumnFilterFunc';
@@ -10,15 +9,15 @@ import {Computed, Disposable, MutableObsArray, obsArray, Observable, UseCB} from
export {ColumnFilterFunc} from 'app/common/ColumnFilterFunc';
interface OpenColumnFilter {
fieldRef: number;
colRef: number;
colFilter: ColumnFilter;
}
type ColFilterCB = (field: ViewFieldRec, colFilter: ColumnFilterFunc|null) => ColumnFilterFunc|null;
type ColFilterCB = (fieldOrColumn: ViewFieldRec|ColumnRec, colFilter: ColumnFilterFunc|null) => ColumnFilterFunc|null;
/**
* SectionFilter represents a collection of column filters in place for a view section. It is created
* out of `viewFields` and `tableData`, and provides a Computed `sectionFilterFunc` that users can
* out of `filters` (in `viewSection`) and `tableData`, and provides a Computed `sectionFilterFunc` that users can
* subscribe to in order to update their FilteredRowSource.
*
* Additionally, `setFilterOverride()` provides a way to override the current filter for a given colRef,
@@ -32,13 +31,13 @@ export class SectionFilter extends Disposable {
private _openFilterOverride: Observable<OpenColumnFilter|null> = Observable.create(this, null);
private _tempRows: MutableObsArray<RowId> = obsArray();
constructor(public viewFields: ko.Computed<KoArray<ViewFieldRec>>, private _tableData: TableData) {
constructor(public viewSection: ViewSectionRec, private _tableData: TableData) {
super();
const columnFilterFunc = Computed.create(this, this._openFilterOverride, (use, openFilter) => {
const openFilterFilterFunc = openFilter && use(openFilter.colFilter.filterFunc);
function getFilterFunc(field: ViewFieldRec, colFilter: ColumnFilterFunc|null) {
if (openFilter?.fieldRef === field.getRowId()) {
function getFilterFunc(fieldOrColumn: ViewFieldRec|ColumnRec, colFilter: ColumnFilterFunc|null) {
if (openFilter?.colRef === fieldOrColumn.getRowId()) {
return openFilterFilterFunc;
}
return colFilter;
@@ -56,11 +55,11 @@ export class SectionFilter extends Disposable {
}
/**
* Allows to override a single filter for a given fieldRef. Multiple calls to `setFilterOverride` will overwrite
* Allows to override a single filter for a given colRef. Multiple calls to `setFilterOverride` will overwrite
* previously set values.
*/
public setFilterOverride(fieldRef: number, colFilter: ColumnFilter) {
this._openFilterOverride.set(({fieldRef, colFilter}));
public setFilterOverride(colRef: number, colFilter: ColumnFilter) {
this._openFilterOverride.set(({colRef, colFilter}));
colFilter.onDispose(() => {
const override = this._openFilterOverride.get();
if (override && override.colFilter === colFilter) {
@@ -81,8 +80,8 @@ export class SectionFilter extends Disposable {
}
/**
* Builds a filter function that combines the filter function of all the fields. You can use
* `getFilterFunc(field, colFilter)` to customize the filter func for each field. It calls
* 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` right away. Also, all the rows that were added with `addTemporaryRow()` bypass
* the filter.
*/
@@ -96,16 +95,16 @@ export class SectionFilter extends Disposable {
/**
* Internal that helps build a filter function that combines the filter function of all
* fields. You can use `getFilterFunc(field, colFilter)` to customize the filter func for each
* field. It calls `getFilterFunc` right away
* 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<RowId> {
const fields = use(use(this.viewFields).getObservable());
const funcs: Array<RowFilterFunc<RowId> | null> = fields.map(f => {
const colFilter = buildColFilter(use(f.activeFilter), use(use(f.column).type));
const filterFunc = getFilterFunc(f, colFilter);
const filters = use(this.viewSection.filters);
const funcs: Array<RowFilterFunc<RowId> | null> = filters.map(({filter, fieldOrColumn}) => {
const colFilter = buildColFilter(use(filter), use(use(fieldOrColumn.origCol).type));
const filterFunc = getFilterFunc(fieldOrColumn, colFilter);
const getter = this._tableData.getRowPropFunc(f.colId.peek());
const getter = this._tableData.getRowPropFunc(fieldOrColumn.colId.peek());
if (!filterFunc || !getter) { return null; }

View File

@@ -3,6 +3,7 @@ import {DocModel, IRowModel, recordSet, refRecord, TableRec, ViewFieldRec} from
import {jsonObservable, ObjObservable} from 'app/client/models/modelUtil';
import * as gristTypes from 'app/common/gristTypes';
import {getReferencedTableId} from 'app/common/gristTypes';
import {BaseFormatter, createFormatter} from 'app/common/ValueFormatter';
import * as ko from 'knockout';
// Represents a column in a user-defined table.
@@ -35,6 +36,13 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> {
// The column's display column
_displayColModel: ko.Computed<ColumnRec>;
// Display col ref to use for the column, defaulting to the plain column itself.
displayColRef: ko.Computed<number>;
// The display column to use for the column, or the column itself when no displayCol is set.
displayColModel: ko.Computed<ColumnRec>;
visibleColModel: ko.Computed<ColumnRec>;
disableModifyBase: ko.Computed<boolean>; // True if column config can't be modified (name, type, etc.)
disableModify: ko.Computed<boolean>; // True if column can't be modified or is being transformed.
disableEditData: ko.Computed<boolean>; // True to disable editing of the data in this column.
@@ -46,6 +54,10 @@ export interface ColumnRec extends IRowModel<"_grist_Tables_column"> {
// Helper which adds/removes/updates column's displayCol to match the formula.
saveDisplayFormula(formula: string): Promise<void>|undefined;
// Helper for Reference/ReferenceList columns, which returns a formatter according
// to the visibleCol associated with column. Subscribes to observables if used within a computed.
createVisibleColFormatter(): BaseFormatter;
}
export function createColumnRec(this: ColumnRec, docModel: DocModel): void {
@@ -84,6 +96,13 @@ export function createColumnRec(this: ColumnRec, docModel: DocModel): void {
}
};
// Display col ref to use for the column, defaulting to the plain column itself.
this.displayColRef = ko.pureComputed(() => this.displayCol() || this.origColRef());
// The display column to use for the column, or the column itself when no displayCol is set.
this.displayColModel = refRecord(docModel.columns, this.displayColRef);
this.visibleColModel = refRecord(docModel.columns, this.visibleCol);
this.disableModifyBase = ko.pureComputed(() => Boolean(this.summarySourceCol()));
this.disableModify = ko.pureComputed(() => this.disableModifyBase() || this.isTransforming());
this.disableEditData = ko.pureComputed(() => Boolean(this.summarySourceCol()));
@@ -95,4 +114,16 @@ export function createColumnRec(this: ColumnRec, docModel: DocModel): void {
const refTableId = getReferencedTableId(this.type() || "");
return refTableId ? docModel.allTables.all().find(t => t.tableId() === refTableId) || null : null;
});
// Helper for Reference/ReferenceList columns, which returns a formatter according to the visibleCol
// associated with this column. If no visible column available, return formatting for the column itself.
// Subscribes to observables if used within a computed.
// TODO: It would be better to replace this with a pureComputed whose value is a formatter.
this.createVisibleColFormatter = function() {
const vcol = this.visibleColModel();
const documentSettings = docModel.docInfoRow.documentSettingsJson();
return (vcol.getRowId() !== 0) ?
createFormatter(vcol.type(), vcol.widgetOptionsJson(), documentSettings) :
createFormatter(this.type(), this.widgetOptionsJson(), documentSettings);
};
}

View File

@@ -0,0 +1,22 @@
import {ColumnRec, DocModel, IRowModel, refRecord, ViewSectionRec} from 'app/client/models/DocModel';
import * as modelUtil from 'app/client/models/modelUtil';
import * as ko from 'knockout';
// Represents a column filter for a view section.
export interface FilterRec extends IRowModel<"_grist_Filters"> {
viewSection: ko.Computed<ViewSectionRec>;
column: ko.Computed<ColumnRec>;
// Observable for the parsed filter object.
activeFilter: modelUtil.CustomComputed<string>;
}
export function createFilterRec(this: FilterRec, docModel: DocModel): void {
this.viewSection = refRecord(docModel.viewSections, this.viewSectionRef);
this.column = refRecord(docModel.columns, this.colRef);
// Observable for the active filter that's initialized from the value saved to the server.
this.activeFilter = modelUtil.customComputed({
read: () => { const f = this.filter(); return f === 'null' ? '' : f; }, // To handle old empty filters.
});
}

View File

@@ -7,7 +7,6 @@ import {DocumentSettings} from 'app/common/DocumentSettings';
import {isFullReferencingType} from 'app/common/gristTypes';
import {BaseFormatter, createFormatter} from 'app/common/ValueFormatter';
import {createParser} from 'app/common/ValueParser';
import {Computed, fromKo} from 'grainjs';
import * as ko from 'knockout';
// Represents a page entry in the tree of pages.
@@ -65,12 +64,6 @@ export interface ViewFieldRec extends IRowModel<"_grist_Views_section_field"> {
// Whether lines should wrap in a cell.
wrapping: ko.Computed<boolean>;
// Observable for the parsed filter object saved to the field.
activeFilter: modelUtil.CustomComputed<string>;
// Computed boolean that's true when there's a saved filter
isFiltered: Computed<boolean>;
disableModify: ko.Computed<boolean>;
disableEditData: ko.Computed<boolean>;
@@ -232,14 +225,6 @@ export function createViewFieldRec(this: ViewFieldRec, docModel: DocModel): void
return this.widgetOptionsJson().wrap ?? (this.viewSection().parentKey() !== 'record');
});
// Observable for the active filter that's initialized from the value saved to the server.
this.activeFilter = modelUtil.customComputed({
read: () => { const f = this.filter(); return f === 'null' ? '' : f; }, // To handle old empty filters
save: (val) => this.filter.saveOnly(val),
});
this.isFiltered = Computed.create(this, fromKo(this.activeFilter), (_use, f) => f !== '');
this.disableModify = ko.pureComputed(() => this.column().disableModify());
this.disableEditData = ko.pureComputed(() => this.column().disableEditData());

View File

@@ -1,5 +1,5 @@
import * as BaseView from 'app/client/components/BaseView';
import { ColumnRec, TableRec, ViewFieldRec, ViewRec } from 'app/client/models/DocModel';
import { ColumnRec, FilterRec, TableRec, ViewFieldRec, ViewRec } from 'app/client/models/DocModel';
import * as modelUtil from 'app/client/models/modelUtil';
import * as ko from 'knockout';
import { CursorPos, } from 'app/client/components/Cursor';
@@ -7,6 +7,7 @@ import { KoArray, } from 'app/client/lib/koArray';
import { DocModel, IRowModel, recordSet, refRecord, } from 'app/client/models/DocModel';
import { RowId, } from 'app/client/models/rowset';
import { getWidgetTypes, } from 'app/client/ui/widgetTypes';
import { arrayRepeat, } from 'app/common/gutil';
import { Sort, } from 'app/common/SortSpec';
import { Computed, } from 'grainjs';
import defaults = require('lodash/defaults');
@@ -16,6 +17,9 @@ import defaults = require('lodash/defaults');
export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> {
viewFields: ko.Computed<KoArray<ViewFieldRec>>;
// All table columns associated with this view section, excluding hidden helper columns.
columns: ko.Computed<ColumnRec[]>;
optionsObj: modelUtil.SaveableObjObservable<any>;
customDef: CustomViewSectionDef;
@@ -33,13 +37,36 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> {
layoutSpecObj: modelUtil.ObjObservable<any>;
// Helper metadata item which indicates whether any of the section's fields have unsaved
_savedFilters: ko.Computed<KoArray<FilterRec>>;
/**
* Unsaved client-side filters, keyed by original col ref. Currently only wiped when unsaved filters
* are applied or reverted.
*
* If saved filters exist for a col ref, unsaved filters take priority and are applied instead. This
* prevents disruption when changes are made to saved filters for the same field/column, but there
* may be some cases where we'd want to reset _unsavedFilters on some indirect change to the document.
*
* NOTE: See `filters`, where `_unsavedFilters` is merged with `savedFilters`.
*/
_unsavedFilters: Map<number, string>;
/**
* Filter information for all fields/section in the section.
*
* Re-computed on changes to `savedFilters`, as well as any changes to `viewFields` or `columns`. Any
* unsaved filters saved in `_unsavedFilters` are applied on computation, taking priority over saved
* filters for the same field/column, if any exist.
*/
filters: ko.Computed<FilterInfo[]>;
// Subset of `filters` containing non-blank active filters.
activeFilters: Computed<FilterInfo[]>;
// Helper metadata item which indicates whether any of the section's fields/columns have unsaved
// changes to their filters. (True indicates unsaved changes)
filterSpecChanged: Computed<boolean>;
// Array of fields with an active filter
filteredFields: Computed<ViewFieldRec[]>;
// Customizable version of the JSON-stringified sort spec. It may diverge from the saved one.
activeSortJson: modelUtil.CustomComputed<string>;
@@ -96,14 +123,14 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> {
// We won't freeze all the columns on a grid, it will leave at least 1 column unfrozen.
numFrozen: ko.Computed<number>;
// Save all filters of fields in the section.
// Save all filters of fields/columns in the section.
saveFilters(): Promise<void>;
// Revert all filters of fields in the section.
// Revert all filters of fields/columns in the section.
revertFilters(): void;
// Clear and save all filters of fields in the section.
clearFilters(): void;
// Apply `filter` to the field or column identified by `colRef`.
setFilter(colRef: number, filter: string): void;
}
export interface CustomViewSectionDef {
@@ -129,10 +156,22 @@ export interface CustomViewSectionDef {
sectionId: ko.Observable<string>;
}
// Information about filters for a field or hidden column.
export interface FilterInfo {
// The field or column associated with this filter info.
fieldOrColumn: ViewFieldRec|ColumnRec;
// Filter that applies to this field/column, if any.
filter: modelUtil.CustomComputed<string>;
// True if `filter` has a non-blank value.
isFiltered: ko.PureComputed<boolean>;
}
export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): void {
this.viewFields = recordSet(this, docModel.viewFields, 'parentId', {sortBy: 'parentPos'});
// All table columns associated with this view section, excluding any hidden helper columns.
this.columns = this.autoDispose(ko.pureComputed(() => this.table().columns().all().filter(c => !c.isHiddenCol())));
const defaultOptions = {
verticalGridlines: true,
horizontalGridlines: true,
@@ -180,28 +219,128 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
this.layoutSpecObj = modelUtil.jsonObservable(this.layoutSpec);
// Helper metadata item which indicates whether any of the section's fields have unsaved
this._savedFilters = recordSet(this, docModel.filters, 'viewSectionRef');
/**
* Unsaved client-side filters, keyed by original col ref. Currently only wiped when unsaved filters
* are applied or reverted.
*
* If saved filters exist for a col ref, unsaved filters take priority and are applied instead. This
* prevents disruption when changes are made to saved filters for the same field/column, but there
* may be some cases where we'd want to reset _unsavedFilters on some indirect change to the document.
*
* NOTE: See `filters`, where `_unsavedFilters` is merged with `savedFilters`.
*/
this._unsavedFilters = new Map();
/**
* Filter information for all fields/section in the section.
*
* Re-computed on changes to `savedFilters`, as well as any changes to `viewFields` or `columns`. Any
* unsaved filters saved in `_unsavedFilters` are applied on computation, taking priority over saved
* filters for the same field/column, if any exist.
*/
this.filters = this.autoDispose(ko.computed(() => {
const savedFiltersByColRef = new Map(this._savedFilters().all().map(f => [f.colRef(), f]));
const viewFieldsByColRef = new Map(this.viewFields().all().map(f => [f.colRef(), f]));
return this.columns().map(column => {
const savedFilter = savedFiltersByColRef.get(column.origColRef());
const filter = modelUtil.customComputed({
// Initialize with a saved filter, if one exists. Otherwise, use a blank filter.
read: () => { return savedFilter ? savedFilter.activeFilter() : ''; },
});
// If an unsaved filter exists, overwrite `filter` with it.
const unsavedFilter = this._unsavedFilters.get(column.origColRef());
if (unsavedFilter !== undefined) { filter(unsavedFilter); }
return {
filter,
fieldOrColumn: viewFieldsByColRef.get(column.origColRef()) ?? column,
isFiltered: ko.pureComputed(() => filter() !== '')
};
});
}));
// List of `filters` that have non-blank active filters.
this.activeFilters = Computed.create(this, use => use(this.filters).filter(col => use(col.isFiltered)));
// Helper metadata item which indicates whether any of the section's fields/columns have unsaved
// changes to their filters. (True indicates unsaved changes)
this.filterSpecChanged = Computed.create(this, use =>
use(use(this.viewFields).getObservable()).some(field => !use(field.activeFilter.isSaved)));
this.filterSpecChanged = Computed.create(this, use => {
return use(this.filters).some(col => !use(col.filter.isSaved));
});
this.filteredFields = Computed.create(this, use =>
use(use(this.viewFields).getObservable()).filter(field => use(field.isFiltered)));
// Save all filters of fields in the section.
// Save all filters of fields/columns in the section.
this.saveFilters = () => {
return docModel.docData.bundleActions(`Save all filters in ${this.titleDef()}`,
async () => { await Promise.all(this.viewFields().all().map(field => field.activeFilter.save())); }
async () => {
const savedFiltersByColRef = new Map(this._savedFilters().all().map(f => [f.colRef(), f]));
const updatedFilters: [number, string][] = []; // Pairs of row ids and filters to update.
const removedFilterIds: number[] = []; // Row ids of filters to remove.
const newFilters: [number, string][] = []; // Pairs of column refs and filters to add.
for (const c of this.filters()) {
// Skip saved filters (i.e. filters whose local values are unchanged from server).
if (c.filter.isSaved()) { continue; }
const savedFilter = savedFiltersByColRef.get(c.fieldOrColumn.origCol().origColRef());
if (!savedFilter) {
// Since no saved filter exists, we must add a new record to the filters table.
newFilters.push([c.fieldOrColumn.origCol().origColRef(), c.filter()]);
} else if (c.filter() === '') {
// Mark the saved filter for removal from the filters table.
removedFilterIds.push(savedFilter.id());
} else {
// Mark the saved filter for update in the filters table.
updatedFilters.push([savedFilter.id(), c.filter()]);
}
}
// Remove records of any deleted filters.
if (removedFilterIds.length > 0) {
await docModel.filters.sendTableAction(['BulkRemoveRecord', removedFilterIds]);
}
// Update existing filter records with new filter values.
if (updatedFilters.length > 0) {
await docModel.filters.sendTableAction(['BulkUpdateRecord',
updatedFilters.map(([id]) => id),
{filter: updatedFilters.map(([, filter]) => filter)}
]);
}
// Add new filter records.
if (newFilters.length > 0) {
await docModel.filters.sendTableAction(['BulkAddRecord',
arrayRepeat(newFilters.length, null),
{
viewSectionRef: arrayRepeat(newFilters.length, this.id()),
colRef: newFilters.map(([colRef]) => colRef),
filter: newFilters.map(([, filter]) => filter),
}
]);
}
// Reset client filter state.
this.revertFilters();
}
);
};
// Revert all filters of fields in the section.
// Revert all filters of fields/columns in the section.
this.revertFilters = () => {
this.viewFields().all().forEach(field => { field.activeFilter.revert(); });
this._unsavedFilters = new Map();
this.filters().forEach(c => { c.filter.revert(); });
};
// Reset all filters of fields in the section to their default (i.e. unset) values.
this.clearFilters = () => this.viewFields().all().forEach(field => field.activeFilter(''));
// Apply `filter` to the field or column identified by `colRef`.
this.setFilter = (colRef: number, filter: string) => {
this._unsavedFilters.set(colRef, filter);
const filterInfo = this.filters().find(c => c.fieldOrColumn.origCol().origColRef() === colRef);
filterInfo?.filter(filter);
};
// Customizable version of the JSON-stringified sort spec. It may diverge from the saved one.
this.activeSortJson = modelUtil.customValue(this.sortColRefs);
@@ -230,9 +369,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
// Evaluates to an array of column models, which are not referenced by anything in viewFields.
this.hiddenColumns = this.autoDispose(ko.pureComputed(() => {
const included = new Set(this.viewFields().all().map((f) => f.column().origColRef()));
return this.table().columns().all().filter(function(col) {
return !included.has(col.getRowId()) && !col.isHiddenCol();
});
return this.columns().filter(c => !included.has(c.getRowId()));
}));
this.hasFocus = ko.pureComputed({