(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]; 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. * 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, 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 {menuToggle} = require('app/client/ui/MenuToggle');
const {showTooltip} = require('app/client/ui/tooltips'); const {showTooltip} = require('app/client/ui/tooltips');
const {parsePasteForView} = require("./BaseView2"); const {parsePasteForView} = require("./BaseView2");
const {NEW_FILTER_JSON} = require('app/client/models/ColumnFilter');
const {CombinedStyle} = require("app/client/models/Styles"); const {CombinedStyle} = require("app/client/models/Styles");
// A threshold for interpreting a motionless click as a click rather than a drag. // 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. // Select the column if it's not part of a multiselect.
dom.on('click', (ev) => this.maybeSelectColumn(ev.currentTarget.parentNode, field)), dom.on('click', (ev) => this.maybeSelectColumn(ev.currentTarget.parentNode, field)),
(elem) => { (elem) => {
filterTriggerCtl = setPopupToCreateDom(elem, ctl => this._columnFilterMenu(ctl, field), { filterTriggerCtl = setPopupToCreateDom(
elem,
ctl => this._columnFilterMenu(ctl, field, {showAllFiltersButton: true}),
{
attach: 'body', attach: 'body',
placement: 'bottom-start', placement: 'bottom-start',
boundaries: 'viewport', boundaries: 'viewport',
trigger: [], trigger: [],
}); }
);
}, },
menu(ctl => this.columnContextMenu(ctl, this.getSelection(), field, filterTriggerCtl)), menu(ctl => this.columnContextMenu(ctl, this.getSelection(), field, filterTriggerCtl)),
testId('column-menu-trigger'), 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); this.ctxMenuHolder.autoDispose(ctl);
const filterInfo = this.viewSection.filters() const filterInfo = this.viewSection.filters()
.find(({fieldOrColumn}) => fieldOrColumn.origCol().origColRef() === field.column().origColRef()); .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) { GridView.prototype.maybeSelectColumn = function (elem, field) {

View File

@ -8,22 +8,13 @@ var koArray = require('../lib/koArray');
var commands = require('./commands'); var commands = require('./commands');
var {CustomSectionElement} = require('../lib/CustomSectionElement'); var {CustomSectionElement} = require('../lib/CustomSectionElement');
const {ChartConfig} = require('./ChartView'); 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 {cssRow} = require('app/client/ui/RightPanelStyles');
const {updatePositions} = require('app/client/lib/sortUtil'); const {SortFilterConfig} = require('app/client/ui/SortFilterConfig');
const {attachColumnFilterMenu} = require('app/client/ui/ColumnFilterMenu'); const {primaryButton} = require('app/client/ui2018/buttons');
const {addFilterMenu} = require('app/client/ui/FilterBar'); const {select} = require('app/client/ui2018/menus');
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 {confirmModal} = require('app/client/ui2018/modals'); 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 {makeT} = require('app/client/lib/localization');
const testId = makeTestId('test-vconfigtab-'); const testId = makeTestId('test-vconfigtab-');
@ -56,12 +47,6 @@ function ViewConfigTab(options) {
.setAutoDisposeValues() .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() { this.isDetail = this.autoDispose(ko.computed(function() {
return ['detail', 'single'].includes(this.viewModel.activeSection().parentKey()); return ['detail', 'single'].includes(this.viewModel.activeSection().parentKey());
}, this)); }, this));
@ -71,267 +56,31 @@ function ViewConfigTab(options) {
return this.viewModel.activeSection().parentKey() === 'record';}, this)); return this.viewModel.activeSection().parentKey() === 'record';}, this));
this.isCustom = this.autoDispose(ko.computed(function() { 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); dispose.makeDisposable(ViewConfigTab);
ViewConfigTab.prototype.buildSortDom = function() { ViewConfigTab.prototype.buildSortFilterDom = function() {
return grainjsDom.maybe(this.activeSectionData, (sectionData) => { return grainjsDom.maybe(this.activeSectionData, ({section}) => {
const section = sectionData.section; return grainjsDom.create(SortFilterConfig, section, this.gristDoc);
// 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')
);
});
};
// 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) { ViewConfigTab.prototype._makeOnDemand = function(table) {
@ -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() { ViewConfigTab.prototype._buildThemeDom = function() {
return kd.maybe(this.activeSectionData, (sectionData) => { return kd.maybe(this.activeSectionData, (sectionData) => {
var section = sectionData.section; 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; module.exports = ViewConfigTab;

View File

@ -155,6 +155,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
nextSection: () => { this._otherSection(+1); }, nextSection: () => { this._otherSection(+1); },
prevSection: () => { this._otherSection(-1); }, prevSection: () => { this._otherSection(-1); },
printSection: () => { printViewSection(this._layout, this.viewModel.activeSection()).catch(reportError); }, printSection: () => { printViewSection(this._layout, this.viewModel.activeSection()).catch(reportError); },
sortFilterMenuOpen: (sectionId?: number) => { this._openSortFilterMenu(sectionId); },
}; };
this.autoDispose(commands.createGroup(commandGroup, this, true)); 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()); 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: { export function buildViewSectionDom(options: {
@ -305,11 +320,10 @@ export function buildViewSectionDom(options: {
buildWidgetTitle(vs, options, testId('viewsection-title'), cssTestClick(testId("viewsection-blank"))), buildWidgetTitle(vs, options, testId('viewsection-title'), cssTestClick(testId("viewsection-blank"))),
viewInstance.buildTitleControls(), viewInstance.buildTitleControls(),
dom('span.viewsection_buttons', 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.maybe<BaseView|null>(vs.viewInstance, (viewInstance) => [
dom('div.view_data_pane_container.flexvbox', dom('div.view_data_pane_container.flexvbox',
cssResizing.cls('', isResizing), cssResizing.cls('', isResizing),

View File

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

View File

@ -39,6 +39,7 @@ declare module "app/client/components/BaseView" {
import {ViewFieldRec, ViewSectionRec} from "app/client/models/DocModel"; import {ViewFieldRec, ViewSectionRec} from "app/client/models/DocModel";
import {FilterInfo} from 'app/client/models/entities/ViewSectionRec'; import {FilterInfo} from 'app/client/models/entities/ViewSectionRec';
import {SortedRowSet} from 'app/client/models/rowset'; import {SortedRowSet} from 'app/client/models/rowset';
import {IColumnFilterMenuOptions} from 'app/client/ui/ColumnFilterMenu';
import {FieldBuilder} from "app/client/widgets/FieldBuilder"; import {FieldBuilder} from "app/client/widgets/FieldBuilder";
import {DomArg} from 'grainjs'; import {DomArg} from 'grainjs';
import {IOpenController} from 'popweasel'; import {IOpenController} from 'popweasel';
@ -66,7 +67,8 @@ declare module "app/client/components/BaseView" {
constructor(gristDoc: GristDoc, viewSectionModel: any, options?: {addNewRow?: boolean, isPreview?: boolean}); constructor(gristDoc: GristDoc, viewSectionModel: any, options?: {addNewRow?: boolean, isPreview?: boolean});
public setCursorPos(cursorPos: CursorPos): void; public setCursorPos(cursorPos: CursorPos): void;
public createFilterMenu(ctl: IOpenController, filterInfo: FilterInfo, onClose?: () => void): HTMLElement; public createFilterMenu(ctl: IOpenController, filterInfo: FilterInfo,
options?: IColumnFilterMenuOptions): HTMLElement;
public buildTitleControls(): DomArg; public buildTitleControls(): DomArg;
public getLoadingDonePromise(): Promise<void>; public getLoadingDonePromise(): Promise<void>;
public activateEditorAtCursor(options?: Options): void; public activateEditorAtCursor(options?: Options): void;
@ -95,10 +97,9 @@ declare module "app/client/components/ViewConfigTab" {
class ViewConfigTab extends Disposable { class ViewConfigTab extends Disposable {
constructor(options: {gristDoc: GristDoc, viewModel: ViewRec}); constructor(options: {gristDoc: GristDoc, viewModel: ViewRec});
public buildSortDom(): DomContents; public buildSortFilterDom(): DomContents;
// TODO: these should be made private or renamed. // TODO: these should be made private or renamed.
public _buildAdvancedSettingsDom(): DomArg; public _buildAdvancedSettingsDom(): DomArg;
public _buildFilterDom(): DomArg;
public _buildThemeDom(): DomArg; public _buildThemeDom(): DomArg;
public _buildChartConfigDom(): DomContents; public _buildChartConfigDom(): DomContents;
public _buildLayoutDom(): DomArg; public _buildLayoutDom(): DomArg;

View File

@ -40,6 +40,10 @@ export class ColumnFilter extends Disposable {
return this._columnType; return this._columnType;
} }
public get initialFilterJson() {
return this._initialFilterJson;
}
public setState(filterJson: string|FilterSpec) { public setState(filterJson: string|FilterSpec) {
const state = makeFilterState(filterJson); const state = makeFilterState(filterJson);
if (isRangeFilter(state)) { if (isRangeFilter(state)) {
@ -138,4 +142,18 @@ export class ColumnFilter extends Disposable {
} }
} }
export const allInclusive = '{"excluded":[]}'; /**
* A JSON-encoded filter spec that includes every value.
*/
export const ALL_INCLUSIVE_FILTER_JSON = '{"excluded":[]}';
/**
* A blank JSON-encoded filter spec.
*
* This is interpreted the same as `ALL_INCLUSIVE_FILTER_JSON` in the context
* of parsing filters. However, it's still useful in scenarios where it's
* necessary to discern between new filters and existing filters; initializing
* a `ColumnFilter` with `NEW_FIlTER_JSON` makes it clear that a new filter
* is being created.
*/
export const NEW_FILTER_JSON = '{}';

View File

@ -1,4 +1,5 @@
import { ColumnFilter } from "app/client/models/ColumnFilter"; import { ColumnFilter } from "app/client/models/ColumnFilter";
import { FilterInfo } from "app/client/models/entities/ViewSectionRec";
import { CellValue } from "app/plugin/GristData"; import { CellValue } from "app/plugin/GristData";
import { Computed, Disposable, Observable } from "grainjs"; import { Computed, Disposable, Observable } from "grainjs";
import escapeRegExp = require("lodash/escapeRegExp"); import escapeRegExp = require("lodash/escapeRegExp");
@ -23,7 +24,21 @@ type ICompare<T> = (a: T, b: T) => number
const localeCompare = new Intl.Collator('en-US', {numeric: true}).compare; const localeCompare = new Intl.Collator('en-US', {numeric: true}).compare;
interface ColumnFilterMenuModelParams {
columnFilter: ColumnFilter;
filterInfo: FilterInfo;
valueCount: Array<[CellValue, IFilterCount]>;
limitShow?: number;
}
export class ColumnFilterMenuModel extends Disposable { export class ColumnFilterMenuModel extends Disposable {
public readonly columnFilter = this._params.columnFilter;
public readonly filterInfo = this._params.filterInfo;
public readonly initialPinned = this.filterInfo.isPinned.peek();
public readonly limitShown = this._params.limitShow ?? MAXIMUM_SHOWN_FILTER_ITEMS;
public readonly searchValue = Observable.create(this, ''); public readonly searchValue = Observable.create(this, '');
@ -34,7 +49,7 @@ export class ColumnFilterMenuModel extends Disposable {
const searchRegex = new RegExp(escapeRegExp(searchValue), 'i'); const searchRegex = new RegExp(escapeRegExp(searchValue), 'i');
const showAllOptions = ['Bool', 'Choice', 'ChoiceList'].includes(this.columnFilter.columnType); const showAllOptions = ['Bool', 'Choice', 'ChoiceList'].includes(this.columnFilter.columnType);
return new Set( return new Set(
this._valueCount this._params.valueCount
.filter(([_, {label, count}]) => (showAllOptions ? true : count) && searchRegex.test(label)) .filter(([_, {label, count}]) => (showAllOptions ? true : count) && searchRegex.test(label))
.map(([key]) => key) .map(([key]) => key)
); );
@ -56,7 +71,7 @@ export class ColumnFilterMenuModel extends Disposable {
return localeCompare(a, b); return localeCompare(a, b);
}; };
return this._valueCount return this._params.valueCount
.filter(([key]) => filter.has(key)) .filter(([key]) => filter.has(key))
.sort((a, b) => comparator(a[1][prop], b[1][prop])); .sort((a, b) => comparator(a[1][prop], b[1][prop]));
} }
@ -64,12 +79,12 @@ export class ColumnFilterMenuModel extends Disposable {
// computes the array of all values that does NOT matches the search text // computes the array of all values that does NOT matches the search text
public readonly otherValues = Computed.create(this, this.filterSet, (_use, filter) => { public readonly otherValues = Computed.create(this, this.filterSet, (_use, filter) => {
return this._valueCount.filter(([key]) => !filter.has(key)); return this._params.valueCount.filter(([key]) => !filter.has(key));
}); });
// computes the array of keys that matches the search text // computes the array of keys that matches the search text
public readonly filteredKeys = Computed.create(this, this.filterSet, (_use, filter) => { public readonly filteredKeys = Computed.create(this, this.filterSet, (_use, filter) => {
return this._valueCount return this._params.valueCount
.filter(([key]) => filter.has(key)) .filter(([key]) => filter.has(key))
.map(([key]) => key); .map(([key]) => key);
}); });
@ -78,8 +93,7 @@ export class ColumnFilterMenuModel extends Disposable {
return filteredValues.slice(this.limitShown); return filteredValues.slice(this.limitShown);
}); });
constructor(public columnFilter: ColumnFilter, private _valueCount: Array<[CellValue, IFilterCount]>, constructor(private _params: ColumnFilterMenuModelParams) {
public limitShown: number = MAXIMUM_SHOWN_FILTER_ITEMS) {
super(); super();
} }
} }

View File

@ -19,6 +19,7 @@ import {RowId} from 'app/client/models/rowset';
import {LinkConfig} from 'app/client/ui/selectBy'; import {LinkConfig} from 'app/client/ui/selectBy';
import {getWidgetTypes} from 'app/client/ui/widgetTypes'; import {getWidgetTypes} from 'app/client/ui/widgetTypes';
import {AccessLevel, ICustomWidget} from 'app/common/CustomWidget'; import {AccessLevel, ICustomWidget} from 'app/common/CustomWidget';
import {UserAction} from 'app/common/DocActions';
import {arrayRepeat} from 'app/common/gutil'; import {arrayRepeat} from 'app/common/gutil';
import {Sort} from 'app/common/SortSpec'; import {Sort} from 'app/common/SortSpec';
import {ColumnsToMap, WidgetColumnMap} from 'app/plugin/CustomSectionAPI'; import {ColumnsToMap, WidgetColumnMap} from 'app/plugin/CustomSectionAPI';
@ -72,7 +73,7 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
* *
* NOTE: See `filters`, where `_unsavedFilters` is merged with `savedFilters`. * NOTE: See `filters`, where `_unsavedFilters` is merged with `savedFilters`.
*/ */
_unsavedFilters: Map<number, string>; _unsavedFilters: Map<number, Partial<Filter>>;
/** /**
* Filter information for all fields/section in the section. * Filter information for all fields/section in the section.
@ -86,6 +87,9 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
// Subset of `filters` containing non-blank active filters. // Subset of `filters` containing non-blank active filters.
activeFilters: Computed<FilterInfo[]>; activeFilters: Computed<FilterInfo[]>;
// Subset of `activeFilters` that are pinned.
pinnedActiveFilters: Computed<FilterInfo[]>;
// Helper metadata item which indicates whether any of the section's fields/columns have unsaved // Helper metadata item which indicates whether any of the section's fields/columns have unsaved
// changes to their filters. (True indicates unsaved changes) // changes to their filters. (True indicates unsaved changes)
filterSpecChanged: Computed<boolean>; filterSpecChanged: Computed<boolean>;
@ -146,7 +150,6 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
isSorted: ko.Computed<boolean>; isSorted: ko.Computed<boolean>;
disableDragRows: ko.Computed<boolean>; disableDragRows: ko.Computed<boolean>;
activeFilterBar: modelUtil.CustomComputed<boolean>;
// Number of frozen columns // Number of frozen columns
rawNumFrozen: modelUtil.CustomComputed<number>; rawNumFrozen: modelUtil.CustomComputed<number>;
// Number for frozen columns to display. // Number for frozen columns to display.
@ -191,8 +194,11 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
// Revert all filters of fields/columns in the section. // Revert all filters of fields/columns in the section.
revertFilters(): void; revertFilters(): void;
// Apply `filter` to the field or column identified by `colRef`. // Set `filter` for the field or column identified by `colRef`.
setFilter(colRef: number, filter: string): void; setFilter(colRef: number, filter: Partial<Filter>): void;
// Revert the filter of the field or column identified by `colRef`.
revertFilter(colRef: number): void;
// Saves custom definition (bundles change) // Saves custom definition (bundles change)
saveCustomDef(): Promise<void>; saveCustomDef(): Promise<void>;
@ -236,14 +242,25 @@ export interface CustomViewSectionDef {
sectionId: modelUtil.KoSaveableObservable<string>; sectionId: modelUtil.KoSaveableObservable<string>;
} }
// Information about filters for a field or hidden column. /** Information about filters for a field or hidden column. */
export interface FilterInfo { export interface FilterInfo {
// The field or column associated with this filter info (field if column is visible, else column). /** The section that's being filtered. */
viewSection: ViewSectionRec;
/** The field or column that's being filtered. (Field if column is visible.) */
fieldOrColumn: ViewFieldRec|ColumnRec; fieldOrColumn: ViewFieldRec|ColumnRec;
// Filter that applies to this field/column, if any. /** Filter that applies to this field/column, if any. */
filter: modelUtil.CustomComputed<string>; filter: modelUtil.CustomComputed<string>;
// True if `filter` has a non-blank value. /** Whether this filter is pinned to the filter bar. */
pinned: modelUtil.CustomComputed<boolean>;
/** True if `filter` has a non-blank value. */
isFiltered: ko.PureComputed<boolean>; isFiltered: ko.PureComputed<boolean>;
/** True if `pinned` is true. */
isPinned: ko.PureComputed<boolean>;
}
export interface Filter {
filter: string;
pinned: boolean;
} }
export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): void { export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): void {
@ -262,7 +279,6 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
horizontalGridlines: true, horizontalGridlines: true,
zebraStripes: false, zebraStripes: false,
customView: '', customView: '',
filterBar: false,
numFrozen: 0 numFrozen: 0
}; };
this.optionsObj = modelUtil.jsonObservable(this.options, this.optionsObj = modelUtil.jsonObservable(this.options,
@ -365,7 +381,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
this._unsavedFilters = new Map(); this._unsavedFilters = new Map();
/** /**
* Filter information for all fields/section in the section. * Filter information for all fields/columns in the section.
* *
* Re-computed on changes to `savedFilters`, as well as any changes to `viewFields` or `columns`. Any * Re-computed on changes to `savedFilters`, as well as any changes to `viewFields` or `columns`. Any
* unsaved filters saved in `_unsavedFilters` are applied on computation, taking priority over saved * unsaved filters saved in `_unsavedFilters` are applied on computation, taking priority over saved
@ -377,30 +393,43 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
return this.columns().map(column => { return this.columns().map(column => {
const savedFilter = savedFiltersByColRef.get(column.origColRef()); const savedFilter = savedFiltersByColRef.get(column.origColRef());
const filter = modelUtil.customComputed({
// Initialize with a saved filter, if one exists. Otherwise, use a blank filter. // Initialize with a saved filter, if one exists. Otherwise, use a blank filter.
const filter = modelUtil.customComputed({
read: () => { return savedFilter ? savedFilter.activeFilter() : ''; }, read: () => { return savedFilter ? savedFilter.activeFilter() : ''; },
}); });
const pinned = modelUtil.customComputed({
read: () => { return savedFilter ? savedFilter.pinned() : false; },
});
// If an unsaved filter exists, overwrite `filter` with it. // If an unsaved filter exists, overwrite the filter with it.
const unsavedFilter = this._unsavedFilters.get(column.origColRef()); const unsavedFilter = this._unsavedFilters.get(column.origColRef());
if (unsavedFilter !== undefined) { filter(unsavedFilter); } if (unsavedFilter) {
const {filter: f, pinned: p} = unsavedFilter;
if (f !== undefined) { filter(f); }
if (p !== undefined) { pinned(p); }
}
return { return {
viewSection: this,
filter, filter,
pinned,
fieldOrColumn: viewFieldsByColRef.get(column.origColRef()) ?? column, fieldOrColumn: viewFieldsByColRef.get(column.origColRef()) ?? column,
isFiltered: ko.pureComputed(() => filter() !== '') isFiltered: ko.pureComputed(() => filter() !== ''),
isPinned: ko.pureComputed(() => pinned()),
}; };
}); });
})); }));
// List of `filters` that have non-blank active filters. // List of `filters` that have non-blank active filters.
this.activeFilters = Computed.create(this, use => use(this.filters).filter(col => use(col.isFiltered))); this.activeFilters = Computed.create(this, use => use(this.filters).filter(f => use(f.isFiltered)));
// List of `activeFilters` that are pinned.
this.pinnedActiveFilters = Computed.create(this, use => use(this.activeFilters).filter(f => use(f.isPinned)));
// Helper metadata item which indicates whether any of the section's fields/columns have unsaved // Helper metadata item which indicates whether any of the section's fields/columns have unsaved
// changes to their filters. (True indicates unsaved changes) // changes to their filters. (True indicates unsaved changes)
this.filterSpecChanged = Computed.create(this, use => { this.filterSpecChanged = Computed.create(this, use => {
return use(this.filters).some(col => !use(col.filter.isSaved)); return use(this.filters).some(col => !use(col.filter.isSaved) || !use(col.pinned.isSaved));
}); });
// Save all filters of fields/columns in the section. // Save all filters of fields/columns in the section.
@ -408,52 +437,72 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
return docModel.docData.bundleActions(`Save all filters in ${this.titleDef()}`, return docModel.docData.bundleActions(`Save all filters in ${this.titleDef()}`,
async () => { async () => {
const savedFiltersByColRef = new Map(this._savedFilters().all().map(f => [f.colRef(), f])); const savedFiltersByColRef = new Map(this._savedFilters().all().map(f => [f.colRef(), f]));
const updatedFilters: [number, string][] = []; // Pairs of row ids and filters to update. const updatedFilters: [number, Filter][] = []; // Pairs of row ids and filters to update.
const removedFilterIds: number[] = []; // Row ids of filters to remove. const removedFilterIds: number[] = []; // Row ids of filters to remove.
const newFilters: [number, string][] = []; // Pairs of column refs and filters to add. const newFilters: [number, Filter][] = []; // Pairs of column refs and filters to add.
for (const c of this.filters()) { for (const f of this.filters()) {
const {fieldOrColumn, filter, pinned} = f;
// Skip saved filters (i.e. filters whose local values are unchanged from server). // Skip saved filters (i.e. filters whose local values are unchanged from server).
if (c.filter.isSaved()) { continue; } if (filter.isSaved() && pinned.isSaved()) { continue; }
const savedFilter = savedFiltersByColRef.get(c.fieldOrColumn.origCol().origColRef()); const savedFilter = savedFiltersByColRef.get(fieldOrColumn.origCol().origColRef());
if (!savedFilter) { if (!savedFilter) {
// Never save blank filters. (This is primarily a sanity check.)
if (filter() === '') { continue; }
// Since no saved filter exists, we must add a new record to the filters table. // Since no saved filter exists, we must add a new record to the filters table.
newFilters.push([c.fieldOrColumn.origCol().origColRef(), c.filter()]); newFilters.push([fieldOrColumn.origCol().origColRef(), {
} else if (c.filter() === '') { filter: filter(),
pinned: pinned(),
}]);
} else if (filter() === '') {
// Mark the saved filter for removal from the filters table. // Mark the saved filter for removal from the filters table.
removedFilterIds.push(savedFilter.id()); removedFilterIds.push(savedFilter.id());
} else { } else {
// Mark the saved filter for update in the filters table. // Mark the saved filter for update in the filters table.
updatedFilters.push([savedFilter.id(), c.filter()]); updatedFilters.push([savedFilter.id(), {
filter: filter(),
pinned: pinned(),
}]);
} }
} }
const actions: UserAction[] = [];
// Remove records of any deleted filters. // Remove records of any deleted filters.
if (removedFilterIds.length > 0) { if (removedFilterIds.length > 0) {
await docModel.filters.sendTableAction(['BulkRemoveRecord', removedFilterIds]); actions.push(['BulkRemoveRecord', removedFilterIds]);
} }
// Update existing filter records with new filter values. // Update existing filter records with new filter values.
if (updatedFilters.length > 0) { if (updatedFilters.length > 0) {
await docModel.filters.sendTableAction(['BulkUpdateRecord', actions.push(['BulkUpdateRecord',
updatedFilters.map(([id]) => id), updatedFilters.map(([id]) => id),
{filter: updatedFilters.map(([, filter]) => filter)} {
filter: updatedFilters.map(([, {filter}]) => filter),
pinned: updatedFilters.map(([, {pinned}]) => pinned),
}
]); ]);
} }
// Add new filter records. // Add new filter records.
if (newFilters.length > 0) { if (newFilters.length > 0) {
await docModel.filters.sendTableAction(['BulkAddRecord', actions.push(['BulkAddRecord',
arrayRepeat(newFilters.length, null), arrayRepeat(newFilters.length, null),
{ {
viewSectionRef: arrayRepeat(newFilters.length, this.id()), viewSectionRef: arrayRepeat(newFilters.length, this.id()),
colRef: newFilters.map(([colRef]) => colRef), colRef: newFilters.map(([colRef]) => colRef),
filter: newFilters.map(([, filter]) => filter), filter: newFilters.map(([, {filter}]) => filter),
pinned: newFilters.map(([, {pinned}]) => pinned),
} }
]); ]);
} }
if (actions.length > 0) {
await docModel.filters.sendTableActions(actions);
}
// Reset client filter state. // Reset client filter state.
this.revertFilters(); this.revertFilters();
} }
@ -462,15 +511,32 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
// Revert all filters of fields/columns in the section. // Revert all filters of fields/columns in the section.
this.revertFilters = () => { this.revertFilters = () => {
this._unsavedFilters = new Map(); this._unsavedFilters.clear();
this.filters().forEach(c => { c.filter.revert(); }); this.filters().forEach(c => {
c.filter.revert();
c.pinned.revert();
});
}; };
// Apply `filter` to the field or column identified by `colRef`. // Set `filter` for the field or column identified by `colRef`.
this.setFilter = (colRef: number, filter: string) => { this.setFilter = (colRef: number, filter: Partial<Filter>) => {
this._unsavedFilters.set(colRef, filter); this._unsavedFilters.set(colRef, {...this._unsavedFilters.get(colRef), ...filter});
const filterInfo = this.filters().find(c => c.fieldOrColumn.origCol().origColRef() === colRef); const filterInfo = this.filters().find(c => c.fieldOrColumn.origCol().origColRef() === colRef);
filterInfo?.filter(filter); if (!filterInfo) { return; }
const {filter: newFilter, pinned: newPinned} = filter;
if (newFilter !== undefined) { filterInfo.filter(newFilter); }
if (newPinned !== undefined) { filterInfo.pinned(newPinned); }
};
// Revert the filter of the field or column identified by `colRef`.
this.revertFilter = (colRef: number) => {
this._unsavedFilters.delete(colRef);
const filterInfo = this.filters().find(c => c.fieldOrColumn.origCol().origColRef() === colRef);
if (!filterInfo) { return; }
filterInfo.filter.revert();
filterInfo.pinned.revert();
}; };
// Customizable version of the JSON-stringified sort spec. It may diverge from the saved one. // Customizable version of the JSON-stringified sort spec. It may diverge from the saved one.
@ -571,8 +637,6 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
this.isSorted = ko.pureComputed(() => this.activeSortSpec().length > 0); this.isSorted = ko.pureComputed(() => this.activeSortSpec().length > 0);
this.disableDragRows = ko.pureComputed(() => this.isSorted() || !this.table().supportsManualSort()); this.disableDragRows = ko.pureComputed(() => this.isSorted() || !this.table().supportsManualSort());
this.activeFilterBar = modelUtil.customValue(this.optionsObj.prop('filterBar'));
// Number of frozen columns // Number of frozen columns
this.rawNumFrozen = modelUtil.customValue(this.optionsObj.prop('numFrozen')); this.rawNumFrozen = modelUtil.customValue(this.optionsObj.prop('numFrozen'));
// Number for frozen columns to display // Number for frozen columns to display

View File

@ -3,16 +3,18 @@
* callback that's triggered on Apply or on Cancel. Changes to the UI result in changes to the underlying model, * 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. * but on Cancel the model is reset to its initial state prior to menu closing.
*/ */
import * as commands from 'app/client/components/commands';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {allInclusive, ColumnFilter} from 'app/client/models/ColumnFilter'; import {ColumnFilter, NEW_FILTER_JSON} from 'app/client/models/ColumnFilter';
import {ColumnFilterMenuModel, IFilterCount} from 'app/client/models/ColumnFilterMenuModel'; import {ColumnFilterMenuModel, IFilterCount} from 'app/client/models/ColumnFilterMenuModel';
import {ColumnRec, ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel'; import {ColumnRec, ViewFieldRec} from 'app/client/models/DocModel';
import {FilterInfo} from 'app/client/models/entities/ViewSectionRec'; import {FilterInfo} from 'app/client/models/entities/ViewSectionRec';
import {RowId, RowSource} from 'app/client/models/rowset'; import {RowId, RowSource} from 'app/client/models/rowset';
import {ColumnFilterFunc, SectionFilter} from 'app/client/models/SectionFilter'; import {ColumnFilterFunc, SectionFilter} from 'app/client/models/SectionFilter';
import {TableData} from 'app/client/models/TableData'; import {TableData} from 'app/client/models/TableData';
import {cssInput} from 'app/client/ui/cssInput'; import {cssInput} from 'app/client/ui/cssInput';
import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; import {cssPinButton} from 'app/client/ui/RightPanelStyles';
import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons';
import {cssLabel as cssCheckboxLabel, cssCheckboxSquare, cssLabelText, Indeterminate, labeledTriStateSquareCheckbox import {cssLabel as cssCheckboxLabel, cssCheckboxSquare, cssLabelText, Indeterminate, labeledTriStateSquareCheckbox
} from 'app/client/ui2018/checkbox'; } from 'app/client/ui2018/checkbox';
import {theme, vars} from 'app/client/ui2018/cssVars'; import {theme, vars} from 'app/client/ui2018/cssVars';
@ -40,19 +42,24 @@ const t = makeT('ColumnFilterMenu');
export interface IFilterMenuOptions { export interface IFilterMenuOptions {
model: ColumnFilterMenuModel; model: ColumnFilterMenuModel;
valueCounts: Map<CellValue, IFilterCount>; valueCounts: Map<CellValue, IFilterCount>;
doSave: (reset: boolean) => void; rangeInputOptions?: IRangeInputOptions;
onClose: () => void; showAllFiltersButton?: boolean;
renderValue: (key: CellValue, value: IFilterCount) => DomElementArg; doCancel(): void;
rangeInputOptions?: IRangeInputOptions doSave(reset: boolean): void;
renderValue(key: CellValue, value: IFilterCount): DomElementArg;
onClose(): void;
} }
const testId = makeTestId('test-filter-menu-'); const testId = makeTestId('test-filter-menu-');
/**
* Returns the DOM content for the column filter menu.
*
* For use with setPopupToCreateDom().
*/
export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptions): HTMLElement { export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptions): HTMLElement {
const { model, doSave, onClose, rangeInputOptions = {}, renderValue } = opts; const { model, doCancel, doSave, onClose, rangeInputOptions = {}, renderValue, showAllFiltersButton } = opts;
const { columnFilter } = model; const { columnFilter, filterInfo } = model;
// Save the initial state to allow reverting back to it on Cancel
const initialStateJson = columnFilter.makeFilterJson();
// Map to keep track of displayed checkboxes // Map to keep track of displayed checkboxes
const checkboxMap: Map<CellValue, HTMLInputElement> = new Map(); const checkboxMap: Map<CellValue, HTMLInputElement> = new Map();
@ -74,6 +81,7 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
let searchInput: HTMLInputElement; let searchInput: HTMLInputElement;
let minRangeInput: HTMLInputElement; let minRangeInput: HTMLInputElement;
let cancel = false;
let reset = false; let reset = false;
// Gives focus to the searchInput on open (or to the min input if the range filter is present). // Gives focus to the searchInput on open (or to the min input if the range filter is present).
@ -84,7 +92,8 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
testId('wrapper'), testId('wrapper'),
dom.cls(menuCssClass), dom.cls(menuCssClass),
dom.autoDispose(filterListener), dom.autoDispose(filterListener),
dom.onDispose(() => doSave(reset)), // Save on disposal, which should always happen as part of closing. // Save or cancel on disposal, which should always happen as part of closing.
dom.onDispose(() => cancel ? doCancel() : doSave(reset)),
dom.onKeyDown({ dom.onKeyDown({
Enter: () => onClose(), Enter: () => onClose(),
Escape: () => onClose() Escape: () => onClose()
@ -205,13 +214,39 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
]; ];
} }
}), }),
cssMenuItem( cssFooterButtons(
cssApplyButton('Apply', testId('apply-btn'), dom('div',
dom.on('click', () => { reset = true; onClose(); })), cssPrimaryButton('Close', testId('apply-btn'),
dom.on('click', () => {
reset = true;
onClose();
}),
),
basicButton('Cancel', testId('cancel-btn'), basicButton('Cancel', testId('cancel-btn'),
dom.on('click', () => { columnFilter.setState(initialStateJson); onClose(); } )) 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())),
testId('pin-btn'),
),
),
),
),
); );
return filterMenu; return filterMenu;
} }
@ -350,17 +385,31 @@ function getEmptyCountMap(fieldOrColumn: ViewFieldRec|ColumnRec): Map<CellValue,
return new Map(values.map((v) => [v, {label: String(v), count: 0, displayValue: v}])); return new Map(values.map((v) => [v, {label: String(v), count: 0, displayValue: v}]));
} }
export interface IColumnFilterMenuOptions {
// Callback for when the filter menu is closed.
onClose?: () => void;
// If true, shows a button that opens the sort & filter widget menu.
showAllFiltersButton?: boolean;
}
/** /**
* Returns content for the newly created columnFilterMenu; for use with setPopupToCreateDom(). * Returns content for the newly created columnFilterMenu; for use with setPopupToCreateDom().
*/ */
export function createFilterMenu(openCtl: IOpenController, sectionFilter: SectionFilter, filterInfo: FilterInfo, export function createFilterMenu(
rowSource: RowSource, tableData: TableData, onClose: () => void = noop) { openCtl: IOpenController,
sectionFilter: SectionFilter,
filterInfo: FilterInfo,
rowSource: RowSource,
tableData: TableData,
options: IColumnFilterMenuOptions = {}
) {
const {onClose = noop, showAllFiltersButton} = options;
// Go through all of our shown and hidden rows, and count them up by the values in this column. // Go through all of our shown and hidden rows, and count them up by the values in this column.
const fieldOrColumn = filterInfo.fieldOrColumn; const {fieldOrColumn, filter} = filterInfo;
const columnType = fieldOrColumn.origCol.peek().type.peek(); const columnType = fieldOrColumn.origCol.peek().type.peek();
const visibleColumnType = fieldOrColumn.visibleColModel.peek()?.type.peek() || columnType; const visibleColumnType = fieldOrColumn.visibleColModel.peek()?.type.peek() || columnType;
const {keyMapFunc, labelMapFunc, valueMapFunc} = getMapFuncs(columnType, tableData, filterInfo.fieldOrColumn); const {keyMapFunc, labelMapFunc, valueMapFunc} = getMapFuncs(columnType, tableData, fieldOrColumn);
const activeFilterBar = sectionFilter.viewSection.activeFilterBar;
// range input options // range input options
const valueParser = (fieldOrColumn as any).createValueParser?.(); const valueParser = (fieldOrColumn as any).createValueParser?.();
@ -387,10 +436,14 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
areHiddenRows: true, valueMapFunc}); areHiddenRows: true, valueMapFunc});
const valueCountsArr = Array.from(valueCounts); const valueCountsArr = Array.from(valueCounts);
const columnFilter = ColumnFilter.create(openCtl, filterInfo.filter.peek(), columnType, visibleColumnType, const columnFilter = ColumnFilter.create(openCtl, filter.peek(), columnType, visibleColumnType,
valueCountsArr.map((arr) => arr[0])); valueCountsArr.map((arr) => arr[0]));
sectionFilter.setFilterOverride(fieldOrColumn.origCol().getRowId(), columnFilter); // Will be removed on menu disposal sectionFilter.setFilterOverride(fieldOrColumn.origCol().getRowId(), columnFilter); // Will be removed on menu disposal
const model = ColumnFilterMenuModel.create(openCtl, columnFilter, valueCountsArr); const model = ColumnFilterMenuModel.create(openCtl, {
columnFilter,
filterInfo,
valueCount: valueCountsArr,
});
return columnFilterMenu(openCtl, { return columnFilterMenu(openCtl, {
model, model,
@ -398,20 +451,32 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio
onClose: () => { openCtl.close(); onClose(); }, onClose: () => { openCtl.close(); onClose(); },
doSave: (reset: boolean = false) => { doSave: (reset: boolean = false) => {
const spec = columnFilter.makeFilterJson(); const spec = columnFilter.makeFilterJson();
// If filter is moot and filter bar is hidden, let's remove the filter.
sectionFilter.viewSection.setFilter( sectionFilter.viewSection.setFilter(
fieldOrColumn.origCol().origColRef(), fieldOrColumn.origCol().origColRef(),
spec === allInclusive && !activeFilterBar.peek() ? '' : spec {filter: spec}
); );
if (reset) { if (reset) {
sectionFilter.resetTemporaryRows(); sectionFilter.resetTemporaryRows();
} }
}, },
doCancel: () => {
if (columnFilter.initialFilterJson === NEW_FILTER_JSON) {
sectionFilter.viewSection.revertFilter(fieldOrColumn.origCol().origColRef());
} else {
const initialFilter = columnFilter.initialFilterJson;
columnFilter.setState(initialFilter);
sectionFilter.viewSection.setFilter(
fieldOrColumn.origCol().origColRef(),
{filter: initialFilter, pinned: model.initialPinned}
);
}
},
renderValue: getRenderFunc(columnType, fieldOrColumn), renderValue: getRenderFunc(columnType, fieldOrColumn),
rangeInputOptions: { rangeInputOptions: {
valueParser, valueParser,
valueFormatter, valueFormatter,
} },
showAllFiltersButton,
}); });
} }
@ -571,20 +636,25 @@ const defaultPopupOptions: IPopupOptions = {
trigger: ['click'], trigger: ['click'],
}; };
interface IColumnFilterMenuOptions extends IPopupOptions { interface IColumnFilterPopupOptions {
// callback for when the content of the menu is closed by clicking the apply or revert buttons // Options to pass to the popup component.
onCloseContent?: () => void; popupOptions?: IPopupOptions;
} }
type IAttachColumnFilterMenuOptions = IColumnFilterPopupOptions & IColumnFilterMenuOptions;
// Helper to attach the column filter menu. // Helper to attach the column filter menu.
export function attachColumnFilterMenu(viewSection: ViewSectionRec, filterInfo: FilterInfo, export function attachColumnFilterMenu(
popupOptions: IColumnFilterMenuOptions): DomElementMethod { filterInfo: FilterInfo,
const options = {...defaultPopupOptions, ...popupOptions}; options: IAttachColumnFilterMenuOptions = {}
): DomElementMethod {
const {popupOptions, ...filterMenuOptions} = options;
const popupOptionsWithDefaults = {...defaultPopupOptions, ...popupOptions};
return (elem) => { return (elem) => {
const instance = viewSection.viewInstance(); const instance = filterInfo.viewSection.viewInstance();
if (instance && instance.createFilterMenu) { // Should be set if using BaseView if (instance && instance.createFilterMenu) { // Should be set if using BaseView
setPopupToCreateDom(elem, ctl => setPopupToCreateDom(elem, ctl => instance.createFilterMenu(
instance.createFilterMenu(ctl, filterInfo, popupOptions.onCloseContent), options); ctl, filterInfo, filterMenuOptions), popupOptionsWithDefaults);
} }
}; };
} }
@ -654,8 +724,17 @@ const cssMenuFooter = styled('div', `
flex-direction: column; flex-direction: column;
padding-top: 4px; padding-top: 4px;
`); `);
const cssApplyButton = styled(primaryButton, ` const cssFooterButtons = styled('div', `
margin-right: 4px; 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, ` const cssSearch = styled(input, `
color: ${theme.inputFg}; color: ${theme.inputFg};

View File

@ -1,58 +1,82 @@
import { makeT } from "app/client/lib/localization"; import { NEW_FILTER_JSON } from "app/client/models/ColumnFilter";
import { allInclusive } from "app/client/models/ColumnFilter"; import { ColumnRec, ViewSectionRec } from "app/client/models/DocModel";
import { ColumnRec, ViewFieldRec, ViewSectionRec } from "app/client/models/DocModel";
import { FilterInfo } from "app/client/models/entities/ViewSectionRec"; import { FilterInfo } from "app/client/models/entities/ViewSectionRec";
import { attachColumnFilterMenu } from "app/client/ui/ColumnFilterMenu"; import { attachColumnFilterMenu } from "app/client/ui/ColumnFilterMenu";
import { cssButton, cssButtonGroup } from "app/client/ui2018/buttons"; import { cssButton } from "app/client/ui2018/buttons";
import { testId, theme } from "app/client/ui2018/cssVars"; import { testId, theme, vars } from "app/client/ui2018/cssVars";
import { icon } from "app/client/ui2018/icons"; import { icon } from "app/client/ui2018/icons";
import { menu, menuItemAsync } from "app/client/ui2018/menus"; import { menu, menuItemAsync } from "app/client/ui2018/menus";
import { dom, IDisposableOwner, IDomArgs, styled } from "grainjs"; import { dom, IDisposableOwner, IDomArgs, styled } from "grainjs";
import { IMenuOptions, PopupControl } from "popweasel"; import { IMenuOptions, PopupControl } from "popweasel";
const t = makeT('FilterBar');
export function filterBar(_owner: IDisposableOwner, viewSection: ViewSectionRec) { export function filterBar(_owner: IDisposableOwner, viewSection: ViewSectionRec) {
const popupControls = new WeakMap<ColumnRec, PopupControl>(); const popupControls = new WeakMap<ColumnRec, PopupControl>();
return cssFilterBar( return cssFilterBar(
testId('filter-bar'), testId('filter-bar'),
dom.forEach(viewSection.activeFilters, (filterInfo) => makeFilterField(viewSection, filterInfo, popupControls)), dom.forEach(viewSection.activeFilters, (filterInfo) => makeFilterField(filterInfo, popupControls)),
makePlusButton(viewSection, popupControls), makePlusButton(viewSection, popupControls),
cssFilterBar.cls('-hidden', use => use(viewSection.pinnedActiveFilters).length === 0),
); );
} }
function makeFilterField(viewSection: ViewSectionRec, filterInfo: FilterInfo, function makeFilterField(filterInfo: FilterInfo, popupControls: WeakMap<ColumnRec, PopupControl>) {
popupControls: WeakMap<ColumnRec, PopupControl>) { const {fieldOrColumn, filter, pinned, isPinned} = filterInfo;
return cssFilterBarItem( return cssFilterBarItem(
testId('filter-field'), testId('filter-field'),
primaryButton( primaryButton(
testId('btn'), testId('btn'),
cssIcon('FilterSimple'), cssIcon('FilterSimple'),
cssMenuTextLabel(dom.text(filterInfo.fieldOrColumn.origCol().label)), cssMenuTextLabel(dom.text(fieldOrColumn.origCol().label)),
cssBtn.cls('-grayed', filterInfo.filter.isSaved), cssBtn.cls('-grayed', use => use(filter.isSaved) && use(pinned.isSaved)),
attachColumnFilterMenu(viewSection, filterInfo, { attachColumnFilterMenu(filterInfo, {
placement: 'bottom-start', attach: 'body', popupOptions: {
trigger: ['click', (_el, popupControl) => popupControls.set(filterInfo.fieldOrColumn.origCol(), popupControl)] placement: 'bottom-start',
attach: 'body',
trigger: [
'click',
(_el, popupControl) => popupControls.set(fieldOrColumn.origCol(), popupControl),
],
},
showAllFiltersButton: true,
}), }),
), ),
deleteButton( cssFilterBarItem.cls('-unpinned', use => !use(isPinned)),
testId('delete'),
cssIcon('CrossSmall'),
cssBtn.cls('-grayed', filterInfo.filter.isSaved),
dom.on('click', () => viewSection.setFilter(filterInfo.fieldOrColumn.origCol().origColRef(), '')),
)
); );
} }
export function addFilterMenu(filters: FilterInfo[], viewSection: ViewSectionRec, export interface AddFilterMenuOptions {
popupControls: WeakMap<ColumnRec, PopupControl>, options?: IMenuOptions) { /**
* If 'only-unfiltered', only columns without active filters will be selectable in
* the menu.
*
* If 'unpinned-or-unfiltered', columns that have active filters but are not pinned
* will also be selectable.
*
* Defaults to `only-unfiltered'.
*/
allowedColumns?: 'only-unfiltered' | 'unpinned-or-unfiltered';
/**
* Options that are passed to the menu component.
*/
menuOptions?: IMenuOptions;
}
export function addFilterMenu(
filters: FilterInfo[],
popupControls: WeakMap<ColumnRec, PopupControl>,
options: AddFilterMenuOptions = {}
) {
const {allowedColumns, menuOptions} = options;
return ( return (
menu((ctl) => [ menu((ctl) => [
...filters.map((filterInfo) => ( ...filters.map((filterInfo) => (
menuItemAsync( menuItemAsync(
() => turnOnAndOpenFilter(filterInfo.fieldOrColumn, viewSection, popupControls), () => openFilter(filterInfo, popupControls),
filterInfo.fieldOrColumn.origCol().label.peek(), filterInfo.fieldOrColumn.origCol().label.peek(),
dom.cls('disabled', filterInfo.isFiltered), dom.cls('disabled', allowedColumns === 'unpinned-or-unfiltered'
? use => use(filterInfo.isPinned) && use(filterInfo.isFiltered)
: use => use(filterInfo.isFiltered)
),
testId('add-filter-item'), testId('add-filter-item'),
) )
)), )),
@ -62,25 +86,30 @@ export function addFilterMenu(filters: FilterInfo[], viewSection: ViewSectionRec
ctl.close(); ctl.close();
ev.stopPropagation(); ev.stopPropagation();
}), }),
], options) ], menuOptions)
); );
} }
function turnOnAndOpenFilter(fieldOrColumn: ViewFieldRec|ColumnRec, viewSection: ViewSectionRec, function openFilter(
popupControls: WeakMap<ColumnRec, PopupControl>) { {fieldOrColumn, isFiltered, viewSection}: FilterInfo,
viewSection.setFilter(fieldOrColumn.origCol().origColRef(), allInclusive); popupControls: WeakMap<ColumnRec, PopupControl>,
) {
viewSection.setFilter(fieldOrColumn.origCol().origColRef(), {
filter: isFiltered.peek() ? undefined : NEW_FILTER_JSON,
pinned: true,
});
popupControls.get(fieldOrColumn.origCol())?.open(); popupControls.get(fieldOrColumn.origCol())?.open();
} }
function makePlusButton(viewSectionRec: ViewSectionRec, popupControls: WeakMap<ColumnRec, PopupControl>) { function makePlusButton(viewSectionRec: ViewSectionRec, popupControls: WeakMap<ColumnRec, PopupControl>) {
return dom.domComputed((use) => { return dom.domComputed((use) => {
const filters = use(viewSectionRec.filters); const filters = use(viewSectionRec.filters);
const anyFilter = use(viewSectionRec.activeFilters).length > 0;
return cssPlusButton( return cssPlusButton(
cssBtn.cls('-grayed'), cssBtn.cls('-grayed'),
cssIcon('Plus'), cssIcon('Plus'),
addFilterMenu(filters, viewSectionRec, popupControls), addFilterMenu(filters, popupControls, {
anyFilter ? null : cssPlusLabel(t('AddFilter')), allowedColumns: 'unpinned-or-unfiltered',
}),
testId('add-filter-btn') testId('add-filter-btn')
); );
}); });
@ -96,12 +125,16 @@ const cssFilterBar = styled('div.filter_bar', `
&::-webkit-scrollbar { &::-webkit-scrollbar {
display: none; display: none;
} }
&-hidden {
display: none;
}
`); `);
const cssFilterBarItem = styled(cssButtonGroup, ` const cssFilterBarItem = styled('div', `
border-radius: ${vars.controlBorderRadius};
flex-shrink: 0; flex-shrink: 0;
margin: 0 4px; margin: 0 4px;
& > .${cssButton.className}:first-child { &-unpinned {
border-right: 0.5px solid white; display: none;
} }
`); `);
const cssMenuTextLabel = styled('span', ` const cssMenuTextLabel = styled('span', `
@ -134,12 +167,6 @@ const primaryButton = (...args: IDomArgs<HTMLDivElement>) => (
dom('div', cssButton.cls(''), cssButton.cls('-primary'), dom('div', cssButton.cls(''), cssButton.cls('-primary'),
cssBtn.cls(''), ...args) cssBtn.cls(''), ...args)
); );
const deleteButton = styled(primaryButton, `
padding: 3px 4px;
`);
const cssPlusButton = styled(primaryButton, ` const cssPlusButton = styled(primaryButton, `
padding: 3px 3px padding: 3px 3px
`); `);
const cssPlusLabel = styled('span', `
margin: 0 12px 0 4px;
`);

View File

@ -0,0 +1,149 @@
import {makeT} from 'app/client/lib/localization';
import {ViewSectionRec} from 'app/client/models/DocModel';
import {attachColumnFilterMenu} from 'app/client/ui/ColumnFilterMenu';
import {addFilterMenu} from 'app/client/ui/FilterBar';
import {cssIcon, cssPinButton, cssRow, cssSortFilterColumn} from 'app/client/ui/RightPanelStyles';
import {theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {Computed, Disposable, dom, makeTestId, styled} from 'grainjs';
import {IMenuOptions} from 'popweasel';
const testId = makeTestId('test-filter-config-');
const t = makeT('SortConfig');
export interface FilterConfigOptions {
/** Options to pass to the menu and popup components. */
menuOptions?: IMenuOptions;
}
/**
* Component that renders controls for managing filters for a view section.
*
* Active filters (i.e. columns that have non-blank filters set) are displayed in
* a vertical list of pill-shaped buttons. These buttons can be clicked to open their
* respective filter menu. Additionally, there are buttons to the right of each filter
* for removing and pinning them.
*/
export class FilterConfig extends Disposable {
private _popupControls = new WeakMap();
private _canAddFilter = Computed.create(this, (use) => {
return use(this._section.filters).some(f => !use(f.isFiltered));
});
constructor(private _section: ViewSectionRec, private _options: FilterConfigOptions = {}) {
super();
}
public buildDom() {
const {menuOptions} = this._options;
return dom('div',
dom.forEach(this._section.activeFilters, (filterInfo) => {
const {fieldOrColumn, filter, pinned, isPinned} = filterInfo;
return cssRow(
cssSortFilterColumn(
cssIconWrapper(
cssFilterIcon('FilterSimple',
cssFilterIcon.cls('-accent', use => !use(filter.isSaved) || !use(pinned.isSaved)),
testId('filter-icon'),
),
),
cssLabel(dom.text(fieldOrColumn.label)),
attachColumnFilterMenu(filterInfo, {
popupOptions: {
placement: 'bottom-end',
...menuOptions,
trigger: [
'click',
(_el, popupControl) => this._popupControls.set(fieldOrColumn.origCol(), popupControl)
],
},
}),
testId('column'),
),
cssPinFilterButton(
icon('PinTilted'),
dom.on('click', () => this._section.setFilter(fieldOrColumn.origCol().origColRef(), {
pinned: !isPinned.peek()
})),
cssPinButton.cls('-pinned', isPinned),
testId('pin-filter'),
),
cssIconWrapper(
cssRemoveFilterButton('Remove',
dom.on('click',
() => this._section.setFilter(fieldOrColumn.origCol().origColRef(), {
filter: '',
pinned: false,
})),
testId('remove-filter'),
),
),
testId('filter'),
);
}),
cssRow(
dom.domComputed((use) => {
const filters = use(this._section.filters);
return cssTextBtn(
t('AddColumn'),
addFilterMenu(filters, this._popupControls, {
menuOptions: {
placement: 'bottom-end',
...this._options.menuOptions,
},
}),
dom.on('click', (ev) => ev.stopPropagation()),
dom.hide(u => !u(this._canAddFilter)),
testId('add-filter-btn'),
);
}),
),
testId('container'),
);
}
}
const cssIconWrapper = styled('div', ``);
const cssLabel = styled('div', `
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex-grow: 1;
`);
const cssTextBtn = styled('div', `
color: ${theme.controlFg};
cursor: pointer;
&:hover {
color: ${theme.controlHoverFg};
}
`);
const cssFilterIcon = styled(cssIcon, `
flex: none;
margin: 0px 6px 0px 0px;
background-color: ${theme.controlSecondaryFg};
&-accent {
background-color: ${theme.accentIcon};
}
`);
const cssRemoveFilterButton = styled(cssIcon, `
flex: none;
margin: 0 6px;
background-color: ${theme.controlSecondaryFg};
cursor: pointer;
&:hover {
background-color: ${theme.controlSecondaryHoverFg};
}
`);
const cssPinFilterButton = styled(cssPinButton, `
margin-left: 6px;
`);

View File

@ -28,6 +28,7 @@ import {GridOptions} from 'app/client/ui/GridOptions';
import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {linkId, selectBy} from 'app/client/ui/selectBy'; import {linkId, selectBy} from 'app/client/ui/selectBy';
import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig'; import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig';
import {cssLabel} from 'app/client/ui/RightPanelStyles';
import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig'; import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig';
import {IWidgetType, widgetTypes} from 'app/client/ui/widgetTypes'; import {IWidgetType, widgetTypes} from 'app/client/ui/widgetTypes';
import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
@ -429,14 +430,7 @@ export class RightPanel extends Disposable {
private _buildPageSortFilterConfig(owner: MultiHolder) { private _buildPageSortFilterConfig(owner: MultiHolder) {
const viewConfigTab = this._createViewConfigTab(owner); const viewConfigTab = this._createViewConfigTab(owner);
return [ return dom.maybe(viewConfigTab, (vct) => vct.buildSortFilterDom());
cssLabel(t('Sort')),
dom.maybe(viewConfigTab, (vct) => vct.buildSortDom()),
cssSeparator(),
cssLabel(t('Filter')),
dom.maybe(viewConfigTab, (vct) => dom('div', vct._buildFilterDom())),
];
} }
private _buildPageDataConfig(owner: MultiHolder, activeSection: ViewSectionRec) { private _buildPageDataConfig(owner: MultiHolder, activeSection: ViewSectionRec) {
@ -635,13 +629,6 @@ const cssBottomText = styled('span', `
padding: 4px 16px; padding: 4px 16px;
`); `);
const cssLabel = styled('div', `
color: ${theme.text};
text-transform: uppercase;
margin: 16px 16px 12px 16px;
font-size: ${vars.xsmallFontSize};
`);
const cssRow = styled('div', ` const cssRow = styled('div', `
color: ${theme.text}; color: ${theme.text};
display: flex; display: flex;

View File

@ -33,6 +33,18 @@ export const cssRow = styled('div', `
} }
`); `);
export const cssSortFilterColumn = styled('div', `
cursor: pointer;
display: flex;
flex-grow: 1;
align-items: center;
color: ${theme.text};
background-color: ${theme.hover};
overflow: hidden;
border-radius: 4px;
padding: 4px 8px;
`);
export const cssBlockedCursor = styled('span', ` export const cssBlockedCursor = styled('span', `
&, & * { &, & * {
cursor: not-allowed !important; cursor: not-allowed !important;
@ -51,3 +63,23 @@ export const cssSeparator = styled('div', `
border-bottom: 1px solid ${theme.pagePanelsBorder}; border-bottom: 1px solid ${theme.pagePanelsBorder};
margin-top: 16px; margin-top: 16px;
`); `);
export const cssSaveButtonsRow = styled('div', `
margin: 16px 16px 12px 16px;
`);
export const cssPinButton = styled('div', `
cursor: pointer;
--icon-color: ${theme.controlSecondaryFg};
border-radius: ${vars.controlBorderRadius};
padding: 3px;
&-pinned {
background-color: ${theme.controlPrimaryBg};
--icon-color: ${theme.controlPrimaryFg};
}
&:not(&-pinned):hover {
background-color: ${theme.hover};
}
`);

372
app/client/ui/SortConfig.ts Normal file
View File

@ -0,0 +1,372 @@
import {GristDoc} from 'app/client/components/GristDoc';
import koArray from 'app/client/lib/koArray';
import * as kf from 'app/client/lib/koForm';
import {makeT} from 'app/client/lib/localization';
import {addToSort, updatePositions} from 'app/client/lib/sortUtil';
import {ViewSectionRec} from 'app/client/models/DocModel';
import {ObjObservable} from 'app/client/models/modelUtil';
import {cssIcon, cssRow, cssSortFilterColumn} from 'app/client/ui/RightPanelStyles';
import {labeledLeftSquareCheckbox} from 'app/client/ui2018/checkbox';
import {theme} from 'app/client/ui2018/cssVars';
import {cssDragger} from 'app/client/ui2018/draggableList';
import {menu, menuItem} from 'app/client/ui2018/menus';
import {Sort} from 'app/common/SortSpec';
import {Computed, Disposable, dom, makeTestId, MultiHolder, styled} from 'grainjs';
import difference = require('lodash/difference');
import isEqual = require('lodash/isEqual');
import {cssMenuItem, IMenuOptions} from 'popweasel';
interface SortableColumn {
label: string;
value: number;
icon: 'FieldColumn';
type: string;
}
export interface SortConfigOptions {
/** Options to pass to all menus created by `SortConfig`. */
menuOptions?: IMenuOptions;
}
const testId = makeTestId('test-sort-config-');
const t = makeT('SortConfig');
/**
* Component that renders controls for managing sorting for a view section.
*
* Sorted columns are displayed in a vertical list of pill-shaped buttons. These
* buttons can be clicked to toggle their sort direction, and can be clicked and
* dragged to re-arrange their order. Additionally, there are buttons to the right
* of each sorted column for removing them, and opening a menu with advanced sort
* options.
*/
export class SortConfig extends Disposable {
// Computed array of sortable columns.
private _columns: Computed<SortableColumn[]> = Computed.create(this, (use) => {
// Columns is an observable holding an observable array - must call 'use' on it 2x.
const cols = use(use(use(this._section.table).columns).getObservable());
return cols.filter(col => !use(col.isHiddenCol)).map(col => ({
label: use(col.label),
value: col.getRowId(),
icon: 'FieldColumn',
type: col.type(),
}));
});
// We only want to recreate rows, when the actual columns change.
private _colRefs = Computed.create(this, (use) => {
return use(this._section.activeSortSpec).map(col => Sort.getColRef(col));
});
private _sortRows = this.autoDispose(koArray(this._colRefs.get()));
private _changedColRefs = Computed.create(this, (use) => {
const changedSpecs = difference(
use(this._section.activeSortSpec),
Sort.parseSortColRefs(use(this._section.sortColRefs))
);
return new Set(changedSpecs.map(spec => Sort.getColRef(spec)));
});
constructor(private _section: ViewSectionRec, private _gristDoc: GristDoc, private _options: SortConfigOptions = {}) {
super();
this.autoDispose(this._colRefs.addListener((curr, prev) => {
if (!isEqual(curr, prev)){
this._sortRows.assign(curr);
}
}));
}
public buildDom() {
return dom('div',
// Sort rows.
kf.draggableList(this._sortRows, (colRef: number) => this._createRow(colRef), {
reorder: (colRef: number, nextColRef: number | null) => this._reorder(colRef, nextColRef),
removeButton: false,
drag_indicator: cssDragger,
itemClass: cssDragRow.className,
}),
// Add to sort btn & menu.
this._buildAddToSortButton(this._columns),
this._buildUpdateDataButton(),
testId('container'),
);
}
private _createRow(colRef: number) {
return this._buildSortRow(colRef, this._section.activeSortSpec, this._columns);
}
/**
* 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.
*/
private _buildSortRow(
colRef: number,
sortSpec: ObjObservable<Sort.SortSpec>,
columns: Computed<SortableColumn[]>
) {
const holder = new MultiHolder();
const {menuOptions} = this._options;
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, (_, specDetails) => Sort.hasOptions(specDetails));
const isAscending = Computed.create(holder, details, (_, specDetails) => specDetails.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: keyof Sort.ColSpecDetails,
allowedTypes: string[] | null,
label: string
) => {
const computed = Computed.create(holder, details, (_, d) => d[flag] || false);
computed.onWrite(value => {
const specs = sortSpec.peek();
// Get existing details
const specDetails = Sort.specToDetails(Sort.findCol(specs, colRef)!) as any;
// Update flags
specDetails[flag] = value;
// Replace the colSpec at the index
this._saveSort(Sort.replace(specs, Sort.getColRef(colRef), specDetails));
});
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(c => c.value === Sort.getColRef(colRef));
return cssSortRow(
dom.autoDispose(holder),
cssSortFilterColumn(
dom.domComputed(isAscending, ascending =>
cssSortIcon(
"Sort",
cssSortIcon.cls('-accent', use => use(this._changedColRefs).has(column!.value)),
dom.style("transform", ascending ? "scaleY(-1)" : "none"),
testId('order'),
testId(ascending ? "sort-order-asc" : "sort-order-desc"),
)
),
cssLabel(column!.label),
dom.on("click", () => {
this._saveSort(Sort.flipSort(sortSpec.peek(), colRef));
}),
testId('column'),
),
cssMenu(
cssBigIconWrapper(
cssIcon('Dots', dom.cls(cssBgAccent.className, hasSpecs)),
testId('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 as any,
label,
dom.prop('disabled', !enabled),
),
dom.cls(cssOptionMenuItem.className),
dom.cls('disabled', !enabled),
testId('option'),
testId(`option-${flag}`),
);
},
), menuOptions),
),
cssSortIconBtn('Remove',
dom.on('click', () => {
const specs = sortSpec.peek();
if (Sort.findCol(specs, colRef)) {
this._saveSort(Sort.removeCol(specs, colRef));
}
}),
testId('remove')
),
testId('row'),
);
}
private _buildAddToSortButton(columns: Computed<SortableColumn[]>) {
const available = Computed.create(null, (use) => {
const currentSection = this._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));
});
const {menuOptions} = this._options;
return cssButtonRow(
dom.autoDispose(available),
dom.domComputed(use => {
const cols = use(available);
return cssTextBtn(
t('AddColumn'),
menu((ctl) => [
...cols.map((col) => (
menuItem(
() => addToSort(this._section.activeSortSpec, col.value, 1),
col.label,
testId('add-menu-row')
)
)),
// We need to stop click event to propagate otherwise it would cause view section menu to
// close.
dom.on('click', (ev) => {
ctl.close();
ev.stopPropagation();
}),
], menuOptions),
dom.on('click', (ev) => { ev.stopPropagation(); }),
testId('add'),
);
}),
dom.hide(use => !use(available).length),
);
}
private _buildUpdateDataButton() {
return dom.maybe(this._section.isSorted, () =>
cssButtonRow(
cssTextBtn(t('UpdateData'),
dom.on('click', () => updatePositions(this._gristDoc, this._section)),
testId('update'),
dom.show((use) => (
use(use(this._section.table).supportsManualSort)
&& !use(this._gristDoc.isReadonly)
)),
),
),
);
}
private _reorder(colRef: number, nextColRef: number | null) {
const activeSortSpec = this._section.activeSortSpec.peek();
const colSpec = Sort.findCol(activeSortSpec, colRef);
if (colSpec === undefined) {
throw new Error(`Col ${colRef} not found in active sort spec`);
}
const newSpec = Sort.reorderSortRefs(this._section.activeSortSpec.peek(), colSpec, nextColRef);
this._saveSort(newSpec);
}
private _saveSort(sortSpec: Sort.SortSpec) {
this._section.activeSortSpec(sortSpec);
}
}
const cssDragRow = styled('div', `
display: flex !important;
align-items: center;
margin: 0 16px 0px 0px;
& > .kf_draggable_content {
margin: 4px 0;
flex: 1 1 0px;
min-width: 0px;
}
`);
const cssLabel = styled('div', `
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex-grow: 1;
`);
const cssSortRow = styled('div', `
display: flex;
align-items: center;
width: 100%;
`);
const cssTextBtn = styled('div', `
color: ${theme.controlFg};
cursor: pointer;
&:hover {
color: ${theme.controlHoverFg};
}
`);
const cssSortIconBtn = styled(cssIcon, `
flex: none;
margin: 0 6px;
cursor: pointer;
background-color: ${theme.controlSecondaryFg};
&:hover {
background-color: ${theme.controlSecondaryHoverFg};
}
`);
const cssSortIcon = styled(cssIcon, `
flex: none;
margin: 0px 6px 0px 0px;
background-color: ${theme.controlSecondaryFg};
&-accent {
background-color: ${theme.accentIcon};
}
`);
const cssBigIconWrapper = styled('div', `
padding: 3px;
border-radius: 3px;
cursor: pointer;
user-select: none;
`);
const cssBgAccent = styled(`div`, `
background: ${theme.accentIcon}
`);
const cssMenu = styled('div', `
display: inline-flex;
cursor: pointer;
border-radius: 3px;
border: 1px solid transparent;
margin-left: 6px;
&:hover, &.weasel-popup-open {
background-color: ${theme.hover};
}
`);
const cssOptionMenuItem = styled('div', `
&:hover {
background-color: ${theme.hover};
}
& label {
flex: 1;
cursor: pointer;
}
&.disabled * {
color: ${theme.menuItemDisabledFg} important;
cursor: not-allowed;
}
`);
const cssButtonRow = styled(cssRow, `
margin-top: 4px;
`);

