mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Use individual choices for filtering choice lists
Test Plan: Wrote unit and browser tests that verify new behavior. Reviewers: paulfitz, dsagal Reviewed By: dsagal Subscribers: alexmojaki Differential Revision: https://phab.getgrist.com/D2855
This commit is contained in:
parent
5d3a4b5b5b
commit
b94eb107d4
@ -24,7 +24,7 @@ export class ColumnFilter extends Disposable {
|
|||||||
private _include: boolean;
|
private _include: boolean;
|
||||||
private _values: Set<CellValue>;
|
private _values: Set<CellValue>;
|
||||||
|
|
||||||
constructor(private _initialFilterJson: string) {
|
constructor(private _initialFilterJson: string, private _columnType?: string) {
|
||||||
super();
|
super();
|
||||||
this.setState(_initialFilterJson);
|
this.setState(_initialFilterJson);
|
||||||
}
|
}
|
||||||
@ -85,7 +85,7 @@ export class ColumnFilter extends Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _updateState(): void {
|
private _updateState(): void {
|
||||||
this.filterFunc.set(makeFilterFunc(this._getState()));
|
this.filterFunc.set(makeFilterFunc(this._getState(), this._columnType));
|
||||||
}
|
}
|
||||||
|
|
||||||
private _getState(): FilterState {
|
private _getState(): FilterState {
|
||||||
|
@ -36,7 +36,7 @@ export class SectionFilter extends Disposable {
|
|||||||
const funcs: Array<RowFilterFunc<RowId> | null> = fields.map(f => {
|
const funcs: Array<RowFilterFunc<RowId> | null> = fields.map(f => {
|
||||||
const filterFunc = (openFilter && openFilter.fieldRef === f.getRowId()) ?
|
const filterFunc = (openFilter && openFilter.fieldRef === f.getRowId()) ?
|
||||||
use(openFilter.colFilter.filterFunc) :
|
use(openFilter.colFilter.filterFunc) :
|
||||||
buildColFilter(use(f.activeFilter));
|
buildColFilter(use(f.activeFilter), use(f.column).type());
|
||||||
|
|
||||||
const getter = tableData.getRowPropFunc(use(f.colId));
|
const getter = tableData.getRowPropFunc(use(f.colId));
|
||||||
|
|
||||||
|
@ -21,6 +21,8 @@ import identity = require('lodash/identity');
|
|||||||
import noop = require('lodash/noop');
|
import noop = require('lodash/noop');
|
||||||
import {IOpenController, IPopupOptions, setPopupToCreateDom} from 'popweasel';
|
import {IOpenController, IPopupOptions, setPopupToCreateDom} from 'popweasel';
|
||||||
import {isEquivalentFilter} from "app/common/FilterState";
|
import {isEquivalentFilter} from "app/common/FilterState";
|
||||||
|
import {decodeObject} from 'app/plugin/objtypes';
|
||||||
|
import {isList} from 'app/common/gristTypes';
|
||||||
|
|
||||||
interface IFilterMenuOptions {
|
interface IFilterMenuOptions {
|
||||||
model: ColumnFilterMenuModel;
|
model: ColumnFilterMenuModel;
|
||||||
@ -261,20 +263,21 @@ function buildSummary(label: string, SummaryModelCtor: SummaryModelCreator, mode
|
|||||||
export function createFilterMenu(openCtl: IOpenController, sectionFilter: SectionFilter, field: ViewFieldRec,
|
export function createFilterMenu(openCtl: IOpenController, sectionFilter: SectionFilter, field: ViewFieldRec,
|
||||||
rowSource: FilteredRowSource, tableData: TableData, onClose: () => void = noop) {
|
rowSource: FilteredRowSource, tableData: TableData, onClose: () => void = noop) {
|
||||||
// Go through all of our shown and hidden rows, and count them up by the values in this column.
|
// Go through all of our shown and hidden rows, and count them up by the values in this column.
|
||||||
const valueGetter = tableData.getRowPropFunc(field.column().colId())!;
|
const keyMapFunc = tableData.getRowPropFunc(field.column().colId())!;
|
||||||
const labelGetter = tableData.getRowPropFunc(field.displayColModel().colId())!;
|
const labelGetter = tableData.getRowPropFunc(field.displayColModel().colId())!;
|
||||||
const formatter = field.createVisibleColFormatter();
|
const formatter = field.createVisibleColFormatter();
|
||||||
const valueMapFunc = (rowId: number) => formatter.formatAny(labelGetter(rowId));
|
const labelMapFunc = (rowId: number) => formatter.formatAny(labelGetter(rowId));
|
||||||
const activeFilterBar = field.viewSection.peek().activeFilterBar;
|
const activeFilterBar = field.viewSection.peek().activeFilterBar;
|
||||||
|
const columnType = field.column().type.peek();
|
||||||
|
|
||||||
const valueCounts: Map<CellValue, {label: string, count: number}> = new Map();
|
const valueCounts: Map<CellValue, {label: string, count: number}> = new Map();
|
||||||
// TODO: as of now, this is not working for non text-or-numeric columns, ie: for Date column it is
|
// TODO: as of now, this is not working for non text-or-numeric columns, ie: for Date column it is
|
||||||
// not possible to search for anything. Likely caused by the key being something completely
|
// not possible to search for anything. Likely caused by the key being something completely
|
||||||
// different than the label.
|
// different than the label.
|
||||||
addCountsToMap(valueCounts, rowSource.getAllRows() as Iterable<number>, valueGetter, valueMapFunc);
|
addCountsToMap(valueCounts, rowSource.getAllRows() as Iterable<number>, { keyMapFunc, labelMapFunc, columnType });
|
||||||
addCountsToMap(valueCounts, rowSource.getHiddenRows() as Iterable<number>, valueGetter, valueMapFunc);
|
addCountsToMap(valueCounts, rowSource.getHiddenRows() as Iterable<number>, { keyMapFunc, labelMapFunc, columnType });
|
||||||
|
|
||||||
const columnFilter = ColumnFilter.create(openCtl, field.activeFilter.peek());
|
const columnFilter = ColumnFilter.create(openCtl, field.activeFilter.peek(), columnType);
|
||||||
const model = ColumnFilterMenuModel.create(openCtl, columnFilter, Array.from(valueCounts));
|
const model = ColumnFilterMenuModel.create(openCtl, columnFilter, Array.from(valueCounts));
|
||||||
sectionFilter.setFilterOverride(field.getRowId(), columnFilter); // Will be removed on menu disposal
|
sectionFilter.setFilterOverride(field.getRowId(), columnFilter); // Will be removed on menu disposal
|
||||||
|
|
||||||
@ -293,21 +296,51 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ICountOptions {
|
||||||
|
keyMapFunc?: (v: any) => any;
|
||||||
|
labelMapFunc?: (v: any) => any;
|
||||||
|
columnType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For each value in Iterable, adds a key mapped with `keyMapFunc` and a value object with a `label` mapped
|
* For each row id in Iterable, adds a key mapped with `keyMapFunc` and a value object with a `label` mapped
|
||||||
* with `labelMapFunc` and a `count` representing the total number of times the key has been encountered.
|
* with `labelMapFunc` and a `count` representing the total number of times the key has been encountered.
|
||||||
|
*
|
||||||
|
* The optional column type controls how complex cell values are decomposed into keys (e.g. Choice Lists have
|
||||||
|
* the possible choices as keys).
|
||||||
*/
|
*/
|
||||||
function addCountsToMap(valueMap: Map<CellValue, IFilterCount>, values: Iterable<CellValue>,
|
function addCountsToMap(valueMap: Map<CellValue, IFilterCount>, rowIds: Iterable<Number>,
|
||||||
keyMapFunc: (v: any) => any = identity, labelMapFunc: (v: any) => any = identity) {
|
{ keyMapFunc = identity, labelMapFunc = identity, columnType }: ICountOptions) {
|
||||||
for (const v of values) {
|
|
||||||
let key = keyMapFunc(v);
|
for (const rowId of rowIds) {
|
||||||
|
let key = keyMapFunc(rowId);
|
||||||
|
|
||||||
|
// If row contains a list and the column is a Choice List, treat each choice as a separate key
|
||||||
|
if (isList(key) && columnType === 'ChoiceList') {
|
||||||
|
const list = decodeObject(key);
|
||||||
|
addListCountsToMap(valueMap, list as unknown[]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// For complex values, serialize the value to allow them to be properly stored
|
// For complex values, serialize the value to allow them to be properly stored
|
||||||
if (Array.isArray(key)) { key = JSON.stringify(key); }
|
if (Array.isArray(key)) { key = JSON.stringify(key); }
|
||||||
if (valueMap.get(key)) {
|
if (valueMap.get(key)) {
|
||||||
valueMap.get(key)!.count++;
|
valueMap.get(key)!.count++;
|
||||||
} else {
|
} else {
|
||||||
valueMap.set(key, { label: labelMapFunc(v), count: 1 });
|
valueMap.set(key, { label: labelMapFunc(rowId), count: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds each item in `list` to `valueMap`.
|
||||||
|
*/
|
||||||
|
function addListCountsToMap(valueMap: Map<CellValue, IFilterCount>, list: any[]) {
|
||||||
|
for (const item of list) {
|
||||||
|
if (valueMap.get(item)) {
|
||||||
|
valueMap.get(item)!.count++;
|
||||||
|
} else {
|
||||||
|
valueMap.set(item, { label: item, count: 1 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,18 +1,29 @@
|
|||||||
import { CellValue } from "app/common/DocActions";
|
import { CellValue } from "app/common/DocActions";
|
||||||
import { FilterState, makeFilterState } from "app/common/FilterState";
|
import { FilterState, makeFilterState } from "app/common/FilterState";
|
||||||
|
import { decodeObject } from "app/plugin/objtypes";
|
||||||
|
import { isList } from "./gristTypes";
|
||||||
|
|
||||||
export type ColumnFilterFunc = (value: CellValue) => boolean;
|
export type ColumnFilterFunc = (value: CellValue) => boolean;
|
||||||
|
|
||||||
// Returns a filter function for a particular column: the function takes a cell value and returns
|
// Returns a filter function for a particular column: the function takes a cell value and returns
|
||||||
// whether it's accepted according to the given FilterState.
|
// whether it's accepted according to the given FilterState.
|
||||||
export function makeFilterFunc({ include, values }: FilterState): ColumnFilterFunc {
|
export function makeFilterFunc({ include, values }: FilterState,
|
||||||
|
columnType?: string): ColumnFilterFunc {
|
||||||
// NOTE: This logic results in complex values and their stringified JSON representations as equivalent.
|
// NOTE: This logic results in complex values and their stringified JSON representations as equivalent.
|
||||||
// For example, a TypeError in the formula column and the string '["E","TypeError"]' would be seen as the same.
|
// For example, a TypeError in the formula column and the string '["E","TypeError"]' would be seen as the same.
|
||||||
// TODO: This narrow corner case seems acceptable for now, but may be worth revisiting.
|
// TODO: This narrow corner case seems acceptable for now, but may be worth revisiting.
|
||||||
return (val: CellValue) => (values.has(Array.isArray(val) ? JSON.stringify(val) : val) === include);
|
return (val: CellValue) => {
|
||||||
|
if (isList(val) && columnType === 'ChoiceList') {
|
||||||
|
const list = decodeObject(val) as unknown[];
|
||||||
|
return list.some(item => values.has(item as any) === include);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (values.has(Array.isArray(val) ? JSON.stringify(val) : val) === include);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Given a JSON string, returns a ColumnFilterFunc
|
// Given a JSON string, returns a ColumnFilterFunc
|
||||||
export function buildColFilter(filterJson: string | undefined): ColumnFilterFunc | null {
|
export function buildColFilter(filterJson: string | undefined,
|
||||||
return filterJson ? makeFilterFunc(makeFilterState(filterJson)) : null;
|
columnType?: string): ColumnFilterFunc | null {
|
||||||
|
return filterJson ? makeFilterFunc(makeFilterState(filterJson), columnType) : null;
|
||||||
}
|
}
|
||||||
|
@ -136,12 +136,19 @@ export function isCensored(value: CellValue): value is [GristObjCode.Censored] {
|
|||||||
return getObjCode(value) === GristObjCode.Censored;
|
return getObjCode(value) === GristObjCode.Censored;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether a value (as received in a DocAction) represents a list.
|
||||||
|
*/
|
||||||
|
export function isList(value: CellValue): value is [GristObjCode.List, ...unknown[]] {
|
||||||
|
return Array.isArray(value) && value[0] === GristObjCode.List;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns whether a value (as received in a DocAction) represents a list or is null,
|
* Returns whether a value (as received in a DocAction) represents a list or is null,
|
||||||
* which is a valid value for list types in grist.
|
* which is a valid value for list types in grist.
|
||||||
*/
|
*/
|
||||||
export function isListOrNull(value: CellValue): boolean {
|
export function isListOrNull(value: CellValue): boolean {
|
||||||
return value === null || (Array.isArray(value) && value[0] === GristObjCode.List);
|
return value === null || isList(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -90,7 +90,7 @@ export async function makeCSV(
|
|||||||
const displayCol = tableColsById[field.displayCol || col.displayCol || col.id];
|
const displayCol = tableColsById[field.displayCol || col.displayCol || col.id];
|
||||||
const colWidgetOptions = gutil.safeJsonParse(col.widgetOptions, {});
|
const colWidgetOptions = gutil.safeJsonParse(col.widgetOptions, {});
|
||||||
const fieldWidgetOptions = gutil.safeJsonParse(field.widgetOptions, {});
|
const fieldWidgetOptions = gutil.safeJsonParse(field.widgetOptions, {});
|
||||||
const filterFunc = buildColFilter(filters.find(x => x.colRef === field.colRef)?.filter);
|
const filterFunc = buildColFilter(filters.find(x => x.colRef === field.colRef)?.filter, col.type);
|
||||||
return {
|
return {
|
||||||
id: displayCol.id,
|
id: displayCol.id,
|
||||||
colId: displayCol.colId,
|
colId: displayCol.colId,
|
||||||
|
Loading…
Reference in New Issue
Block a user