2020-10-02 15:10:00 +00:00
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*/
|
2022-11-17 20:17:51 +00:00
|
|
|
import * as commands from 'app/client/components/commands';
|
2022-12-20 02:06:39 +00:00
|
|
|
import {GristDoc} from 'app/client/components/GristDoc';
|
2023-08-28 09:16:17 +00:00
|
|
|
import {FocusLayer} from 'app/client/lib/FocusLayer';
|
2022-10-28 16:11:08 +00:00
|
|
|
import {makeT} from 'app/client/lib/localization';
|
2022-11-17 20:17:51 +00:00
|
|
|
import {ColumnFilter, NEW_FILTER_JSON} from 'app/client/models/ColumnFilter';
|
2021-05-06 12:23:50 +00:00
|
|
|
import {ColumnFilterMenuModel, IFilterCount} from 'app/client/models/ColumnFilterMenuModel';
|
2022-11-17 20:17:51 +00:00
|
|
|
import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel';
|
2021-11-19 20:30:11 +00:00
|
|
|
import {FilterInfo} from 'app/client/models/entities/ViewSectionRec';
|
2023-07-07 08:54:01 +00:00
|
|
|
import {RowSource} from 'app/client/models/rowset';
|
2021-06-17 15:26:43 +00:00
|
|
|
import {ColumnFilterFunc, SectionFilter} from 'app/client/models/SectionFilter';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {TableData} from 'app/client/models/TableData';
|
2023-08-28 09:16:17 +00:00
|
|
|
import {ColumnFilterCalendarView} from 'app/client/ui/ColumnFilterCalendarView';
|
|
|
|
import {relativeDatesControl} from 'app/client/ui/ColumnFilterMenuUtils';
|
2022-09-06 01:51:57 +00:00
|
|
|
import {cssInput} from 'app/client/ui/cssInput';
|
2024-03-08 06:30:30 +00:00
|
|
|
import {getDateRangeOptions, IDateRangeOption} from 'app/client/ui/DateRangeOptions';
|
2022-11-17 20:17:51 +00:00
|
|
|
import {cssPinButton} from 'app/client/ui/RightPanelStyles';
|
|
|
|
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
|
2023-08-28 09:16:17 +00:00
|
|
|
import {cssLabel as cssCheckboxLabel, cssCheckboxSquare,
|
|
|
|
cssLabelText, Indeterminate, labeledTriStateSquareCheckbox} from 'app/client/ui2018/checkbox';
|
2022-09-06 01:51:57 +00:00
|
|
|
import {theme, vars} from 'app/client/ui2018/cssVars';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {icon} from 'app/client/ui2018/icons';
|
2022-09-14 09:04:20 +00:00
|
|
|
import {cssOptionRowIcon, menu, menuCssClass, menuDivider, menuItem} from 'app/client/ui2018/menus';
|
2023-08-28 09:16:17 +00:00
|
|
|
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';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {CellValue} from 'app/common/DocActions';
|
2023-08-28 09:16:17 +00:00
|
|
|
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';
|
2021-06-17 15:26:43 +00:00
|
|
|
import concat = require('lodash/concat');
|
2020-10-02 15:10:00 +00:00
|
|
|
import identity = require('lodash/identity');
|
2021-05-06 12:23:50 +00:00
|
|
|
import noop = require('lodash/noop');
|
2021-06-17 15:26:43 +00:00
|
|
|
import partition = require('lodash/partition');
|
|
|
|
import some = require('lodash/some');
|
|
|
|
import tail = require('lodash/tail');
|
2022-05-24 14:59:12 +00:00
|
|
|
import debounce = require('lodash/debounce');
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2022-10-28 16:11:08 +00:00
|
|
|
const t = makeT('ColumnFilterMenu');
|
|
|
|
|
2022-06-23 08:01:12 +00:00
|
|
|
export interface IFilterMenuOptions {
|
2021-03-25 15:14:03 +00:00
|
|
|
model: ColumnFilterMenuModel;
|
2020-10-02 15:10:00 +00:00
|
|
|
valueCounts: Map<CellValue, IFilterCount>;
|
2022-11-17 20:17:51 +00:00
|
|
|
rangeInputOptions?: IRangeInputOptions;
|
|
|
|
showAllFiltersButton?: boolean;
|
|
|
|
doCancel(): void;
|
2023-12-18 17:50:57 +00:00
|
|
|
doSave(): void;
|
2022-11-17 20:17:51 +00:00
|
|
|
renderValue(key: CellValue, value: IFilterCount): DomElementArg;
|
|
|
|
onClose(): void;
|
2022-09-14 09:04:20 +00:00
|
|
|
valueParser?(val: string): any;
|
|
|
|
valueFormatter?(val: any): string;
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
2021-03-25 15:14:03 +00:00
|
|
|
const testId = makeTestId('test-filter-menu-');
|
|
|
|
|
2022-09-14 09:04:20 +00:00
|
|
|
export type IColumnFilterViewType = 'listView'|'calendarView';
|
|
|
|
|
2022-11-17 20:17:51 +00:00
|
|
|
/**
|
|
|
|
* Returns the DOM content for the column filter menu.
|
|
|
|
*
|
|
|
|
* For use with setPopupToCreateDom().
|
|
|
|
*/
|
2021-03-25 15:14:03 +00:00
|
|
|
export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptions): HTMLElement {
|
2022-09-14 09:04:20 +00:00
|
|
|
const { model, doCancel, doSave, onClose, renderValue, valueParser, showAllFiltersButton } = opts;
|
2022-12-20 02:06:39 +00:00
|
|
|
const { columnFilter, filterInfo, gristDoc } = model;
|
2022-09-14 09:04:20 +00:00
|
|
|
const valueFormatter = opts.valueFormatter || ((val) => val?.toString() || '');
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
// Map to keep track of displayed checkboxes
|
|
|
|
const checkboxMap: Map<CellValue, HTMLInputElement> = new Map();
|
|
|
|
|
2022-05-24 14:59:12 +00:00
|
|
|
// 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 => {
|
2020-10-02 15:10:00 +00:00
|
|
|
for (const [value, elem] of checkboxMap) {
|
|
|
|
elem.checked = func(value);
|
|
|
|
}
|
2022-05-24 14:59:12 +00:00
|
|
|
}));
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2021-06-17 15:26:43 +00:00
|
|
|
const {searchValue: searchValueObs, filteredValues, filteredKeys, isSortedByCount} = model;
|
2021-03-08 16:31:06 +00:00
|
|
|
|
2021-03-25 15:14:03 +00:00
|
|
|
const isAboveLimitObs = Computed.create(owner, (use) => use(model.valuesBeyondLimit).length > 0);
|
|
|
|
const isSearchingObs = Computed.create(owner, (use) => Boolean(use(searchValueObs)));
|
2022-06-23 08:01:12 +00:00
|
|
|
const showRangeFilter = isNumberType(columnFilter.columnType) || isDateLikeType(columnFilter.columnType);
|
2022-09-14 09:04:20 +00:00
|
|
|
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'));
|
2021-03-08 16:31:06 +00:00
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
let searchInput: HTMLInputElement;
|
2022-11-17 20:17:51 +00:00
|
|
|
let cancel = false;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
const filterMenu: HTMLElement = cssMenu(
|
|
|
|
{ tabindex: '-1' }, // Allow menu to be focused
|
|
|
|
testId('wrapper'),
|
2022-09-14 09:04:20 +00:00
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
|
|
},
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
dom.cls(menuCssClass),
|
|
|
|
dom.autoDispose(filterListener),
|
2022-11-17 20:17:51 +00:00
|
|
|
// Save or cancel on disposal, which should always happen as part of closing.
|
2023-12-18 17:50:57 +00:00
|
|
|
dom.onDispose(() => cancel ? doCancel() : doSave()),
|
2020-10-02 15:10:00 +00:00
|
|
|
dom.onKeyDown({
|
|
|
|
Enter: () => onClose(),
|
2022-09-14 09:04:20 +00:00
|
|
|
Escape: () => onClose(),
|
2020-10-02 15:10:00 +00:00
|
|
|
}),
|
2022-05-24 14:59:12 +00:00
|
|
|
|
|
|
|
// Filter by range
|
2022-06-23 08:01:12 +00:00
|
|
|
dom.maybe(showRangeFilter, () => [
|
2022-05-24 14:59:12 +00:00
|
|
|
cssRangeContainer(
|
2022-09-14 09:04:20 +00:00
|
|
|
rangeInput(
|
|
|
|
columnFilter.min, {
|
|
|
|
isDateFilter,
|
2023-01-03 09:59:36 +00:00
|
|
|
placeholder: isDateFilter ? t("Start") : t("Min"),
|
2022-09-14 09:04:20 +00:00
|
|
|
valueParser,
|
|
|
|
valueFormatter,
|
|
|
|
isSelected: isMinSelected,
|
|
|
|
viewTypeObs,
|
|
|
|
nextSelected: () => selectedBoundObs.set('max'),
|
2021-03-08 16:31:06 +00:00
|
|
|
},
|
2022-09-14 09:04:20 +00:00
|
|
|
testId('min'),
|
|
|
|
dom.onKeyDown({Tab: (e) => e.shiftKey || selectedBoundObs.set('max')}),
|
|
|
|
),
|
|
|
|
rangeInput(
|
|
|
|
columnFilter.max, {
|
|
|
|
isDateFilter,
|
2023-01-03 09:59:36 +00:00
|
|
|
placeholder: isDateFilter ? t("End") : t("Max"),
|
2022-09-14 09:04:20 +00:00
|
|
|
valueParser,
|
|
|
|
valueFormatter,
|
|
|
|
isSelected: isMaxSelected,
|
|
|
|
viewTypeObs,
|
|
|
|
},
|
|
|
|
testId('max'),
|
|
|
|
dom.onKeyDown({Tab: (e) => e.shiftKey ? selectedBoundObs.set('min') : selectedBoundObs.set('max')}),
|
|
|
|
),
|
2021-03-08 16:31:06 +00:00
|
|
|
),
|
2022-09-14 09:04:20 +00:00
|
|
|
|
|
|
|
// 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');
|
|
|
|
}
|
2021-03-08 16:31:06 +00:00
|
|
|
return [
|
2022-09-14 09:04:20 +00:00
|
|
|
cssLinkRow(
|
|
|
|
testId('presets-links'),
|
|
|
|
cssLink(
|
2024-03-08 06:30:30 +00:00
|
|
|
getDateRangeOptions()[0].label,
|
|
|
|
dom.on('click', () => action(getDateRangeOptions()[0]))
|
2022-09-14 09:04:20 +00:00
|
|
|
),
|
|
|
|
cssLink(
|
2024-03-08 06:30:30 +00:00
|
|
|
getDateRangeOptions()[1].label,
|
|
|
|
dom.on('click', () => action(getDateRangeOptions()[1]))
|
2022-09-14 09:04:20 +00:00
|
|
|
),
|
|
|
|
cssLink(
|
|
|
|
'More ', icon('Dropdown'),
|
2024-03-08 06:30:30 +00:00
|
|
|
menu(() => getDateRangeOptions().map(
|
2022-09-14 09:04:20 +00:00
|
|
|
(option) => menuItem(() => action(option), option.label)
|
|
|
|
), {attach: '.' + cssMenu.className})
|
|
|
|
),
|
2021-03-08 16:31:06 +00:00
|
|
|
),
|
|
|
|
];
|
2021-06-17 15:26:43 +00:00
|
|
|
}),
|
2022-09-14 09:04:20 +00:00
|
|
|
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'),
|
2023-01-03 09:59:36 +00:00
|
|
|
{ type: 'search', placeholder: t('Search values') },
|
2022-09-14 09:04:20 +00:00
|
|
|
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(
|
2023-01-03 09:59:36 +00:00
|
|
|
dom.text(searchValue ? t('All Shown') : t('All')),
|
2022-09-14 09:04:20 +00:00
|
|
|
dom.prop('disabled', isEquivalentFilter(state, allSpec)),
|
|
|
|
dom.on('click', () => columnFilter.setState(allSpec)),
|
|
|
|
testId('bulk-action'),
|
2021-07-15 15:50:28 +00:00
|
|
|
),
|
2022-09-14 09:04:20 +00:00
|
|
|
cssDotSeparator('•'),
|
|
|
|
cssSelectAll(
|
2023-01-03 09:59:36 +00:00
|
|
|
searchValue ? t('All Except') : t('None'),
|
2022-09-14 09:04:20 +00:00
|
|
|
dom.prop('disabled', isEquivalentFilter(state, noneSpec)),
|
|
|
|
dom.on('click', () => columnFilter.setState(noneSpec)),
|
|
|
|
testId('bulk-action'),
|
|
|
|
)
|
2021-03-25 15:14:03 +00:00
|
|
|
];
|
2022-09-14 09:04:20 +00:00
|
|
|
}),
|
|
|
|
cssSortIcon(
|
|
|
|
'Sort',
|
|
|
|
cssSortIcon.cls('-active', isSortedByCount),
|
|
|
|
dom.on('click', () => isSortedByCount.set(!isSortedByCount.get())),
|
|
|
|
)
|
|
|
|
),
|
|
|
|
cssItemList(
|
|
|
|
testId('list'),
|
2023-01-03 09:59:36 +00:00
|
|
|
dom.maybe(use => use(filteredValues).length === 0, () => cssNoResults(t("No matching values"))),
|
2022-09-14 09:04:20 +00:00
|
|
|
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 ? [
|
2023-01-03 09:59:36 +00:00
|
|
|
buildSummary(t("Other Matching"), valuesBeyondLimit, false, model),
|
|
|
|
buildSummary(t("Other Non-Matching"), otherValues, true, model),
|
2022-09-14 09:04:20 +00:00
|
|
|
] : [
|
2023-01-03 09:59:36 +00:00
|
|
|
buildSummary(t("Other Values"), concat(otherValues, valuesBeyondLimit), false, model),
|
|
|
|
buildSummary(t("Future Values"), [], true, model),
|
2022-09-14 09:04:20 +00:00
|
|
|
];
|
|
|
|
} else {
|
|
|
|
return anyOtherValues ? [
|
|
|
|
buildSummary(t('Others'), otherValues, true, model)
|
|
|
|
] : [
|
2023-01-03 09:59:36 +00:00
|
|
|
buildSummary(t("Future Values"), [], true, model)
|
2022-09-14 09:04:20 +00:00
|
|
|
];
|
|
|
|
}
|
|
|
|
}),
|
|
|
|
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'),
|
|
|
|
),
|
2022-11-17 20:17:51 +00:00
|
|
|
),
|
2022-09-14 09:04:20 +00:00
|
|
|
dom('div',
|
|
|
|
cssPinButton(
|
|
|
|
icon('PinTilted'),
|
|
|
|
cssPinButton.cls('-pinned', model.filterInfo.isPinned),
|
|
|
|
dom.on('click', () => filterInfo.pinned(!filterInfo.pinned())),
|
2024-02-14 21:18:09 +00:00
|
|
|
gristDoc.behavioralPromptsManager.attachPopup('filterButtons', {
|
2022-12-20 02:06:39 +00:00
|
|
|
popupOptions: {
|
|
|
|
attach: null,
|
2022-12-22 03:36:26 +00:00
|
|
|
placement: 'right',
|
|
|
|
},
|
2022-12-20 02:06:39 +00:00
|
|
|
}),
|
2022-09-14 09:04:20 +00:00
|
|
|
testId('pin-btn'),
|
|
|
|
),
|
2022-11-17 20:17:51 +00:00
|
|
|
),
|
2022-09-14 09:04:20 +00:00
|
|
|
)
|
|
|
|
)
|
|
|
|
];
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
return filterMenu;
|
|
|
|
}
|
|
|
|
|
2022-06-23 08:01:12 +00:00
|
|
|
export interface IRangeInputOptions {
|
2022-09-14 09:04:20 +00:00
|
|
|
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,
|
|
|
|
);
|
2022-06-23 08:01:12 +00:00
|
|
|
}
|
|
|
|
|
2022-09-14 09:04:20 +00:00
|
|
|
// 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>[]) {
|
2022-05-24 14:59:12 +00:00
|
|
|
const valueParser = opts.valueParser || Number;
|
2022-09-14 09:04:20 +00:00
|
|
|
const formatValue = opts.valueFormatter;
|
|
|
|
const placeholder = opts.placeholder;
|
2022-05-24 14:59:12 +00:00
|
|
|
let editMode = false;
|
2022-09-14 09:04:20 +00:00
|
|
|
let inputEl: HTMLInputElement;
|
2022-05-24 14:59:12 +00:00
|
|
|
// handle change
|
|
|
|
const onBlur = () => {
|
|
|
|
onInput.flush();
|
|
|
|
editMode = false;
|
2022-09-14 09:04:20 +00:00
|
|
|
inputEl.value = formatValue(obs.get());
|
|
|
|
|
2023-07-05 01:13:53 +00:00
|
|
|
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();
|
|
|
|
}
|
|
|
|
});
|
2022-05-24 14:59:12 +00:00
|
|
|
};
|
|
|
|
const onInput = debounce(() => {
|
2022-09-14 09:04:20 +00:00
|
|
|
if (isRelativeBound(obs.get())) { return; }
|
2022-05-24 14:59:12 +00:00
|
|
|
editMode = true;
|
2022-09-14 09:04:20 +00:00
|
|
|
const val = inputEl.value ? valueParser(inputEl.value) : undefined;
|
|
|
|
if (val === undefined || typeof val === 'number' && !isNaN(val)) {
|
2022-05-24 14:59:12 +00:00
|
|
|
obs.set(val);
|
|
|
|
}
|
2022-06-23 08:01:12 +00:00
|
|
|
}, 100);
|
2022-09-14 09:04:20 +00:00
|
|
|
// TODO: could be nice to have the cursor positioned at the end of the input
|
|
|
|
return inputEl = cssRangeInput(
|
2022-05-24 14:59:12 +00:00
|
|
|
{inputmode: 'numeric', placeholder, value: formatValue(obs.get())},
|
|
|
|
dom.on('input', onInput),
|
|
|
|
dom.on('blur', onBlur),
|
2022-09-14 09:04:20 +00:00
|
|
|
// 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'),
|
|
|
|
),
|
2022-05-24 14:59:12 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-03-25 15:14:03 +00:00
|
|
|
|
2021-06-17 15:26:43 +00:00
|
|
|
/**
|
|
|
|
* 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
|
2022-02-19 09:46:49 +00:00
|
|
|
* `switchFilterType` is true it also converts the filter into an exclusion filter.
|
2021-06-17 15:26:43 +00:00
|
|
|
*/
|
|
|
|
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});
|
|
|
|
}
|
2021-03-25 15:14:03 +00:00
|
|
|
|
2021-06-17 15:26:43 +00:00
|
|
|
// At this point if sub options is still empty let's just return false (unchecked).
|
|
|
|
if (!subOptions.length) { return false; }
|
2021-03-25 15:14:03 +00:00
|
|
|
|
2021-06-17 15:26:43 +00:00
|
|
|
// 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;
|
2021-03-25 15:14:03 +00:00
|
|
|
|
2021-06-17 15:26:43 +00:00
|
|
|
}).onWrite((val) => {
|
2021-03-25 15:14:03 +00:00
|
|
|
|
2021-06-17 15:26:43 +00:00
|
|
|
if (switchFilterType) {
|
2021-03-25 15:14:03 +00:00
|
|
|
|
2021-06-17 15:26:43 +00:00
|
|
|
// 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);
|
2021-03-25 15:14:03 +00:00
|
|
|
|
2021-06-17 15:26:43 +00:00
|
|
|
} else {
|
2021-03-25 15:14:03 +00:00
|
|
|
|
2021-06-17 15:26:43 +00:00
|
|
|
const keys = values.map(([key]) => key);
|
|
|
|
if (val) {
|
|
|
|
columnFilter.addMany(keys);
|
|
|
|
} else {
|
|
|
|
columnFilter.deleteMany(keys);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
2021-03-25 15:14:03 +00:00
|
|
|
|
|
|
|
return cssMenuItem(
|
2021-06-17 15:26:43 +00:00
|
|
|
dom.autoDispose(checkboxState),
|
2021-03-25 15:14:03 +00:00
|
|
|
testId('summary'),
|
2021-06-17 15:26:43 +00:00
|
|
|
labeledTriStateSquareCheckbox(
|
|
|
|
checkboxState,
|
|
|
|
`${label} ${formatUniqueCount(values)}`.trim()
|
2021-03-25 15:14:03 +00:00
|
|
|
),
|
2021-06-17 15:26:43 +00:00
|
|
|
cssItemCount(formatCount(values), testId('count')),
|
2021-03-25 15:14:03 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-06-17 15:26:43 +00:00
|
|
|
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() + ')' : '';
|
|
|
|
}
|
|
|
|
|
2022-05-19 08:51:04 +00:00
|
|
|
/**
|
|
|
|
* 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;
|
2022-07-15 17:24:11 +00:00
|
|
|
values = options.prop('choices')() ?? [];
|
2022-05-19 08:51:04 +00:00
|
|
|
}
|
2022-05-24 07:10:15 +00:00
|
|
|
return new Map(values.map((v) => [v, {label: String(v), count: 0, displayValue: v}]));
|
2022-05-19 08:51:04 +00:00
|
|
|
}
|
|
|
|
|
2022-11-17 20:17:51 +00:00
|
|
|
export interface IColumnFilterMenuOptions {
|
2022-12-20 02:06:39 +00:00
|
|
|
/** If true, shows a button that opens the sort & filter widget menu. */
|
2022-11-17 20:17:51 +00:00
|
|
|
showAllFiltersButton?: boolean;
|
2022-12-20 02:06:39 +00:00
|
|
|
/** 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;
|
2022-11-17 20:17:51 +00:00
|
|
|
}
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
/**
|
|
|
|
* Returns content for the newly created columnFilterMenu; for use with setPopupToCreateDom().
|
|
|
|
*/
|
2022-12-20 02:06:39 +00:00
|
|
|
export function createFilterMenu(params: ICreateFilterMenuParams) {
|
|
|
|
const {
|
|
|
|
openCtl,
|
|
|
|
sectionFilter,
|
|
|
|
filterInfo,
|
|
|
|
rowSource,
|
|
|
|
tableData,
|
|
|
|
gristDoc,
|
|
|
|
showAllFiltersButton,
|
|
|
|
onClose = noop
|
|
|
|
} = params;
|
2022-11-17 20:17:51 +00:00
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
// Go through all of our shown and hidden rows, and count them up by the values in this column.
|
2022-12-20 02:06:39 +00:00
|
|
|
const {fieldOrColumn, filter, isPinned} = filterInfo;
|
2021-11-19 20:30:11 +00:00
|
|
|
const columnType = fieldOrColumn.origCol.peek().type.peek();
|
2022-05-24 07:10:15 +00:00
|
|
|
const visibleColumnType = fieldOrColumn.visibleColModel.peek()?.type.peek() || columnType;
|
2022-11-17 20:17:51 +00:00
|
|
|
const {keyMapFunc, labelMapFunc, valueMapFunc} = getMapFuncs(columnType, tableData, fieldOrColumn);
|
2022-06-23 08:01:12 +00:00
|
|
|
|
|
|
|
// range input options
|
2022-05-24 14:59:12 +00:00
|
|
|
const valueParser = (fieldOrColumn as any).createValueParser?.();
|
2022-09-14 09:04:20 +00:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
2022-06-23 08:01:12 +00:00
|
|
|
// 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
|
2022-09-14 09:04:20 +00:00
|
|
|
// underlying value. Maybe worse, both 0.499 and 0.495 format to 50% but they can have different
|
2022-06-23 08:01:12 +00:00
|
|
|
// effects depending on data. Hence as of writing better to keep it only for Date.
|
|
|
|
const valueFormatter = isDateLikeType(visibleColumnType) ?
|
2022-09-14 09:04:20 +00:00
|
|
|
(val: any) => colFormatter.formatAny(val) : undefined;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2021-11-19 20:30:11 +00:00
|
|
|
function getFilterFunc(col: ViewFieldRec|ColumnRec, colFilter: ColumnFilterFunc|null) {
|
|
|
|
return col.getRowId() === fieldOrColumn.getRowId() ? null : colFilter;
|
2021-06-17 15:26:43 +00:00
|
|
|
}
|
|
|
|
const filterFunc = Computed.create(null, use => sectionFilter.buildFilterFunc(getFilterFunc, use));
|
|
|
|
openCtl.autoDispose(filterFunc);
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2021-06-17 15:26:43 +00:00
|
|
|
const [allRows, hiddenRows] = partition(Array.from(rowSource.getAllRows()), filterFunc.get());
|
2022-05-19 08:51:04 +00:00
|
|
|
const valueCounts = getEmptyCountMap(fieldOrColumn);
|
2022-05-24 07:10:15 +00:00
|
|
|
addCountsToMap(valueCounts, allRows, {keyMapFunc, labelMapFunc, columnType,
|
|
|
|
valueMapFunc});
|
2021-06-17 15:26:43 +00:00
|
|
|
addCountsToMap(valueCounts, hiddenRows, {keyMapFunc, labelMapFunc, columnType,
|
2022-05-24 07:10:15 +00:00
|
|
|
areHiddenRows: true, valueMapFunc});
|
2021-06-17 15:26:43 +00:00
|
|
|
|
2022-05-24 14:59:12 +00:00
|
|
|
const valueCountsArr = Array.from(valueCounts);
|
2022-11-17 20:17:51 +00:00
|
|
|
const columnFilter = ColumnFilter.create(openCtl, filter.peek(), columnType, visibleColumnType,
|
2022-05-24 14:59:12 +00:00
|
|
|
valueCountsArr.map((arr) => arr[0]));
|
2022-06-23 08:01:12 +00:00
|
|
|
sectionFilter.setFilterOverride(fieldOrColumn.origCol().getRowId(), columnFilter); // Will be removed on menu disposal
|
2022-11-17 20:17:51 +00:00
|
|
|
const model = ColumnFilterMenuModel.create(openCtl, {
|
|
|
|
columnFilter,
|
|
|
|
filterInfo,
|
|
|
|
valueCount: valueCountsArr,
|
2022-12-20 02:06:39 +00:00
|
|
|
gristDoc,
|
2022-11-17 20:17:51 +00:00
|
|
|
});
|
2021-06-17 15:26:43 +00:00
|
|
|
|
2021-03-08 16:31:06 +00:00
|
|
|
return columnFilterMenu(openCtl, {
|
2021-03-25 15:14:03 +00:00
|
|
|
model,
|
2020-10-02 15:10:00 +00:00
|
|
|
valueCounts,
|
2021-05-06 12:23:50 +00:00
|
|
|
onClose: () => { openCtl.close(); onClose(); },
|
2023-12-18 17:50:57 +00:00
|
|
|
doSave: () => {
|
2020-10-02 15:10:00 +00:00
|
|
|
const spec = columnFilter.makeFilterJson();
|
2022-12-20 02:06:39 +00:00
|
|
|
const {viewSection} = sectionFilter;
|
|
|
|
viewSection.setFilter(
|
2021-11-19 20:30:11 +00:00
|
|
|
fieldOrColumn.origCol().origColRef(),
|
2022-11-17 20:17:51 +00:00
|
|
|
{filter: spec}
|
2021-11-19 20:30:11 +00:00
|
|
|
);
|
2022-12-20 02:06:39 +00:00
|
|
|
|
|
|
|
// 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);
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
},
|
2022-11-17 20:17:51 +00:00
|
|
|
doCancel: () => {
|
2022-12-20 02:06:39 +00:00
|
|
|
const {viewSection} = sectionFilter;
|
2022-11-17 20:17:51 +00:00
|
|
|
if (columnFilter.initialFilterJson === NEW_FILTER_JSON) {
|
2022-12-20 02:06:39 +00:00
|
|
|
viewSection.revertFilter(fieldOrColumn.origCol().origColRef());
|
2022-11-17 20:17:51 +00:00
|
|
|
} else {
|
|
|
|
const initialFilter = columnFilter.initialFilterJson;
|
|
|
|
columnFilter.setState(initialFilter);
|
2022-12-20 02:06:39 +00:00
|
|
|
viewSection.setFilter(
|
2022-11-17 20:17:51 +00:00
|
|
|
fieldOrColumn.origCol().origColRef(),
|
|
|
|
{filter: initialFilter, pinned: model.initialPinned}
|
|
|
|
);
|
|
|
|
}
|
|
|
|
},
|
2021-11-19 20:30:11 +00:00
|
|
|
renderValue: getRenderFunc(columnType, fieldOrColumn),
|
2022-09-14 09:04:20 +00:00
|
|
|
valueParser,
|
|
|
|
valueFormatter,
|
2022-11-17 20:17:51 +00:00
|
|
|
showAllFiltersButton,
|
2020-10-02 15:10:00 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2021-08-12 18:06:40 +00:00
|
|
|
/**
|
2022-05-24 07:10:15 +00:00
|
|
|
* Returns three callback functions, `keyMapFunc`, `labelMapFunc`
|
|
|
|
* and `valueMapFunc`, which map row ids to cell values, labels
|
|
|
|
* and visible col value respectively.
|
2021-08-12 18:06:40 +00:00
|
|
|
*
|
|
|
|
* 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.
|
|
|
|
*/
|
2021-11-19 20:30:11 +00:00
|
|
|
function getMapFuncs(columnType: string, tableData: TableData, fieldOrColumn: ViewFieldRec|ColumnRec) {
|
|
|
|
const keyMapFunc = tableData.getRowPropFunc(fieldOrColumn.colId())!;
|
|
|
|
const labelGetter = tableData.getRowPropFunc(fieldOrColumn.displayColModel().colId())!;
|
2021-12-15 22:31:53 +00:00
|
|
|
const formatter = fieldOrColumn.visibleColFormatter();
|
2021-08-12 18:06:40 +00:00
|
|
|
|
|
|
|
let labelMapFunc: (rowId: number) => string | string[];
|
2022-05-24 07:10:15 +00:00
|
|
|
const valueMapFunc: (rowId: number) => any = (rowId: number) => decodeObject(labelGetter(rowId)!);
|
|
|
|
|
2021-08-12 18:06:40 +00:00
|
|
|
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));
|
|
|
|
}
|
2022-05-24 07:10:15 +00:00
|
|
|
return {keyMapFunc, labelMapFunc, valueMapFunc};
|
2021-08-12 18:06:40 +00:00
|
|
|
}
|
|
|
|
|
2021-07-15 15:50:28 +00:00
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*/
|
2021-11-19 20:30:11 +00:00
|
|
|
function getRenderFunc(columnType: string, fieldOrColumn: ViewFieldRec|ColumnRec) {
|
2021-07-15 15:50:28 +00:00
|
|
|
if (['Choice', 'ChoiceList'].includes(columnType)) {
|
2021-11-19 20:30:11 +00:00
|
|
|
const options = fieldOrColumn.widgetOptionsJson.peek();
|
2021-07-15 15:50:28 +00:00
|
|
|
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,
|
2022-04-07 14:58:16 +00:00
|
|
|
fontBold: choiceOptions[value.label]?.fontBold ?? false,
|
|
|
|
fontUnderline: choiceOptions[value.label]?.fontUnderline ?? false,
|
|
|
|
fontItalic: choiceOptions[value.label]?.fontItalic ?? false,
|
|
|
|
fontStrikethrough: choiceOptions[value.label]?.fontStrikethrough ?? false,
|
2022-06-06 17:42:51 +00:00
|
|
|
invalid: !choiceSet.has(value.label),
|
2021-07-15 15:50:28 +00:00
|
|
|
},
|
|
|
|
dom.cls(cssToken.className),
|
|
|
|
testId('choice-token')
|
|
|
|
);
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
return (key: CellValue, value: IFilterCount) =>
|
|
|
|
cssItemValue(value.label === undefined ? String(key) : value.label);
|
|
|
|
}
|
|
|
|
|
2021-06-11 14:32:05 +00:00
|
|
|
interface ICountOptions {
|
2021-08-12 18:06:40 +00:00
|
|
|
columnType: string;
|
2022-05-24 07:10:15 +00:00
|
|
|
// returns the indexing key for the filter
|
2021-06-11 14:32:05 +00:00
|
|
|
keyMapFunc?: (v: any) => any;
|
2022-05-24 07:10:15 +00:00
|
|
|
// returns the string representation of the value (can involves some formatting).
|
2021-06-11 14:32:05 +00:00
|
|
|
labelMapFunc?: (v: any) => any;
|
2022-05-24 07:10:15 +00:00
|
|
|
// returns the underlying value (useful for comparison)
|
|
|
|
valueMapFunc: (v: any) => any;
|
2021-06-17 15:26:43 +00:00
|
|
|
areHiddenRows?: boolean;
|
2021-06-11 14:32:05 +00:00
|
|
|
}
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
/**
|
2022-05-24 07:10:15 +00:00
|
|
|
* 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`.
|
2021-06-11 14:32:05 +00:00
|
|
|
*
|
|
|
|
* The optional column type controls how complex cell values are decomposed into keys (e.g. Choice Lists have
|
|
|
|
* the possible choices as keys).
|
2022-04-18 14:40:29 +00:00
|
|
|
* Note that this logic is replicated in BaseView.prototype.filterByThisCellValue.
|
2020-10-02 15:10:00 +00:00
|
|
|
*/
|
2023-07-07 08:54:01 +00:00
|
|
|
function addCountsToMap(valueMap: Map<CellValue, IFilterCount>, rowIds: UIRowId[],
|
2021-06-17 15:26:43 +00:00
|
|
|
{ keyMapFunc = identity, labelMapFunc = identity, columnType,
|
2022-05-24 07:10:15 +00:00
|
|
|
areHiddenRows = false, valueMapFunc }: ICountOptions) {
|
2021-06-11 14:32:05 +00:00
|
|
|
|
|
|
|
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
|
2021-08-12 18:06:40 +00:00
|
|
|
if (isList(key) && (columnType === 'ChoiceList')) {
|
2021-06-17 15:26:43 +00:00
|
|
|
const list = decodeObject(key) as unknown[];
|
2022-06-10 21:34:12 +00:00
|
|
|
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);
|
|
|
|
}
|
2021-06-17 15:26:43 +00:00
|
|
|
for (const item of list) {
|
2022-05-24 07:10:15 +00:00
|
|
|
addSingleCountToMap(valueMap, item, () => item, () => item, areHiddenRows);
|
2021-06-17 15:26:43 +00:00
|
|
|
}
|
2021-06-11 14:32:05 +00:00
|
|
|
continue;
|
|
|
|
}
|
2021-08-12 18:06:40 +00:00
|
|
|
|
|
|
|
// If row contains a Reference List, treat each reference as a separate key
|
|
|
|
if (isList(key) && isRefListType(columnType)) {
|
|
|
|
const refIds = decodeObject(key) as unknown[];
|
2022-06-10 21:34:12 +00:00
|
|
|
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);
|
|
|
|
}
|
2021-08-12 18:06:40 +00:00
|
|
|
const refLabels = labelMapFunc(rowId);
|
2022-05-24 07:10:15 +00:00
|
|
|
const displayValues = valueMapFunc(rowId);
|
2021-08-12 18:06:40 +00:00
|
|
|
refIds.forEach((id, i) => {
|
2022-05-24 07:10:15 +00:00
|
|
|
addSingleCountToMap(valueMap, id, () => refLabels[i], () => displayValues[i], areHiddenRows);
|
2021-08-12 18:06:40 +00:00
|
|
|
});
|
|
|
|
continue;
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
// For complex values, serialize the value to allow them to be properly stored
|
|
|
|
if (Array.isArray(key)) { key = JSON.stringify(key); }
|
2022-05-24 07:10:15 +00:00
|
|
|
addSingleCountToMap(valueMap, key, () => labelMapFunc(rowId), () => valueMapFunc(rowId), areHiddenRows);
|
2021-06-11 14:32:05 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2021-06-17 15:26:43 +00:00
|
|
|
* Adds the `value` to `valueMap` using `labelGetter` to get the label and increments `count` unless
|
|
|
|
* isHiddenRow is true.
|
2021-06-11 14:32:05 +00:00
|
|
|
*/
|
2022-05-24 07:10:15 +00:00
|
|
|
function addSingleCountToMap(valueMap: Map<CellValue, IFilterCount>, value: any, label: () => any,
|
|
|
|
displayValue: () => any, isHiddenRow: boolean) {
|
2021-06-17 15:26:43 +00:00
|
|
|
if (!valueMap.has(value)) {
|
2022-05-24 07:10:15 +00:00
|
|
|
valueMap.set(value, { label: label(), count: 0, displayValue: displayValue() });
|
2021-06-17 15:26:43 +00:00
|
|
|
}
|
|
|
|
if (!isHiddenRow) {
|
|
|
|
valueMap.get(value)!.count++;
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-03-25 15:14:03 +00:00
|
|
|
function getCount(values: Array<[CellValue, IFilterCount]>) {
|
|
|
|
return values.reduce((acc, val) => acc + val[1].count, 0);
|
|
|
|
}
|
|
|
|
|
2021-04-14 15:17:45 +00:00
|
|
|
const defaultPopupOptions: IPopupOptions = {
|
|
|
|
placement: 'bottom-start',
|
|
|
|
boundaries: 'viewport',
|
|
|
|
trigger: ['click'],
|
|
|
|
};
|
|
|
|
|
2022-11-17 20:17:51 +00:00
|
|
|
interface IColumnFilterPopupOptions {
|
|
|
|
// Options to pass to the popup component.
|
|
|
|
popupOptions?: IPopupOptions;
|
2021-05-06 12:23:50 +00:00
|
|
|
}
|
|
|
|
|
2022-11-17 20:17:51 +00:00
|
|
|
type IAttachColumnFilterMenuOptions = IColumnFilterPopupOptions & IColumnFilterMenuOptions;
|
|
|
|
|
2021-04-14 15:17:45 +00:00
|
|
|
// Helper to attach the column filter menu.
|
2022-11-17 20:17:51 +00:00
|
|
|
export function attachColumnFilterMenu(
|
|
|
|
filterInfo: FilterInfo,
|
|
|
|
options: IAttachColumnFilterMenuOptions = {}
|
|
|
|
): DomElementMethod {
|
|
|
|
const {popupOptions, ...filterMenuOptions} = options;
|
|
|
|
const popupOptionsWithDefaults = {...defaultPopupOptions, ...popupOptions};
|
2021-04-14 15:17:45 +00:00
|
|
|
return (elem) => {
|
2022-11-17 20:17:51 +00:00
|
|
|
const instance = filterInfo.viewSection.viewInstance();
|
2021-04-14 15:17:45 +00:00
|
|
|
if (instance && instance.createFilterMenu) { // Should be set if using BaseView
|
2022-11-17 20:17:51 +00:00
|
|
|
setPopupToCreateDom(elem, ctl => instance.createFilterMenu(
|
|
|
|
ctl, filterInfo, filterMenuOptions), popupOptionsWithDefaults);
|
2021-04-14 15:17:45 +00:00
|
|
|
}
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
const cssMenu = styled('div', `
|
|
|
|
display: flex;
|
|
|
|
flex-direction: column;
|
2022-09-14 09:04:20 +00:00
|
|
|
min-width: 252px;
|
2020-10-02 15:10:00 +00:00
|
|
|
max-width: 400px;
|
|
|
|
max-height: 90vh;
|
|
|
|
outline: none;
|
2022-09-06 01:51:57 +00:00
|
|
|
background-color: ${theme.menuBg};
|
2021-03-08 16:31:06 +00:00
|
|
|
padding-top: 0;
|
|
|
|
padding-bottom: 12px;
|
2020-10-02 15:10:00 +00:00
|
|
|
`);
|
|
|
|
const cssMenuHeader = styled('div', `
|
2021-03-08 16:31:06 +00:00
|
|
|
height: 40px;
|
2020-10-02 15:10:00 +00:00
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
align-items: center;
|
|
|
|
|
2021-03-08 16:31:06 +00:00
|
|
|
margin: 0 16px;
|
2020-10-02 15:10:00 +00:00
|
|
|
`);
|
2022-09-14 09:04:20 +00:00
|
|
|
const cssSelectAll = styled(textButton, `
|
|
|
|
--icon-color: ${theme.controlFg};
|
2021-03-08 16:31:06 +00:00
|
|
|
`);
|
|
|
|
const cssDotSeparator = styled('span', `
|
2022-09-06 01:51:57 +00:00
|
|
|
color: ${theme.controlFg};
|
2021-03-08 16:31:06 +00:00
|
|
|
margin: 0 4px;
|
2021-06-17 15:26:43 +00:00
|
|
|
user-select: none;
|
2020-10-02 15:10:00 +00:00
|
|
|
`);
|
|
|
|
const cssMenuDivider = styled(menuDivider, `
|
|
|
|
flex-shrink: 0;
|
2021-03-08 16:31:06 +00:00
|
|
|
margin: 0;
|
2020-10-02 15:10:00 +00:00
|
|
|
`);
|
|
|
|
const cssItemList = styled('div', `
|
|
|
|
flex-shrink: 1;
|
|
|
|
overflow: auto;
|
|
|
|
min-height: 80px;
|
2021-03-08 16:31:06 +00:00
|
|
|
margin-top: 4px;
|
|
|
|
padding-bottom: 8px;
|
2020-10-02 15:10:00 +00:00
|
|
|
`);
|
|
|
|
const cssMenuItem = styled('div', `
|
|
|
|
display: flex;
|
2021-03-08 16:31:06 +00:00
|
|
|
padding: 8px 16px;
|
2020-10-02 15:10:00 +00:00
|
|
|
`);
|
2022-09-14 09:04:20 +00:00
|
|
|
const cssLink = textButton;
|
|
|
|
const cssLinkRow = styled(cssMenuItem, `
|
|
|
|
column-gap: 12px;
|
|
|
|
padding-top: 0;
|
|
|
|
padding-bottom: 16px;
|
|
|
|
`);
|
2021-07-15 15:50:28 +00:00
|
|
|
export const cssItemValue = styled(cssLabelText, `
|
2020-10-02 15:10:00 +00:00
|
|
|
margin-right: 12px;
|
2021-07-15 15:50:28 +00:00
|
|
|
white-space: pre;
|
2020-10-02 15:10:00 +00:00
|
|
|
`);
|
|
|
|
const cssItemCount = styled('div', `
|
|
|
|
flex-grow: 1;
|
|
|
|
align-self: normal;
|
|
|
|
text-align: right;
|
2022-09-06 01:51:57 +00:00
|
|
|
color: ${theme.lightText};
|
2020-10-02 15:10:00 +00:00
|
|
|
`);
|
|
|
|
const cssMenuFooter = styled('div', `
|
|
|
|
display: flex;
|
|
|
|
flex-shrink: 0;
|
2021-03-08 16:31:06 +00:00
|
|
|
flex-direction: column;
|
2021-03-25 15:14:03 +00:00
|
|
|
padding-top: 4px;
|
2020-10-02 15:10:00 +00:00
|
|
|
`);
|
2022-11-17 20:17:51 +00:00
|
|
|
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;
|
2020-10-02 15:10:00 +00:00
|
|
|
`);
|
|
|
|
const cssSearch = styled(input, `
|
2022-09-06 01:51:57 +00:00
|
|
|
color: ${theme.inputFg};
|
|
|
|
background-color: ${theme.inputBg};
|
2020-10-02 15:10:00 +00:00
|
|
|
flex-grow: 1;
|
|
|
|
min-width: 1px;
|
|
|
|
-webkit-appearance: none;
|
|
|
|
-moz-appearance: none;
|
|
|
|
|
2021-03-08 16:31:06 +00:00
|
|
|
font-size: ${vars.mediumFontSize};
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
margin: 0px 16px 0px 8px;
|
|
|
|
padding: 0px;
|
|
|
|
border: none;
|
|
|
|
outline: none;
|
|
|
|
|
2022-09-06 01:51:57 +00:00
|
|
|
&::placeholder {
|
|
|
|
color: ${theme.inputPlaceholderFg};
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
`);
|
|
|
|
const cssSearchIcon = styled(icon, `
|
2022-09-06 01:51:57 +00:00
|
|
|
--icon-color: ${theme.lightText};
|
2020-10-02 15:10:00 +00:00
|
|
|
flex-shrink: 0;
|
|
|
|
margin-left: auto;
|
|
|
|
margin-right: 4px;
|
|
|
|
`);
|
|
|
|
const cssNoResults = styled(cssMenuItem, `
|
|
|
|
font-style: italic;
|
2022-09-06 01:51:57 +00:00
|
|
|
color: ${theme.lightText};
|
2020-10-02 15:10:00 +00:00
|
|
|
justify-content: center;
|
|
|
|
`);
|
2021-06-17 15:26:43 +00:00
|
|
|
const cssSortIcon = styled(icon, `
|
2022-09-06 01:51:57 +00:00
|
|
|
--icon-color: ${theme.controlSecondaryFg};
|
2021-06-17 15:26:43 +00:00
|
|
|
margin-left: auto;
|
|
|
|
&-active {
|
2022-09-06 01:51:57 +00:00
|
|
|
--icon-color: ${theme.controlFg}
|
2021-06-17 15:26:43 +00:00
|
|
|
}
|
|
|
|
`);
|
2021-07-15 15:50:28 +00:00
|
|
|
const cssLabel = styled(cssCheckboxLabel, `
|
|
|
|
align-items: center;
|
|
|
|
font-weight: initial; /* negate bootstrap */
|
|
|
|
`);
|
|
|
|
const cssToken = styled('div', `
|
|
|
|
margin-left: 8px;
|
|
|
|
margin-right: 12px;
|
|
|
|
`);
|
2022-05-24 14:59:12 +00:00
|
|
|
const cssRangeContainer = styled(cssMenuItem, `
|
|
|
|
display: flex;
|
2022-09-06 01:51:57 +00:00
|
|
|
align-items: center;
|
2022-09-14 09:04:20 +00:00
|
|
|
row-gap: 6px;
|
|
|
|
flex-direction: column;
|
|
|
|
padding: 16px 16px;
|
2022-05-24 14:59:12 +00:00
|
|
|
`);
|
2022-09-14 09:04:20 +00:00
|
|
|
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;
|
|
|
|
}
|
2022-05-24 14:59:12 +00:00
|
|
|
`);
|
2022-09-14 09:04:20 +00:00
|
|
|
const cssRangeInputIcon = cssOptionRowIcon;
|
2022-09-06 01:51:57 +00:00
|
|
|
const cssRangeInput = styled(cssInput, `
|
|
|
|
height: unset;
|
2022-09-14 09:04:20 +00:00
|
|
|
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;
|
2022-05-24 14:59:12 +00:00
|
|
|
`);
|