(core) Update sort and filter UI

Summary:
The sort and filter UI now has a more unified UI, with similar
capabilities that are accessible from different parts of Grist.
It's now also possible to pin individual filters to the filter bar,
which replaces the old toggle for showing all filters in the
filter bar.

Test Plan: Various tests (browser, migration, project).

Reviewers: jarek, dsagal

Reviewed By: jarek, dsagal

Subscribers: dsagal

Differential Revision: https://phab.getgrist.com/D3669
This commit is contained in:
George Gevoian
2022-11-17 15:17:51 -05:00
parent af462fc938
commit 1a6d427339
34 changed files with 1350 additions and 933 deletions

View File

@@ -430,7 +430,7 @@ BaseView.prototype.filterByThisCellValue = function() {
}
filterValues = [value];
}
this.viewSection.setFilter(col.getRowId(), JSON.stringify({included: filterValues}));
this.viewSection.setFilter(col.getRowId(), {filter: JSON.stringify({included: filterValues})});
};
/**
@@ -732,9 +732,9 @@ BaseView.prototype.getLastDataRowIndex = function() {
/**
* Creates and opens ColumnFilterMenu for a given field/column, and returns its PopupControl.
*/
BaseView.prototype.createFilterMenu = function(openCtl, filterInfo, onClose) {
BaseView.prototype.createFilterMenu = function(openCtl, filterInfo, options) {
return createFilterMenu(openCtl, this._sectionFilter, filterInfo, this._mainRowSource,
this.tableModel.tableData, onClose);
this.tableModel.tableData, options);
};
/**

View File

@@ -46,6 +46,7 @@ const {mouseDragMatchElem} = require('app/client/ui/mouseDrag');
const {menuToggle} = require('app/client/ui/MenuToggle');
const {showTooltip} = require('app/client/ui/tooltips');
const {parsePasteForView} = require("./BaseView2");
const {NEW_FILTER_JSON} = require('app/client/models/ColumnFilter');
const {CombinedStyle} = require("app/client/models/Styles");
// A threshold for interpreting a motionless click as a click rather than a drag.
@@ -1073,12 +1074,16 @@ GridView.prototype.buildDom = function() {
// Select the column if it's not part of a multiselect.
dom.on('click', (ev) => this.maybeSelectColumn(ev.currentTarget.parentNode, field)),
(elem) => {
filterTriggerCtl = setPopupToCreateDom(elem, ctl => this._columnFilterMenu(ctl, field), {
attach: 'body',
placement: 'bottom-start',
boundaries: 'viewport',
trigger: [],
});
filterTriggerCtl = setPopupToCreateDom(
elem,
ctl => this._columnFilterMenu(ctl, field, {showAllFiltersButton: true}),
{
attach: 'body',
placement: 'bottom-start',
boundaries: 'viewport',
trigger: [],
}
);
},
menu(ctl => this.columnContextMenu(ctl, this.getSelection(), field, filterTriggerCtl)),
testId('column-menu-trigger'),
@@ -1623,11 +1628,18 @@ GridView.prototype._getColumnMenuOptions = function(copySelection) {
};
}
GridView.prototype._columnFilterMenu = function(ctl, field) {
GridView.prototype._columnFilterMenu = function(ctl, field, options) {
this.ctxMenuHolder.autoDispose(ctl);
const filterInfo = this.viewSection.filters()
.find(({fieldOrColumn}) => fieldOrColumn.origCol().origColRef() === field.column().origColRef());
return this.createFilterMenu(ctl, filterInfo);
if (!filterInfo.isFiltered.peek()) {
// This is a new filter - initialize its spec and pin it.
this.viewSection.setFilter(filterInfo.fieldOrColumn.origCol().origColRef(), {
filter: NEW_FILTER_JSON,
pinned: true,
});
}
return this.createFilterMenu(ctl, filterInfo, options);
};
GridView.prototype.maybeSelectColumn = function (elem, field) {

View File

@@ -8,22 +8,13 @@ var koArray = require('../lib/koArray');
var commands = require('./commands');
var {CustomSectionElement} = require('../lib/CustomSectionElement');
const {ChartConfig} = require('./ChartView');
const {Computed, dom: grainjsDom, makeTestId, Observable, styled, MultiHolder} = require('grainjs');
const {Computed, dom: grainjsDom, makeTestId} = require('grainjs');
const {addToSort} = require('app/client/lib/sortUtil');
const {updatePositions} = require('app/client/lib/sortUtil');
const {attachColumnFilterMenu} = require('app/client/ui/ColumnFilterMenu');
const {addFilterMenu} = require('app/client/ui/FilterBar');
const {cssIcon, cssRow} = require('app/client/ui/RightPanelStyles');
const {basicButton, primaryButton} = require('app/client/ui2018/buttons');
const {labeledLeftSquareCheckbox} = require("app/client/ui2018/checkbox");
const {theme} = require('app/client/ui2018/cssVars');
const {cssDragger} = require('app/client/ui2018/draggableList');
const {menu, menuItem, select} = require('app/client/ui2018/menus');
const {cssRow} = require('app/client/ui/RightPanelStyles');
const {SortFilterConfig} = require('app/client/ui/SortFilterConfig');
const {primaryButton} = require('app/client/ui2018/buttons');
const {select} = require('app/client/ui2018/menus');
const {confirmModal} = require('app/client/ui2018/modals');
const {Sort} = require('app/common/SortSpec');
const isEqual = require('lodash/isEqual');
const {cssMenuItem} = require('popweasel');
const {makeT} = require('app/client/lib/localization');
const testId = makeTestId('test-vconfigtab-');
@@ -56,284 +47,42 @@ function ViewConfigTab(options) {
.setAutoDisposeValues()
);
this.activeSectionData = this.autoDispose(ko.computed(function() {
return _.find(self.viewSectionData.all(), function(sectionData) {
return sectionData.section &&
sectionData.section.getRowId() === self.viewModel.activeSectionId();
}) || self.viewSectionData.at(0);
}));
this.isDetail = this.autoDispose(ko.computed(function() {
return ['detail', 'single'].includes(this.viewModel.activeSection().parentKey());
}, this));
this.isChart = this.autoDispose(ko.computed(function() {
return this.viewModel.activeSection().parentKey() === 'chart';}, this));
return this.viewModel.activeSection().parentKey() === 'chart';}, this));
this.isGrid = this.autoDispose(ko.computed(function() {
return this.viewModel.activeSection().parentKey() === 'record';}, this));
return this.viewModel.activeSection().parentKey() === 'record';}, this));
this.isCustom = this.autoDispose(ko.computed(function() {
return this.viewModel.activeSection().parentKey() === 'custom';}, this));
return this.viewModel.activeSection().parentKey() === 'custom';}, this));
this.isRaw = this.autoDispose(ko.computed(function() {
return this.viewModel.activeSection().isRaw();}, this));
this.activeRawSectionData = this.autoDispose(ko.computed(function() {
return self.isRaw() ? ViewSectionData.create(self.viewModel.activeSection()) : null;
}));
this.activeSectionData = this.autoDispose(ko.computed(function() {
return (
_.find(self.viewSectionData.all(), function(sectionData) {
return sectionData.section &&
sectionData.section.getRowId() === self.viewModel.activeSectionId();
})
|| self.activeRawSectionData()
|| self.viewSectionData.at(0)
);
}));
}
dispose.makeDisposable(ViewConfigTab);
ViewConfigTab.prototype.buildSortDom = function() {
return grainjsDom.maybe(this.activeSectionData, (sectionData) => {
const section = sectionData.section;
// Computed to indicate if sort has changed from saved.
const hasChanged = Computed.create(null, (use) =>
!isEqual(use(section.activeSortSpec), Sort.parseSortColRefs(use(section.sortColRefs))));
// Computed array of sortable columns.
const columns = Computed.create(null, (use) => {
// Columns is an observable holding an observable array - must call 'use' on it 2x.
const cols = use(use(use(section.table).columns));
return cols.filter(col => !use(col.isHiddenCol))
.map(col => ({
label: use(col.colId),
value: col.getRowId(),
icon: 'FieldColumn',
type: col.type()
}));
});
// We only want to recreate rows, when the actual columns change.
const colRefs = Computed.create(null, (use) => {
return use(section.activeSortSpec).map(col => Sort.getColRef(col));
});
const sortRows = koArray(colRefs.get());
colRefs.addListener((curr, prev) => {
if (!isEqual(curr, prev)){
sortRows.assign(curr);
}
})
// Sort row create function for each sort row in the draggableList.
const rowCreateFn = colRef =>
this._buildSortRow(colRef, section.activeSortSpec, columns);
// Reorder function called when sort rows are reordered via dragging.
const reorder = (...args) => {
const spec = Sort.reorderSortRefs(section.activeSortSpec.peek(), ...args);
this._saveSort(spec);
};
return grainjsDom('div',
grainjsDom.autoDispose(hasChanged),
grainjsDom.autoDispose(columns),
grainjsDom.autoDispose(colRefs),
grainjsDom.autoDispose(sortRows),
// Sort rows.
kf.draggableList(sortRows, rowCreateFn, {
reorder,
removeButton: false,
drag_indicator: cssDragger,
itemClass: cssDragRow.className
}),
// Add to sort btn & menu & fake sort row.
this._buildAddToSortBtn(columns),
// Update/save/reset buttons visible when the sort has changed.
cssRow(
cssExtraMarginTop.cls(''),
grainjsDom.maybe(hasChanged, () => [
primaryButton(t('Save'), {style: 'margin-right: 8px;'},
grainjsDom.on('click', () => { section.activeSortJson.save(); }),
testId('sort-save'),
grainjsDom.boolAttr('disabled', this.gristDoc.isReadonly),
),
// Let's use same label (revert) as the similar button which appear in the view section.
// menu.
basicButton(t('Revert'),
grainjsDom.on('click', () => { section.activeSortJson.revert(); }),
testId('sort-reset')
)
]),
cssFlex(),
grainjsDom.maybe(section.isSorted, () =>
basicButton(t('UpdateData'), {style: 'margin-left: 8px; white-space: nowrap;'},
grainjsDom.on('click', () => { updatePositions(this.gristDoc, section); }),
testId('sort-update'),
grainjsDom.show((use) => use(use(section.table).supportsManualSort)),
grainjsDom.boolAttr('disabled', this.gristDoc.isReadonly),
)
),
grainjsDom.show((use) => use(hasChanged) || use(section.isSorted))
),
testId('sort-menu')
);
ViewConfigTab.prototype.buildSortFilterDom = function() {
return grainjsDom.maybe(this.activeSectionData, ({section}) => {
return grainjsDom.create(SortFilterConfig, section, this.gristDoc);
});
};
// Builds a single row of the sort dom
// Takes the colRef, current sortSpec and array of column select options to show
// in the column select dropdown.
ViewConfigTab.prototype._buildSortRow = function(colRef, sortSpec, columns) {
const holder = new MultiHolder();
const col = Computed.create(holder, () => colRef);
const details = Computed.create(holder, (use) => Sort.specToDetails(Sort.findCol(use(sortSpec), colRef)));
const hasSpecs = Computed.create(holder, details, (_, details) => Sort.hasOptions(details));
const isAscending = Computed.create(holder, details, (_, details) => details.direction === Sort.ASC);
col.onWrite((newRef) => {
let specs = sortSpec.peek();
const colSpec = Sort.findCol(specs, colRef);
const newSpec = Sort.findCol(specs, newRef);
if (newSpec) {
// this column is already there so only swap order
specs = Sort.swap(specs, colRef, newRef);
// but keep the directions
specs = Sort.setSortDirection(specs, colRef, Sort.direction(newSpec))
specs = Sort.setSortDirection(specs, newRef, Sort.direction(colSpec))
} else {
specs = Sort.replace(specs, colRef, Sort.createColSpec(newRef, Sort.direction(colSpec)));
}
this._saveSort(specs);
});
const computedFlag = (flag, allowedTypes, label) => {
const computed = Computed.create(holder, details, (_, details) => details[flag] || false);
computed.onWrite(value => {
const specs = sortSpec.peek();
// Get existing details
const details = Sort.specToDetails(Sort.findCol(specs, colRef));
// Update flags
details[flag] = value;
// Replace the colSpec at the index
this._saveSort(Sort.replace(specs, Sort.getColRef(colRef), details));
});
return {computed, allowedTypes, flag, label};
}
const orderByChoice = computedFlag('orderByChoice', ['Choice'], t('UseChoicePosition'));
const naturalSort = computedFlag('naturalSort', ['Text'], t('NaturalSort'));
const emptyLast = computedFlag('emptyLast', null, t('EmptyValuesLast'));
const flags = [orderByChoice, emptyLast, naturalSort];
const column = columns.get().find(col => col.value === Sort.getColRef(colRef));
return cssSortRow(
grainjsDom.autoDispose(holder),
cssSortSelect(
select(col, columns)
),
// Use domComputed method for this icon, for dynamic testId, otherwise
// we are not able add it dynamically.
grainjsDom.domComputed(isAscending, isAscending =>
cssSortIconPrimaryBtn(
"Sort",
grainjsDom.style("transform", isAscending ? "scaleY(-1)" : "none"),
grainjsDom.on("click", () => {
this._saveSort(Sort.flipSort(sortSpec.peek(), colRef));
}),
testId("sort-order"),
testId(isAscending ? "sort-order-asc" : "sort-order-desc")
)
),
cssSortIconBtn('Remove',
grainjsDom.on('click', () => {
const specs = sortSpec.peek();
if (Sort.findCol(specs, colRef)) {
this._saveSort(Sort.removeCol(specs, colRef));
}
}),
testId('sort-remove')
),
cssMenu(
cssBigIconWrapper(
cssIcon('Dots', grainjsDom.cls(cssBgAccent.className, hasSpecs)),
testId('sort-options-icon'),
),
menu(_ctl => flags.map(({computed, allowedTypes, flag, label}) => {
// when allowedTypes is null, flag can be used for every column
const enabled = !allowedTypes || allowedTypes.includes(column.type);
return cssMenuItem(
labeledLeftSquareCheckbox(
computed,
label,
grainjsDom.prop('disabled', !enabled),
),
grainjsDom.cls(cssOptionMenuItem.className),
grainjsDom.cls('disabled', !enabled),
testId('sort-option'),
testId(`sort-option-${flag}`),
);
},
))
),
testId('sort-row')
);
};
// Build the button to open the menu to add a sort item to the sort dom.
// Takes the full array of sortable column select options.
ViewConfigTab.prototype._buildAddToSortBtn = function(columns) {
// Observable indicating whether the add new column row is visible.
const showAddNew = Observable.create(null, false);
const available = Computed.create(null, (use) => {
const currentSection = use(this.activeSectionData).section;
const currentSortSpec = use(currentSection.activeSortSpec);
const specRowIds = new Set(currentSortSpec.map(_sortRef => Sort.getColRef(_sortRef)));
return use(columns)
.filter(_col => !specRowIds.has(_col.value))
});
return [
// Add column button.
cssRow(
grainjsDom.autoDispose(showAddNew),
grainjsDom.autoDispose(available),
cssTextBtn(
cssPlusIcon('Plus'), t('AddColumn'),
testId('sort-add')
),
grainjsDom.hide((use) => use(showAddNew) || !use(available).length),
grainjsDom.on('click', () => { showAddNew.set(true); }),
),
// Fake add column row that appears only when the menu is open to select a new column
// to add to the sort. Immediately destroyed when menu is closed.
grainjsDom.maybe((use) => use(showAddNew) && use(available), _columns => {
const col = Observable.create(null, 0);
const currentSection = this.activeSectionData().section;
// Function called when a column select value is clicked.
const onClick = (_col) => {
showAddNew.set(false); // Remove add row ASAP to prevent flickering
addToSort(currentSection.activeSortSpec, _col.value, 1);
};
const menuCols = _columns.map(_col =>
menuItem(() => onClick(_col),
cssMenuIcon(_col.icon),
_col.label,
testId('sort-add-menu-row')
)
);
return cssRow(cssSortRow(
dom.autoDispose(col),
cssSortSelect(
select(col, [], {defaultLabel: t('AddColumn')}),
menu(() => [
menuCols,
grainjsDom.onDispose(() => { showAddNew.set(false); })
], {
// Trigger to make menu open immediately
trigger: [(elem, ctl) => {
ctl.open();
grainjsDom.onElem(elem, 'click', () => { ctl.close(); });
}],
stretchToSelector: `.${cssSortSelect.className}`
})
),
cssSortIconPrimaryBtn('Sort',
grainjsDom.style('transform', 'scaleY(-1)')
),
cssSortIconBtn('Remove'),
cssBigIconWrapper(cssIcon('Dots')),
));
})
];
};
ViewConfigTab.prototype._saveSort = function(sortSpec) {
this.activeSectionData().section.activeSortSpec(sortSpec);
};
ViewConfigTab.prototype._makeOnDemand = function(table) {
// After saving the changed setting, force the reload of the document.
const onConfirm = () => {
@@ -394,90 +143,6 @@ ViewConfigTab.prototype._buildAdvancedSettingsDom = function() {
});
};
ViewConfigTab.prototype._buildFilterDom = function() {
return grainjsDom.maybe(this.activeSectionData, (sectionData) => {
const section = sectionData.section;
const docModel = this.gristDoc.docModel;
const popupControls = new WeakMap();
const activeFilterBar = section.activeFilterBar;
const hasChangedObs = Computed.create(null, (use) => use(section.filterSpecChanged) || !use(section.activeFilterBar.isSaved))
async function save() {
await docModel.docData.bundleActions(t("UpdateFilterSettings"), () => Promise.all([
section.saveFilters(), // Save filter
section.activeFilterBar.save(), // Save bar
]));
}
function revert() {
section.revertFilters(); // Revert filter
section.activeFilterBar.revert(); // Revert bar
}
return [
grainjsDom.forEach(section.activeFilters, (filterInfo) => {
return cssRow(
cssIconWrapper(
cssFilterIcon('FilterSimple', cssNoMarginLeft.cls('')),
attachColumnFilterMenu(section, filterInfo, {
placement: 'bottom-end', attach: 'body',
trigger: [
'click',
(_el, popupControl) => popupControls.set(filterInfo.fieldOrColumn.origCol(), popupControl)
],
}),
),
cssLabel(grainjsDom.text(filterInfo.fieldOrColumn.label)),
cssIconWrapper(
cssFilterIcon('Remove',
dom.on('click', () => section.setFilter(filterInfo.fieldOrColumn.origCol().origColRef(), '')),
testId('remove-filter')
),
),
testId('filter'),
);
}),
cssRow(
grainjsDom.domComputed((use) => {
const filters = use(section.filters);
return cssTextBtn(
cssPlusIcon('Plus'), t('AddFilter'),
addFilterMenu(filters, section, popupControls, {placement: 'bottom-end'}),
testId('add-filter-btn'),
);
}),
),
grainjsDom.maybe((use) => !use(section.isRaw),
() => cssRow(cssTextBtn(
testId('toggle-filter-bar'),
grainjsDom.domComputed((use) => {
const filterBar = use(activeFilterBar);
return cssPlusIcon(
filterBar ? "Tick" : "Plus",
cssIcon.cls('-green', Boolean(filterBar)),
testId('toggle-filter-bar-icon'),
);
}),
grainjsDom.on('click', () => activeFilterBar(!activeFilterBar.peek())),
'Toggle Filter Bar',
))),
grainjsDom.maybe(hasChangedObs, () => cssRow(
cssExtraMarginTop.cls(''),
testId('save-filter-btns'),
primaryButton(
t('Save'), {style: 'margin-right: 8px'},
grainjsDom.on('click', save),
grainjsDom.boolAttr('disabled', this.gristDoc.isReadonly),
),
basicButton(
t('Revert'),
grainjsDom.on('click', revert),
)
))
];
});
};
ViewConfigTab.prototype._buildThemeDom = function() {
return kd.maybe(this.activeSectionData, (sectionData) => {
var section = sectionData.section;
@@ -570,132 +235,4 @@ ViewConfigTab.prototype._buildCustomTypeItems = function() {
}];
};
const cssMenuIcon = styled(cssIcon, `
margin: 0 8px 0 0;
.${cssMenuItem.className}-sel > & {
background-color: ${theme.iconButtonFg};
}
`);
// Note that the width is set to 0 so that flex-shrink works properly with long text values.
const cssSortSelect = styled('div', `
flex: 1 1 0px;
margin: 0 6px 0 0;
min-width: 0;
`);
const cssSortIconBtn = styled(cssIcon, `
flex: none;
margin: 0 6px;
cursor: pointer;
background-color: ${theme.controlSecondaryFg};
&:hover {
background-color: ${theme.controlSecondaryHoverFg};
}
`);
const cssSortIconPrimaryBtn = styled(cssSortIconBtn, `
background-color: ${theme.controlFg};
&:hover {
background-color: ${theme.controlHoverFg};
}
`);
const cssTextBtn = styled('div', `
color: ${theme.controlFg};
cursor: pointer;
&:hover {
color: ${theme.controlHoverFg};
}
`);
const cssPlusIcon = styled(cssIcon, `
background-color: ${theme.controlFg};
cursor: pointer;
margin: 0px 4px 3px 0;
.${cssTextBtn.className}:hover > & {
background-color: ${theme.controlHoverFg};
}
`);
const cssDragRow = styled('div', `
display: flex !important;
align-items: center;
margin: 0 16px 0px 0px;
& > .kf_draggable_content {
margin: 6px 0;
flex: 1 1 0px;
min-width: 0px;
}
`);
const cssSortRow = styled('div', `
display: flex;
align-items: center;
width: 100%;
`);
const cssFlex = styled('div', `
flex: 1 1 0;
`);
const cssLabel = styled('div', `
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex-grow: 1;
`);
const cssExtraMarginTop = styled('div', `
margin-top: 28px;
`);
const cssFilterIcon = cssSortIconBtn;
const cssNoMarginLeft = styled('div', `
margin-left: 0;
`);
const cssIconWrapper = styled('div', ``);
const cssBigIconWrapper = styled('div', `
padding: 3px;
border-radius: 3px;
cursor: pointer;
user-select: none;
`);
const cssMenu = styled('div', `
display: inline-flex;
cursor: pointer;
border-radius: 3px;
border: 1px solid transparent;
&:hover, &.weasel-popup-open {
background-color: ${theme.hover};
}
`);
const cssBgAccent = styled(`div`, `
background: ${theme.accentIcon}
`)
const cssOptionMenuItem = styled('div', `
&:hover {
background-color: ${theme.hover};
}
& label {
flex: 1;
cursor: pointer;
}
&.disabled * {
color: ${theme.menuItemDisabledFg} important;
cursor: not-allowed;
}
`)
module.exports = ViewConfigTab;

View File

@@ -155,6 +155,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
nextSection: () => { this._otherSection(+1); },
prevSection: () => { this._otherSection(-1); },
printSection: () => { printViewSection(this._layout, this.viewModel.activeSection()).catch(reportError); },
sortFilterMenuOpen: (sectionId?: number) => { this._openSortFilterMenu(sectionId); },
};
this.autoDispose(commands.createGroup(commandGroup, this, true));
}
@@ -267,6 +268,20 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
this.gristDoc.viewModel.activeSectionId(layoutBox.leafId.peek());
}
}
/**
* Opens the sort and filter menu of the active view section.
*
* Optionally accepts a `sectionId` for opening a specific section's menu.
*/
private _openSortFilterMenu(sectionId?: number) {
const id = sectionId ?? this.viewModel.activeSectionId();
const leafBoxDom = this._layout.getLeafBox(id)?.dom;
if (!leafBoxDom) { return; }
const menu: HTMLElement | null = leafBoxDom.querySelector('.test-section-menu-sortAndFilter');
menu?.click();
}
}
export function buildViewSectionDom(options: {
@@ -305,11 +320,10 @@ export function buildViewSectionDom(options: {
buildWidgetTitle(vs, options, testId('viewsection-title'), cssTestClick(testId("viewsection-blank"))),
viewInstance.buildTitleControls(),
dom('span.viewsection_buttons',
dom.create(viewSectionMenu, gristDoc.docModel, vs, gristDoc.isReadonly)
dom.create(viewSectionMenu, gristDoc, vs)
)
)),
dom.maybe((use) => use(vs.activeFilterBar) || use(vs.isRaw) && use(vs.activeFilters).length,
() => dom.create(filterBar, vs)),
dom.create(filterBar, vs),
dom.maybe<BaseView|null>(vs.viewInstance, (viewInstance) => [
dom('div.view_data_pane_container.flexvbox',
cssResizing.cls('', isResizing),

View File

@@ -66,11 +66,6 @@ exports.groups = [{
keys: ['Esc'],
desc: null, // Shortcut to close active menu
},
{
name: 'filterMenuOpen',
keys: [],
desc: 'Shortcut to open filter menu'
},
{
name: 'docTabOpen',
keys: [],
@@ -91,6 +86,11 @@ exports.groups = [{
keys: [],
desc: 'Shortcut to sort & filter tab'
},
{
name: 'sortFilterMenuOpen',
keys: [],
desc: 'Shortcut to open sort & filter menu'
},
{
name: 'dataSelectionTabOpen',
keys: [],