View File

@ -0,0 +1,68 @@
import {GristDoc} from 'app/client/components/GristDoc';
import {makeT} from 'app/client/lib/localization';
import {ViewSectionRec} from 'app/client/models/DocModel';
import {FilterConfig} from 'app/client/ui/FilterConfig';
import {cssLabel, cssSaveButtonsRow} from 'app/client/ui/RightPanelStyles';
import {SortConfig} from 'app/client/ui/SortConfig';
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {Computed, Disposable, dom, makeTestId, styled} from 'grainjs';
const testId = makeTestId('test-sort-filter-config-');
const t = makeT('SortFilterConfig');
export class SortFilterConfig extends Disposable {
private _docModel = this._gristDoc.docModel;
private _isReadonly = this._gristDoc.isReadonly;
private _hasChanges: Computed<boolean> = Computed.create(this, (use) => (
use(this._section.filterSpecChanged) || !use(this._section.activeSortJson.isSaved)
));
constructor(private _section: ViewSectionRec, private _gristDoc: GristDoc) {
super();
}
public buildDom() {
return [
cssLabel(t('Sort')),
dom.create(SortConfig, this._section, this._gristDoc, {
menuOptions: {attach: 'body'},
}),
cssLabel(t('Filter')),
dom.create(FilterConfig, this._section, {
menuOptions: {attach: 'body'},
}),
dom.maybe(this._hasChanges, () => [
cssSaveButtonsRow(
cssSaveButton(t('Save'),
dom.on('click', () => this._save()),
dom.boolAttr('disabled', this._isReadonly),
testId('save'),
),
basicButton(t('Revert'),
dom.on('click', () => this._revert()),
testId('revert'),
),
testId('save-btns'),
),
]),
];
}
private async _save() {
await this._docModel.docData.bundleActions(t('UpdateSortFilterSettings'), () => Promise.all([
this._section.activeSortJson.save(),
this._section.saveFilters(),
]));
}
private _revert() {
this._section.activeSortJson.revert();
this._section.revertFilters();
}
}
const cssSaveButton = styled(primaryButton, `
margin-right: 8px;
`);

