mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +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