mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Makes filter counts take other column filters into account
Summary: Makes filter counts take other column filters into account. - Changes the summaries rows to reflect hidden rows: - hidden rows are added to the `Other Values` summary - show the unique number of other values as `Other Values (12)` - Also, adds a sort button to the column filter menu Test Plan: Adds browser test. Reviewers: paulfitz, jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D2861
This commit is contained in:
parent
16f297a250
commit
7a0cd6c2b4
@ -647,7 +647,7 @@ BaseView.prototype.getLastDataRowIndex = function() {
|
||||
* Creates and opens ColumnFilterMenu for a given field, and returns its PopupControl.
|
||||
*/
|
||||
BaseView.prototype.createFilterMenu = function(openCtl, field, onClose) {
|
||||
return createFilterMenu(openCtl, this._sectionFilter, field, this._filteredRowSource, this.tableModel.tableData, onClose);
|
||||
return createFilterMenu(openCtl, this._sectionFilter, field, this._mainRowSource, this.tableModel.tableData, onClose);
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -1,8 +1,8 @@
|
||||
import {ColumnFilterFunc, makeFilterFunc} from "app/common/ColumnFilterFunc";
|
||||
import {CellValue} from 'app/common/DocActions';
|
||||
import {FilterSpec, FilterState, makeFilterState} from "app/common/FilterState";
|
||||
import {nativeCompare} from 'app/common/gutil';
|
||||
import {Computed, Disposable, Observable} from 'grainjs';
|
||||
import {ColumnFilterFunc, makeFilterFunc} from "app/common/ColumnFilterFunc";
|
||||
import {FilterSpec, FilterState, makeFilterState} from "app/common/FilterState";
|
||||
|
||||
/**
|
||||
* ColumnFilter implements a custom filter on a column, i.e. a filter that's diverged from what's
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { ColumnFilter } from "app/client/models/ColumnFilter";
|
||||
import { localeCompare, nativeCompare } from "app/common/gutil";
|
||||
import { CellValue } from "app/plugin/GristData";
|
||||
import { Computed, Disposable, Observable } from "grainjs";
|
||||
import escapeRegExp = require("lodash/escapeRegExp");
|
||||
import { CellValue } from "app/plugin/GristData";
|
||||
import { localeCompare } from "app/common/gutil";
|
||||
import { ColumnFilter } from "./ColumnFilter";
|
||||
|
||||
const MAXIMUM_SHOWN_FILTER_ITEMS = 500;
|
||||
|
||||
@ -11,22 +11,37 @@ export interface IFilterCount {
|
||||
count: number;
|
||||
}
|
||||
|
||||
type ICompare<T> = (a: T, b: T) => number
|
||||
|
||||
export class ColumnFilterMenuModel extends Disposable {
|
||||
|
||||
public readonly searchValue = Observable.create(this, '');
|
||||
|
||||
public readonly isSortedByCount = Observable.create(this, false);
|
||||
|
||||
// computes a set of all keys that matches the search text.
|
||||
public readonly filterSet = Computed.create(this, this.searchValue, (_use, searchValue) => {
|
||||
const searchRegex = new RegExp(escapeRegExp(searchValue), 'i');
|
||||
return new Set(this._valueCount.filter(([_, {label}]) => searchRegex.test(label)).map(([key]) => key));
|
||||
return new Set(
|
||||
this._valueCount
|
||||
.filter(([_, {count}]) => count)
|
||||
.filter(([_, {label}]) => searchRegex.test(label))
|
||||
.map(([key]) => key)
|
||||
);
|
||||
});
|
||||
|
||||
// computes the sorted array of all values (ie: pair of key and IFilterCount) that matches the search text.
|
||||
public readonly filteredValues = Computed.create(this, this.filterSet, (_use, filter) => {
|
||||
return this._valueCount.filter(([key]) => filter.has(key))
|
||||
.sort((a, b) => localeCompare(a[1].label, b[1].label));
|
||||
});
|
||||
public readonly filteredValues = Computed.create(
|
||||
this, this.filterSet, this.isSortedByCount,
|
||||
(_use, filter, isSortedByCount) => {
|
||||
const comparator: ICompare<[CellValue, IFilterCount]> = isSortedByCount ?
|
||||
(a, b) => nativeCompare(b[1].count, a[1].count) :
|
||||
(a, b) => localeCompare(a[1].label, b[1].label);
|
||||
return this._valueCount
|
||||
.filter(([key]) => filter.has(key))
|
||||
.sort(comparator);
|
||||
}
|
||||
);
|
||||
|
||||
// computes the array of all values that does NOT matches the search text
|
||||
public readonly otherValues = Computed.create(this, this.filterSet, (_use, filter) => {
|
||||
@ -34,13 +49,15 @@ export class ColumnFilterMenuModel extends Disposable {
|
||||
});
|
||||
|
||||
// computes the array of keys that matches the search text
|
||||
public readonly filteredKeys = Computed.create(this, this.filteredValues, (_use, values) => (
|
||||
values.map(([key]) => key)
|
||||
));
|
||||
public readonly filteredKeys = Computed.create(this, this.filterSet, (_use, filter) => {
|
||||
return this._valueCount
|
||||
.filter(([key]) => filter.has(key))
|
||||
.map(([key]) => key);
|
||||
});
|
||||
|
||||
public readonly valuesBeyondLimit = Computed.create(this, this.filteredValues, (_use, values) => (
|
||||
values.slice(this.limitShown)
|
||||
));
|
||||
public readonly valuesBeyondLimit = Computed.create(this, this.filteredValues, (_use, filteredValues) => {
|
||||
return filteredValues.slice(this.limitShown);
|
||||
});
|
||||
|
||||
constructor(public columnFilter: ColumnFilter, private _valueCount: Array<[CellValue, IFilterCount]>,
|
||||
public limitShown: number = MAXIMUM_SHOWN_FILTER_ITEMS) {
|
||||
|
@ -1,17 +1,21 @@
|
||||
import {KoArray} from 'app/client/lib/koArray';
|
||||
import {ColumnFilter} from 'app/client/models/ColumnFilter';
|
||||
import {ViewFieldRec} from 'app/client/models/DocModel';
|
||||
import {RowId} from 'app/client/models/rowset';
|
||||
import {TableData} from 'app/client/models/TableData';
|
||||
import {Computed, Disposable, MutableObsArray, obsArray, Observable} from 'grainjs';
|
||||
import {ColumnFilter} from './ColumnFilter';
|
||||
import {buildRowFilter, RowFilterFunc, RowValueFunc } from "app/common/RowFilterFunc";
|
||||
import {buildColFilter} from "app/common/ColumnFilterFunc";
|
||||
import {buildColFilter, ColumnFilterFunc} from 'app/common/ColumnFilterFunc';
|
||||
import {buildRowFilter, RowFilterFunc, RowValueFunc } from 'app/common/RowFilterFunc';
|
||||
import {Computed, Disposable, MutableObsArray, obsArray, Observable, UseCB} from 'grainjs';
|
||||
|
||||
export {ColumnFilterFunc} from 'app/common/ColumnFilterFunc';
|
||||
|
||||
interface OpenColumnFilter {
|
||||
fieldRef: number;
|
||||
colFilter: ColumnFilter;
|
||||
}
|
||||
|
||||
type ColFilterCB = (field: ViewFieldRec, 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
|
||||
@ -28,31 +32,22 @@ export class SectionFilter extends Disposable {
|
||||
private _openFilterOverride: Observable<OpenColumnFilter|null> = Observable.create(this, null);
|
||||
private _tempRows: MutableObsArray<RowId> = obsArray();
|
||||
|
||||
constructor(viewFields: ko.Computed<KoArray<ViewFieldRec>>, tableData: TableData) {
|
||||
constructor(public viewFields: ko.Computed<KoArray<ViewFieldRec>>, private _tableData: TableData) {
|
||||
super();
|
||||
|
||||
const columnFilterFunc = Computed.create(this, this._openFilterOverride, (use, openFilter) => {
|
||||
const fields = use(use(viewFields).getObservable());
|
||||
const funcs: Array<RowFilterFunc<RowId> | null> = fields.map(f => {
|
||||
const filterFunc = (openFilter && openFilter.fieldRef === f.getRowId()) ?
|
||||
use(openFilter.colFilter.filterFunc) :
|
||||
buildColFilter(use(f.activeFilter), use(f.column).type());
|
||||
|
||||
const getter = tableData.getRowPropFunc(use(f.colId));
|
||||
|
||||
if (!filterFunc || !getter) { return null; }
|
||||
|
||||
return buildRowFilter(getter as RowValueFunc<RowId>, filterFunc);
|
||||
})
|
||||
.filter(f => f !== null); // Filter out columns that don't have a filter
|
||||
|
||||
return (rowId: RowId) => funcs.every(f => Boolean(f && f(rowId)));
|
||||
const openFilterFilterFunc = openFilter && use(openFilter.colFilter.filterFunc);
|
||||
function getFilterFunc(field: ViewFieldRec, colFilter: ColumnFilterFunc|null) {
|
||||
if (openFilter?.fieldRef === field.getRowId()) {
|
||||
return openFilterFilterFunc;
|
||||
}
|
||||
return colFilter;
|
||||
}
|
||||
return this._buildPlainFilterFunc(getFilterFunc, use);
|
||||
});
|
||||
|
||||
this.sectionFilterFunc = Computed.create(this, columnFilterFunc, this._tempRows,
|
||||
(_use, filterFunc, tempRows) => {
|
||||
return (rowId: RowId) => tempRows.includes(rowId) || (typeof rowId !== 'number') || filterFunc(rowId);
|
||||
});
|
||||
(_use, filterFunc, tempRows) => this._addRowsToFilter(filterFunc, tempRows));
|
||||
|
||||
// Prune temporary rowIds that are no longer being filtered out.
|
||||
this.autoDispose(columnFilterFunc.addListener(f => {
|
||||
@ -84,4 +79,39 @@ export class SectionFilter extends Disposable {
|
||||
public resetTemporaryRows() {
|
||||
this._tempRows.set([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* `getFilterFunc` right away. Also, all the rows that were added with `addTemporaryRow()` bypass
|
||||
* the filter.
|
||||
*/
|
||||
public buildFilterFunc(getFilterFunc: ColFilterCB, use: UseCB) {
|
||||
return this._addRowsToFilter(this._buildPlainFilterFunc(getFilterFunc, use), this._tempRows.get());
|
||||
}
|
||||
|
||||
private _addRowsToFilter(filterFunc: RowFilterFunc<RowId>, rows: RowId[]) {
|
||||
return (rowId: RowId) => rows.includes(rowId) || (typeof rowId !== 'number') || filterFunc(rowId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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 getter = this._tableData.getRowPropFunc(f.colId.peek());
|
||||
|
||||
if (!filterFunc || !getter) { return null; }
|
||||
|
||||
return buildRowFilter(getter as RowValueFunc<RowId>, filterFunc);
|
||||
}).filter(f => f !== null); // Filter out columns that don't have a filter
|
||||
|
||||
return (rowId: RowId) => funcs.every(f => Boolean(f && f(rowId)));
|
||||
}
|
||||
}
|
||||
|
@ -7,20 +7,25 @@
|
||||
import {allInclusive, ColumnFilter} from 'app/client/models/ColumnFilter';
|
||||
import {ColumnFilterMenuModel, IFilterCount} from 'app/client/models/ColumnFilterMenuModel';
|
||||
import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {FilteredRowSource} from 'app/client/models/rowset';
|
||||
import {SectionFilter} from 'app/client/models/SectionFilter';
|
||||
import {RowId, RowSource} from 'app/client/models/rowset';
|
||||
import {ColumnFilterFunc, SectionFilter} from 'app/client/models/SectionFilter';
|
||||
import {TableData} from 'app/client/models/TableData';
|
||||
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
||||
import {cssCheckboxSquare, cssLabel, cssLabelText} from 'app/client/ui2018/checkbox';
|
||||
import {cssCheckboxSquare, cssLabel, cssLabelText, Indeterminate,
|
||||
labeledTriStateSquareCheckbox} from 'app/client/ui2018/checkbox';
|
||||
import {colors, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {menuCssClass, menuDivider} from 'app/client/ui2018/menus';
|
||||
import {CellValue} from 'app/common/DocActions';
|
||||
import {Computed, Disposable, dom, DomElementMethod, IDisposableOwner, input, makeTestId, styled} from 'grainjs';
|
||||
import {isEquivalentFilter} from "app/common/FilterState";
|
||||
import {Computed, dom, DomElementMethod, IDisposableOwner, input, makeTestId, styled} from 'grainjs';
|
||||
import concat = require('lodash/concat');
|
||||
import identity = require('lodash/identity');
|
||||
import noop = require('lodash/noop');
|
||||
import partition = require('lodash/partition');
|
||||
import some = require('lodash/some');
|
||||
import tail = require('lodash/tail');
|
||||
import {IOpenController, IPopupOptions, setPopupToCreateDom} from 'popweasel';
|
||||
import {isEquivalentFilter} from "app/common/FilterState";
|
||||
import {decodeObject} from 'app/plugin/objtypes';
|
||||
import {isList} from 'app/common/gristTypes';
|
||||
|
||||
@ -49,7 +54,7 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
|
||||
}
|
||||
});
|
||||
|
||||
const {searchValue: searchValueObs, filteredValues, filteredKeys} = model;
|
||||
const {searchValue: searchValueObs, filteredValues, filteredKeys, isSortedByCount} = model;
|
||||
|
||||
const isAboveLimitObs = Computed.create(owner, (use) => use(model.valuesBeyondLimit).length > 0);
|
||||
const isSearchingObs = Computed.create(owner, (use) => Boolean(use(searchValueObs)));
|
||||
@ -124,7 +129,12 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
|
||||
testId('bulk-action'),
|
||||
)
|
||||
];
|
||||
})
|
||||
}),
|
||||
cssSortIcon(
|
||||
'Sort',
|
||||
cssSortIcon.cls('-active', isSortedByCount),
|
||||
dom.on('click', () => isSortedByCount.set(!isSortedByCount.get())),
|
||||
)
|
||||
),
|
||||
cssItemList(
|
||||
testId('list'),
|
||||
@ -146,19 +156,22 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
|
||||
dom.domComputed((use) => {
|
||||
const isAboveLimit = use(isAboveLimitObs);
|
||||
const searchValue = use(isSearchingObs);
|
||||
const otherValues = use(model.otherValues);
|
||||
const anyOtherValues = Boolean(otherValues.length);
|
||||
const valuesBeyondLimit = use(model.valuesBeyondLimit);
|
||||
if (isAboveLimit) {
|
||||
return searchValue ? [
|
||||
buildSummary('Other Matching', BeyondLimit, model),
|
||||
buildSummary('Other Non-Matching', OtherValues, model),
|
||||
buildSummary('Other Matching', valuesBeyondLimit, false, model),
|
||||
buildSummary('Other Non-Matching', otherValues, true, model),
|
||||
] : [
|
||||
buildSummary('Other Values', BeyondLimit, model),
|
||||
buildSummary('Future Values', OtherValues, model)
|
||||
buildSummary('Other Values', concat(otherValues, valuesBeyondLimit), false, model),
|
||||
buildSummary('Future Values', [], true, model),
|
||||
];
|
||||
} else {
|
||||
return searchValue ? [
|
||||
buildSummary('Others', OtherValues, model)
|
||||
return anyOtherValues ? [
|
||||
buildSummary('Others', otherValues, true, model)
|
||||
] : [
|
||||
buildSummary('Future Values', OtherValues, model)
|
||||
buildSummary('Future Values', [], true, model)
|
||||
];
|
||||
}
|
||||
}),
|
||||
@ -173,95 +186,93 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
|
||||
return filterMenu;
|
||||
}
|
||||
|
||||
// Describes the model for one summary checkbox.
|
||||
interface SummaryModel extends Disposable {
|
||||
|
||||
// Whether checkbox is checked
|
||||
isChecked: Computed<boolean> ;
|
||||
/**
|
||||
* Builds a tri-state checkbox that summaries the state of all the `values`. The special value
|
||||
* `Future Values` which turns the filter into an inclusion filter or exclusion filter, can be
|
||||
* added to the summary using `switchFilterType`. Uses `label` as label and also expects
|
||||
* `model` as the column filter menu model.
|
||||
*
|
||||
* The checkbox appears checked if all values of the summary are included, unchecked if none, and in
|
||||
* the indeterminate state if values are in mixed state.
|
||||
*
|
||||
* On user clicks, if checkbox is checked, it does uncheck all the values, and if the
|
||||
* `switchFilterType` is true it also converts the filter into an inclusion filter. But if the
|
||||
* checkbox is unchecked, or in the Indeterminate state, it does check all the values, and if the
|
||||
* `switchFilterType` is true it also converts the filter into an exlusion filter.
|
||||
*/
|
||||
function buildSummary(label: string|Computed<string>, values: Array<[CellValue, IFilterCount]>,
|
||||
switchFilterType: boolean, model: ColumnFilterMenuModel) {
|
||||
const columnFilter = model.columnFilter;
|
||||
const checkboxState = Computed.create(
|
||||
null, columnFilter.isInclusionFilter, columnFilter.filterFunc,
|
||||
(_use, isInclusionFilter) => {
|
||||
|
||||
// Callback for when the checkbox is changed.
|
||||
callback: (checked: boolean) => void;
|
||||
// let's gather all sub options.
|
||||
const subOptions = values.map((val) => ({getState: () => columnFilter.includes(val[0])}));
|
||||
if (switchFilterType) {
|
||||
subOptions.push({getState: () => !isInclusionFilter});
|
||||
}
|
||||
|
||||
// The count.
|
||||
count: Computed<string>;
|
||||
}
|
||||
// At this point if sub options is still empty let's just return false (unchecked).
|
||||
if (!subOptions.length) { return false; }
|
||||
|
||||
// Ctor that construct a SummaryModel.
|
||||
type SummaryModelCreator = new(columnFilter: ColumnFilterMenuModel) => SummaryModel;
|
||||
// let's compare the state for first sub options against all the others. If there is one
|
||||
// different, then state should be `Indeterminate`, otherwise, the state will the the same as
|
||||
// the one of the first sub option.
|
||||
const first = subOptions[0].getState();
|
||||
if (some(tail(subOptions), (val) => val.getState() !== first)) { return Indeterminate; }
|
||||
return first;
|
||||
|
||||
// Summaries all the values that are in `columnFilter.valuesBeyondLimit`, ie: it includes a count
|
||||
// for all the values and clicking the checkbox successively add/delete these values from the
|
||||
// `columnFilter`.
|
||||
class BeyondLimit extends Disposable implements SummaryModel {
|
||||
}).onWrite((val) => {
|
||||
|
||||
public columnFilter = this.model.columnFilter;
|
||||
if (switchFilterType) {
|
||||
|
||||
public isChecked = Computed.create(this, (use) => (
|
||||
!use(this.model.valuesBeyondLimit).find(([key, _val]) => !this.columnFilter.includes(key))
|
||||
));
|
||||
// Note that if `includeFutureValues` is true, we only needs to toggle the filter type
|
||||
// between exclusive and inclusive. Doing this will automatically excludes/includes all
|
||||
// other values, so no need for extra steps.
|
||||
const state = val ?
|
||||
{excluded: model.filteredKeys.get().filter((key) => !columnFilter.includes(key))} :
|
||||
{included: model.filteredKeys.get().filter((key) => columnFilter.includes(key))};
|
||||
columnFilter.setState(state);
|
||||
|
||||
public count = Computed.create(this, (use) => getCount(use(this.model.valuesBeyondLimit)).toLocaleString());
|
||||
} else {
|
||||
|
||||
constructor(public model: ColumnFilterMenuModel) { super(); }
|
||||
const keys = values.map(([key]) => key);
|
||||
if (val) {
|
||||
columnFilter.addMany(keys);
|
||||
} else {
|
||||
columnFilter.deleteMany(keys);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
public callback(checked: boolean) {
|
||||
const keys = this.model.valuesBeyondLimit.get().map(([key, _val]) => key);
|
||||
if (checked) {
|
||||
this.columnFilter.addMany(keys);
|
||||
} else {
|
||||
this.columnFilter.deleteMany(keys);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Summaries the values that are not in columnFilter.filteredValues, it includes both the values in
|
||||
// `columnFilter.otherValues` (ie: the values that are filtered out if user is using the search) and
|
||||
// the future values. The checkbox successively turns columnFilter into an inclusion/exclusion
|
||||
// filter. The count is hidden if `columnFilter.otherValues` is empty (ie: no search is peformed =>
|
||||
// only checkbox only toggles future values)
|
||||
class OtherValues extends Disposable implements SummaryModel {
|
||||
public columnFilter = this.model.columnFilter;
|
||||
public isChecked = Computed.create(this, (use) => !use(this.columnFilter.isInclusionFilter));
|
||||
|
||||
public count = Computed.create(this, (use) => {
|
||||
const c = getCount(use(this.model.otherValues));
|
||||
return c ? c.toLocaleString() : '';
|
||||
});
|
||||
|
||||
constructor(public model: ColumnFilterMenuModel) { super(); }
|
||||
|
||||
public callback(checked: boolean) {
|
||||
const columnFilter = this.columnFilter;
|
||||
const filteredKeys = this.model.filteredKeys;
|
||||
const state = checked ?
|
||||
{excluded: filteredKeys.get().filter((key) => !columnFilter.includes(key))} :
|
||||
{included: filteredKeys.get().filter((key) => columnFilter.includes(key))};
|
||||
return columnFilter.setState(state);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSummary(label: string, SummaryModelCtor: SummaryModelCreator, model: ColumnFilterMenuModel) {
|
||||
const summaryModel = new SummaryModelCtor(model);
|
||||
return cssMenuItem(
|
||||
dom.autoDispose(summaryModel),
|
||||
dom.autoDispose(checkboxState),
|
||||
testId('summary'),
|
||||
cssLabel(
|
||||
cssCheckboxSquare(
|
||||
{type: 'checkbox'},
|
||||
dom.on('change', (_ev, elem) => summaryModel.callback(elem.checked)),
|
||||
dom.prop('checked', summaryModel.isChecked)
|
||||
),
|
||||
cssItemValue(label),
|
||||
labeledTriStateSquareCheckbox(
|
||||
checkboxState,
|
||||
`${label} ${formatUniqueCount(values)}`.trim()
|
||||
),
|
||||
summaryModel.count !== undefined ? cssItemCount(dom.text(summaryModel.count), testId('count')) : null,
|
||||
cssItemCount(formatCount(values), testId('count')),
|
||||
);
|
||||
}
|
||||
|
||||
function formatCount(values: Array<[CellValue, IFilterCount]>) {
|
||||
const count = getCount(values);
|
||||
return count ? count.toLocaleString() : '';
|
||||
}
|
||||
|
||||
function formatUniqueCount(values: Array<[CellValue, IFilterCount]>) {
|
||||
const count = values.length;
|
||||
return count ? '(' + count.toLocaleString() + ')' : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns content for the newly created columnFilterMenu; for use with setPopupToCreateDom().
|
||||
*/
|
||||
export function createFilterMenu(openCtl: IOpenController, sectionFilter: SectionFilter, field: ViewFieldRec,
|
||||
rowSource: FilteredRowSource, tableData: TableData, onClose: () => void = noop) {
|
||||
rowSource: RowSource, 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 keyMapFunc = tableData.getRowPropFunc(field.column().colId())!;
|
||||
const labelGetter = tableData.getRowPropFunc(field.displayColModel().colId())!;
|
||||
@ -270,17 +281,24 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
|
||||
const activeFilterBar = field.viewSection.peek().activeFilterBar;
|
||||
const columnType = field.column().type.peek();
|
||||
|
||||
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
|
||||
// not possible to search for anything. Likely caused by the key being something completely
|
||||
// different than the label.
|
||||
addCountsToMap(valueCounts, rowSource.getAllRows() as Iterable<number>, { keyMapFunc, labelMapFunc, columnType });
|
||||
addCountsToMap(valueCounts, rowSource.getHiddenRows() as Iterable<number>, { keyMapFunc, labelMapFunc, columnType });
|
||||
function getFilterFunc(f: ViewFieldRec, colFilter: ColumnFilterFunc|null) {
|
||||
return f.getRowId() === field.getRowId() ? null : colFilter;
|
||||
}
|
||||
const filterFunc = Computed.create(null, use => sectionFilter.buildFilterFunc(getFilterFunc, use));
|
||||
openCtl.autoDispose(filterFunc);
|
||||
|
||||
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
|
||||
|
||||
const [allRows, hiddenRows] = partition(Array.from(rowSource.getAllRows()), filterFunc.get());
|
||||
const valueCounts: Map<CellValue, {label: string, count: number}> = new Map();
|
||||
addCountsToMap(valueCounts, allRows, {keyMapFunc, labelMapFunc, columnType});
|
||||
addCountsToMap(valueCounts, hiddenRows, {keyMapFunc, labelMapFunc, columnType,
|
||||
areHiddenRows: true});
|
||||
|
||||
const model = ColumnFilterMenuModel.create(openCtl, columnFilter, Array.from(valueCounts));
|
||||
|
||||
|
||||
return columnFilterMenu(openCtl, {
|
||||
model,
|
||||
valueCounts,
|
||||
@ -300,6 +318,7 @@ interface ICountOptions {
|
||||
keyMapFunc?: (v: any) => any;
|
||||
labelMapFunc?: (v: any) => any;
|
||||
columnType?: string;
|
||||
areHiddenRows?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -309,39 +328,38 @@ interface ICountOptions {
|
||||
* 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>, rowIds: Iterable<Number>,
|
||||
{ keyMapFunc = identity, labelMapFunc = identity, columnType }: ICountOptions) {
|
||||
function addCountsToMap(valueMap: Map<CellValue, IFilterCount>, rowIds: RowId[],
|
||||
{ keyMapFunc = identity, labelMapFunc = identity, columnType,
|
||||
areHiddenRows = false }: 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[]);
|
||||
const list = decodeObject(key) as unknown[];
|
||||
for (const item of list) {
|
||||
addSingleCountToMap(valueMap, item, () => item, areHiddenRows);
|
||||
}
|
||||
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(rowId), count: 1 });
|
||||
}
|
||||
addSingleCountToMap(valueMap, key, () => labelMapFunc(rowId), areHiddenRows);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds each item in `list` to `valueMap`.
|
||||
* Adds the `value` to `valueMap` using `labelGetter` to get the label and increments `count` unless
|
||||
* isHiddenRow is true.
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
function addSingleCountToMap(valueMap: Map<CellValue, IFilterCount>, value: any, labelGetter: () => any,
|
||||
isHiddenRow: boolean) {
|
||||
if (!valueMap.has(value)) {
|
||||
valueMap.set(value, { label: labelGetter(), count: 0 });
|
||||
}
|
||||
if (!isHiddenRow) {
|
||||
valueMap.get(value)!.count++;
|
||||
}
|
||||
}
|
||||
|
||||
@ -404,6 +422,7 @@ const cssSelectAll = styled('div', `
|
||||
const cssDotSeparator = styled('span', `
|
||||
color: ${colors.lightGreen};
|
||||
margin: 0 4px;
|
||||
user-select: none;
|
||||
`);
|
||||
const cssMenuDivider = styled(menuDivider, `
|
||||
flex-shrink: 0;
|
||||
@ -464,3 +483,10 @@ const cssNoResults = styled(cssMenuItem, `
|
||||
color: ${colors.slate};
|
||||
justify-content: center;
|
||||
`);
|
||||
const cssSortIcon = styled(icon, `
|
||||
--icon-color: ${colors.slate};
|
||||
margin-left: auto;
|
||||
&-active {
|
||||
--icon-color: ${colors.lightGreen}
|
||||
}
|
||||
`);
|
||||
|
Loading…
Reference in New Issue
Block a user