diff --git a/app/client/models/ColumnFilter.ts b/app/client/models/ColumnFilter.ts index eb7ad413..51829c62 100644 --- a/app/client/models/ColumnFilter.ts +++ b/app/client/models/ColumnFilter.ts @@ -24,7 +24,7 @@ export class ColumnFilter extends Disposable { private _include: boolean; private _values: Set; - constructor(private _initialFilterJson: string) { + constructor(private _initialFilterJson: string, private _columnType?: string) { super(); this.setState(_initialFilterJson); } @@ -85,7 +85,7 @@ export class ColumnFilter extends Disposable { } private _updateState(): void { - this.filterFunc.set(makeFilterFunc(this._getState())); + this.filterFunc.set(makeFilterFunc(this._getState(), this._columnType)); } private _getState(): FilterState { diff --git a/app/client/models/SectionFilter.ts b/app/client/models/SectionFilter.ts index d2c1fe24..98bba19c 100644 --- a/app/client/models/SectionFilter.ts +++ b/app/client/models/SectionFilter.ts @@ -36,7 +36,7 @@ export class SectionFilter extends Disposable { const funcs: Array | null> = fields.map(f => { const filterFunc = (openFilter && openFilter.fieldRef === f.getRowId()) ? use(openFilter.colFilter.filterFunc) : - buildColFilter(use(f.activeFilter)); + buildColFilter(use(f.activeFilter), use(f.column).type()); const getter = tableData.getRowPropFunc(use(f.colId)); diff --git a/app/client/ui/ColumnFilterMenu.ts b/app/client/ui/ColumnFilterMenu.ts index 76415e17..d9e6b05e 100644 --- a/app/client/ui/ColumnFilterMenu.ts +++ b/app/client/ui/ColumnFilterMenu.ts @@ -21,6 +21,8 @@ import identity = require('lodash/identity'); import noop = require('lodash/noop'); import {IOpenController, IPopupOptions, setPopupToCreateDom} from 'popweasel'; import {isEquivalentFilter} from "app/common/FilterState"; +import {decodeObject} from 'app/plugin/objtypes'; +import {isList} from 'app/common/gristTypes'; interface IFilterMenuOptions { model: ColumnFilterMenuModel; @@ -261,20 +263,21 @@ function buildSummary(label: string, SummaryModelCtor: SummaryModelCreator, mode export function createFilterMenu(openCtl: IOpenController, sectionFilter: SectionFilter, field: ViewFieldRec, 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. - const valueGetter = tableData.getRowPropFunc(field.column().colId())!; + const keyMapFunc = tableData.getRowPropFunc(field.column().colId())!; const labelGetter = tableData.getRowPropFunc(field.displayColModel().colId())!; 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 columnType = field.column().type.peek(); const valueCounts: Map = new Map(); // 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 // different than the label. - addCountsToMap(valueCounts, rowSource.getAllRows() as Iterable, valueGetter, valueMapFunc); - addCountsToMap(valueCounts, rowSource.getHiddenRows() as Iterable, valueGetter, valueMapFunc); + addCountsToMap(valueCounts, rowSource.getAllRows() as Iterable, { keyMapFunc, labelMapFunc, columnType }); + addCountsToMap(valueCounts, rowSource.getHiddenRows() as Iterable, { 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)); 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. + * + * 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, values: Iterable, - keyMapFunc: (v: any) => any = identity, labelMapFunc: (v: any) => any = identity) { - for (const v of values) { - let key = keyMapFunc(v); +function addCountsToMap(valueMap: Map, rowIds: Iterable, + { keyMapFunc = identity, labelMapFunc = identity, columnType }: ICountOptions) { + + 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 if (Array.isArray(key)) { key = JSON.stringify(key); } if (valueMap.get(key)) { valueMap.get(key)!.count++; } 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, list: any[]) { + for (const item of list) { + if (valueMap.get(item)) { + valueMap.get(item)!.count++; + } else { + valueMap.set(item, { label: item, count: 1 }); } } } diff --git a/app/common/ColumnFilterFunc.ts b/app/common/ColumnFilterFunc.ts index 7f2d0e91..d8757c95 100644 --- a/app/common/ColumnFilterFunc.ts +++ b/app/common/ColumnFilterFunc.ts @@ -1,18 +1,29 @@ import { CellValue } from "app/common/DocActions"; import { FilterState, makeFilterState } from "app/common/FilterState"; +import { decodeObject } from "app/plugin/objtypes"; +import { isList } from "./gristTypes"; export type ColumnFilterFunc = (value: CellValue) => boolean; // 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. -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. // 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. - 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 -export function buildColFilter(filterJson: string | undefined): ColumnFilterFunc | null { - return filterJson ? makeFilterFunc(makeFilterState(filterJson)) : null; +export function buildColFilter(filterJson: string | undefined, + columnType?: string): ColumnFilterFunc | null { + return filterJson ? makeFilterFunc(makeFilterState(filterJson), columnType) : null; } diff --git a/app/common/gristTypes.ts b/app/common/gristTypes.ts index 851a3281..f2999176 100644 --- a/app/common/gristTypes.ts +++ b/app/common/gristTypes.ts @@ -136,12 +136,19 @@ export function isCensored(value: CellValue): value is [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, * which is a valid value for list types in grist. */ export function isListOrNull(value: CellValue): boolean { - return value === null || (Array.isArray(value) && value[0] === GristObjCode.List); + return value === null || isList(value); } /** diff --git a/app/server/serverMethods.ts b/app/server/serverMethods.ts index 60a19ea9..174bc6ae 100644 --- a/app/server/serverMethods.ts +++ b/app/server/serverMethods.ts @@ -90,7 +90,7 @@ export async function makeCSV( const displayCol = tableColsById[field.displayCol || col.displayCol || col.id]; const colWidgetOptions = gutil.safeJsonParse(col.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 { id: displayCol.id, colId: displayCol.colId,