View File

@ -1,20 +1,18 @@
import {GristDoc} from 'app/client/components/GristDoc';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {reportError} from 'app/client/models/AppModel'; import {reportError} from 'app/client/models/AppModel';
import {ColumnRec, DocModel, ViewSectionRec} from 'app/client/models/DocModel'; import {DocModel, ViewSectionRec} from 'app/client/models/DocModel';
import {FilterInfo} from 'app/client/models/entities/ViewSectionRec'; import {FilterConfig} from 'app/client/ui/FilterConfig';
import {CustomComputed} from 'app/client/models/modelUtil'; import {cssLabel, cssSaveButtonsRow} from 'app/client/ui/RightPanelStyles';
import {attachColumnFilterMenu} from 'app/client/ui/ColumnFilterMenu';
import {addFilterMenu} from 'app/client/ui/FilterBar';
import {hoverTooltip} from 'app/client/ui/tooltips'; import {hoverTooltip} from 'app/client/ui/tooltips';
import {SortConfig} from 'app/client/ui/SortConfig';
import {makeViewLayoutMenu} from 'app/client/ui/ViewLayoutMenu'; import {makeViewLayoutMenu} from 'app/client/ui/ViewLayoutMenu';
import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {theme, vars} from 'app/client/ui2018/cssVars'; import {theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {menu} from 'app/client/ui2018/menus'; import {menu} from 'app/client/ui2018/menus';
import {Sort} from 'app/common/SortSpec'; import {Computed, dom, IDisposableOwner, makeTestId, styled} from 'grainjs';
import {Computed, dom, fromKo, IDisposableOwner, makeTestId, Observable, styled} from 'grainjs'; import {defaultMenuOptions} from 'popweasel';
import {PopupControl} from 'popweasel';
import difference = require('lodash/difference');
const testId = makeTestId('test-section-menu-'); const testId = makeTestId('test-section-menu-');
const t = makeT('ViewSectionMenu'); const t = makeT('ViewSectionMenu');
@ -24,7 +22,6 @@ async function doSave(docModel: DocModel, viewSection: ViewSectionRec): Promise<
await docModel.docData.bundleActions(t("UpdateSortFilterSettings"), () => Promise.all([ await docModel.docData.bundleActions(t("UpdateSortFilterSettings"), () => Promise.all([
viewSection.activeSortJson.save(), // Save sort viewSection.activeSortJson.save(), // Save sort
viewSection.saveFilters(), // Save filter viewSection.saveFilters(), // Save filter
viewSection.activeFilterBar.save(), // Save bar
viewSection.activeCustomOptions.save(), // Save widget options viewSection.activeCustomOptions.save(), // Save widget options
])); ]));
} }
@ -33,24 +30,24 @@ async function doSave(docModel: DocModel, viewSection: ViewSectionRec): Promise<
function doRevert(viewSection: ViewSectionRec) { function doRevert(viewSection: ViewSectionRec) {
viewSection.activeSortJson.revert(); // Revert sort viewSection.activeSortJson.revert(); // Revert sort
viewSection.revertFilters(); // Revert filter viewSection.revertFilters(); // Revert filter
viewSection.activeFilterBar.revert(); // Revert bar
viewSection.activeCustomOptions.revert(); // Revert widget options viewSection.activeCustomOptions.revert(); // Revert widget options
} }
// [Filter Icon] (v) (x) - Filter toggle and all the components in the menu. // [Filter Icon] - Filter toggle and all the components in the menu.
export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, viewSection: ViewSectionRec, export function viewSectionMenu(
isReadonly: Observable<boolean>) { owner: IDisposableOwner,
gristDoc: GristDoc,
viewSection: ViewSectionRec,
) {
const {docModel, isReadonly} = gristDoc;
const popupControls = new WeakMap<ColumnRec, PopupControl>(); // If there is any filter (should [Filter Icon] background be filled).
// If there is any filter (should [Filter Icon] be green).
const anyFilter = Computed.create(owner, (use) => Boolean(use(viewSection.activeFilters).length)); const anyFilter = Computed.create(owner, (use) => Boolean(use(viewSection.activeFilters).length));
// Should border be green, and should we show [Save] [Revert] (v) (x) buttons. // Should we show [Save] [Revert] buttons.
const displaySaveObs: Computed<boolean> = Computed.create(owner, (use) => ( const displaySaveObs: Computed<boolean> = Computed.create(owner, (use) => (
use(viewSection.filterSpecChanged) use(viewSection.filterSpecChanged)
|| !use(viewSection.activeSortJson.isSaved) || !use(viewSection.activeSortJson.isSaved)
|| !use(viewSection.activeFilterBar.isSaved)
|| !use(viewSection.activeCustomOptions.isSaved) || !use(viewSection.activeCustomOptions.isSaved)
)); ));
@ -64,189 +61,111 @@ export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, vie
testId('wrapper'), testId('wrapper'),
cssMenu( cssMenu(
testId('sortAndFilter'), testId('sortAndFilter'),
// [Filter icon] grey or green // [Filter icon]
cssFilterIconWrapper( cssFilterIconWrapper(
testId('filter-icon'), testId('filter-icon'),
// Make green when there are some filters. If there are only sort options, leave grey. // Fill background when there are some filters. Ignore sort options.
cssFilterIconWrapper.cls('-any', anyFilter), cssFilterIconWrapper.cls('-any', anyFilter),
cssFilterIcon('Filter'), cssFilterIcon('Filter'),
hoverTooltip('Sort and filter', {key: 'sortFilterBtnTooltip'}), hoverTooltip('Sort and filter', {key: 'sortFilterBtnTooltip'}),
), ),
),
// [Save] [Revert] buttons when there are unsaved options.
dom.maybe(displaySaveObs, () => cssSectionSaveButtonsWrapper(
cssSaveTextButton(
t('Save'),
cssSaveTextButton.cls('-accent'),
dom.on('click', save),
hoverTooltip('Save sort & filter settings', {key: 'sortFilterBtnTooltip'}),
testId('small-btn-save'),
dom.hide(isReadonly),
),
cssRevertIconButton(
cssRevertIcon('Revert', cssRevertIcon.cls('-normal')),
dom.on('click', revert),
hoverTooltip('Revert sort & filter settings', {key: 'sortFilterBtnTooltip'}),
testId('small-btn-revert'),
),
)),
menu(ctl => [ menu(ctl => [
// Sorted by section. // Sort section.
dom.domComputed(use => { makeSortPanel(viewSection, gristDoc),
use(viewSection.activeSortJson.isSaved); // Rebuild sort panel if sort gets saved. A little hacky. // Filter section.
return makeSortPanel(viewSection, use(viewSection.activeSortSpec), makeFilterPanel(viewSection),
(row: number) => docModel.columns.getRowModel(row));
}),
// Filtered by section.
dom.domComputed(viewSection.activeFilters, filters =>
makeFilterPanel(viewSection, filters, popupControls, () => ctl.close())),
// [+] Add filter
makeAddFilterButton(viewSection, popupControls),
// [+] Toggle filter bar
dom.maybe((use) => !use(viewSection.isRaw),
() => makeFilterBarToggle(viewSection.activeFilterBar)),
// Widget options // Widget options
dom.maybe(use => use(viewSection.parentKey) === 'custom', () => dom.maybe(use => use(viewSection.parentKey) === 'custom', () =>
makeCustomOptions(viewSection) makeCustomOptions(viewSection)
), ),
// [Save] [Revert] buttons // [Save] [Revert] buttons
dom.domComputed(displaySaveObs, displaySave => [ dom.domComputed(displaySaveObs, displaySave => [
displaySave ? cssMenuInfoHeader( displaySave ? cssSaveButtonsRow(
cssSaveButton(t('Save'), testId('btn-save'), cssSaveButton(t('Save'), testId('btn-save'),
dom.on('click', () => { save(); ctl.close(); }), dom.on('click', () => { ctl.close(); save(); }),
dom.boolAttr('disabled', isReadonly)), dom.boolAttr('disabled', isReadonly)),
basicButton(t('Revert'), testId('btn-revert'), basicButton(t('Revert'), testId('btn-revert'),
dom.on('click', () => { revert(); ctl.close(); })) dom.on('click', () => { ctl.close(); revert(); }))
) : null, ) : null,
]), ]),
]), // Updates to active sort or filters can cause menu contents to grow, while
), // leaving the position of the popup unchanged. This can sometimes lead to
// Two icons (v) (x) left to the toggle, when there are unsaved filters or sort options. // the menu growing beyond the boundaries of the viewport. To mitigate this,
// Those buttons are equivalent of the [Save] [Revert] buttons in the menu. // we subscribe to changes to the sort/filters and manually update the popup's
dom.maybe(displaySaveObs, () => cssSaveIconsWrapper( // position, which will re-position the popup if necessary so that it's fully
// (v) // visible.
cssSmallIconWrapper( dom.autoDispose(viewSection.activeFilters.addListener(() => ctl.update())),
cssIcon('Tick'), cssSmallIconWrapper.cls('-green'), dom.autoDispose(viewSection.activeSortJson.subscribe(() => ctl.update())),
dom.on('click', save), ], {...defaultMenuOptions, placement: 'bottom-end', trigger: [
hoverTooltip('Save sort & filter settings', {key: 'sortFilterBtnTooltip'}), // Toggle the menu whenever the filter icon button is clicked.
testId('small-btn-save'), (el, ctl) => dom.onMatchElem(el, '.test-section-menu-sortAndFilter', 'click', () => {
dom.hide(isReadonly), ctl.toggle();
), }),
// (x) // Close the menu whenever the save or revert button is clicked.
cssSmallIconWrapper( (el, ctl) => dom.onMatchElem(el, '.test-section-menu-small-btn-save', 'click', () => {
cssIcon('CrossSmall'), cssSmallIconWrapper.cls('-gray'), ctl.close();
dom.on('click', revert), }),
hoverTooltip('Revert sort & filter settings', {key: 'sortFilterBtnTooltip'}), (el, ctl) => dom.onMatchElem(el, '.test-section-menu-small-btn-revert', 'click', () => {
testId('small-btn-revert'), ctl.close();
), }),
)), ]}),
), ),
cssMenu( cssMenu(
testId('viewLayout'), testId('viewLayout'),
cssFixHeight.cls(''), cssFixHeight.cls(''),
cssDotsIconWrapper(cssIcon('Dots')), cssDotsIconWrapper(cssIcon('Dots')),
menu(_ctl => makeViewLayoutMenu(viewSection, isReadonly.get())) menu(_ctl => makeViewLayoutMenu(viewSection, isReadonly.get()), {
) ...defaultMenuOptions,
]; placement: 'bottom-end',
}
// Sorted by section (and all columns underneath or (Default) label).
function makeSortPanel(section: ViewSectionRec, sortSpec: Sort.SortSpec, getColumn: (row: number) => ColumnRec) {
const changedColumns = difference(sortSpec, Sort.parseSortColRefs(section.sortColRefs.peek()));
const sortColumns = sortSpec.map(colSpec => {
// colRef is a rowId of a column or its negative value (indicating descending order).
const col = getColumn(Sort.getColRef(colSpec));
return cssMenuText(
cssMenuIconWrapper(
cssMenuIconWrapper.cls('-changed', changedColumns.includes(colSpec)),
cssMenuIconWrapper.cls(Sort.isAscending(colSpec) ? '-asc' : '-desc'),
cssIcon('Sort',
dom.style('transform', Sort.isAscending(colSpec) ? 'scaleY(-1)' : 'none'),
dom.on('click', () => {
section.activeSortSpec(Sort.flipSort(sortSpec, colSpec));
}) })
) )
),
cssMenuTextLabel(col.colId()),
cssMenuIconWrapper(
cssIcon('Remove', testId('btn-remove-sort'), dom.on('click', () => {
if (Sort.findCol(sortSpec, colSpec)) {
section.activeSortSpec(Sort.removeCol(sortSpec, colSpec));
}
}))
),
testId('sort-col')
);
});
return [
cssMenuInfoHeader(t('SortedBy'), testId('heading-sorted')),
sortColumns.length > 0 ? sortColumns : cssGrayedMenuText('(Default)')
]; ];
} }
// [+] Add Filter. function makeSortPanel(section: ViewSectionRec, gristDoc: GristDoc) {
export function makeAddFilterButton(viewSectionRec: ViewSectionRec, popupControls: WeakMap<ColumnRec, PopupControl>) {
return dom.domComputed((use) => {
const filters = use(viewSectionRec.filters);
return cssMenuText(
cssMenuIconWrapper(
cssIcon('Plus'),
addFilterMenu(filters, viewSectionRec, popupControls, {
placement: 'bottom-end',
// Attach content to triggerElem's parent, which is needed to prevent view section menu to
// close when clicking an item of the add filter menu.
attach: null
}),
testId('plus-button'),
dom.on('click', (ev) => ev.stopPropagation()),
),
cssMenuTextLabel(t('AddFilter')),
);
});
}
// [v] or [x] Toggle Filter Bar.
export function makeFilterBarToggle(activeFilterBar: CustomComputed<boolean>) {
return cssMenuText(
cssMenuIconWrapper(
testId('btn'),
cssMenuIconWrapper.cls('-changed', (use) => !use(activeFilterBar.isSaved)),
dom.domComputed((use) => {
const filterBar = use(activeFilterBar);
const isSaved = use(activeFilterBar.isSaved);
return cssIcon(filterBar ? "Tick" : (isSaved ? "Plus" : "CrossSmall"),
cssIcon.cls('-green', Boolean(filterBar)),
testId('icon'));
}),
),
dom.on('click', () => activeFilterBar(!activeFilterBar.peek())),
cssMenuTextLabel(t("ToggleFilterBar")),
);
}
// Filtered by - section in the menu (contains all filtered columns or (Not filtered) label).
function makeFilterPanel(section: ViewSectionRec, activeFilters: FilterInfo[],
popupControls: WeakMap<ColumnRec, PopupControl>,
onCloseContent: () => void) {
const filters = activeFilters.map(filterInfo => {
const filterChanged = Computed.create(null, fromKo(filterInfo.filter.isSaved), (_use, isSaved) => !isSaved);
return cssMenuText(
cssMenuIconWrapper(
cssMenuIconWrapper.cls('-changed', filterChanged),
cssIcon('FilterSimple'),
attachColumnFilterMenu(section, filterInfo, {
placement: 'bottom-end',
trigger: [
'click',
(_el, popupControl) => popupControls.set(filterInfo.fieldOrColumn.origCol(), popupControl)
],
onCloseContent,
}),
testId('filter-icon'),
),
cssMenuTextLabel(filterInfo.fieldOrColumn.label()),
cssMenuIconWrapper(cssIcon('Remove',
dom.on('click', () => section.setFilter(filterInfo.fieldOrColumn.origCol().origColRef(), ''))),
testId('btn-remove-filter')
),
testId('filter-col')
);
});
return [ return [
cssMenuInfoHeader(t('FilteredBy'), {style: 'margin-top: 4px'}, testId('heading-filtered')), cssLabel(t('Sort'), testId('heading-sort')),
activeFilters.length > 0 ? filters : cssGrayedMenuText('(Not filtered)') dom.create(SortConfig, section, gristDoc, {
// Attach content to triggerElem's parent, which is needed to prevent view
// section menu to close when clicking an item in the advanced sort menu.
menuOptions: {attach: null},
}),
]; ];
} }
function makeFilterPanel(section: ViewSectionRec) {
return [
cssLabel(t('Filter'), testId('heading-filter')),
dom.create(FilterConfig, section, {
// Attach content to triggerElem's parent, which is needed to prevent view
// section menu to close when clicking an item of the add filter menu.
menuOptions: {attach: null},
}),
];
}
// Custom Options // Custom Options
// (empty)|(customized)|(modified) [Remove Icon] // (empty)|(customized)|(modified) [Remove Icon]
function makeCustomOptions(section: ViewSectionRec) { function makeCustomOptions(section: ViewSectionRec) {
const color = Computed.create(null, use => use(section.activeCustomOptions.isSaved) ? "-gray" : "-green"); const color = Computed.create(null, use => use(section.activeCustomOptions.isSaved) ? "-normal" : "-accent");
const text = Computed.create(null, use => { const text = Computed.create(null, use => {
if (use(section.activeCustomOptions)) { if (use(section.activeCustomOptions)) {
return use(section.activeCustomOptions.isSaved) ? t("Customized") : t("Modified"); return use(section.activeCustomOptions.isSaved) ? t("Customized") : t("Modified");
@ -348,7 +267,7 @@ const cssIcon = styled(icon, `
background-color: ${theme.controlPrimaryFg}; background-color: ${theme.controlPrimaryFg};
} }
&-green { &-accent {
background-color: ${theme.accentIcon}; background-color: ${theme.accentIcon};
} }
`); `);
@ -363,14 +282,18 @@ const cssDotsIconWrapper = styled(cssIconWrapper, `
const cssFilterIconWrapper = styled(cssIconWrapper, ` const cssFilterIconWrapper = styled(cssIconWrapper, `
border-radius: 2px 0px 0px 2px; border-radius: 2px 0px 0px 2px;
&-any {
border-radius: 2px;
background-color: ${theme.controlSecondaryFg};
}
.${cssFilterMenuWrapper.className}-unsaved & { .${cssFilterMenuWrapper.className}-unsaved & {
background-color: ${theme.accentIcon}; background-color: ${theme.controlPrimaryBg};
} }
`); `);
const cssFilterIcon = styled(cssIcon, ` const cssFilterIcon = styled(cssIcon, `
.${cssFilterIconWrapper.className}-any & { .${cssFilterIconWrapper.className}-any & {
background-color: ${theme.accentIcon}; background-color: ${theme.controlPrimaryFg};
} }
.${cssFilterMenuWrapper.className}-unsaved & { .${cssFilterMenuWrapper.className}-unsaved & {
background-color: ${theme.controlPrimaryFg}; background-color: ${theme.controlPrimaryFg};
@ -390,51 +313,48 @@ const cssMenuText = styled('div', `
padding: 0px 24px 8px 24px; padding: 0px 24px 8px 24px;
cursor: default; cursor: default;
white-space: nowrap; white-space: nowrap;
&-green { &-accent {
color: ${theme.accentText}; color: ${theme.accentText};
} }
&-gray { &-normal {
color: ${theme.lightText}; color: ${theme.lightText};
} }
`); `);
const cssGrayedMenuText = styled(cssMenuText, `
color: ${theme.lightText};
`);
const cssMenuTextLabel = styled('span', `
color: ${theme.menuItemFg};
flex-grow: 1;
padding: 0 4px;
overflow: hidden;
text-overflow: ellipsis;
`);
const cssSaveButton = styled(primaryButton, ` const cssSaveButton = styled(primaryButton, `
margin-right: 8px; margin-right: 8px;
`); `);
const cssSmallIconWrapper = styled('div', ` const cssSaveTextButton = styled('div', `
width: 16px; display: flex;
height: 16px; align-items: center;
border-radius: 8px; cursor: pointer;
margin: 0 5px 0 5px; font-size: ${vars.mediumFontSize};
padding: 0px 5px;
border-right: 1px solid ${theme.accentBorder};
&-green { &-accent {
background-color: ${theme.accentIcon}; color: ${theme.accentText};
}
&-gray {
background-color: ${theme.lightText};
}
& > .${cssIcon.className} {
background-color: ${theme.controlPrimaryFg};
} }
`); `);
const cssSaveIconsWrapper = styled('div', ` const cssRevertIconButton = styled('div', `
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
`);
const cssRevertIcon = styled(icon, `
--icon-color: ${theme.accentIcon};
margin: 0 5px 0 5px;
`);
const cssSectionSaveButtonsWrapper = styled('div', `
padding: 0 1px 0 1px; padding: 0 1px 0 1px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-self: normal;
`); `);
const cssSpacer = styled('div', ` const cssSpacer = styled('div', `

View File

@ -90,6 +90,7 @@ export type IconName = "ChartArea" |
"Pencil" | "Pencil" |
"PinBig" | "PinBig" |
"PinSmall" | "PinSmall" |
"PinTilted" |
"Pivot" | "Pivot" |
"PivotLight" | "PivotLight" |
"Plus" | "Plus" |
@ -101,6 +102,7 @@ export type IconName = "ChartArea" |
"Remove" | "Remove" |
"Repl" | "Repl" |
"ResizePanel" | "ResizePanel" |
"Revert" |
"RightAlign" | "RightAlign" |
"Script" | "Script" |
"Search" | "Search" |
@ -220,6 +222,7 @@ export const IconList: IconName[] = ["ChartArea",
"Pencil", "Pencil",
"PinBig", "PinBig",
"PinSmall", "PinSmall",
"PinTilted",
"Pivot", "Pivot",
"PivotLight", "PivotLight",
"Plus", "Plus",
@ -231,6 +234,7 @@ export const IconList: IconName[] = ["ChartArea",
"Remove", "Remove",
"Repl", "Repl",
"ResizePanel", "ResizePanel",
"Revert",
"RightAlign", "RightAlign",
"Script", "Script",
"Search", "Search",

View File

@ -22,6 +22,7 @@ export const cssButton = styled('button', `
outline: none; outline: none;
border-style: none; border-style: none;
line-height: normal; line-height: normal;
user-select: none;
/* Vars */ /* Vars */
font-size: ${vars.mediumFontSize}; font-size: ${vars.mediumFontSize};

View File

@ -4,7 +4,7 @@ import { GristObjCode } from "app/plugin/GristData";
// tslint:disable:object-literal-key-quotes // tslint:disable:object-literal-key-quotes
export const SCHEMA_VERSION = 33; export const SCHEMA_VERSION = 34;
export const schema = { export const schema = {
@ -194,6 +194,7 @@ export const schema = {
viewSectionRef : "Ref:_grist_Views_section", viewSectionRef : "Ref:_grist_Views_section",
colRef : "Ref:_grist_Tables_column", colRef : "Ref:_grist_Tables_column",
filter : "Text", filter : "Text",
pinned : "Bool",
}, },
"_grist_Cells": { "_grist_Cells": {
@ -397,6 +398,7 @@ export interface SchemaTypes {
viewSectionRef: number; viewSectionRef: number;
colRef: number; colRef: number;
filter: string; filter: string;
pinned: boolean;
}; };
"_grist_Cells": { "_grist_Cells": {

View File

@ -6,7 +6,7 @@ export const GRIST_DOC_SQL = `
PRAGMA foreign_keys=OFF; PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION; BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',33,'',''); INSERT INTO _grist_DocInfo VALUES(1,'','','',34,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL); CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0);
@ -33,7 +33,7 @@ INSERT INTO _grist_ACLPrincipals VALUES(2,'group','','','Admins','');
INSERT INTO _grist_ACLPrincipals VALUES(3,'group','','','Editors',''); INSERT INTO _grist_ACLPrincipals VALUES(3,'group','','','Editors','');
INSERT INTO _grist_ACLPrincipals VALUES(4,'group','','','Viewers',''); INSERT INTO _grist_ACLPrincipals VALUES(4,'group','','','Viewers','');
CREATE TABLE IF NOT EXISTS "_grist_ACLMemberships" (id INTEGER PRIMARY KEY, "parent" INTEGER DEFAULT 0, "child" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_ACLMemberships" (id INTEGER PRIMARY KEY, "parent" INTEGER DEFAULT 0, "child" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Filters" (id INTEGER PRIMARY KEY, "viewSectionRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "filter" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_Filters" (id INTEGER PRIMARY KEY, "viewSectionRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '', "pinned" BOOLEAN DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Cells" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "rowId" INTEGER DEFAULT 0, "root" BOOLEAN DEFAULT 0, "parentId" INTEGER DEFAULT 0, "type" INTEGER DEFAULT 0, "content" TEXT DEFAULT '', "userRef" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_Cells" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "rowId" INTEGER DEFAULT 0, "root" BOOLEAN DEFAULT 0, "parentId" INTEGER DEFAULT 0, "type" INTEGER DEFAULT 0, "content" TEXT DEFAULT '', "userRef" TEXT DEFAULT '');
CREATE INDEX _grist_Attachments_fileIdent ON _grist_Attachments(fileIdent); CREATE INDEX _grist_Attachments_fileIdent ON _grist_Attachments(fileIdent);
COMMIT; COMMIT;
@ -43,7 +43,7 @@ export const GRIST_DOC_WITH_TABLE1_SQL = `
PRAGMA foreign_keys=OFF; PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION; BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',33,'',''); INSERT INTO _grist_DocInfo VALUES(1,'','','',34,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0);
INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2); INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL); CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
@ -86,7 +86,7 @@ INSERT INTO _grist_ACLPrincipals VALUES(2,'group','','','Admins','');
INSERT INTO _grist_ACLPrincipals VALUES(3,'group','','','Editors',''); INSERT INTO _grist_ACLPrincipals VALUES(3,'group','','','Editors','');
INSERT INTO _grist_ACLPrincipals VALUES(4,'group','','','Viewers',''); INSERT INTO _grist_ACLPrincipals VALUES(4,'group','','','Viewers','');
CREATE TABLE IF NOT EXISTS "_grist_ACLMemberships" (id INTEGER PRIMARY KEY, "parent" INTEGER DEFAULT 0, "child" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_ACLMemberships" (id INTEGER PRIMARY KEY, "parent" INTEGER DEFAULT 0, "child" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Filters" (id INTEGER PRIMARY KEY, "viewSectionRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "filter" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_Filters" (id INTEGER PRIMARY KEY, "viewSectionRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '', "pinned" BOOLEAN DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Cells" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "rowId" INTEGER DEFAULT 0, "root" BOOLEAN DEFAULT 0, "parentId" INTEGER DEFAULT 0, "type" INTEGER DEFAULT 0, "content" TEXT DEFAULT '', "userRef" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_Cells" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "rowId" INTEGER DEFAULT 0, "root" BOOLEAN DEFAULT 0, "parentId" INTEGER DEFAULT 0, "type" INTEGER DEFAULT 0, "content" TEXT DEFAULT '', "userRef" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "Table1" (id INTEGER PRIMARY KEY, "manualSort" NUMERIC DEFAULT 1e999, "A" BLOB DEFAULT NULL, "B" BLOB DEFAULT NULL, "C" BLOB DEFAULT NULL); CREATE TABLE IF NOT EXISTS "Table1" (id INTEGER PRIMARY KEY, "manualSort" NUMERIC DEFAULT 1e999, "A" BLOB DEFAULT NULL, "B" BLOB DEFAULT NULL, "C" BLOB DEFAULT NULL);
CREATE INDEX _grist_Attachments_fileIdent ON _grist_Attachments(fileIdent); CREATE INDEX _grist_Attachments_fileIdent ON _grist_Attachments(fileIdent);

View File

@ -1112,3 +1112,45 @@ def migration33(tdset):
] ]
return tdset.apply_doc_actions(doc_actions) return tdset.apply_doc_actions(doc_actions)
@migration(schema_version=34)
def migration34(tdset):
""""
Add pinned column to _grist_Filters and populate based on existing sections.
When populating, pinned will be set to true for filters that either belong to
a section where the filter bar is toggled or a raw view section.
From this version on, _grist_Views_section.options.filterBar is deprecated.
"""
doc_actions = [add_column('_grist_Filters', 'pinned', 'Bool')]
tables = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Tables']))
sections = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Views_section']))
filters = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Filters']))
raw_section_ids = set(t.rawViewSectionRef for t in tables)
filter_bar_by_section_id = {
# Pre-migration, raw sections always showed the filter bar in the UI. Since we want
# existing raw section filters to continue appearing in the filter bar, we'll pretend
# here that raw sections have a filterBar value of True. Note that after this migration
# it will be possible for raw sections to have unpinned filters.
s.id: bool(s.id in raw_section_ids or safe_parse(s.options).get('filterBar', False))
for s in sections
}
# List of (filter_rec, pinned) pairs.
filter_updates = []
for filter_rec in filters:
filter_updates.append((
filter_rec,
filter_bar_by_section_id.get(filter_rec.viewSectionRef, False)
))
if filter_updates:
doc_actions.append(actions.BulkUpdateRecord(
'_grist_Filters',
[filter_rec.id for filter_rec, _ in filter_updates],
{'pinned': [pinned for _, pinned in filter_updates]},
))
return tdset.apply_doc_actions(doc_actions)

View File

@ -15,7 +15,7 @@ import six
import actions import actions
SCHEMA_VERSION = 33 SCHEMA_VERSION = 34
def make_column(col_id, col_type, formula='', isFormula=False): def make_column(col_id, col_type, formula='', isFormula=False):
return { return {
@ -315,7 +315,10 @@ def schema_create_actions():
# `excluded` string to an array of column values: # `excluded` string to an array of column values:
# Ex1: { included: ['foo', 'bar'] } # Ex1: { included: ['foo', 'bar'] }
# Ex2: { excluded: ['apple', 'orange'] } # Ex2: { excluded: ['apple', 'orange'] }
make_column("filter", "Text") make_column("filter", "Text"),
# Filters can be pinned to the filter bar, which causes a button to be displayed
# that opens the filter menu when clicked.
make_column("pinned", "Bool"),
]), ]),
# Additional metadata for cells # Additional metadata for cells

View File

@ -954,14 +954,16 @@ class TestUserActions(test_engine.EngineTestCase):
self.apply_user_action(['BulkAddRecord', '_grist_Filters', [None], { self.apply_user_action(['BulkAddRecord', '_grist_Filters', [None], {
"viewSectionRef": [1], "viewSectionRef": [1],
"colRef": [1], "colRef": [1],
"filter": [json.dumps({"included": ["b", "c"]})] "filter": [json.dumps({"included": ["b", "c"]})],
"pinned": [True],
}]) }])
# Add the same filter for second column (to make sure it is not renamed) # Add the same filter for second column (to make sure it is not renamed)
self.apply_user_action(['BulkAddRecord', '_grist_Filters', [None], { self.apply_user_action(['BulkAddRecord', '_grist_Filters', [None], {
"viewSectionRef": [1], "viewSectionRef": [1],
"colRef": [2], "colRef": [2],
"filter": [json.dumps({"included": ["b", "c"]})] "filter": [json.dumps({"included": ["b", "c"]})],
"pinned": [False],
}]) }])
# Rename choices # Rename choices
@ -971,9 +973,9 @@ class TestUserActions(test_engine.EngineTestCase):
# Test filters # Test filters
self.assertTableData('_grist_Filters', data=[ self.assertTableData('_grist_Filters', data=[
["id", "colRef", "filter", "setAutoRemove", "viewSectionRef"], ["id", "colRef", "filter", "setAutoRemove", "viewSectionRef", "pinned"],
[1, 1, json.dumps({"included": ["z", "b"]}), None, 1], [1, 1, json.dumps({"included": ["z", "b"]}), None, 1, True],
[2, 2, json.dumps({"included": ["b", "c"]}), None, 1] [2, 2, json.dumps({"included": ["b", "c"]}), None, 1, False]
]) ])
def test_add_or_update(self): def test_add_or_update(self):

View File

@ -91,6 +91,7 @@
--icon-Pencil: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9Ii0yIC0yIDIwIDIwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxnIGNsYXNzPSJuYy1pY29uLXdyYXBwZXIiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzIxMjEyMSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xMyAuNUwxNS41IDMgNy41IDExIDQgMTIgNSA4LjV6TTExIDIuNUwxMy41IDUiLz48cGF0aCBkPSJNMTMuNSw5LjV2NSBjMCwwLjU1Mi0wLjQ0OCwxLTEsMWgtMTFjLTAuNTUyLDAtMS0wLjQ0OC0xLTF2LTExYzAtMC41NTIsMC40NDgtMSwxLTFoNSIvPjwvZz48L3N2Zz4='); --icon-Pencil: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9Ii0yIC0yIDIwIDIwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxnIGNsYXNzPSJuYy1pY29uLXdyYXBwZXIiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzIxMjEyMSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xMyAuNUwxNS41IDMgNy41IDExIDQgMTIgNSA4LjV6TTExIDIuNUwxMy41IDUiLz48cGF0aCBkPSJNMTMuNSw5LjV2NSBjMCwwLjU1Mi0wLjQ0OCwxLTEsMWgtMTFjLTAuNTUyLDAtMS0wLjQ0OC0xLTF2LTExYzAtMC41NTIsMC40NDgtMSwxLTFoNSIvPjwvZz48L3N2Zz4=');
--icon-PinBig: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjgyOTcyODEsLTcuNjAyODA3MjdlLTEzIEw0LjE5Mjk3NDkxLC03LjU3NjE2MTkyZS0xMyBDMy42MjcyODk0OCwtNy41MzE3NTNlLTEzIDMuMjAzMDI1NDEsMC40MTAyNTY0MSAzLjIwMzAyNTQxLDAuOTU3MjY0OTU3IEMzLjIwMzAyNTQxLDEuNTA0MjczNSAzLjYyNzI4OTQ4LDEuOTE0NTI5OTEgNC4xOTI5NzQ5MSwxLjkxNDUyOTkxIEw1LjA0MTUwMzA0LDEuOTE0NTI5OTEgTDIuODQ5NDcyMDIsOS4wOTQwMTcwOSBMMS40MzUyNTg0Niw5LjA5NDAxNzA5IEMwLjg2OTU3MzAzNiw5LjA5NDAxNzA5IDAuNDQ1MzA4OTY3LDkuNTA0MjczNSAwLjQ0NTMwODk2NywxMC4wNTEyODIxIEMwLjQ0NTMwODk2NywxMC41OTgyOTA2IDAuODY5NTczMDM2LDExLjAwODU0NyAxLjQzNTI1ODQ2LDExLjAwODU0NyBMMy41NTY1Nzg4LDExLjAwODU0NyBMNi45NTA2OTEzNSwxMS4wMDg1NDcgTDcuMDIxNDAyMDMsMTYgTDkuMDAxMzAxMDIsMTYgTDkuMDAxMzAxMDIsMTAuOTQwMTcwOSBMMTQuNjU4MTU1MywxMC45NDAxNzA5IEMxNS4zNjUyNjIxLDEwLjk0MDE3MDkgMTUuNjQ4MTA0OCwxMC4yNTY0MTAzIDE1LjY0ODEwNDgsOS45ODI5MDU5OCBDMTUuNjQ4MTA0OCw5LjQzNTg5NzQ0IDE1LjIyMzg0MDcsOS4wMjU2NDEwMyAxNC42NTgxNTUzLDkuMDI1NjQxMDMgTDEzLjI0Mzk0MTcsOS4wMjU2NDEwMyBMMTEuMTIyNjIxNCwxLjkxNDUyOTkxIEwxMS45NzExNDk1LDEuOTE0NTI5OTEgQzEyLjgxOTY3NzYsMS45MTQ1Mjk5MSAxMi45NjEwOTksMS4yMzA3NjkyMyAxMi45NjEwOTksMC45NTcyNjQ5NTcgQzEyLjg5MDM4ODMsMC40Nzg2MzI0NzkgMTIuMzk1NDEzNiwtNy42NjQ5Nzk3NmUtMTMgMTEuODI5NzI4MSwtNy42MDI4MDcyN2UtMTMgWiBNMTEuMDUxOTEwNyw5LjA5NDAxNzA5IEw0Ljk3MDc5MjM3LDkuMDk0MDE3MDkgTDcuMDkyMTEyNzEsMS45ODI5MDU5OCBMOC45MzA1OTAzNCwxLjk4MjkwNTk4IEwxMS4wNTE5MTA3LDkuMDk0MDE3MDkgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); --icon-PinBig: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjgyOTcyODEsLTcuNjAyODA3MjdlLTEzIEw0LjE5Mjk3NDkxLC03LjU3NjE2MTkyZS0xMyBDMy42MjcyODk0OCwtNy41MzE3NTNlLTEzIDMuMjAzMDI1NDEsMC40MTAyNTY0MSAzLjIwMzAyNTQxLDAuOTU3MjY0OTU3IEMzLjIwMzAyNTQxLDEuNTA0MjczNSAzLjYyNzI4OTQ4LDEuOTE0NTI5OTEgNC4xOTI5NzQ5MSwxLjkxNDUyOTkxIEw1LjA0MTUwMzA0LDEuOTE0NTI5OTEgTDIuODQ5NDcyMDIsOS4wOTQwMTcwOSBMMS40MzUyNTg0Niw5LjA5NDAxNzA5IEMwLjg2OTU3MzAzNiw5LjA5NDAxNzA5IDAuNDQ1MzA4OTY3LDkuNTA0MjczNSAwLjQ0NTMwODk2NywxMC4wNTEyODIxIEMwLjQ0NTMwODk2NywxMC41OTgyOTA2IDAuODY5NTczMDM2LDExLjAwODU0NyAxLjQzNTI1ODQ2LDExLjAwODU0NyBMMy41NTY1Nzg4LDExLjAwODU0NyBMNi45NTA2OTEzNSwxMS4wMDg1NDcgTDcuMDIxNDAyMDMsMTYgTDkuMDAxMzAxMDIsMTYgTDkuMDAxMzAxMDIsMTAuOTQwMTcwOSBMMTQuNjU4MTU1MywxMC45NDAxNzA5IEMxNS4zNjUyNjIxLDEwLjk0MDE3MDkgMTUuNjQ4MTA0OCwxMC4yNTY0MTAzIDE1LjY0ODEwNDgsOS45ODI5MDU5OCBDMTUuNjQ4MTA0OCw5LjQzNTg5NzQ0IDE1LjIyMzg0MDcsOS4wMjU2NDEwMyAxNC42NTgxNTUzLDkuMDI1NjQxMDMgTDEzLjI0Mzk0MTcsOS4wMjU2NDEwMyBMMTEuMTIyNjIxNCwxLjkxNDUyOTkxIEwxMS45NzExNDk1LDEuOTE0NTI5OTEgQzEyLjgxOTY3NzYsMS45MTQ1Mjk5MSAxMi45NjEwOTksMS4yMzA3NjkyMyAxMi45NjEwOTksMC45NTcyNjQ5NTcgQzEyLjg5MDM4ODMsMC40Nzg2MzI0NzkgMTIuMzk1NDEzNiwtNy42NjQ5Nzk3NmUtMTMgMTEuODI5NzI4MSwtNy42MDI4MDcyN2UtMTMgWiBNMTEuMDUxOTEwNyw5LjA5NDAxNzA5IEw0Ljk3MDc5MjM3LDkuMDk0MDE3MDkgTDcuMDkyMTEyNzEsMS45ODI5MDU5OCBMOC45MzA1OTAzNCwxLjk4MjkwNTk4IEwxMS4wNTE5MTA3LDkuMDk0MDE3MDkgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--icon-PinSmall: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuNSwxMSBMMi41LDExIEMyLjIyMzg1NzYzLDExIDIsMTAuNzc2MTQyNCAyLDEwLjUgQzIsMTAuMjIzODU3NiAyLjIyMzg1NzYzLDEwIDIuNSwxMCBMNC4yNSwxMCBMNS43NSw0IEw0LjUsNCBDNC4yMjM4NTc2Myw0IDQsMy43NzYxNDIzNyA0LDMuNSBDNCwzLjIyMzg1NzYzIDQuMjIzODU3NjMsMyA0LjUsMyBMMTEuNSwzIEMxMS43NzYxNDI0LDMgMTIsMy4yMjM4NTc2MyAxMiwzLjUgQzEyLDMuNzc2MTQyMzcgMTEuNzc2MTQyNCw0IDExLjUsNCBMMTAuMjUsNCBMMTEuNzUsMTAgTDEzLjUsMTAgQzEzLjc3NjE0MjQsMTAgMTQsMTAuMjIzODU3NiAxNCwxMC41IEMxNCwxMC43NzYxNDI0IDEzLjc3NjE0MjQsMTEgMTMuNSwxMSBMOC41LDExIEw4LjUsMTQgTDcuNSwxNCBMNy41LDExIFogTTUuMjgwNzc2NDEsMTAgTDEwLjcxOTIyMzYsMTAgTDkuMjE5MjIzNTksNCBMNi43ODA3NzY0MSw0IEw1LjI4MDc3NjQxLDEwIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-PinSmall: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuNSwxMSBMMi41LDExIEMyLjIyMzg1NzYzLDExIDIsMTAuNzc2MTQyNCAyLDEwLjUgQzIsMTAuMjIzODU3NiAyLjIyMzg1NzYzLDEwIDIuNSwxMCBMNC4yNSwxMCBMNS43NSw0IEw0LjUsNCBDNC4yMjM4NTc2Myw0IDQsMy43NzYxNDIzNyA0LDMuNSBDNCwzLjIyMzg1NzYzIDQuMjIzODU3NjMsMyA0LjUsMyBMMTEuNSwzIEMxMS43NzYxNDI0LDMgMTIsMy4yMjM4NTc2MyAxMiwzLjUgQzEyLDMuNzc2MTQyMzcgMTEuNzc2MTQyNCw0IDExLjUsNCBMMTAuMjUsNCBMMTEuNzUsMTAgTDEzLjUsMTAgQzEzLjc3NjE0MjQsMTAgMTQsMTAuMjIzODU3NiAxNCwxMC41IEMxNCwxMC43NzYxNDI0IDEzLjc3NjE0MjQsMTEgMTMuNSwxMSBMOC41LDExIEw4LjUsMTQgTDcuNSwxNCBMNy41LDExIFogTTUuMjgwNzc2NDEsMTAgTDEwLjcxOTIyMzYsMTAgTDkuMjE5MjIzNTksNCBMNi43ODA3NzY0MSw0IEw1LjI4MDc3NjQxLDEwIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-PinTilted: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIgNi4wMDAyNEwxMCAxNC4wMDAyTTkuMjAwMiAyTDE0LjAwMDIgNi44TTMuMzE2ODkgNy4zMTY2MUwxMC41MTQ1IDMuMzE0MjFNOC42ODMxMSAxMi42ODNMMTIuNjg1NSA1LjQ4NTM1TTQuOCAxMS4yMDAyTDIgMTQuMDAwMiIgc3Ryb2tlPSIjMTZCMzc4IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=');
--icon-Pivot: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMsMSBMMTMsMSBDMTQuMTA0NTY5NSwxIDE1LDEuODk1NDMwNSAxNSwzIEwxNSwxMyBDMTUsMTQuMTA0NTY5NSAxNC4xMDQ1Njk1LDE1IDEzLDE1IEwzLDE1IEMxLjg5NTQzMDUsMTUgMSwxNC4xMDQ1Njk1IDEsMTMgTDEsMyBDMSwxLjg5NTQzMDUgMS44OTU0MzA1LDEgMywxIFogTTUuMzc1LDQuNSBMNS4zNzUsNC43ODU0NjM0MSBMNy42MzQ2NTAxLDguMDIyNzA3MzIgTDUuMzc1LDExLjA5NDUxMjIgTDUuMzc1LDExLjUgTDEwLjYyNSwxMS41IEwxMC42MjUsMTAuMDMyODE3MSBMOS44MjY0OTkwMSwxMC43MjY0MTQ2IEw2LjkxOTczMTYxLDEwLjcyNjQxNDYgTDYuOTE5NzMxNjEsMTAuNDE2OTYzNCBMOC45MDI4MzMsNy42MTU4NTM2NiBMNi45MTk3MzE2MSw0Ljk5MDg1MzY2IEw5Ljc3NDMxMjEzLDQuOTg3MzUzNjYgTDEwLjYyNSw2LjA5NTMxNzA3IEwxMC42MjUsNC41IEw1LjM3NSw0LjUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); --icon-Pivot: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMsMSBMMTMsMSBDMTQuMTA0NTY5NSwxIDE1LDEuODk1NDMwNSAxNSwzIEwxNSwxMyBDMTUsMTQuMTA0NTY5NSAxNC4xMDQ1Njk1LDE1IDEzLDE1IEwzLDE1IEMxLjg5NTQzMDUsMTUgMSwxNC4xMDQ1Njk1IDEsMTMgTDEsMyBDMSwxLjg5NTQzMDUgMS44OTU0MzA1LDEgMywxIFogTTUuMzc1LDQuNSBMNS4zNzUsNC43ODU0NjM0MSBMNy42MzQ2NTAxLDguMDIyNzA3MzIgTDUuMzc1LDExLjA5NDUxMjIgTDUuMzc1LDExLjUgTDEwLjYyNSwxMS41IEwxMC42MjUsMTAuMDMyODE3MSBMOS44MjY0OTkwMSwxMC43MjY0MTQ2IEw2LjkxOTczMTYxLDEwLjcyNjQxNDYgTDYuOTE5NzMxNjEsMTAuNDE2OTYzNCBMOC45MDI4MzMsNy42MTU4NTM2NiBMNi45MTk3MzE2MSw0Ljk5MDg1MzY2IEw5Ljc3NDMxMjEzLDQuOTg3MzUzNjYgTDEwLjYyNSw2LjA5NTMxNzA3IEwxMC42MjUsNC41IEw1LjM3NSw0LjUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--icon-PivotLight: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cmVjdCBzdHJva2U9IiM5Nzk3OTciIHg9Ii41IiB5PSIuNSIgd2lkdGg9IjE1IiBoZWlnaHQ9IjE1IiByeD0iMiIvPjxwYXRoIGZpbGw9IiM5Nzk3OTciIGQ9Ik01LjM3NSA0LjVMMTAuNjI1IDQuNSAxMC42MjUgNi4wOTUzMTcwNyA5Ljc3NDMxMjEzIDQuOTg3MzUzNjYgNi45MTk3MzE2MSA0Ljk5MDg1MzY2IDguOTAyODMzIDcuNjE1ODUzNjYgNi45MTk3MzE2MSAxMC40MTY5NjM0IDYuOTE5NzMxNjEgMTAuNzI2NDE0NiA5LjgyNjQ5OTAxIDEwLjcyNjQxNDYgMTAuNjI1IDEwLjAzMjgxNzEgMTAuNjI1IDExLjUgNS4zNzUgMTEuNSA1LjM3NSAxMS4wOTQ1MTIyIDcuNjM0NjUwMSA4LjAyMjcwNzMyIDUuMzc1IDQuNzg1NDYzNDF6Ii8+PC9nPjwvc3ZnPg=='); --icon-PivotLight: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cmVjdCBzdHJva2U9IiM5Nzk3OTciIHg9Ii41IiB5PSIuNSIgd2lkdGg9IjE1IiBoZWlnaHQ9IjE1IiByeD0iMiIvPjxwYXRoIGZpbGw9IiM5Nzk3OTciIGQ9Ik01LjM3NSA0LjVMMTAuNjI1IDQuNSAxMC42MjUgNi4wOTUzMTcwNyA5Ljc3NDMxMjEzIDQuOTg3MzUzNjYgNi45MTk3MzE2MSA0Ljk5MDg1MzY2IDguOTAyODMzIDcuNjE1ODUzNjYgNi45MTk3MzE2MSAxMC40MTY5NjM0IDYuOTE5NzMxNjEgMTAuNzI2NDE0NiA5LjgyNjQ5OTAxIDEwLjcyNjQxNDYgMTAuNjI1IDEwLjAzMjgxNzEgMTAuNjI1IDExLjUgNS4zNzUgMTEuNSA1LjM3NSAxMS4wOTQ1MTIyIDcuNjM0NjUwMSA4LjAyMjcwNzMyIDUuMzc1IDQuNzg1NDYzNDF6Ii8+PC9nPjwvc3ZnPg==');
--icon-Plus: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTguNSw3LjUgTDEzLDcuNSBDMTMuMjc2MTQyNCw3LjUgMTMuNSw3LjcyMzg1NzYzIDEzLjUsOCBDMTMuNSw4LjI3NjE0MjM3IDEzLjI3NjE0MjQsOC41IDEzLDguNSBMOC41LDguNSBMOC41LDEzIEM4LjUsMTMuMjc2MTQyNCA4LjI3NjE0MjM3LDEzLjUgOCwxMy41IEM3LjcyMzg1NzYzLDEzLjUgNy41LDEzLjI3NjE0MjQgNy41LDEzIEw3LjUsOC41IEwzLDguNSBDMi43MjM4NTc2Myw4LjUgMi41LDguMjc2MTQyMzcgMi41LDggQzIuNSw3LjcyMzg1NzYzIDIuNzIzODU3NjMsNy41IDMsNy41IEw3LjUsNy41IEw3LjUsMyBDNy41LDIuNzIzODU3NjMgNy43MjM4NTc2MywyLjUgOCwyLjUgQzguMjc2MTQyMzcsMi41IDguNSwyLjcyMzg1NzYzIDguNSwzIEw4LjUsNy41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-Plus: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTguNSw3LjUgTDEzLDcuNSBDMTMuMjc2MTQyNCw3LjUgMTMuNSw3LjcyMzg1NzYzIDEzLjUsOCBDMTMuNSw4LjI3NjE0MjM3IDEzLjI3NjE0MjQsOC41IDEzLDguNSBMOC41LDguNSBMOC41LDEzIEM4LjUsMTMuMjc2MTQyNCA4LjI3NjE0MjM3LDEzLjUgOCwxMy41IEM3LjcyMzg1NzYzLDEzLjUgNy41LDEzLjI3NjE0MjQgNy41LDEzIEw3LjUsOC41IEwzLDguNSBDMi43MjM4NTc2Myw4LjUgMi41LDguMjc2MTQyMzcgMi41LDggQzIuNSw3LjcyMzg1NzYzIDIuNzIzODU3NjMsNy41IDMsNy41IEw3LjUsNy41IEw3LjUsMyBDNy41LDIuNzIzODU3NjMgNy43MjM4NTc2MywyLjUgOCwyLjUgQzguMjc2MTQyMzcsMi41IDguNSwyLjcyMzg1NzYzIDguNSwzIEw4LjUsNy41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
@ -102,6 +103,7 @@
--icon-Remove: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYsNCBMNiwyLjUgQzYsMi4yMjM4NTc2MyA2LjIyMzg1NzYzLDIgNi41LDIgTDkuNSwyIEM5Ljc3NjE0MjM3LDIgMTAsMi4yMjM4NTc2MyAxMCwyLjUgTDEwLDQgTDEzLjUsNCBDMTMuNzc2MTQyNCw0IDE0LDQuMjIzODU3NjMgMTQsNC41IEMxNCw0Ljc3NjE0MjM3IDEzLjc3NjE0MjQsNSAxMy41LDUgTDIuNSw1IEMyLjIyMzg1NzYzLDUgMiw0Ljc3NjE0MjM3IDIsNC41IEMyLDQuMjIzODU3NjMgMi4yMjM4NTc2Myw0IDIuNSw0IEw2LDQgWiBNNyw0IEw5LDQgTDksMyBMNywzIEw3LDQgWiBNMTEsNi41IEMxMSw2LjIyMzg1NzYzIDExLjIyMzg1NzYsNiAxMS41LDYgQzExLjc3NjE0MjQsNiAxMiw2LjIyMzg1NzYzIDEyLDYuNSBMMTIsMTIuNSBDMTIsMTMuMzI4NDI3MSAxMS4zMjg0MjcxLDE0IDEwLjUsMTQgTDUuNSwxNCBDNC42NzE1NzI4OCwxNCA0LDEzLjMyODQyNzEgNCwxMi41IEw0LDYuNSBDNCw2LjIyMzg1NzYzIDQuMjIzODU3NjMsNiA0LjUsNiBDNC43NzYxNDIzNyw2IDUsNi4yMjM4NTc2MyA1LDYuNSBMNSwxMi41IEM1LDEyLjc3NjE0MjQgNS4yMjM4NTc2MywxMyA1LjUsMTMgTDEwLjUsMTMgQzEwLjc3NjE0MjQsMTMgMTEsMTIuNzc2MTQyNCAxMSwxMi41IEwxMSw2LjUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); --icon-Remove: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYsNCBMNiwyLjUgQzYsMi4yMjM4NTc2MyA2LjIyMzg1NzYzLDIgNi41LDIgTDkuNSwyIEM5Ljc3NjE0MjM3LDIgMTAsMi4yMjM4NTc2MyAxMCwyLjUgTDEwLDQgTDEzLjUsNCBDMTMuNzc2MTQyNCw0IDE0LDQuMjIzODU3NjMgMTQsNC41IEMxNCw0Ljc3NjE0MjM3IDEzLjc3NjE0MjQsNSAxMy41LDUgTDIuNSw1IEMyLjIyMzg1NzYzLDUgMiw0Ljc3NjE0MjM3IDIsNC41IEMyLDQuMjIzODU3NjMgMi4yMjM4NTc2Myw0IDIuNSw0IEw2LDQgWiBNNyw0IEw5LDQgTDksMyBMNywzIEw3LDQgWiBNMTEsNi41IEMxMSw2LjIyMzg1NzYzIDExLjIyMzg1NzYsNiAxMS41LDYgQzExLjc3NjE0MjQsNiAxMiw2LjIyMzg1NzYzIDEyLDYuNSBMMTIsMTIuNSBDMTIsMTMuMzI4NDI3MSAxMS4zMjg0MjcxLDE0IDEwLjUsMTQgTDUuNSwxNCBDNC42NzE1NzI4OCwxNCA0LDEzLjMyODQyNzEgNCwxMi41IEw0LDYuNSBDNCw2LjIyMzg1NzYzIDQuMjIzODU3NjMsNiA0LjUsNiBDNC43NzYxNDIzNyw2IDUsNi4yMjM4NTc2MyA1LDYuNSBMNSwxMi41IEM1LDEyLjc3NjE0MjQgNS4yMjM4NTc2MywxMyA1LjUsMTMgTDEwLjUsMTMgQzEwLjc3NjE0MjQsMTMgMTEsMTIuNzc2MTQyNCAxMSwxMi41IEwxMSw2LjUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--icon-Repl: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE0LjI5Mjg5MzIsMTIgTDEyLjE0NjQ0NjYsOS44NTM1NTMzOSBDMTEuOTUxMTg0NSw5LjY1ODI5MTI0IDExLjk1MTE4NDUsOS4zNDE3MDg3NiAxMi4xNDY0NDY2LDkuMTQ2NDQ2NjEgQzEyLjM0MTcwODgsOC45NTExODQ0NiAxMi42NTgyOTEyLDguOTUxMTg0NDYgMTIuODUzNTUzNCw5LjE0NjQ0NjYxIEwxNS44NTM1NTM0LDEyLjE0NjQ0NjYgQzE2LjA0ODgxNTUsMTIuMzQxNzA4OCAxNi4wNDg4MTU1LDEyLjY1ODI5MTIgMTUuODUzNTUzNCwxMi44NTM1NTM0IEwxMi44NTM1NTM0LDE1Ljg1MzU1MzQgQzEyLjY1ODI5MTIsMTYuMDQ4ODE1NSAxMi4zNDE3MDg4LDE2LjA0ODgxNTUgMTIuMTQ2NDQ2NiwxNS44NTM1NTM0IEMxMS45NTExODQ1LDE1LjY1ODI5MTIgMTEuOTUxMTg0NSwxNS4zNDE3MDg4IDEyLjE0NjQ0NjYsMTUuMTQ2NDQ2NiBMMTQuMjkyODkzMiwxMyBMMTIuMjk3LDEzIEMxMS4wMTE0MDE0LDEzIDkuNzg3MzEwNDYsMTIuNDUwMDg5NiA4LjkzMzM4NDQzLDExLjQ4OTI4MjEgTDcuOTA0Mzg0NDMsMTAuMzMyMjgyMSBDNy43MjA4NzAwOSwxMC4xMjU5Mzk5IDcuNzM5Mzc1Nyw5LjgwOTg5ODc4IDcuOTQ1NzE3ODgsOS42MjYzODQ0MyBDOC4xNTIwNjAwNiw5LjQ0Mjg3MDA5IDguNDY4MTAxMjIsOS40NjEzNzU3IDguNjUxNjE1NTcsOS42Njc3MTc4OCBMOS42ODA3Mjg4LDEwLjgyNDg0NTIgQzEwLjM0NTAyNTUsMTEuNTcyMjg4NCAxMS4yOTcxMDQsMTIgMTIuMjk3LDEyIEwxNC4yOTI4OTMyLDEyIFogTTAuNSw0IEMwLjIyMzg1NzYyNSw0IC01LjE5NTg0Mzc2ZS0xNCwzLjc3NjE0MjM3IC01LjE5NTg0Mzc2ZS0xNCwzLjUgQy01LjE5NTg0Mzc2ZS0xNCwzLjIyMzg1NzYzIDAuMjIzODU3NjI1LDMgMC41LDMgTDAuNzA0LDMgQzEuOTg5NTk4NTUsMyAzLjIxMzY4OTU0LDMuNTQ5OTEwNDIgNC4wNjc3NzU5NSw0LjUxMDg5ODMgTDUuMDk1Nzc1OTUsNS42Njc4OTgzIEM1LjI3OTE5MDY1LDUuODc0MzI5MDUgNS4yNjA1MzI0Niw2LjE5MDM2MTI0IDUuMDU0MTAxNyw2LjM3Mzc3NTk1IEM0Ljg0NzY3MDk1LDYuNTU3MTkwNjUgNC41MzE2Mzg3Niw2LjUzODUzMjQ2IDQuMzQ4MjI0MDUsNi4zMzIxMDE3IEwzLjMyMDI3MTIsNS4xNzUxNTQ3NiBDMi42NTU5NzQ1Miw0LjQyNzcxMTU5IDEuNzAzODk1OTksNCAwLjcwNCw0IEwwLjUsNCBaIE0xNC4yOTE4OTMyLDIuOTk5IEwxMi4xNDY0NDY2LDAuODUzNTUzMzkxIEMxMS45NTExODQ1LDAuNjU4MjkxMjQ1IDExLjk1MTE4NDUsMC4zNDE3MDg3NTUgMTIuMTQ2NDQ2NiwwLjE0NjQ0NjYwOSBDMTIuMzQxNzA4OCwtMC4wNDg4MTU1MzY1IDEyLjY1ODI5MTIsLTAuMDQ4ODE1NTM2NSAxMi44NTM1NTM0LDAuMTQ2NDQ2NjA5IEwxNS44MzQxOTg2LDMuMTI3MDkxODMgQzE1LjkzMTE4OSwzLjIxNDMwNTE3IDE1Ljk5Mzg4MDQsMy4zMzg5MTI3OCAxNS45OTk1NzU5LDMuNDc4MjE3NzQgQzE1Ljk5OTgzNzEsMy40ODUzNzc4MyAxNS45OTk5OTI4LDMuNDkyNDM5ODEgMTUuOTk5OTk5OCwzLjQ5OTUwMTkyIEMxNS45OTk5ODg0LDMuNTExMDYxMTMgMTUuOTk5NTg0OCwzLjUyMjUyODQ5IDE1Ljk5ODgwMTUsMy41MzM4OTE0MyBDMTUuOTkwOTk0NCwzLjY1MDMzMjU2IDE1Ljk0MjU1OTgsMy43NjQ1NDcwMiAxNS44NTM1NTM0LDMuODUzNTUzMzkgTDEyLjg1MzU1MzQsNi44NTM1NTMzOSBDMTIuNjU4MjkxMiw3LjA0ODgxNTU0IDEyLjM0MTcwODgsNy4wNDg4MTU1NCAxMi4xNDY0NDY2LDYuODUzNTUzMzkgQzExLjk1MTE4NDUsNi42NTgyOTEyNCAxMS45NTExODQ1LDYuMzQxNzA4NzYgMTIuMTQ2NDQ2Niw2LjE0NjQ0NjYxIEwxNC4yOTM4OTMyLDMuOTk5IEwxMi4yOTcsMy45OTkgQzExLjI5NzEwNCwzLjk5OSAxMC4zNDUwMjU1LDQuNDI2NzExNTkgOS42ODA3MTQ0NSw1LjE3NDE3MDkgTDQuMDY3NzI4OCwxMS40ODkxNTQ4IEMzLjIxMzY4OTU0LDEyLjQ1MDA4OTYgMS45ODk1OTg1NSwxMyAwLjcwNCwxMyBMMC41LDEzIEMwLjIyMzg1NzYyNSwxMyAtMi4yNjQ4NTQ5N2UtMTMsMTIuNzc2MTQyNCAtMi4yNjQ4NTQ5N2UtMTMsMTIuNSBDLTIuMjY0ODU0OTdlLTEzLDEyLjIyMzg1NzYgMC4yMjM4NTc2MjUsMTIgMC41LDEyIEwwLjcwNCwxMiBDMS43MDM4OTU5OSwxMiAyLjY1NTk3NDUyLDExLjU3MjI4ODQgMy4zMjAyODU1NSwxMC44MjQ4MjkxIEw4LjkzMzI3MTIsNC41MDk4NDUyNCBDOS43ODczMTA0NiwzLjU0ODkxMDQyIDExLjAxMTQwMTQsMi45OTkgMTIuMjk3LDIuOTk5IEwxNC4yOTE4OTMyLDIuOTk5IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-Repl: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE0LjI5Mjg5MzIsMTIgTDEyLjE0NjQ0NjYsOS44NTM1NTMzOSBDMTEuOTUxMTg0NSw5LjY1ODI5MTI0IDExLjk1MTE4NDUsOS4zNDE3MDg3NiAxMi4xNDY0NDY2LDkuMTQ2NDQ2NjEgQzEyLjM0MTcwODgsOC45NTExODQ0NiAxMi42NTgyOTEyLDguOTUxMTg0NDYgMTIuODUzNTUzNCw5LjE0NjQ0NjYxIEwxNS44NTM1NTM0LDEyLjE0NjQ0NjYgQzE2LjA0ODgxNTUsMTIuMzQxNzA4OCAxNi4wNDg4MTU1LDEyLjY1ODI5MTIgMTUuODUzNTUzNCwxMi44NTM1NTM0IEwxMi44NTM1NTM0LDE1Ljg1MzU1MzQgQzEyLjY1ODI5MTIsMTYuMDQ4ODE1NSAxMi4zNDE3MDg4LDE2LjA0ODgxNTUgMTIuMTQ2NDQ2NiwxNS44NTM1NTM0IEMxMS45NTExODQ1LDE1LjY1ODI5MTIgMTEuOTUxMTg0NSwxNS4zNDE3MDg4IDEyLjE0NjQ0NjYsMTUuMTQ2NDQ2NiBMMTQuMjkyODkzMiwxMyBMMTIuMjk3LDEzIEMxMS4wMTE0MDE0LDEzIDkuNzg3MzEwNDYsMTIuNDUwMDg5NiA4LjkzMzM4NDQzLDExLjQ4OTI4MjEgTDcuOTA0Mzg0NDMsMTAuMzMyMjgyMSBDNy43MjA4NzAwOSwxMC4xMjU5Mzk5IDcuNzM5Mzc1Nyw5LjgwOTg5ODc4IDcuOTQ1NzE3ODgsOS42MjYzODQ0MyBDOC4xNTIwNjAwNiw5LjQ0Mjg3MDA5IDguNDY4MTAxMjIsOS40NjEzNzU3IDguNjUxNjE1NTcsOS42Njc3MTc4OCBMOS42ODA3Mjg4LDEwLjgyNDg0NTIgQzEwLjM0NTAyNTUsMTEuNTcyMjg4NCAxMS4yOTcxMDQsMTIgMTIuMjk3LDEyIEwxNC4yOTI4OTMyLDEyIFogTTAuNSw0IEMwLjIyMzg1NzYyNSw0IC01LjE5NTg0Mzc2ZS0xNCwzLjc3NjE0MjM3IC01LjE5NTg0Mzc2ZS0xNCwzLjUgQy01LjE5NTg0Mzc2ZS0xNCwzLjIyMzg1NzYzIDAuMjIzODU3NjI1LDMgMC41LDMgTDAuNzA0LDMgQzEuOTg5NTk4NTUsMyAzLjIxMzY4OTU0LDMuNTQ5OTEwNDIgNC4wNjc3NzU5NSw0LjUxMDg5ODMgTDUuMDk1Nzc1OTUsNS42Njc4OTgzIEM1LjI3OTE5MDY1LDUuODc0MzI5MDUgNS4yNjA1MzI0Niw2LjE5MDM2MTI0IDUuMDU0MTAxNyw2LjM3Mzc3NTk1IEM0Ljg0NzY3MDk1LDYuNTU3MTkwNjUgNC41MzE2Mzg3Niw2LjUzODUzMjQ2IDQuMzQ4MjI0MDUsNi4zMzIxMDE3IEwzLjMyMDI3MTIsNS4xNzUxNTQ3NiBDMi42NTU5NzQ1Miw0LjQyNzcxMTU5IDEuNzAzODk1OTksNCAwLjcwNCw0IEwwLjUsNCBaIE0xNC4yOTE4OTMyLDIuOTk5IEwxMi4xNDY0NDY2LDAuODUzNTUzMzkxIEMxMS45NTExODQ1LDAuNjU4MjkxMjQ1IDExLjk1MTE4NDUsMC4zNDE3MDg3NTUgMTIuMTQ2NDQ2NiwwLjE0NjQ0NjYwOSBDMTIuMzQxNzA4OCwtMC4wNDg4MTU1MzY1IDEyLjY1ODI5MTIsLTAuMDQ4ODE1NTM2NSAxMi44NTM1NTM0LDAuMTQ2NDQ2NjA5IEwxNS44MzQxOTg2LDMuMTI3MDkxODMgQzE1LjkzMTE4OSwzLjIxNDMwNTE3IDE1Ljk5Mzg4MDQsMy4zMzg5MTI3OCAxNS45OTk1NzU5LDMuNDc4MjE3NzQgQzE1Ljk5OTgzNzEsMy40ODUzNzc4MyAxNS45OTk5OTI4LDMuNDkyNDM5ODEgMTUuOTk5OTk5OCwzLjQ5OTUwMTkyIEMxNS45OTk5ODg0LDMuNTExMDYxMTMgMTUuOTk5NTg0OCwzLjUyMjUyODQ5IDE1Ljk5ODgwMTUsMy41MzM4OTE0MyBDMTUuOTkwOTk0NCwzLjY1MDMzMjU2IDE1Ljk0MjU1OTgsMy43NjQ1NDcwMiAxNS44NTM1NTM0LDMuODUzNTUzMzkgTDEyLjg1MzU1MzQsNi44NTM1NTMzOSBDMTIuNjU4MjkxMiw3LjA0ODgxNTU0IDEyLjM0MTcwODgsNy4wNDg4MTU1NCAxMi4xNDY0NDY2LDYuODUzNTUzMzkgQzExLjk1MTE4NDUsNi42NTgyOTEyNCAxMS45NTExODQ1LDYuMzQxNzA4NzYgMTIuMTQ2NDQ2Niw2LjE0NjQ0NjYxIEwxNC4yOTM4OTMyLDMuOTk5IEwxMi4yOTcsMy45OTkgQzExLjI5NzEwNCwzLjk5OSAxMC4zNDUwMjU1LDQuNDI2NzExNTkgOS42ODA3MTQ0NSw1LjE3NDE3MDkgTDQuMDY3NzI4OCwxMS40ODkxNTQ4IEMzLjIxMzY4OTU0LDEyLjQ1MDA4OTYgMS45ODk1OTg1NSwxMyAwLjcwNCwxMyBMMC41LDEzIEMwLjIyMzg1NzYyNSwxMyAtMi4yNjQ4NTQ5N2UtMTMsMTIuNzc2MTQyNCAtMi4yNjQ4NTQ5N2UtMTMsMTIuNSBDLTIuMjY0ODU0OTdlLTEzLDEyLjIyMzg1NzYgMC4yMjM4NTc2MjUsMTIgMC41LDEyIEwwLjcwNCwxMiBDMS43MDM4OTU5OSwxMiAyLjY1NTk3NDUyLDExLjU3MjI4ODQgMy4zMjAyODU1NSwxMC44MjQ4MjkxIEw4LjkzMzI3MTIsNC41MDk4NDUyNCBDOS43ODczMTA0NiwzLjU0ODkxMDQyIDExLjAxMTQwMTQsMi45OTkgMTIuMjk3LDIuOTk5IEwxNC4yOTE4OTMyLDIuOTk5IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-ResizePanel: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB4PSI0IiB5PSIyIiB3aWR0aD0iMiIgaGVpZ2h0PSIxMiIgcng9IjEiLz48L3N2Zz4='); --icon-ResizePanel: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB4PSI0IiB5PSIyIiB3aWR0aD0iMiIgaGVpZ2h0PSIxMiIgcng9IjEiLz48L3N2Zz4=');
--icon-Revert: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuMTIzMTcgMy4wMDMzTDMuMDg3MjggNi45MTEwM0w2LjgxMzM2IDUuNjY0MzYiIHN0cm9rZT0iIzE2QjM3OCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTEzLjYxNTggMTAuMDc5OUMxNC4wMzY0IDguNjg0NzggMTMuODg1NSA3LjE3OTc0IDEzLjE5NjUgNS44OTU4NiAxMi41MDc0IDQuNjExOTggMTEuMzM2NSAzLjY1NDQzIDkuOTQxMzcgMy4yMzM4NSA4LjU0NjI3IDIuODEzMjcgNy4wNDEyMyAyLjk2NDExIDUuNzU3MzUgMy42NTMyIDQuNDczNDcgNC4zNDIzIDMuNTE1OTIgNS41MTMxOCAzLjA5NTM0IDYuOTA4MjhNNi40Mjk0MyAxMy41ODc2QzUuODQ4OCAxMy4zNjgzIDUuMzA5NjIgMTMuMDUyIDQuODM0ODQgMTIuNjUyMk0xMC4wMzk4IDEzLjY4MTZDOS40OTk1MyAxMy44NTM0IDguOTM1OTUgMTMuOTQwNyA4LjM2OTAzIDEzLjk0MDJIOC4yNDMwNE0xMi44ODY4IDExLjU3MjhDMTIuNTQ4MiAxMi4wNjA4IDEyLjEzMzIgMTIuNDkxMiAxMS42NTc3IDEyLjg0NzNNMy42NjIxNSAxMS4yODA0QzMuMzUxNDggMTAuNzY2NiAzLjEyNzc2IDEwLjIwNTEgMyA5LjYxODQxIiBzdHJva2U9IiMxNkIzNzgiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg==');
--icon-RightAlign: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNSw4LjUgQzIuMjIzODU3NjMsOC41IDIsOC4yNzYxNDIzNyAyLDggQzIsNy43MjM4NTc2MyAyLjIyMzg1NzYzLDcuNSAyLjUsNy41IEwxMy41LDcuNSBDMTMuNzc2MTQyNCw3LjUgMTQsNy43MjM4NTc2MyAxNCw4IEMxNCw4LjI3NjE0MjM3IDEzLjc3NjE0MjQsOC41IDEzLjUsOC41IEwyLjUsOC41IFogTTIuNSw0IEMyLjIyMzg1NzYzLDQgMiwzLjc3NjE0MjM3IDIsMy41IEMyLDMuMjIzODU3NjMgMi4yMjM4NTc2MywzIDIuNSwzIEwxMy41LDMgQzEzLjc3NjE0MjQsMyAxNCwzLjIyMzg1NzYzIDE0LDMuNSBDMTQsMy43NzYxNDIzNyAxMy43NzYxNDI0LDQgMTMuNSw0IEwyLjUsNCBaIE04LjUsMTMgQzguMjIzODU3NjMsMTMgOCwxMi43NzYxNDI0IDgsMTIuNSBDOCwxMi4yMjM4NTc2IDguMjIzODU3NjMsMTIgOC41LDEyIEwxMy41LDEyIEMxMy43NzYxNDI0LDEyIDE0LDEyLjIyMzg1NzYgMTQsMTIuNSBDMTQsMTIuNzc2MTQyNCAxMy43NzYxNDI0LDEzIDEzLjUsMTMgTDguNSwxMyBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4='); --icon-RightAlign: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNSw4LjUgQzIuMjIzODU3NjMsOC41IDIsOC4yNzYxNDIzNyAyLDggQzIsNy43MjM4NTc2MyAyLjIyMzg1NzYzLDcuNSAyLjUsNy41IEwxMy41LDcuNSBDMTMuNzc2MTQyNCw3LjUgMTQsNy43MjM4NTc2MyAxNCw4IEMxNCw4LjI3NjE0MjM3IDEzLjc3NjE0MjQsOC41IDEzLjUsOC41IEwyLjUsOC41IFogTTIuNSw0IEMyLjIyMzg1NzYzLDQgMiwzLjc3NjE0MjM3IDIsMy41IEMyLDMuMjIzODU3NjMgMi4yMjM4NTc2MywzIDIuNSwzIEwxMy41LDMgQzEzLjc3NjE0MjQsMyAxNCwzLjIyMzg1NzYzIDE0LDMuNSBDMTQsMy43NzYxNDIzNyAxMy43NzYxNDI0LDQgMTMuNSw0IEwyLjUsNCBaIE04LjUsMTMgQzguMjIzODU3NjMsMTMgOCwxMi43NzYxNDI0IDgsMTIuNSBDOCwxMi4yMjM4NTc2IDguMjIzODU3NjMsMTIgOC41LDEyIEwxMy41LDEyIEMxMy43NzYxNDI0LDEyIDE0LDEyLjIyMzg1NzYgMTQsMTIuNSBDMTQsMTIuNzc2MTQyNCAxMy43NzYxNDI0LDEzIDEzLjUsMTMgTDguNSwxMyBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
--icon-Script: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0iZmVhdGhlciBmZWF0aGVyLWRhdGFiYXNlIj48cmVjdCB3aWR0aD0iMTkuMzI4IiBoZWlnaHQ9IjE5LjMyOCIgeD0iMi4zMzYiIHk9IjIuMzM2IiByeT0iNC4wMjYiIHJ4PSIzLjc0NSIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJidXR0Ii8+PGcgc3Ryb2tlLXdpZHRoPSIxLjU0MyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0iYnV0dCIgc3Ryb2tlLWxpbmVqb2luPSJtaXRlciI+PHBhdGggZD0iTTE4LjczNDQ0OSA5LjAyMTI1NTZMNS4yNjU1NTEyIDkuMDA5NTM2OE0xOC43MzQ0NDkgMTQuNzM4NTg2TDUuMjY1NTUxMiAxNC43MjY4NjciIHRyYW5zZm9ybT0ibWF0cml4KC45Mzk2OSAwIDAgMS4wMDUzMSAuNzI0IC0uMDYzKSIvPjwvZz48L3N2Zz4='); --icon-Script: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0iZmVhdGhlciBmZWF0aGVyLWRhdGFiYXNlIj48cmVjdCB3aWR0aD0iMTkuMzI4IiBoZWlnaHQ9IjE5LjMyOCIgeD0iMi4zMzYiIHk9IjIuMzM2IiByeT0iNC4wMjYiIHJ4PSIzLjc0NSIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJidXR0Ii8+PGcgc3Ryb2tlLXdpZHRoPSIxLjU0MyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0iYnV0dCIgc3Ryb2tlLWxpbmVqb2luPSJtaXRlciI+PHBhdGggZD0iTTE4LjczNDQ0OSA5LjAyMTI1NTZMNS4yNjU1NTEyIDkuMDA5NTM2OE0xOC43MzQ0NDkgMTQuNzM4NTg2TDUuMjY1NTUxMiAxNC43MjY4NjciIHRyYW5zZm9ybT0ibWF0cml4KC45Mzk2OSAwIDAgMS4wMDUzMSAuNzI0IC0uMDYzKSIvPjwvZz48L3N2Zz4=');
--icon-Search: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjQzNjIxMjcsMTAuNzI5MTA1OSBMMTQuODUzNTUzNCwxNC4xNDY0NDY2IEMxNS4wNDg4MTU1LDE0LjM0MTcwODggMTUuMDQ4ODE1NSwxNC42NTgyOTEyIDE0Ljg1MzU1MzQsMTQuODUzNTUzNCBDMTQuNjU4MjkxMiwxNS4wNDg4MTU1IDE0LjM0MTcwODgsMTUuMDQ4ODE1NSAxNC4xNDY0NDY2LDE0Ljg1MzU1MzQgTDEwLjcyOTEwNTksMTEuNDM2MjEyNyBDOS41OTIzMzg0OCwxMi40MTEwNDg3IDguMTE0OTQ3NzEsMTMgNi41LDEzIEMyLjkxMDE0OTEzLDEzIDAsMTAuMDg5ODUwOSAwLDYuNSBDMCwyLjkxMDE0OTEzIDIuOTEwMTQ5MTMsMCA2LjUsMCBDMTAuMDg5ODUwOSwwIDEzLDIuOTEwMTQ5MTMgMTMsNi41IEMxMyw4LjExNDk0NzcxIDEyLjQxMTA0ODcsOS41OTIzMzg0OCAxMS40MzYyMTI3LDEwLjcyOTEwNTkgWiBNMTAuNDA5NTc0NywxMC4zNjg0OTIxIEMxMS4zOTI4MzI1LDkuMzc0ODU3OCAxMiw4LjAwODMzNDY4IDEyLDYuNSBDMTIsMy40NjI0MzM4OCA5LjUzNzU2NjEyLDEgNi41LDEgQzMuNDYyNDMzODgsMSAxLDMuNDYyNDMzODggMSw2LjUgQzEsOS41Mzc1NjYxMiAzLjQ2MjQzMzg4LDEyIDYuNSwxMiBDOC4wMDgzMzQ2OCwxMiA5LjM3NDg1NzgsMTEuMzkyODMyNSAxMC4zNjg0OTIxLDEwLjQwOTU3NDcgQzEwLjM3NDkwMDEsMTAuNDAyMzg3OSAxMC4zODE1NTE2LDEwLjM5NTM0MTYgMTAuMzg4NDQ2NiwxMC4zODg0NDY2IEMxMC4zOTUzNDE2LDEwLjM4MTU1MTYgMTAuNDAyMzg3OSwxMC4zNzQ5MDAxIDEwLjQwOTU3NDcsMTAuMzY4NDkyMSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4='); --icon-Search: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjQzNjIxMjcsMTAuNzI5MTA1OSBMMTQuODUzNTUzNCwxNC4xNDY0NDY2IEMxNS4wNDg4MTU1LDE0LjM0MTcwODggMTUuMDQ4ODE1NSwxNC42NTgyOTEyIDE0Ljg1MzU1MzQsMTQuODUzNTUzNCBDMTQuNjU4MjkxMiwxNS4wNDg4MTU1IDE0LjM0MTcwODgsMTUuMDQ4ODE1NSAxNC4xNDY0NDY2LDE0Ljg1MzU1MzQgTDEwLjcyOTEwNTksMTEuNDM2MjEyNyBDOS41OTIzMzg0OCwxMi40MTEwNDg3IDguMTE0OTQ3NzEsMTMgNi41LDEzIEMyLjkxMDE0OTEzLDEzIDAsMTAuMDg5ODUwOSAwLDYuNSBDMCwyLjkxMDE0OTEzIDIuOTEwMTQ5MTMsMCA2LjUsMCBDMTAuMDg5ODUwOSwwIDEzLDIuOTEwMTQ5MTMgMTMsNi41IEMxMyw4LjExNDk0NzcxIDEyLjQxMTA0ODcsOS41OTIzMzg0OCAxMS40MzYyMTI3LDEwLjcyOTEwNTkgWiBNMTAuNDA5NTc0NywxMC4zNjg0OTIxIEMxMS4zOTI4MzI1LDkuMzc0ODU3OCAxMiw4LjAwODMzNDY4IDEyLDYuNSBDMTIsMy40NjI0MzM4OCA5LjUzNzU2NjEyLDEgNi41LDEgQzMuNDYyNDMzODgsMSAxLDMuNDYyNDMzODggMSw2LjUgQzEsOS41Mzc1NjYxMiAzLjQ2MjQzMzg4LDEyIDYuNSwxMiBDOC4wMDgzMzQ2OCwxMiA5LjM3NDg1NzgsMTEuMzkyODMyNSAxMC4zNjg0OTIxLDEwLjQwOTU3NDcgQzEwLjM3NDkwMDEsMTAuNDAyMzg3OSAxMC4zODE1NTE2LDEwLjM5NTM0MTYgMTAuMzg4NDQ2NiwxMC4zODg0NDY2IEMxMC4zOTUzNDE2LDEwLjM4MTU1MTYgMTAuNDAyMzg3OSwxMC4zNzQ5MDAxIDEwLjQwOTU3NDcsMTAuMzY4NDkyMSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');

View File

@ -240,8 +240,8 @@
"Settings_savecommon": "Save as common settings", "Settings_savecommon": "Save as common settings",
"Settings_revertcommon": "Revert to common settings" "Settings_revertcommon": "Revert to common settings"
}, },
"FilterBar": { "FilterConfig":{
"AddFilter": "Add Filter" "AddColumn": "Add Column"
}, },
"GridOptions": { "GridOptions": {
"GridOptions": "Grid Options", "GridOptions": "Grid Options",
@ -469,6 +469,20 @@
"SwitchSites":"Switch Sites", "SwitchSites":"Switch Sites",
"CreateNewTeamSite":"Create new team site" "CreateNewTeamSite":"Create new team site"
}, },
"SortConfig":{
"AddColumn": "Add Column",
"UpdateData": "Update Data",
"UseChoicePosition": "Use choice position",
"NaturalSort": "Natural sort",
"EmptyValuesLast": "Empty values last"
},
"SortFilterConfig":{
"Save": "Save",
"Revert": "Revert",
"Sort": "SORT",
"Filter": "FILTER",
"UpdateSortFilterSettings": "Update Sort & Filter settings"
},
"ThemeConfig": { "ThemeConfig": {
"Appearance": "Appearance ", "Appearance": "Appearance ",
"SyncWithOS": "Switch appearance automatically to match system" "SyncWithOS": "Switch appearance automatically to match system"
@ -533,7 +547,9 @@
"Customized":"(customized)", "Customized":"(customized)",
"Modified":"(modified)", "Modified":"(modified)",
"Empty":"(empty)", "Empty":"(empty)",
"CustomOptions":"Custom options" "CustomOptions":"Custom options",
"Sort": "SORT",
"Filter": "FILTER"
}, },
"aclui": { "aclui": {
"AccessRules": { "AccessRules": {
@ -687,13 +703,6 @@
"Rows": "Rows" "Rows": "Rows"
}, },
"ViewConfigTab": { "ViewConfigTab": {
"Save": "Save",
"Revert": "Revert",
"UpdateData": "Update Data",
"UseChoicePosition": "Use choice position",
"NaturalSort": "Natural sort",
"EmptyValuesLast": "Empty values last",
"AddColumn": "Add Column",
"UnmarkOnDemandTitle": "Unmark table On-Demand?", "UnmarkOnDemandTitle": "Unmark table On-Demand?",
"UnmarkOnDemandButton": "Unmark On-Demand", "UnmarkOnDemandButton": "Unmark On-Demand",
"UnmarkOnDemandText": "If you unmark table {{- table}}' as On-Demand, its data will be loaded into the calculation engine and will be available for use in formulas. For a big table, this may greatly increase load times.{{- br}}{{-br}}Changing this setting will reload the document for all users.", "UnmarkOnDemandText": "If you unmark table {{- table}}' as On-Demand, its data will be loaded into the calculation engine and will be available for use in formulas. For a big table, this may greatly increase load times.{{- br}}{{-br}}Changing this setting will reload the document for all users.",
@ -702,8 +711,6 @@
"MakeOnDemandText": "If you make table {{table}} On-Demand, its data will no longer be loaded into the calculation engine and will not be available for use in formulas. It will remain available for viewing and editing.", "MakeOnDemandText": "If you make table {{table}} On-Demand, its data will no longer be loaded into the calculation engine and will not be available for use in formulas. It will remain available for viewing and editing.",
"AdvancedSettings": "Advanced settings", "AdvancedSettings": "Advanced settings",
"BigTablesMayBeMarked": "Big tables may be marked as \"on-demand\" to avoid loading them into the data engine.", "BigTablesMayBeMarked": "Big tables may be marked as \"on-demand\" to avoid loading them into the data engine.",
"UpdateFilterSettings": "Update Filter settings",
"AddFilter": "Add Filter",
"Form": "Form", "Form": "Form",
"Compact": "Compact", "Compact": "Compact",
"Blocks": "Blocks", "Blocks": "Blocks",

View File

@ -238,8 +238,8 @@
"Settings_savecommon": "Save common settings", "Settings_savecommon": "Save common settings",
"Settings_revertcommon": "Revert common settings" "Settings_revertcommon": "Revert common settings"
}, },
"FilterBar": { "FilterConfig": {
"AddFilter": "Ajouter un filtre" "AddColumn": "Ajouter une colonne"
}, },
"GridOptions": { "GridOptions": {
"GridOptions": "Options de la grille", "GridOptions": "Options de la grille",
@ -466,6 +466,20 @@
"SwitchSites": "Changer despace", "SwitchSites": "Changer despace",
"CreateNewTeamSite": "Créer un nouvel espace d'équipe" "CreateNewTeamSite": "Créer un nouvel espace d'équipe"
}, },
"SortConfig":{
"AddColumn": "Ajouter une colonne",
"UpdateData": "Mettre à jour les données",
"UseChoicePosition": "Use choice position",
"NaturalSort": "Natural sort",
"EmptyValuesLast": "Valeurs vides en dernier"
},
"SortFilterConfig":{
"Save": "Enregistrer",
"Revert": "Restaurer",
"Sort": "TRI",
"Filter": "FILTRE",
"UpdateSortFilterSettings": "Mettre à jour le tri et le filtre"
},
"ThemeConfig": { "ThemeConfig": {
"Appearance": "Apparence ", "Appearance": "Apparence ",
"SyncWithOS": "Adapter l'apparence au système" "SyncWithOS": "Adapter l'apparence au système"
@ -530,7 +544,9 @@
"Customized": "(personnalisé)", "Customized": "(personnalisé)",
"Modified": "(modifié)", "Modified": "(modifié)",
"Empty": "(vide)", "Empty": "(vide)",
"CustomOptions": "Options personnalisées" "CustomOptions": "Options personnalisées",
"Sort": "TRI",
"Filter": "FILTRE"
}, },
"aclui": { "aclui": {
"AccessRules": { "AccessRules": {
@ -684,13 +700,6 @@
"Rows": "Lignes" "Rows": "Lignes"
}, },
"ViewConfigTab": { "ViewConfigTab": {
"Save": "Enregistrer",
"Revert": "Restaurer",
"UpdateData": "Mettre à jour les données",
"UseChoicePosition": "Use choice position",
"NaturalSort": "Natural sort",
"EmptyValuesLast": "Valeurs vides en dernier",
"AddColumn": "Ajouter une colonne",
"UnmarkOnDemandTitle": "Unmark table On-Demand?", "UnmarkOnDemandTitle": "Unmark table On-Demand?",
"UnmarkOnDemandButton": "Unmark On-Demand", "UnmarkOnDemandButton": "Unmark On-Demand",
"UnmarkOnDemandText": "If you unmark table {{- table}}' as On-Demand, its data will be loaded into the calculation engine and will be available for use in formulas. For a big table, this may greatly increase load times.{{- br}}{{-br}}Changing this setting will reload the document for all users.", "UnmarkOnDemandText": "If you unmark table {{- table}}' as On-Demand, its data will be loaded into the calculation engine and will be available for use in formulas. For a big table, this may greatly increase load times.{{- br}}{{-br}}Changing this setting will reload the document for all users.",
@ -699,8 +708,6 @@
"MakeOnDemandText": "If you make table {{table}} On-Demand, its data will no longer be loaded into the calculation engine and will not be available for use in formulas. It will remain available for viewing and editing.", "MakeOnDemandText": "If you make table {{table}} On-Demand, its data will no longer be loaded into the calculation engine and will not be available for use in formulas. It will remain available for viewing and editing.",
"AdvancedSettings": "Paramètres avancés", "AdvancedSettings": "Paramètres avancés",
"BigTablesMayBeMarked": "Big tables may be marked as \"on-demand\" to avoid loading them into the data engine.", "BigTablesMayBeMarked": "Big tables may be marked as \"on-demand\" to avoid loading them into the data engine.",
"UpdateFilterSettings": "Régler les filtres",
"AddFilter": "Ajouter un filtre",
"Form": "Formulaire", "Form": "Formulaire",
"Compact": "Compact", "Compact": "Compact",
"Blocks": "Blocs", "Blocks": "Blocs",

View File

@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 6.00024L10 14.0002" stroke="#16B378" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.2002 2L14.0002 6.8" stroke="#16B378" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.31689 7.31661L10.5145 3.31421" stroke="#16B378" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.68311 12.683L12.6855 5.48535" stroke="#16B378" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.8 11.2002L2 14.0002" stroke="#16B378" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 615 B

View File

@ -0,0 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.12317 3.0033L3.08728 6.91103L6.81336 5.66436" stroke="#16B378" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.6158 10.0799C14.0364 8.68478 13.8855 7.17974 13.1965 5.89586C12.5074 4.61198 11.3365 3.65443 9.94137 3.23385C8.54627 2.81327 7.04123 2.96411 5.75735 3.6532C4.47347 4.3423 3.51592 5.51318 3.09534 6.90828" stroke="#16B378" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.42943 13.5876C5.8488 13.3683 5.30962 13.052 4.83484 12.6522" stroke="#16B378" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.0398 13.6816C9.49953 13.8534 8.93595 13.9407 8.36903 13.9402H8.24304" stroke="#16B378" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.8868 11.5728C12.5482 12.0608 12.1332 12.4912 11.6577 12.8473" stroke="#16B378" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.66215 11.2804C3.35148 10.7666 3.12776 10.2051 3 9.61841" stroke="#16B378" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,4 +1,4 @@
import {allInclusive, ColumnFilter} from 'app/client/models/ColumnFilter'; import {ALL_INCLUSIVE_FILTER_JSON, ColumnFilter} from 'app/client/models/ColumnFilter';
import {GristObjCode} from 'app/plugin/GristData'; import {GristObjCode} from 'app/plugin/GristData';
import {CellValue} from 'app/common/DocActions'; import {CellValue} from 'app/common/DocActions';
import {assert} from 'chai'; import {assert} from 'chai';
@ -57,10 +57,10 @@ describe('ColumnFilter', function() {
assert.isTrue(filter.includes('Carol')); assert.isTrue(filter.includes('Carol'));
}); });
it('should generate an all-inclusive filter from empty string or null', async function() { it('should generate an all-inclusive filter from empty string/object or null', async function() {
const filter = new ColumnFilter(''); const filter = new ColumnFilter('');
const defaultJson = filter.makeFilterJson(); const defaultJson = filter.makeFilterJson();
assert.equal(defaultJson, allInclusive); assert.equal(defaultJson, ALL_INCLUSIVE_FILTER_JSON);
filter.clear(); filter.clear();
assert.equal(filter.makeFilterJson(), '{"included":[]}'); assert.equal(filter.makeFilterJson(), '{"included":[]}');
@ -69,7 +69,10 @@ describe('ColumnFilter', function() {
assert.equal(filter.makeFilterJson(), defaultJson); assert.equal(filter.makeFilterJson(), defaultJson);
// Check that the string 'null' initializes properly // Check that the string 'null' initializes properly
assert.equal(new ColumnFilter('null').makeFilterJson(), allInclusive); assert.equal(new ColumnFilter('null').makeFilterJson(), ALL_INCLUSIVE_FILTER_JSON);
// Check that the empty object initializes properly
assert.equal(new ColumnFilter('{}').makeFilterJson(), ALL_INCLUSIVE_FILTER_JSON);
}); });
it('should generate a proper FilterFunc and JSON string', async function() { it('should generate a proper FilterFunc and JSON string', async function() {

Binary file not shown.

BIN
test/fixtures/docs/World-v33.grist vendored Normal file

Binary file not shown.

View File

@ -345,9 +345,8 @@ describe('ReferenceList', function() {
await driver.find('.test-config-sortAndFilter').click(); await driver.find('.test-config-sortAndFilter').click();
// Sort the Favorite Film column. // Sort the Favorite Film column.
await driver.find('.test-vconfigtab-sort-add').click(); await gu.addColumnToSort('Favorite Film');
await driver.findContent('.test-vconfigtab-sort-add-menu-row', /Favorite_Film/).click(); await gu.saveSortConfig();
await driver.find('.test-vconfigtab-sort-save').click();
// Check that the records are sorted by display value. // Check that the records are sorted by display value.
assert.deepEqual( assert.deepEqual(

View File

@ -1103,6 +1103,13 @@ export async function selectWidget(
await waitForServer(); await waitForServer();
} }
export async function changeWidget(type: string) {
await openWidgetPanel();
await driver.findContent('.test-right-panel button', /Change Widget/).click();
await selectWidget(type);
await waitForServer();
}
/** /**
* Toggle elem if not selected. Expects elem to be clickable and to have a class ending with * Toggle elem if not selected. Expects elem to be clickable and to have a class ending with
* -selected when selected. * -selected when selected.
@ -1329,6 +1336,14 @@ export async function openWidgetPanel() {
await driver.find('.test-right-tab-pagewidget').click(); await driver.find('.test-right-tab-pagewidget').click();
} }
/**
* Opens a Creator Panel on Widget/Table settings tab.
*/
export async function openColumnPanel() {
await toggleSidePanel('right', 'open');
await driver.find('.test-right-tab-field').click();
}
/** /**
* Moves a column from a hidden to visible section. * Moves a column from a hidden to visible section.
* Needs a visible Creator panel. * Needs a visible Creator panel.
@ -1448,25 +1463,6 @@ export async function closeRawTable() {
await driver.find('.test-raw-data-close-button').click(); await driver.find('.test-raw-data-close-button').click();
} }
/**
* Toggles (opens or closes) the filter bar for a section.
*/
export async function toggleFilterBar(goal: 'open'|'close'|'toggle' = 'toggle',
options: {section?: string|WebElement, save?: boolean} = {}) {
const isOpen = await driver.find('.test-filter-bar').isPresent();
if ((goal === 'close') && !isOpen ||
(goal === 'open') && isOpen ) {
return;
}
const menu = await openSectionMenu('sortAndFilter', options.section);
await menu.findContent('.grist-floating-menu > div', /Toggle Filter Bar/).find('.test-section-menu-btn').click();
if (options.save) {
await menu.findContent('.grist-floating-menu button', /Save/).click();
await waitForServer();
}
await menu.sendKeys(Key.ESCAPE);
}
/** /**
* Opens the section menu for a section, or the active section if no section is given. * Opens the section menu for a section, or the active section if no section is given.
*/ */
@ -1521,13 +1517,17 @@ export async function deleteColumn(col: IColHeader|string) {
/** /**
* Sets the type of the currently selected field to value. * Sets the type of the currently selected field to value.
*/ */
export async function setType(type: RegExp|string, options: {skipWait?: boolean} = {}) { export async function setType(type: RegExp|string, options: {skipWait?: boolean, apply?: boolean} = {}) {
await toggleSidePanel('right', 'open'); await toggleSidePanel('right', 'open');
await driver.find('.test-right-tab-field').click(); await driver.find('.test-right-tab-field').click();
await driver.find('.test-fbuilder-type-select').click(); await driver.find('.test-fbuilder-type-select').click();
type = typeof type === 'string' ? exactMatch(type) : type; type = typeof type === 'string' ? exactMatch(type) : type;
await driver.findContentWait('.test-select-menu .test-select-row', type, 500).click(); await driver.findContentWait('.test-select-menu .test-select-row', type, 500).click();
if (!options.skipWait) { await waitForServer(); } if (!options.skipWait || options.apply) { await waitForServer(); }
if (options.apply) {
await driver.findWait('.test-type-transform-apply', 1000).click();
await waitForServer();
}
} }
/** /**
@ -2504,58 +2504,52 @@ export async function setRefTable(table: string) {
// Add column to sort. // Add column to sort.
export async function addColumnToSort(colName: RegExp|string) { export async function addColumnToSort(colName: RegExp|string) {
await driver.find(".test-vconfigtab-sort-add").click(); await driver.find(".test-sort-config-add").click();
await driver.findContent(".test-vconfigtab-sort-add-menu-row", colName).click(); await driver.findContent(".test-sort-config-add-menu-row", colName).click();
await driver.findContentWait(".test-vconfigtab-sort-row", colName, 100); await driver.findContentWait(".test-sort-config-row", colName, 100);
} }
// Remove column from sort. // Remove column from sort.
export async function removeColumnFromSort(colName: RegExp|string) { export async function removeColumnFromSort(colName: RegExp|string) {
await findSortRow(colName).find(".test-vconfigtab-sort-remove").click(); await findSortRow(colName).find(".test-sort-config-remove").click();
} }
// Toggle column sort order from ascending to descending, or vice-versa. // Toggle column sort order from ascending to descending, or vice-versa.
export async function toggleSortOrder(colName: RegExp|string) { export async function toggleSortOrder(colName: RegExp|string) {
await findSortRow(colName).find(".test-vconfigtab-sort-order").click(); await findSortRow(colName).find(".test-sort-config-order").click();
}
// Change the column at the given sort position.
export async function changeSortDropdown(colName: RegExp|string, newColName: RegExp|string) {
await findSortRow(colName).find(".test-select-row").click();
await driver.findContent("li .test-select-row", newColName).click();
} }
// Reset the sort to the last saved sort. // Reset the sort to the last saved sort.
export async function revertSortConfig() { export async function revertSortConfig() {
await driver.find(".test-vconfigtab-sort-reset").click(); await driver.find(".test-sort-filter-config-revert").click();
} }
// Save the sort. // Save the sort.
export async function saveSortConfig() { export async function saveSortConfig() {
await driver.find(".test-vconfigtab-sort-save").click(); await driver.find(".test-sort-filter-config-save").click();
await waitForServer(); await waitForServer();
} }
// Update the data positions to the given sort. // Update the data positions to the given sort.
export async function updateRowsBySort() { export async function updateRowsBySort() {
await driver.find(".test-vconfigtab-sort-update").click(); await driver.find(".test-sort-config-update").click();
await waitForServer(10000); await waitForServer(10000);
} }
// Returns a WebElementPromise for the sort row of the given col name. // Returns a WebElementPromise for the sort row of the given col name.
export function findSortRow(colName: RegExp|string) { export function findSortRow(colName: RegExp|string) {
return driver.findContent(".test-vconfigtab-sort-row", colName); return driver.findContent(".test-sort-config-row", colName);
} }
// Opens more sort options menu // Opens more sort options menu
export async function openMoreSortOptions(colName: RegExp|string) { export async function openMoreSortOptions(colName: RegExp|string) {
const row = await findSortRow(colName); const row = await findSortRow(colName);
return row.find(".test-vconfigtab-sort-options-icon").click(); return row.find(".test-sort-config-options-icon").click();
} }
// Selects one of the options in the more options menu. // Selects one of the options in the more options menu.
export async function toggleSortOption(option: SortOption) { export async function toggleSortOption(option: SortOption) {
const label = await driver.find(`.test-vconfigtab-sort-option-${option} label`); const label = await driver.find(`.test-sort-config-option-${option} label`);
await label.click(); await label.click();
await waitForServer(); await waitForServer();
} }
@ -2572,7 +2566,7 @@ export const SortOptions: ReadonlyArray<SortOption> = ["orderByChoice", "emptyLa
export async function getSortOptions(): Promise<SortOption[]> { export async function getSortOptions(): Promise<SortOption[]> {
const options: SortOption[] = []; const options: SortOption[] = [];
for(const option of SortOptions) { for(const option of SortOptions) {
const list = await driver.findAll(`.test-vconfigtab-sort-option-${option} input:checked`); const list = await driver.findAll(`.test-sort-config-option-${option} input:checked`);
if (list.length) { if (list.length) {
options.push(option); options.push(option);
} }
@ -2585,7 +2579,7 @@ export async function getSortOptions(): Promise<SortOption[]> {
export async function getEnabledOptions(): Promise<SortOption[]> { export async function getEnabledOptions(): Promise<SortOption[]> {
const options: SortOption[] = []; const options: SortOption[] = [];
for(const option of SortOptions) { for(const option of SortOptions) {
const list = await driver.findAll(`.test-vconfigtab-sort-option-${option}:not(.disabled)`); const list = await driver.findAll(`.test-sort-config-option-${option}:not(.disabled)`);
if (list.length) { if (list.length) {
options.push(option); options.push(option);
} }
@ -2647,6 +2641,48 @@ export async function filterBy(col: IColHeader|string, save: boolean, values: (s
await waitForServer(); await waitForServer();
} }
export interface PinnedFilter {
name: string;
hasUnsavedChanges: boolean;
}
/**
* Returns a list of all pinned filters in the active section.
*/
export async function getPinnedFilters(): Promise<PinnedFilter[]> {
const filterBar = await driver.find('.active_section .test-filter-bar');
const allFilters = await filterBar.findAll('.test-filter-field', async (el) => {
const button = await el.find('.test-btn');
const buttonClass = await button.getAttribute('class');
return {
name: await el.getText(),
isPinned: await el.getCssValue('display') !== 'none',
hasUnsavedChanges: !/\b\w+-grayed\b/.test(buttonClass),
};
});
const pinnedFilters = allFilters.filter(({isPinned}) => isPinned);
return pinnedFilters.map(({name, hasUnsavedChanges}) => ({name, hasUnsavedChanges}));
}
export interface FilterMenuValue {
checked: boolean;
value: string;
count: number;
}
/**
* Returns a list of all values in the filter menu and their associated state.
*/
export async function getFilterMenuState(): Promise<FilterMenuValue[]> {
const items = await driver.findAll('.test-filter-menu-list > *');
return await Promise.all(items.map(async item => {
const checked = (await item.find('input').getAttribute('checked')) === null ? false : true;
const value = await item.find('label').getText();
const count = parseInt(await item.find('label + div').getText(), 10);
return {checked, value, count};
}));
}
/** /**
* Refresh browser and dismiss alert that is shown (for refreshing during edits). * Refresh browser and dismiss alert that is shown (for refreshing during edits).
*/ */