gristlabs_grist-core/app/client/ui/ColumnFilterMenu.ts
Alex Hall bd52665f96 (core) Allow adding rows to widgets filtered by a link using a formula column
Summary:
When a widget `A` is selected by a widget `B` so that `A` is filtered, adding a new row to `A` uses the values in the selected row of `B` and the columns relevant to the linking as default values for the new row. This ensures that the new row matches the current linking filter and remains visible. However this would previously cause a sandbox error when one of the linking columns was a formula column, which doesn't allow setting values. This diff ignores formula columns when picking default values.

Since the value of the formula column in the new row typically won't match the linking filter, extra measures are needed to avoid the new row immediately disappearing. Regular filters already have a mechanism for this, but I didn't manage to extend it to also work for linking. Thanks @dsagal for creating `UnionRowSource` (originally in D4017) which is now used as the solution for temporarily exempting rows from both kinds of filtering.

While testing, I also came across another bug in linking summary tables that caused incorrect filtering, which I fixed with some changes to `DynamicQuerySet`.

Test Plan: Extended an nbrowser test, which both tests for the main change as well as the secondary bugfix.

Reviewers: georgegevoian

Reviewed By: georgegevoian

Subscribers: dsagal

Differential Revision: https://phab.getgrist.com/D4135
2023-12-18 20:28:41 +02:00

1079 lines
38 KiB
TypeScript

/**
* 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 {FocusLayer} from 'app/client/lib/FocusLayer';
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 {RowSource} from 'app/client/models/rowset';
import {ColumnFilterFunc, SectionFilter} from 'app/client/models/SectionFilter';
import {TableData} from 'app/client/models/TableData';
import {ColumnFilterCalendarView} from 'app/client/ui/ColumnFilterCalendarView';
import {relativeDatesControl} from 'app/client/ui/ColumnFilterMenuUtils';
import {cssInput} from 'app/client/ui/cssInput';
import {DateRangeOptions, IDateRangeOption} from 'app/client/ui/DateRangeOptions';
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 {cssDeleteButton, cssDeleteIcon, cssToken as cssTokenTokenBase} from 'app/client/widgets/ChoiceListEditor';
import {ChoiceOptions} from 'app/client/widgets/ChoiceTextBox';
import {choiceToken} from 'app/client/widgets/ChoiceToken';
import {CellValue} from 'app/common/DocActions';
import {IRelativeDateSpec, isEquivalentFilter, isRelativeBound} from 'app/common/FilterState';
import {extractTypeFromColType, isDateLikeType, isList, isNumberType, isRefListType} from 'app/common/gristTypes';
import {formatRelBounds} from 'app/common/RelativeDates';
import {createFormatter} from 'app/common/ValueFormatter';
import {UIRowId} from 'app/plugin/GristAPI';
import {decodeObject} from 'app/plugin/objtypes';
import {Computed, dom, DomArg, DomElementArg, DomElementMethod, IDisposableOwner,
input, makeTestId, Observable, styled} from 'grainjs';
import {IOpenController, IPopupOptions, setPopupToCreateDom} from 'popweasel';
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');
const t = makeT('ColumnFilterMenu');
export interface IFilterMenuOptions {
model: ColumnFilterMenuModel;
valueCounts: Map<CellValue, IFilterCount>;
rangeInputOptions?: IRangeInputOptions;
showAllFiltersButton?: boolean;
doCancel(): void;
doSave(): 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<CellValue, HTMLInputElement> = 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<IColumnFilterViewType>(owner, (
(use) => isDateFilter && use(selectedBoundObs) ? 'calendarView' : 'listView'
));
const isMinSelected = Computed.create<boolean>(owner, (use) => use(selectedBoundObs) === 'min')
.onWrite((val) => val ? selectedBoundObs.set('min') : selectedBoundObs.set('max'));
const isMaxSelected = Computed.create<boolean>(owner, (use) => use(selectedBoundObs) === 'max')
.onWrite((val) => val ? selectedBoundObs.set('max') : selectedBoundObs.set('min'));
let searchInput: HTMLInputElement;
let cancel = 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()),
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', () => {
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.behavioralPromptsManager.attachTip('filterButtons', {
popupOptions: {
attach: null,
placement: 'right',
},
}),
testId('pin-btn'),
),
),
)
)
];
}
return filterMenu;
}
export interface IRangeInputOptions {
isDateFilter: boolean;
placeholder: string;
isSelected: Observable<boolean>;
viewTypeObs: Observable<IColumnFilterViewType>;
valueParser?(val: string): any;
valueFormatter(val: any): string;
nextSelected?(): void;
}
// The range input with the preset links.
function rangeInput(obs: Observable<number|undefined|IRelativeDateSpec>, opts: IRangeInputOptions,
...args: DomArg<HTMLDivElement>[]) {
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<number|undefined|IRelativeDateSpec>,
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<number|undefined|IRelativeDateSpec>,
opts: IRangeInputOptions,
...args: DomArg<HTMLDivElement>[]) {
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());
setTimeout(() => {
// 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.
if (opts.viewTypeObs.get() === 'calendarView' && 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<number|undefined|IRelativeDateSpec>,
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<string>, 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<CellValue, IFilterCount> {
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: () => {
const spec = columnFilter.makeFilterJson();
const {viewSection} = sectionFilter;
viewSection.setFilter(
fieldOrColumn.origCol().origColRef(),
{filter: spec}
);
// 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<string> = 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<CellValue, IFilterCount>, rowIds: UIRowId[],
{ 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<CellValue, IFilterCount>, 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;
`);