/** * Creates a UI for column filter menu given a columnFilter model, a mapping of cell values to counts, and an onClose * callback that's triggered on Apply or on Cancel. Changes to the UI result in changes to the underlying model, * but on Cancel the model is reset to its initial state prior to menu closing. */ import * as commands from 'app/client/components/commands'; import {GristDoc} from 'app/client/components/GristDoc'; import {makeT} from 'app/client/lib/localization'; import {ColumnFilter, NEW_FILTER_JSON} from 'app/client/models/ColumnFilter'; import {ColumnFilterMenuModel, IFilterCount} from 'app/client/models/ColumnFilterMenuModel'; import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel'; import {FilterInfo} from 'app/client/models/entities/ViewSectionRec'; import {RowId, RowSource} from 'app/client/models/rowset'; import {ColumnFilterFunc, SectionFilter} from 'app/client/models/SectionFilter'; import {TableData} from 'app/client/models/TableData'; import {cssInput} from 'app/client/ui/cssInput'; import {cssPinButton} from 'app/client/ui/RightPanelStyles'; import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons'; import {cssLabel as cssCheckboxLabel, cssCheckboxSquare, cssLabelText, Indeterminate, labeledTriStateSquareCheckbox } from 'app/client/ui2018/checkbox'; import {theme, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {cssOptionRowIcon, menu, menuCssClass, menuDivider, menuItem} from 'app/client/ui2018/menus'; import {CellValue} from 'app/common/DocActions'; import {IRelativeDateSpec, isEquivalentFilter, isRelativeBound} from "app/common/FilterState"; import {formatRelBounds} from "app/common/RelativeDates"; import { Computed, dom, DomArg, DomElementArg, DomElementMethod, IDisposableOwner, input, makeTestId, Observable, 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 debounce = require('lodash/debounce'); import {IOpenController, IPopupOptions, setPopupToCreateDom} from 'popweasel'; import {decodeObject} from 'app/plugin/objtypes'; import {extractTypeFromColType, isDateLikeType, isList, isNumberType, isRefListType} from 'app/common/gristTypes'; import {choiceToken} from 'app/client/widgets/ChoiceToken'; import {ChoiceOptions} from 'app/client/widgets/ChoiceTextBox'; import {ColumnFilterCalendarView} from 'app/client/ui/ColumnFilterCalendarView'; import {cssDeleteButton, cssDeleteIcon, cssToken as cssTokenTokenBase} from 'app/client/widgets/ChoiceListEditor'; import {relativeDatesControl} from 'app/client/ui/ColumnFilterMenuUtils'; import {FocusLayer} from 'app/client/lib/FocusLayer'; import {DateRangeOptions, IDateRangeOption} from 'app/client/ui/DateRangeOptions'; import {createFormatter} from 'app/common/ValueFormatter'; const t = makeT('ColumnFilterMenu'); export interface IFilterMenuOptions { model: ColumnFilterMenuModel; valueCounts: Map; rangeInputOptions?: IRangeInputOptions; showAllFiltersButton?: boolean; doCancel(): void; doSave(reset: boolean): void; renderValue(key: CellValue, value: IFilterCount): DomElementArg; onClose(): void; valueParser?(val: string): any; valueFormatter?(val: any): string; } const testId = makeTestId('test-filter-menu-'); export type IColumnFilterViewType = 'listView'|'calendarView'; /** * Returns the DOM content for the column filter menu. * * For use with setPopupToCreateDom(). */ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptions): HTMLElement { const { model, doCancel, doSave, onClose, renderValue, valueParser, showAllFiltersButton } = opts; const { columnFilter, filterInfo, gristDoc } = model; const valueFormatter = opts.valueFormatter || ((val) => val?.toString() || ''); // Map to keep track of displayed checkboxes const checkboxMap: Map = new Map(); // Listen for changes to filterFunc, and update checkboxes accordingly. Debounce is needed to // prevent some weirdness when users click on a checkbox while focus was on a range input (causing // sometimes the checkbox to not toggle) const filterListener = columnFilter.filterFunc.addListener(debounce(func => { for (const [value, elem] of checkboxMap) { elem.checked = func(value); } })); 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))); const showRangeFilter = isNumberType(columnFilter.columnType) || isDateLikeType(columnFilter.columnType); const isDateFilter = isDateLikeType(columnFilter.columnType); const selectedBoundObs = Observable.create<'min'|'max'|null>(owner, null); const viewTypeObs = Computed.create(owner, ( (use) => isDateFilter && use(selectedBoundObs) ? 'calendarView' : 'listView' )); const isMinSelected = Computed.create(owner, (use) => use(selectedBoundObs) === 'min') .onWrite((val) => val ? selectedBoundObs.set('min') : selectedBoundObs.set('max')); const isMaxSelected = Computed.create(owner, (use) => use(selectedBoundObs) === 'max') .onWrite((val) => val ? selectedBoundObs.set('max') : selectedBoundObs.set('min')); let searchInput: HTMLInputElement; let cancel = false; let reset = false; const filterMenu: HTMLElement = cssMenu( { tabindex: '-1' }, // Allow menu to be focused testId('wrapper'), // Makes sure focus goes back to menu container and disable grist keyboard shortcut while open. elem => { FocusLayer.create(owner, {defaultFocusElem: elem, pauseMousetrap: true}); // Gives focus to the searchInput on open (or to the min input if the range filter is // present). Note that this must happen after the instanciation of FocusLayer in order to // correctly override focus set by the latter also using a 0 delay. setTimeout(() => { const el = searchInput; el.focus(); el.select(); }, 0); }, dom.cls(menuCssClass), dom.autoDispose(filterListener), // Save or cancel on disposal, which should always happen as part of closing. dom.onDispose(() => cancel ? doCancel() : doSave(reset)), dom.onKeyDown({ Enter: () => onClose(), Escape: () => onClose(), }), // Filter by range dom.maybe(showRangeFilter, () => [ cssRangeContainer( rangeInput( columnFilter.min, { isDateFilter, placeholder: isDateFilter ? t("Start") : t("Min"), valueParser, valueFormatter, isSelected: isMinSelected, viewTypeObs, nextSelected: () => selectedBoundObs.set('max'), }, testId('min'), dom.onKeyDown({Tab: (e) => e.shiftKey || selectedBoundObs.set('max')}), ), rangeInput( columnFilter.max, { isDateFilter, placeholder: isDateFilter ? t("End") : t("Max"), valueParser, valueFormatter, isSelected: isMaxSelected, viewTypeObs, }, testId('max'), dom.onKeyDown({Tab: (e) => e.shiftKey ? selectedBoundObs.set('min') : selectedBoundObs.set('max')}), ), ), // presets links dom.maybe(isDateFilter, () => { function action(option: IDateRangeOption) { const {min, max} = option; columnFilter.min.set(min); columnFilter.max.set(max); // open the calendar view selectedBoundObs.set('min'); } return [ cssLinkRow( testId('presets-links'), cssLink( DateRangeOptions[0].label, dom.on('click', () => action(DateRangeOptions[0])) ), cssLink( DateRangeOptions[1].label, dom.on('click', () => action(DateRangeOptions[1])) ), cssLink( 'More ', icon('Dropdown'), menu(() => DateRangeOptions.map( (option) => menuItem(() => action(option), option.label) ), {attach: '.' + cssMenu.className}) ), ), ]; }), cssMenuDivider(), ]), dom.domComputed(viewTypeObs, viewType => viewType === 'listView' ? ListView() : dom.create(ColumnFilterCalendarView, { viewTypeObs, selectedBoundObs, columnFilter, })), Footer(), // Prevents click on presets links submenus (any one of the 'More' submenus) from bubling up and // eventually cause the parent menu to close (which used to happen when opening the column // filter from the section sort&filter menu) dom.on('click', ev => ev.stopPropagation()), ); function ListView() { return [ cssMenuHeader( cssSearchIcon('Search'), searchInput = cssSearch( searchValueObs, { onInput: true }, testId('search-input'), { type: 'search', placeholder: t('Search values') }, dom.onKeyDown({ Enter: () => { if (searchValueObs.get()) { columnFilter.setState({included: filteredKeys.get()}); } }, Escape$: (ev) => { if (searchValueObs.get()) { searchValueObs.set(''); searchInput.focus(); ev.stopPropagation(); } } }) ), dom.maybe(searchValueObs, () => cssSearchIcon( 'CrossSmall', testId('search-close'), dom.on('click', () => { searchValueObs.set(''); searchInput.focus(); }), )), ), cssMenuDivider(), cssMenuItem( dom.domComputed((use) => { const searchValue = use(searchValueObs); // This is necessary to avoid a known bug in grainjs where filteredKeys does not get // recalculated. use(filteredKeys); const allSpec = searchValue ? {included: use(filteredKeys)} : {excluded: []}; const noneSpec = searchValue ? {excluded: use(filteredKeys)} : {included: []}; const state = use(columnFilter.state); return [ cssSelectAll( dom.text(searchValue ? t('All Shown') : t('All')), dom.prop('disabled', isEquivalentFilter(state, allSpec)), dom.on('click', () => columnFilter.setState(allSpec)), testId('bulk-action'), ), cssDotSeparator('•'), cssSelectAll( searchValue ? t('All Except') : t('None'), dom.prop('disabled', isEquivalentFilter(state, noneSpec)), dom.on('click', () => columnFilter.setState(noneSpec)), testId('bulk-action'), ) ]; }), cssSortIcon( 'Sort', cssSortIcon.cls('-active', isSortedByCount), dom.on('click', () => isSortedByCount.set(!isSortedByCount.get())), ) ), cssItemList( testId('list'), dom.maybe(use => use(filteredValues).length === 0, () => cssNoResults(t("No matching values"))), dom.domComputed(filteredValues, (values) => values.slice(0, model.limitShown).map(([key, value]) => ( cssMenuItem( cssLabel( cssCheckboxSquare( {type: 'checkbox'}, dom.on('change', (_ev, elem) => { elem.checked ? columnFilter.add(key) : columnFilter.delete(key); }), (elem) => { elem.checked = columnFilter.includes(key); checkboxMap.set(key, elem); }, dom.style('position', 'relative'), ), renderValue(key, value), ), cssItemCount(value.count.toLocaleString(), testId('count'))) ))) // Include comma separator ), ]; } function Footer() { return [ cssMenuDivider(), cssMenuFooter( 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); const isRangeFilter = use(columnFilter.isRange); if (isRangeFilter || use(viewTypeObs) === 'calendarView') { return []; } if (isAboveLimit) { return searchValue ? [ buildSummary(t("Other Matching"), valuesBeyondLimit, false, model), buildSummary(t("Other Non-Matching"), otherValues, true, model), ] : [ buildSummary(t("Other Values"), concat(otherValues, valuesBeyondLimit), false, model), buildSummary(t("Future Values"), [], true, model), ]; } else { return anyOtherValues ? [ buildSummary(t('Others'), otherValues, true, model) ] : [ buildSummary(t("Future Values"), [], true, model) ]; } }), cssFooterButtons( dom('div', cssPrimaryButton('Close', testId('apply-btn'), dom.on('click', () => { reset = true; onClose(); }), ), basicButton('Cancel', testId('cancel-btn'), dom.on('click', () => { cancel = true; onClose(); }), ), !showAllFiltersButton ? null : cssAllFiltersButton( 'All filters', dom.on('click', () => { onClose(); commands.allCommands.sortFilterMenuOpen.run(filterInfo.viewSection.getRowId()); }), testId('all-filters-btn'), ), ), dom('div', cssPinButton( icon('PinTilted'), cssPinButton.cls('-pinned', model.filterInfo.isPinned), dom.on('click', () => filterInfo.pinned(!filterInfo.pinned())), gristDoc.behavioralPrompts.attachTip('filterButtons', { popupOptions: { attach: null, } }), testId('pin-btn'), ), ), ) ) ]; } return filterMenu; } export interface IRangeInputOptions { isDateFilter: boolean; placeholder: string; isSelected: Observable; viewTypeObs: Observable; valueParser?(val: string): any; valueFormatter(val: any): string; nextSelected?(): void; } // The range input with the preset links. function rangeInput(obs: Observable, opts: IRangeInputOptions, ...args: DomArg[]) { const buildInput = () => [ dom.maybe(use => isRelativeBound(use(obs)), () => relativeToken(obs, opts)), numericInput(obs, opts), ]; return cssRangeInputContainer( dom.maybe(opts.isDateFilter, () => [ cssRangeInputIcon('FieldDate'), buildInput(), icon('Dropdown') ]), dom.maybe(!opts.isDateFilter, () => [ buildInput(), ]), cssRangeInputContainer.cls('-relative', use => isRelativeBound(use(obs))), dom.cls('selected', (use) => use(opts.viewTypeObs) === 'calendarView' && use(opts.isSelected)), dom.on('click', () => opts.isSelected.set(true)), (elem) => opts.isDateFilter ? attachRelativeDatesOptions(elem, obs, opts) : null, dom.onKeyDown({ Backspace$: () => isRelativeBound(obs.get()) && obs.set(undefined), }), ...args, ); } // Attach the date options dropdown to elem. function attachRelativeDatesOptions(elem: HTMLElement, obs: Observable, opts: IRangeInputOptions) { const popupCtl = relativeDatesControl(elem, obs, { ...opts, placement: 'right-start', attach: '.' + cssMenu.className }); // makes sure the options are shown any time the value changes. const onValueChange = () => { if (opts.isSelected.get()) { popupCtl.open(); } else { popupCtl.close(); } }; // toggle popup on click dom.update(elem, [ dom.on('click', () => popupCtl.toggle()), dom.autoDispose(opts.isSelected.addListener(onValueChange)), dom.autoDispose(obs.addListener(onValueChange)), dom.onKeyDown({ Enter$: (e) => { if (opts.viewTypeObs.get() === 'listView') { return; } if (opts.isSelected.get()) { if (popupCtl.isOpen()) { opts.nextSelected?.(); } else { popupCtl.open(); } } // Prevents Enter to close filter menu e.stopPropagation(); }, }), ]); } function numericInput(obs: Observable, opts: IRangeInputOptions, ...args: DomArg[]) { const valueParser = opts.valueParser || Number; const formatValue = opts.valueFormatter; const placeholder = opts.placeholder; let editMode = false; let inputEl: HTMLInputElement; // handle change const onBlur = () => { onInput.flush(); editMode = false; inputEl.value = formatValue(obs.get()); // Make sure focus is trapped on input during calendar view, so that uses can still use keyboard // to navigate relative date options just after picking a date on the calendar. setTimeout(() => opts.isSelected.get() && inputEl.focus()); }; const onInput = debounce(() => { if (isRelativeBound(obs.get())) { return; } editMode = true; const val = inputEl.value ? valueParser(inputEl.value) : undefined; if (val === undefined || typeof val === 'number' && !isNaN(val)) { obs.set(val); } }, 100); // TODO: could be nice to have the cursor positioned at the end of the input return inputEl = cssRangeInput( {inputmode: 'numeric', placeholder, value: formatValue(obs.get())}, dom.on('input', onInput), dom.on('blur', onBlur), // keep input content in sync only when no edit are going on. dom.autoDispose(obs.addListener(() => editMode ? null : inputEl.value = formatValue(obs.get()))), dom.autoDispose(opts.isSelected.addListener(val => val && inputEl.focus())), dom.onKeyDown({ Enter$: () => onBlur(), Tab$: () => onBlur(), }), ...args, ); } function relativeToken(obs: Observable, opts: IRangeInputOptions) { return cssTokenContainer( cssTokenToken( dom.text((use) => formatRelBounds(use(obs) as IRelativeDateSpec)), cssDeleteButton( // Ignore mousedown events, so that tokens aren't draggable by the delete button. dom.on('mousedown', (ev) => ev.stopPropagation()), cssDeleteIcon('CrossSmall'), dom.on('click', () => obs.set(undefined)), testId('tokenfield-delete'), ), testId('tokenfield-token'), ), ); } /** * 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 exclusion filter. */ function buildSummary(label: string|Computed, values: Array<[CellValue, IFilterCount]>, switchFilterType: boolean, model: ColumnFilterMenuModel) { const columnFilter = model.columnFilter; const checkboxState = Computed.create( null, columnFilter.isInclusionFilter, columnFilter.filterFunc, (_use, isInclusionFilter) => { // let's gather all sub options. const subOptions = values.map((val) => ({getState: () => columnFilter.includes(val[0])})); if (switchFilterType) { subOptions.push({getState: () => !isInclusionFilter}); } // At this point if sub options is still empty let's just return false (unchecked). if (!subOptions.length) { return false; } // 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; }).onWrite((val) => { if (switchFilterType) { // 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); } else { const keys = values.map(([key]) => key); if (val) { columnFilter.addMany(keys); } else { columnFilter.deleteMany(keys); } } }); return cssMenuItem( dom.autoDispose(checkboxState), testId('summary'), labeledTriStateSquareCheckbox( checkboxState, `${label} ${formatUniqueCount(values)}`.trim() ), 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 a new `Map` object to holds pairs of `CellValue` and `IFilterCount`. For `Bool`, `Choice` * and `ChoiceList` type of column, the map is initialized with all possible values in order to make * sure they get shown to the user. */ function getEmptyCountMap(fieldOrColumn: ViewFieldRec|ColumnRec): Map { const columnType = fieldOrColumn.origCol().type(); let values: any[] = []; if (columnType === 'Bool') { values = [true, false]; } else if (['Choice', 'ChoiceList'].includes(columnType)) { const options = fieldOrColumn.origCol().widgetOptionsJson; values = options.prop('choices')() ?? []; } return new Map(values.map((v) => [v, {label: String(v), count: 0, displayValue: v}])); } export interface IColumnFilterMenuOptions { /** If true, shows a button that opens the sort & filter widget menu. */ showAllFiltersButton?: boolean; /** Callback for when the filter menu is closed. */ onClose?: () => void; } export interface ICreateFilterMenuParams extends IColumnFilterMenuOptions { openCtl: IOpenController; sectionFilter: SectionFilter; filterInfo: FilterInfo; rowSource: RowSource; tableData: TableData; gristDoc: GristDoc; } /** * Returns content for the newly created columnFilterMenu; for use with setPopupToCreateDom(). */ export function createFilterMenu(params: ICreateFilterMenuParams) { const { openCtl, sectionFilter, filterInfo, rowSource, tableData, gristDoc, showAllFiltersButton, onClose = noop } = params; // Go through all of our shown and hidden rows, and count them up by the values in this column. const {fieldOrColumn, filter, isPinned} = filterInfo; const columnType = fieldOrColumn.origCol.peek().type.peek(); const visibleColumnType = fieldOrColumn.visibleColModel.peek()?.type.peek() || columnType; const {keyMapFunc, labelMapFunc, valueMapFunc} = getMapFuncs(columnType, tableData, fieldOrColumn); // range input options const valueParser = (fieldOrColumn as any).createValueParser?.(); let colFormatter = fieldOrColumn.visibleColFormatter(); // Show only the date part of the datetime format in range picker. if (extractTypeFromColType(colFormatter.type) === 'DateTime') { const {docSettings} = colFormatter; const widgetOpts = fieldOrColumn.origCol.peek().widgetOptionsJson(); colFormatter = createFormatter('Date', widgetOpts, docSettings); } // formatting values for Numeric columns entail issues. For instance with '%' when users type // 0.499 and press enter, the input now shows 50% and there's no way to know what is the actual // underlying value. Maybe worse, both 0.499 and 0.495 format to 50% but they can have different // effects depending on data. Hence as of writing better to keep it only for Date. const valueFormatter = isDateLikeType(visibleColumnType) ? (val: any) => colFormatter.formatAny(val) : undefined; function getFilterFunc(col: ViewFieldRec|ColumnRec, colFilter: ColumnFilterFunc|null) { return col.getRowId() === fieldOrColumn.getRowId() ? null : colFilter; } const filterFunc = Computed.create(null, use => sectionFilter.buildFilterFunc(getFilterFunc, use)); openCtl.autoDispose(filterFunc); const [allRows, hiddenRows] = partition(Array.from(rowSource.getAllRows()), filterFunc.get()); const valueCounts = getEmptyCountMap(fieldOrColumn); addCountsToMap(valueCounts, allRows, {keyMapFunc, labelMapFunc, columnType, valueMapFunc}); addCountsToMap(valueCounts, hiddenRows, {keyMapFunc, labelMapFunc, columnType, areHiddenRows: true, valueMapFunc}); const valueCountsArr = Array.from(valueCounts); const columnFilter = ColumnFilter.create(openCtl, filter.peek(), columnType, visibleColumnType, valueCountsArr.map((arr) => arr[0])); sectionFilter.setFilterOverride(fieldOrColumn.origCol().getRowId(), columnFilter); // Will be removed on menu disposal const model = ColumnFilterMenuModel.create(openCtl, { columnFilter, filterInfo, valueCount: valueCountsArr, gristDoc, }); return columnFilterMenu(openCtl, { model, valueCounts, onClose: () => { openCtl.close(); onClose(); }, doSave: (reset: boolean = false) => { const spec = columnFilter.makeFilterJson(); const {viewSection} = sectionFilter; viewSection.setFilter( fieldOrColumn.origCol().origColRef(), {filter: spec} ); if (reset) { sectionFilter.resetTemporaryRows(); } // Check if the save was for a new filter, and if that new filter was pinned. If it was, and // it is the second pinned filter in the section, trigger a tip that explains how multiple // filters in the same section work. const isNewPinnedFilter = columnFilter.initialFilterJson === NEW_FILTER_JSON && isPinned(); if (isNewPinnedFilter && viewSection.pinnedActiveFilters.get().length === 2) { viewSection.showNestedFilteringPopup.set(true); } }, doCancel: () => { const {viewSection} = sectionFilter; if (columnFilter.initialFilterJson === NEW_FILTER_JSON) { viewSection.revertFilter(fieldOrColumn.origCol().origColRef()); } else { const initialFilter = columnFilter.initialFilterJson; columnFilter.setState(initialFilter); viewSection.setFilter( fieldOrColumn.origCol().origColRef(), {filter: initialFilter, pinned: model.initialPinned} ); } }, renderValue: getRenderFunc(columnType, fieldOrColumn), valueParser, valueFormatter, showAllFiltersButton, }); } /** * Returns three callback functions, `keyMapFunc`, `labelMapFunc` * and `valueMapFunc`, which map row ids to cell values, labels * and visible col value respectively. * * The functions vary based on the `columnType`. For example, * Reference Lists have a unique `labelMapFunc` that returns a list * of all labels in a given cell, rather than a single label. * * Used by ColumnFilterMenu to compute counts of unique cell * values and display them with an appropriate label. */ function getMapFuncs(columnType: string, tableData: TableData, fieldOrColumn: ViewFieldRec|ColumnRec) { const keyMapFunc = tableData.getRowPropFunc(fieldOrColumn.colId())!; const labelGetter = tableData.getRowPropFunc(fieldOrColumn.displayColModel().colId())!; const formatter = fieldOrColumn.visibleColFormatter(); let labelMapFunc: (rowId: number) => string | string[]; const valueMapFunc: (rowId: number) => any = (rowId: number) => decodeObject(labelGetter(rowId)!); if (isRefListType(columnType)) { labelMapFunc = (rowId: number) => { const maybeLabels = labelGetter(rowId); if (!maybeLabels) { return ''; } const labels = isList(maybeLabels) ? maybeLabels.slice(1) : [maybeLabels]; return labels.map(l => formatter.formatAny(l)); }; } else { labelMapFunc = (rowId: number) => formatter.formatAny(labelGetter(rowId)); } return {keyMapFunc, labelMapFunc, valueMapFunc}; } /** * Returns a callback function for rendering values in a filter menu. * * For example, Choice and Choice List columns will differ from other * column types by rendering their values as colored tokens instead of * text. */ function getRenderFunc(columnType: string, fieldOrColumn: ViewFieldRec|ColumnRec) { if (['Choice', 'ChoiceList'].includes(columnType)) { const options = fieldOrColumn.widgetOptionsJson.peek(); const choiceSet: Set = new Set(options.choices || []); const choiceOptions: ChoiceOptions = options.choiceOptions || {}; return (_key: CellValue, value: IFilterCount) => { if (value.label === '') { return cssItemValue(value.label); } return choiceToken( value.label, { fillColor: choiceOptions[value.label]?.fillColor, textColor: choiceOptions[value.label]?.textColor, fontBold: choiceOptions[value.label]?.fontBold ?? false, fontUnderline: choiceOptions[value.label]?.fontUnderline ?? false, fontItalic: choiceOptions[value.label]?.fontItalic ?? false, fontStrikethrough: choiceOptions[value.label]?.fontStrikethrough ?? false, invalid: !choiceSet.has(value.label), }, dom.cls(cssToken.className), testId('choice-token') ); }; } return (key: CellValue, value: IFilterCount) => cssItemValue(value.label === undefined ? String(key) : value.label); } interface ICountOptions { columnType: string; // returns the indexing key for the filter keyMapFunc?: (v: any) => any; // returns the string representation of the value (can involves some formatting). labelMapFunc?: (v: any) => any; // returns the underlying value (useful for comparison) valueMapFunc: (v: any) => any; areHiddenRows?: boolean; } /** * 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 and a `displayValues` mapped with `valueMapFunc`. * * The optional column type controls how complex cell values are decomposed into keys (e.g. Choice Lists have * the possible choices as keys). * Note that this logic is replicated in BaseView.prototype.filterByThisCellValue. */ function addCountsToMap(valueMap: Map, rowIds: RowId[], { keyMapFunc = identity, labelMapFunc = identity, columnType, areHiddenRows = false, valueMapFunc }: 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) as unknown[]; if (!list.length) { // If the list is empty, add an item for the whole list, otherwise the row will be missing from filters. addSingleCountToMap(valueMap, '', () => '', () => '', areHiddenRows); } for (const item of list) { addSingleCountToMap(valueMap, item, () => item, () => item, areHiddenRows); } continue; } // If row contains a Reference List, treat each reference as a separate key if (isList(key) && isRefListType(columnType)) { const refIds = decodeObject(key) as unknown[]; if (!refIds.length) { // If the list is empty, add an item for the whole list, otherwise the row will be missing from filters. addSingleCountToMap(valueMap, null, () => null, () => null, areHiddenRows); } const refLabels = labelMapFunc(rowId); const displayValues = valueMapFunc(rowId); refIds.forEach((id, i) => { addSingleCountToMap(valueMap, id, () => refLabels[i], () => displayValues[i], areHiddenRows); }); continue; } // For complex values, serialize the value to allow them to be properly stored if (Array.isArray(key)) { key = JSON.stringify(key); } addSingleCountToMap(valueMap, key, () => labelMapFunc(rowId), () => valueMapFunc(rowId), areHiddenRows); } } /** * Adds the `value` to `valueMap` using `labelGetter` to get the label and increments `count` unless * isHiddenRow is true. */ function addSingleCountToMap(valueMap: Map, value: any, label: () => any, displayValue: () => any, isHiddenRow: boolean) { if (!valueMap.has(value)) { valueMap.set(value, { label: label(), count: 0, displayValue: displayValue() }); } if (!isHiddenRow) { valueMap.get(value)!.count++; } } function getCount(values: Array<[CellValue, IFilterCount]>) { return values.reduce((acc, val) => acc + val[1].count, 0); } const defaultPopupOptions: IPopupOptions = { placement: 'bottom-start', boundaries: 'viewport', trigger: ['click'], }; interface IColumnFilterPopupOptions { // Options to pass to the popup component. popupOptions?: IPopupOptions; } type IAttachColumnFilterMenuOptions = IColumnFilterPopupOptions & IColumnFilterMenuOptions; // Helper to attach the column filter menu. export function attachColumnFilterMenu( filterInfo: FilterInfo, options: IAttachColumnFilterMenuOptions = {} ): DomElementMethod { const {popupOptions, ...filterMenuOptions} = options; const popupOptionsWithDefaults = {...defaultPopupOptions, ...popupOptions}; return (elem) => { const instance = filterInfo.viewSection.viewInstance(); if (instance && instance.createFilterMenu) { // Should be set if using BaseView setPopupToCreateDom(elem, ctl => instance.createFilterMenu( ctl, filterInfo, filterMenuOptions), popupOptionsWithDefaults); } }; } const cssMenu = styled('div', ` display: flex; flex-direction: column; min-width: 252px; max-width: 400px; max-height: 90vh; outline: none; background-color: ${theme.menuBg}; padding-top: 0; padding-bottom: 12px; `); const cssMenuHeader = styled('div', ` height: 40px; flex-shrink: 0; display: flex; align-items: center; margin: 0 16px; `); const cssSelectAll = styled(textButton, ` --icon-color: ${theme.controlFg}; `); const cssDotSeparator = styled('span', ` color: ${theme.controlFg}; margin: 0 4px; user-select: none; `); const cssMenuDivider = styled(menuDivider, ` flex-shrink: 0; margin: 0; `); const cssItemList = styled('div', ` flex-shrink: 1; overflow: auto; min-height: 80px; margin-top: 4px; padding-bottom: 8px; `); const cssMenuItem = styled('div', ` display: flex; padding: 8px 16px; `); const cssLink = textButton; const cssLinkRow = styled(cssMenuItem, ` column-gap: 12px; padding-top: 0; padding-bottom: 16px; `); export const cssItemValue = styled(cssLabelText, ` margin-right: 12px; white-space: pre; `); const cssItemCount = styled('div', ` flex-grow: 1; align-self: normal; text-align: right; color: ${theme.lightText}; `); const cssMenuFooter = styled('div', ` display: flex; flex-shrink: 0; flex-direction: column; padding-top: 4px; `); const cssFooterButtons = styled('div', ` display: flex; justify-content: space-between; align-items: center; padding: 8px 16px; `); const cssPrimaryButton = styled(primaryButton, ` margin-right: 8px; `); const cssAllFiltersButton = styled(textButton, ` margin-left: 8px; `); const cssSearch = styled(input, ` color: ${theme.inputFg}; background-color: ${theme.inputBg}; flex-grow: 1; min-width: 1px; -webkit-appearance: none; -moz-appearance: none; font-size: ${vars.mediumFontSize}; margin: 0px 16px 0px 8px; padding: 0px; border: none; outline: none; &::placeholder { color: ${theme.inputPlaceholderFg}; } `); const cssSearchIcon = styled(icon, ` --icon-color: ${theme.lightText}; flex-shrink: 0; margin-left: auto; margin-right: 4px; `); const cssNoResults = styled(cssMenuItem, ` font-style: italic; color: ${theme.lightText}; justify-content: center; `); const cssSortIcon = styled(icon, ` --icon-color: ${theme.controlSecondaryFg}; margin-left: auto; &-active { --icon-color: ${theme.controlFg} } `); const cssLabel = styled(cssCheckboxLabel, ` align-items: center; font-weight: initial; /* negate bootstrap */ `); const cssToken = styled('div', ` margin-left: 8px; margin-right: 12px; `); const cssRangeContainer = styled(cssMenuItem, ` display: flex; align-items: center; row-gap: 6px; flex-direction: column; padding: 16px 16px; `); const cssRangeInputContainer = styled('div', ` position: relative; width: 100%; display: flex; background-color: ${theme.inputBg}; height: 30px; width: 100%; border-radius: 3px; border: 1px solid ${theme.inputBorder}; outline: none; padding: 5px; &.selected { border: 1px solid ${theme.inputValid}; } &-relative input { padding: 0; max-width: 0; } `); const cssRangeInputIcon = cssOptionRowIcon; const cssRangeInput = styled(cssInput, ` height: unset; border: none; padding: 0; width: unset; flex-grow: 1; `); const cssTokenToken = styled(cssTokenTokenBase, ` height: 18px; line-height: unset; align-self: center; cursor: default; `); const cssTokenContainer = styled('div', ` width: 100%; display: flex; outline: none; `);