diff --git a/app/client/components/BaseView.js b/app/client/components/BaseView.js index 0099c68e..f2298731 100644 --- a/app/client/components/BaseView.js +++ b/app/client/components/BaseView.js @@ -430,7 +430,7 @@ BaseView.prototype.filterByThisCellValue = function() { } filterValues = [value]; } - this.viewSection.setFilter(col.getRowId(), JSON.stringify({included: filterValues})); + this.viewSection.setFilter(col.getRowId(), {filter: JSON.stringify({included: filterValues})}); }; /** @@ -732,9 +732,9 @@ BaseView.prototype.getLastDataRowIndex = function() { /** * Creates and opens ColumnFilterMenu for a given field/column, and returns its PopupControl. */ -BaseView.prototype.createFilterMenu = function(openCtl, filterInfo, onClose) { +BaseView.prototype.createFilterMenu = function(openCtl, filterInfo, options) { return createFilterMenu(openCtl, this._sectionFilter, filterInfo, this._mainRowSource, - this.tableModel.tableData, onClose); + this.tableModel.tableData, options); }; /** diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index 2f1e745d..162ebc41 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -46,6 +46,7 @@ const {mouseDragMatchElem} = require('app/client/ui/mouseDrag'); const {menuToggle} = require('app/client/ui/MenuToggle'); const {showTooltip} = require('app/client/ui/tooltips'); const {parsePasteForView} = require("./BaseView2"); +const {NEW_FILTER_JSON} = require('app/client/models/ColumnFilter'); const {CombinedStyle} = require("app/client/models/Styles"); // A threshold for interpreting a motionless click as a click rather than a drag. @@ -1073,12 +1074,16 @@ GridView.prototype.buildDom = function() { // Select the column if it's not part of a multiselect. dom.on('click', (ev) => this.maybeSelectColumn(ev.currentTarget.parentNode, field)), (elem) => { - filterTriggerCtl = setPopupToCreateDom(elem, ctl => this._columnFilterMenu(ctl, field), { - attach: 'body', - placement: 'bottom-start', - boundaries: 'viewport', - trigger: [], - }); + filterTriggerCtl = setPopupToCreateDom( + elem, + ctl => this._columnFilterMenu(ctl, field, {showAllFiltersButton: true}), + { + attach: 'body', + placement: 'bottom-start', + boundaries: 'viewport', + trigger: [], + } + ); }, menu(ctl => this.columnContextMenu(ctl, this.getSelection(), field, filterTriggerCtl)), testId('column-menu-trigger'), @@ -1623,11 +1628,18 @@ GridView.prototype._getColumnMenuOptions = function(copySelection) { }; } -GridView.prototype._columnFilterMenu = function(ctl, field) { +GridView.prototype._columnFilterMenu = function(ctl, field, options) { this.ctxMenuHolder.autoDispose(ctl); const filterInfo = this.viewSection.filters() .find(({fieldOrColumn}) => fieldOrColumn.origCol().origColRef() === field.column().origColRef()); - return this.createFilterMenu(ctl, filterInfo); + if (!filterInfo.isFiltered.peek()) { + // This is a new filter - initialize its spec and pin it. + this.viewSection.setFilter(filterInfo.fieldOrColumn.origCol().origColRef(), { + filter: NEW_FILTER_JSON, + pinned: true, + }); + } + return this.createFilterMenu(ctl, filterInfo, options); }; GridView.prototype.maybeSelectColumn = function (elem, field) { diff --git a/app/client/components/ViewConfigTab.js b/app/client/components/ViewConfigTab.js index e86b4583..bdec20a9 100644 --- a/app/client/components/ViewConfigTab.js +++ b/app/client/components/ViewConfigTab.js @@ -8,22 +8,13 @@ var koArray = require('../lib/koArray'); var commands = require('./commands'); var {CustomSectionElement} = require('../lib/CustomSectionElement'); const {ChartConfig} = require('./ChartView'); -const {Computed, dom: grainjsDom, makeTestId, Observable, styled, MultiHolder} = require('grainjs'); +const {Computed, dom: grainjsDom, makeTestId} = require('grainjs'); -const {addToSort} = require('app/client/lib/sortUtil'); -const {updatePositions} = require('app/client/lib/sortUtil'); -const {attachColumnFilterMenu} = require('app/client/ui/ColumnFilterMenu'); -const {addFilterMenu} = require('app/client/ui/FilterBar'); -const {cssIcon, cssRow} = require('app/client/ui/RightPanelStyles'); -const {basicButton, primaryButton} = require('app/client/ui2018/buttons'); -const {labeledLeftSquareCheckbox} = require("app/client/ui2018/checkbox"); -const {theme} = require('app/client/ui2018/cssVars'); -const {cssDragger} = require('app/client/ui2018/draggableList'); -const {menu, menuItem, select} = require('app/client/ui2018/menus'); +const {cssRow} = require('app/client/ui/RightPanelStyles'); +const {SortFilterConfig} = require('app/client/ui/SortFilterConfig'); +const {primaryButton} = require('app/client/ui2018/buttons'); +const {select} = require('app/client/ui2018/menus'); const {confirmModal} = require('app/client/ui2018/modals'); -const {Sort} = require('app/common/SortSpec'); -const isEqual = require('lodash/isEqual'); -const {cssMenuItem} = require('popweasel'); const {makeT} = require('app/client/lib/localization'); const testId = makeTestId('test-vconfigtab-'); @@ -56,282 +47,40 @@ function ViewConfigTab(options) { .setAutoDisposeValues() ); - this.activeSectionData = this.autoDispose(ko.computed(function() { - return _.find(self.viewSectionData.all(), function(sectionData) { - return sectionData.section && - sectionData.section.getRowId() === self.viewModel.activeSectionId(); - }) || self.viewSectionData.at(0); - })); this.isDetail = this.autoDispose(ko.computed(function() { return ['detail', 'single'].includes(this.viewModel.activeSection().parentKey()); }, this)); this.isChart = this.autoDispose(ko.computed(function() { - return this.viewModel.activeSection().parentKey() === 'chart';}, this)); + return this.viewModel.activeSection().parentKey() === 'chart';}, this)); this.isGrid = this.autoDispose(ko.computed(function() { - return this.viewModel.activeSection().parentKey() === 'record';}, this)); + return this.viewModel.activeSection().parentKey() === 'record';}, this)); this.isCustom = this.autoDispose(ko.computed(function() { - return this.viewModel.activeSection().parentKey() === 'custom';}, this)); -} -dispose.makeDisposable(ViewConfigTab); - - -ViewConfigTab.prototype.buildSortDom = function() { - return grainjsDom.maybe(this.activeSectionData, (sectionData) => { - const section = sectionData.section; - - // Computed to indicate if sort has changed from saved. - const hasChanged = Computed.create(null, (use) => - !isEqual(use(section.activeSortSpec), Sort.parseSortColRefs(use(section.sortColRefs)))); - - // Computed array of sortable columns. - const columns = Computed.create(null, (use) => { - // Columns is an observable holding an observable array - must call 'use' on it 2x. - const cols = use(use(use(section.table).columns)); - return cols.filter(col => !use(col.isHiddenCol)) - .map(col => ({ - label: use(col.colId), - value: col.getRowId(), - icon: 'FieldColumn', - type: col.type() - })); - }); - - // We only want to recreate rows, when the actual columns change. - const colRefs = Computed.create(null, (use) => { - return use(section.activeSortSpec).map(col => Sort.getColRef(col)); - }); - const sortRows = koArray(colRefs.get()); - colRefs.addListener((curr, prev) => { - if (!isEqual(curr, prev)){ - sortRows.assign(curr); - } - }) + return this.viewModel.activeSection().parentKey() === 'custom';}, this)); + this.isRaw = this.autoDispose(ko.computed(function() { + return this.viewModel.activeSection().isRaw();}, this)); - // 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); - }; + this.activeRawSectionData = this.autoDispose(ko.computed(function() { + return self.isRaw() ? ViewSectionData.create(self.viewModel.activeSection()) : null; + })); - 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') + 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) ); - }); -}; - -// 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)); + })); +} +dispose.makeDisposable(ViewConfigTab); - 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)) +ViewConfigTab.prototype.buildSortFilterDom = function() { + return grainjsDom.maybe(this.activeSectionData, ({section}) => { + return grainjsDom.create(SortFilterConfig, section, this.gristDoc); }); - 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) { @@ -394,90 +143,6 @@ ViewConfigTab.prototype._buildAdvancedSettingsDom = function() { }); }; -ViewConfigTab.prototype._buildFilterDom = function() { - return grainjsDom.maybe(this.activeSectionData, (sectionData) => { - const section = sectionData.section; - const docModel = this.gristDoc.docModel; - const popupControls = new WeakMap(); - const activeFilterBar = section.activeFilterBar; - - const hasChangedObs = Computed.create(null, (use) => use(section.filterSpecChanged) || !use(section.activeFilterBar.isSaved)) - - async function save() { - await docModel.docData.bundleActions(t("UpdateFilterSettings"), () => Promise.all([ - section.saveFilters(), // Save filter - section.activeFilterBar.save(), // Save bar - ])); - } - function revert() { - section.revertFilters(); // Revert filter - section.activeFilterBar.revert(); // Revert bar - } - - return [ - grainjsDom.forEach(section.activeFilters, (filterInfo) => { - return cssRow( - cssIconWrapper( - cssFilterIcon('FilterSimple', cssNoMarginLeft.cls('')), - attachColumnFilterMenu(section, filterInfo, { - placement: 'bottom-end', attach: 'body', - trigger: [ - 'click', - (_el, popupControl) => popupControls.set(filterInfo.fieldOrColumn.origCol(), popupControl) - ], - }), - ), - cssLabel(grainjsDom.text(filterInfo.fieldOrColumn.label)), - cssIconWrapper( - cssFilterIcon('Remove', - dom.on('click', () => section.setFilter(filterInfo.fieldOrColumn.origCol().origColRef(), '')), - testId('remove-filter') - ), - ), - testId('filter'), - ); - }), - cssRow( - grainjsDom.domComputed((use) => { - const filters = use(section.filters); - return cssTextBtn( - cssPlusIcon('Plus'), t('AddFilter'), - addFilterMenu(filters, section, popupControls, {placement: 'bottom-end'}), - testId('add-filter-btn'), - ); - }), - ), - grainjsDom.maybe((use) => !use(section.isRaw), - () => cssRow(cssTextBtn( - testId('toggle-filter-bar'), - grainjsDom.domComputed((use) => { - const filterBar = use(activeFilterBar); - return cssPlusIcon( - filterBar ? "Tick" : "Plus", - cssIcon.cls('-green', Boolean(filterBar)), - testId('toggle-filter-bar-icon'), - ); - }), - grainjsDom.on('click', () => activeFilterBar(!activeFilterBar.peek())), - 'Toggle Filter Bar', - ))), - grainjsDom.maybe(hasChangedObs, () => cssRow( - cssExtraMarginTop.cls(''), - testId('save-filter-btns'), - primaryButton( - t('Save'), {style: 'margin-right: 8px'}, - grainjsDom.on('click', save), - grainjsDom.boolAttr('disabled', this.gristDoc.isReadonly), - ), - basicButton( - t('Revert'), - grainjsDom.on('click', revert), - ) - )) - ]; - }); -}; - ViewConfigTab.prototype._buildThemeDom = function() { return kd.maybe(this.activeSectionData, (sectionData) => { var section = sectionData.section; @@ -570,132 +235,4 @@ ViewConfigTab.prototype._buildCustomTypeItems = function() { }]; }; -const cssMenuIcon = styled(cssIcon, ` - margin: 0 8px 0 0; - - .${cssMenuItem.className}-sel > & { - background-color: ${theme.iconButtonFg}; - } -`); - -// Note that the width is set to 0 so that flex-shrink works properly with long text values. -const cssSortSelect = styled('div', ` - flex: 1 1 0px; - margin: 0 6px 0 0; - min-width: 0; -`); - -const cssSortIconBtn = styled(cssIcon, ` - flex: none; - margin: 0 6px; - cursor: pointer; - background-color: ${theme.controlSecondaryFg}; - - &:hover { - background-color: ${theme.controlSecondaryHoverFg}; - } -`); - -const cssSortIconPrimaryBtn = styled(cssSortIconBtn, ` - background-color: ${theme.controlFg}; - - &:hover { - background-color: ${theme.controlHoverFg}; - } -`); - -const cssTextBtn = styled('div', ` - color: ${theme.controlFg}; - cursor: pointer; - - &:hover { - color: ${theme.controlHoverFg}; - } -`); - -const cssPlusIcon = styled(cssIcon, ` - background-color: ${theme.controlFg}; - cursor: pointer; - margin: 0px 4px 3px 0; - - .${cssTextBtn.className}:hover > & { - background-color: ${theme.controlHoverFg}; - } -`); - -const cssDragRow = styled('div', ` - display: flex !important; - align-items: center; - margin: 0 16px 0px 0px; - & > .kf_draggable_content { - margin: 6px 0; - flex: 1 1 0px; - min-width: 0px; - } -`); - -const cssSortRow = styled('div', ` - display: flex; - align-items: center; - width: 100%; -`); - -const cssFlex = styled('div', ` - flex: 1 1 0; -`); - -const cssLabel = styled('div', ` - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - flex-grow: 1; -`); - -const cssExtraMarginTop = styled('div', ` - margin-top: 28px; -`); - -const cssFilterIcon = cssSortIconBtn; - -const cssNoMarginLeft = styled('div', ` - margin-left: 0; -`); - -const cssIconWrapper = styled('div', ``); - -const cssBigIconWrapper = styled('div', ` - padding: 3px; - border-radius: 3px; - cursor: pointer; - user-select: none; -`); - -const cssMenu = styled('div', ` - display: inline-flex; - cursor: pointer; - border-radius: 3px; - border: 1px solid transparent; - &:hover, &.weasel-popup-open { - background-color: ${theme.hover}; - } -`); - -const cssBgAccent = styled(`div`, ` - background: ${theme.accentIcon} -`) - -const cssOptionMenuItem = styled('div', ` - &:hover { - background-color: ${theme.hover}; - } - & label { - flex: 1; - cursor: pointer; - } - &.disabled * { - color: ${theme.menuItemDisabledFg} important; - cursor: not-allowed; - } -`) - module.exports = ViewConfigTab; diff --git a/app/client/components/ViewLayout.ts b/app/client/components/ViewLayout.ts index 0a3af1f4..c27719f1 100644 --- a/app/client/components/ViewLayout.ts +++ b/app/client/components/ViewLayout.ts @@ -155,6 +155,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { nextSection: () => { this._otherSection(+1); }, prevSection: () => { this._otherSection(-1); }, printSection: () => { printViewSection(this._layout, this.viewModel.activeSection()).catch(reportError); }, + sortFilterMenuOpen: (sectionId?: number) => { this._openSortFilterMenu(sectionId); }, }; this.autoDispose(commands.createGroup(commandGroup, this, true)); } @@ -267,6 +268,20 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { this.gristDoc.viewModel.activeSectionId(layoutBox.leafId.peek()); } } + + /** + * Opens the sort and filter menu of the active view section. + * + * Optionally accepts a `sectionId` for opening a specific section's menu. + */ + private _openSortFilterMenu(sectionId?: number) { + const id = sectionId ?? this.viewModel.activeSectionId(); + const leafBoxDom = this._layout.getLeafBox(id)?.dom; + if (!leafBoxDom) { return; } + + const menu: HTMLElement | null = leafBoxDom.querySelector('.test-section-menu-sortAndFilter'); + menu?.click(); + } } export function buildViewSectionDom(options: { @@ -305,11 +320,10 @@ export function buildViewSectionDom(options: { buildWidgetTitle(vs, options, testId('viewsection-title'), cssTestClick(testId("viewsection-blank"))), viewInstance.buildTitleControls(), dom('span.viewsection_buttons', - dom.create(viewSectionMenu, gristDoc.docModel, vs, gristDoc.isReadonly) + dom.create(viewSectionMenu, gristDoc, vs) ) )), - dom.maybe((use) => use(vs.activeFilterBar) || use(vs.isRaw) && use(vs.activeFilters).length, - () => dom.create(filterBar, vs)), + dom.create(filterBar, vs), dom.maybe(vs.viewInstance, (viewInstance) => [ dom('div.view_data_pane_container.flexvbox', cssResizing.cls('', isResizing), diff --git a/app/client/components/commandList.js b/app/client/components/commandList.js index 3d07f1e5..22a4c309 100644 --- a/app/client/components/commandList.js +++ b/app/client/components/commandList.js @@ -66,11 +66,6 @@ exports.groups = [{ keys: ['Esc'], desc: null, // Shortcut to close active menu }, - { - name: 'filterMenuOpen', - keys: [], - desc: 'Shortcut to open filter menu' - }, { name: 'docTabOpen', keys: [], @@ -91,6 +86,11 @@ exports.groups = [{ keys: [], desc: 'Shortcut to sort & filter tab' }, + { + name: 'sortFilterMenuOpen', + keys: [], + desc: 'Shortcut to open sort & filter menu' + }, { name: 'dataSelectionTabOpen', keys: [], diff --git a/app/client/declarations.d.ts b/app/client/declarations.d.ts index 3be3229e..f44b488f 100644 --- a/app/client/declarations.d.ts +++ b/app/client/declarations.d.ts @@ -39,6 +39,7 @@ declare module "app/client/components/BaseView" { import {ViewFieldRec, ViewSectionRec} from "app/client/models/DocModel"; import {FilterInfo} from 'app/client/models/entities/ViewSectionRec'; import {SortedRowSet} from 'app/client/models/rowset'; + import {IColumnFilterMenuOptions} from 'app/client/ui/ColumnFilterMenu'; import {FieldBuilder} from "app/client/widgets/FieldBuilder"; import {DomArg} from 'grainjs'; import {IOpenController} from 'popweasel'; @@ -66,7 +67,8 @@ declare module "app/client/components/BaseView" { constructor(gristDoc: GristDoc, viewSectionModel: any, options?: {addNewRow?: boolean, isPreview?: boolean}); 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 getLoadingDonePromise(): Promise; public activateEditorAtCursor(options?: Options): void; @@ -95,10 +97,9 @@ declare module "app/client/components/ViewConfigTab" { class ViewConfigTab extends Disposable { constructor(options: {gristDoc: GristDoc, viewModel: ViewRec}); - public buildSortDom(): DomContents; + public buildSortFilterDom(): DomContents; // TODO: these should be made private or renamed. public _buildAdvancedSettingsDom(): DomArg; - public _buildFilterDom(): DomArg; public _buildThemeDom(): DomArg; public _buildChartConfigDom(): DomContents; public _buildLayoutDom(): DomArg; diff --git a/app/client/models/ColumnFilter.ts b/app/client/models/ColumnFilter.ts index e91fedc5..d30dac48 100644 --- a/app/client/models/ColumnFilter.ts +++ b/app/client/models/ColumnFilter.ts @@ -40,6 +40,10 @@ export class ColumnFilter extends Disposable { return this._columnType; } + public get initialFilterJson() { + return this._initialFilterJson; + } + public setState(filterJson: string|FilterSpec) { const state = makeFilterState(filterJson); 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 = '{}'; diff --git a/app/client/models/ColumnFilterMenuModel.ts b/app/client/models/ColumnFilterMenuModel.ts index e6193b61..fffdcd33 100644 --- a/app/client/models/ColumnFilterMenuModel.ts +++ b/app/client/models/ColumnFilterMenuModel.ts @@ -1,4 +1,5 @@ import { ColumnFilter } from "app/client/models/ColumnFilter"; +import { FilterInfo } from "app/client/models/entities/ViewSectionRec"; import { CellValue } from "app/plugin/GristData"; import { Computed, Disposable, Observable } from "grainjs"; import escapeRegExp = require("lodash/escapeRegExp"); @@ -23,7 +24,21 @@ type ICompare = (a: T, b: T) => number 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 { + 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, ''); @@ -34,7 +49,7 @@ export class ColumnFilterMenuModel extends Disposable { const searchRegex = new RegExp(escapeRegExp(searchValue), 'i'); const showAllOptions = ['Bool', 'Choice', 'ChoiceList'].includes(this.columnFilter.columnType); return new Set( - this._valueCount + this._params.valueCount .filter(([_, {label, count}]) => (showAllOptions ? true : count) && searchRegex.test(label)) .map(([key]) => key) ); @@ -56,7 +71,7 @@ export class ColumnFilterMenuModel extends Disposable { return localeCompare(a, b); }; - return this._valueCount + return this._params.valueCount .filter(([key]) => filter.has(key)) .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 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 public readonly filteredKeys = Computed.create(this, this.filterSet, (_use, filter) => { - return this._valueCount + return this._params.valueCount .filter(([key]) => filter.has(key)) .map(([key]) => key); }); @@ -78,8 +93,7 @@ export class ColumnFilterMenuModel extends Disposable { return filteredValues.slice(this.limitShown); }); - constructor(public columnFilter: ColumnFilter, private _valueCount: Array<[CellValue, IFilterCount]>, - public limitShown: number = MAXIMUM_SHOWN_FILTER_ITEMS) { + constructor(private _params: ColumnFilterMenuModelParams) { super(); } } diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts index f597ab5c..7da67ebb 100644 --- a/app/client/models/entities/ViewSectionRec.ts +++ b/app/client/models/entities/ViewSectionRec.ts @@ -19,6 +19,7 @@ import {RowId} from 'app/client/models/rowset'; import {LinkConfig} from 'app/client/ui/selectBy'; import {getWidgetTypes} from 'app/client/ui/widgetTypes'; import {AccessLevel, ICustomWidget} from 'app/common/CustomWidget'; +import {UserAction} from 'app/common/DocActions'; import {arrayRepeat} from 'app/common/gutil'; import {Sort} from 'app/common/SortSpec'; 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`. */ - _unsavedFilters: Map; + _unsavedFilters: Map>; /** * 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. activeFilters: Computed; + // Subset of `activeFilters` that are pinned. + pinnedActiveFilters: Computed; + // Helper metadata item which indicates whether any of the section's fields/columns have unsaved // changes to their filters. (True indicates unsaved changes) filterSpecChanged: Computed; @@ -146,7 +150,6 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO isSorted: ko.Computed; disableDragRows: ko.Computed; - activeFilterBar: modelUtil.CustomComputed; // Number of frozen columns rawNumFrozen: modelUtil.CustomComputed; // 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. revertFilters(): void; - // Apply `filter` to the field or column identified by `colRef`. - setFilter(colRef: number, filter: string): void; + // Set `filter` for the field or column identified by `colRef`. + setFilter(colRef: number, filter: Partial): void; + + // Revert the filter of the field or column identified by `colRef`. + revertFilter(colRef: number): void; // Saves custom definition (bundles change) saveCustomDef(): Promise; @@ -236,14 +242,25 @@ export interface CustomViewSectionDef { sectionId: modelUtil.KoSaveableObservable; } -// Information about filters for a field or hidden column. +/** Information about filters for a field or hidden column. */ 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; - // Filter that applies to this field/column, if any. + /** Filter that applies to this field/column, if any. */ filter: modelUtil.CustomComputed; - // True if `filter` has a non-blank value. + /** Whether this filter is pinned to the filter bar. */ + pinned: modelUtil.CustomComputed; + /** True if `filter` has a non-blank value. */ isFiltered: ko.PureComputed; + /** True if `pinned` is true. */ + isPinned: ko.PureComputed; +} + +export interface Filter { + filter: string; + pinned: boolean; } export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): void { @@ -262,7 +279,6 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): horizontalGridlines: true, zebraStripes: false, customView: '', - filterBar: false, numFrozen: 0 }; this.optionsObj = modelUtil.jsonObservable(this.options, @@ -365,7 +381,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): 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 * 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 => { const savedFilter = savedFiltersByColRef.get(column.origColRef()); + // Initialize with a saved filter, if one exists. Otherwise, use a blank filter. const filter = modelUtil.customComputed({ - // Initialize with a saved filter, if one exists. Otherwise, use a blank filter. 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()); - if (unsavedFilter !== undefined) { filter(unsavedFilter); } + if (unsavedFilter) { + const {filter: f, pinned: p} = unsavedFilter; + if (f !== undefined) { filter(f); } + if (p !== undefined) { pinned(p); } + } return { + viewSection: this, filter, + pinned, 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. - 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 // changes to their filters. (True indicates unsaved changes) 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. @@ -408,52 +437,72 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): return docModel.docData.bundleActions(`Save all filters in ${this.titleDef()}`, async () => { 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 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). - 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) { + // 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. - newFilters.push([c.fieldOrColumn.origCol().origColRef(), c.filter()]); - } else if (c.filter() === '') { + newFilters.push([fieldOrColumn.origCol().origColRef(), { + filter: filter(), + pinned: pinned(), + }]); + } else if (filter() === '') { // Mark the saved filter for removal from the filters table. removedFilterIds.push(savedFilter.id()); } else { // 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. if (removedFilterIds.length > 0) { - await docModel.filters.sendTableAction(['BulkRemoveRecord', removedFilterIds]); + actions.push(['BulkRemoveRecord', removedFilterIds]); } // Update existing filter records with new filter values. if (updatedFilters.length > 0) { - await docModel.filters.sendTableAction(['BulkUpdateRecord', + actions.push(['BulkUpdateRecord', updatedFilters.map(([id]) => id), - {filter: updatedFilters.map(([, filter]) => filter)} + { + filter: updatedFilters.map(([, {filter}]) => filter), + pinned: updatedFilters.map(([, {pinned}]) => pinned), + } ]); } // Add new filter records. if (newFilters.length > 0) { - await docModel.filters.sendTableAction(['BulkAddRecord', + actions.push(['BulkAddRecord', arrayRepeat(newFilters.length, null), { viewSectionRef: arrayRepeat(newFilters.length, this.id()), 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. this.revertFilters(); } @@ -462,15 +511,32 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): // Revert all filters of fields/columns in the section. this.revertFilters = () => { - this._unsavedFilters = new Map(); - this.filters().forEach(c => { c.filter.revert(); }); + this._unsavedFilters.clear(); + this.filters().forEach(c => { + c.filter.revert(); + c.pinned.revert(); + }); + }; + + // Set `filter` for the field or column identified by `colRef`. + this.setFilter = (colRef: number, filter: Partial) => { + this._unsavedFilters.set(colRef, {...this._unsavedFilters.get(colRef), ...filter}); + const filterInfo = this.filters().find(c => c.fieldOrColumn.origCol().origColRef() === colRef); + if (!filterInfo) { return; } + + const {filter: newFilter, pinned: newPinned} = filter; + if (newFilter !== undefined) { filterInfo.filter(newFilter); } + if (newPinned !== undefined) { filterInfo.pinned(newPinned); } }; - // Apply `filter` to the field or column identified by `colRef`. - this.setFilter = (colRef: number, filter: string) => { - this._unsavedFilters.set(colRef, filter); + // 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); - filterInfo?.filter(filter); + if (!filterInfo) { return; } + + filterInfo.filter.revert(); + filterInfo.pinned.revert(); }; // 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.disableDragRows = ko.pureComputed(() => this.isSorted() || !this.table().supportsManualSort()); - this.activeFilterBar = modelUtil.customValue(this.optionsObj.prop('filterBar')); - // Number of frozen columns this.rawNumFrozen = modelUtil.customValue(this.optionsObj.prop('numFrozen')); // Number for frozen columns to display diff --git a/app/client/ui/ColumnFilterMenu.ts b/app/client/ui/ColumnFilterMenu.ts index e3d03e10..5bb27d75 100644 --- a/app/client/ui/ColumnFilterMenu.ts +++ b/app/client/ui/ColumnFilterMenu.ts @@ -3,16 +3,18 @@ * callback that's triggered on Apply or on Cancel. Changes to the UI result in changes to the underlying model, * but on Cancel the model is reset to its initial state prior to menu closing. */ +import * as commands from 'app/client/components/commands'; import {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 {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 {RowId, RowSource} from 'app/client/models/rowset'; import {ColumnFilterFunc, SectionFilter} from 'app/client/models/SectionFilter'; import {TableData} from 'app/client/models/TableData'; import {cssInput} from 'app/client/ui/cssInput'; -import {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 } from 'app/client/ui2018/checkbox'; import {theme, vars} from 'app/client/ui2018/cssVars'; @@ -40,19 +42,24 @@ const t = makeT('ColumnFilterMenu'); export interface IFilterMenuOptions { model: ColumnFilterMenuModel; valueCounts: Map; - doSave: (reset: boolean) => void; - onClose: () => void; - renderValue: (key: CellValue, value: IFilterCount) => DomElementArg; - rangeInputOptions?: IRangeInputOptions + rangeInputOptions?: IRangeInputOptions; + showAllFiltersButton?: boolean; + doCancel(): void; + doSave(reset: boolean): void; + renderValue(key: CellValue, value: IFilterCount): DomElementArg; + onClose(): void; } 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 { - const { model, doSave, onClose, rangeInputOptions = {}, renderValue } = opts; - const { columnFilter } = model; - // Save the initial state to allow reverting back to it on Cancel - const initialStateJson = columnFilter.makeFilterJson(); + const { model, doCancel, doSave, onClose, rangeInputOptions = {}, renderValue, showAllFiltersButton } = opts; + const { columnFilter, filterInfo } = model; // Map to keep track of displayed checkboxes const checkboxMap: Map = new Map(); @@ -74,6 +81,7 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio let searchInput: HTMLInputElement; let minRangeInput: HTMLInputElement; + let cancel = false; let reset = false; // 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'), dom.cls(menuCssClass), 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({ Enter: () => onClose(), Escape: () => onClose() @@ -205,13 +214,39 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio ]; } }), - cssMenuItem( - cssApplyButton('Apply', testId('apply-btn'), - dom.on('click', () => { reset = true; onClose(); })), - basicButton('Cancel', testId('cancel-btn'), - dom.on('click', () => { columnFilter.setState(initialStateJson); onClose(); } )) - ) - ) + cssFooterButtons( + dom('div', + cssPrimaryButton('Close', testId('apply-btn'), + dom.on('click', () => { + reset = true; + onClose(); + }), + ), + basicButton('Cancel', testId('cancel-btn'), + dom.on('click', () => { + cancel = true; + onClose(); + }), + ), + !showAllFiltersButton ? null : cssAllFiltersButton( + 'All filters', + dom.on('click', () => { + onClose(); + commands.allCommands.sortFilterMenuOpen.run(filterInfo.viewSection.getRowId()); + }), + testId('all-filters-btn'), + ), + ), + dom('div', + cssPinButton( + icon('PinTilted'), + cssPinButton.cls('-pinned', model.filterInfo.isPinned), + dom.on('click', () => filterInfo.pinned(!filterInfo.pinned())), + testId('pin-btn'), + ), + ), + ), + ), ); return filterMenu; } @@ -350,17 +385,31 @@ function getEmptyCountMap(fieldOrColumn: ViewFieldRec|ColumnRec): Map [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(). */ -export function createFilterMenu(openCtl: IOpenController, sectionFilter: SectionFilter, filterInfo: FilterInfo, - rowSource: RowSource, tableData: TableData, onClose: () => void = noop) { +export function createFilterMenu( + 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. - const fieldOrColumn = filterInfo.fieldOrColumn; + const {fieldOrColumn, filter} = filterInfo; const columnType = fieldOrColumn.origCol.peek().type.peek(); const visibleColumnType = fieldOrColumn.visibleColModel.peek()?.type.peek() || columnType; - const {keyMapFunc, labelMapFunc, valueMapFunc} = getMapFuncs(columnType, tableData, filterInfo.fieldOrColumn); - const activeFilterBar = sectionFilter.viewSection.activeFilterBar; + const {keyMapFunc, labelMapFunc, valueMapFunc} = getMapFuncs(columnType, tableData, fieldOrColumn); // range input options const valueParser = (fieldOrColumn as any).createValueParser?.(); @@ -387,10 +436,14 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio areHiddenRows: true, valueMapFunc}); 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])); 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, { model, @@ -398,20 +451,32 @@ export function createFilterMenu(openCtl: IOpenController, sectionFilter: Sectio onClose: () => { openCtl.close(); onClose(); }, doSave: (reset: boolean = false) => { const spec = columnFilter.makeFilterJson(); - // If filter is moot and filter bar is hidden, let's remove the filter. sectionFilter.viewSection.setFilter( fieldOrColumn.origCol().origColRef(), - spec === allInclusive && !activeFilterBar.peek() ? '' : spec + {filter: spec} ); if (reset) { 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), rangeInputOptions: { valueParser, valueFormatter, - } + }, + showAllFiltersButton, }); } @@ -571,20 +636,25 @@ const defaultPopupOptions: IPopupOptions = { trigger: ['click'], }; -interface IColumnFilterMenuOptions extends IPopupOptions { - // callback for when the content of the menu is closed by clicking the apply or revert buttons - onCloseContent?: () => void; +interface IColumnFilterPopupOptions { + // Options to pass to the popup component. + popupOptions?: IPopupOptions; } +type IAttachColumnFilterMenuOptions = IColumnFilterPopupOptions & IColumnFilterMenuOptions; + // Helper to attach the column filter menu. -export function attachColumnFilterMenu(viewSection: ViewSectionRec, filterInfo: FilterInfo, - popupOptions: IColumnFilterMenuOptions): DomElementMethod { - const options = {...defaultPopupOptions, ...popupOptions}; +export function attachColumnFilterMenu( + filterInfo: FilterInfo, + options: IAttachColumnFilterMenuOptions = {} +): DomElementMethod { + const {popupOptions, ...filterMenuOptions} = options; + const popupOptionsWithDefaults = {...defaultPopupOptions, ...popupOptions}; return (elem) => { - const instance = viewSection.viewInstance(); + const instance = filterInfo.viewSection.viewInstance(); if (instance && instance.createFilterMenu) { // Should be set if using BaseView - setPopupToCreateDom(elem, ctl => - instance.createFilterMenu(ctl, filterInfo, popupOptions.onCloseContent), options); + setPopupToCreateDom(elem, ctl => instance.createFilterMenu( + ctl, filterInfo, filterMenuOptions), popupOptionsWithDefaults); } }; } @@ -654,8 +724,17 @@ const cssMenuFooter = styled('div', ` flex-direction: column; padding-top: 4px; `); -const cssApplyButton = styled(primaryButton, ` - margin-right: 4px; +const cssFooterButtons = styled('div', ` + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 16px; +`); +const cssPrimaryButton = styled(primaryButton, ` + margin-right: 8px; +`); +const cssAllFiltersButton = styled(textButton, ` + margin-left: 8px; `); const cssSearch = styled(input, ` color: ${theme.inputFg}; diff --git a/app/client/ui/FilterBar.ts b/app/client/ui/FilterBar.ts index a972836f..18afba06 100644 --- a/app/client/ui/FilterBar.ts +++ b/app/client/ui/FilterBar.ts @@ -1,58 +1,82 @@ -import { makeT } from "app/client/lib/localization"; -import { allInclusive } from "app/client/models/ColumnFilter"; -import { ColumnRec, ViewFieldRec, ViewSectionRec } from "app/client/models/DocModel"; +import { NEW_FILTER_JSON } from "app/client/models/ColumnFilter"; +import { ColumnRec, ViewSectionRec } from "app/client/models/DocModel"; import { FilterInfo } from "app/client/models/entities/ViewSectionRec"; import { attachColumnFilterMenu } from "app/client/ui/ColumnFilterMenu"; -import { cssButton, cssButtonGroup } from "app/client/ui2018/buttons"; -import { testId, theme } from "app/client/ui2018/cssVars"; +import { cssButton } from "app/client/ui2018/buttons"; +import { testId, theme, vars } from "app/client/ui2018/cssVars"; import { icon } from "app/client/ui2018/icons"; import { menu, menuItemAsync } from "app/client/ui2018/menus"; import { dom, IDisposableOwner, IDomArgs, styled } from "grainjs"; import { IMenuOptions, PopupControl } from "popweasel"; -const t = makeT('FilterBar'); - export function filterBar(_owner: IDisposableOwner, viewSection: ViewSectionRec) { const popupControls = new WeakMap(); return cssFilterBar( testId('filter-bar'), - dom.forEach(viewSection.activeFilters, (filterInfo) => makeFilterField(viewSection, filterInfo, popupControls)), + dom.forEach(viewSection.activeFilters, (filterInfo) => makeFilterField(filterInfo, popupControls)), makePlusButton(viewSection, popupControls), + cssFilterBar.cls('-hidden', use => use(viewSection.pinnedActiveFilters).length === 0), ); } -function makeFilterField(viewSection: ViewSectionRec, filterInfo: FilterInfo, - popupControls: WeakMap) { +function makeFilterField(filterInfo: FilterInfo, popupControls: WeakMap) { + const {fieldOrColumn, filter, pinned, isPinned} = filterInfo; return cssFilterBarItem( testId('filter-field'), primaryButton( testId('btn'), cssIcon('FilterSimple'), - cssMenuTextLabel(dom.text(filterInfo.fieldOrColumn.origCol().label)), - cssBtn.cls('-grayed', filterInfo.filter.isSaved), - attachColumnFilterMenu(viewSection, filterInfo, { - placement: 'bottom-start', attach: 'body', - trigger: ['click', (_el, popupControl) => popupControls.set(filterInfo.fieldOrColumn.origCol(), popupControl)] + cssMenuTextLabel(dom.text(fieldOrColumn.origCol().label)), + cssBtn.cls('-grayed', use => use(filter.isSaved) && use(pinned.isSaved)), + attachColumnFilterMenu(filterInfo, { + popupOptions: { + placement: 'bottom-start', + attach: 'body', + trigger: [ + 'click', + (_el, popupControl) => popupControls.set(fieldOrColumn.origCol(), popupControl), + ], + }, + showAllFiltersButton: true, }), ), - deleteButton( - testId('delete'), - cssIcon('CrossSmall'), - cssBtn.cls('-grayed', filterInfo.filter.isSaved), - dom.on('click', () => viewSection.setFilter(filterInfo.fieldOrColumn.origCol().origColRef(), '')), - ) + cssFilterBarItem.cls('-unpinned', use => !use(isPinned)), ); } -export function addFilterMenu(filters: FilterInfo[], viewSection: ViewSectionRec, - popupControls: WeakMap, options?: IMenuOptions) { +export interface AddFilterMenuOptions { + /** + * 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, + options: AddFilterMenuOptions = {} +) { + const {allowedColumns, menuOptions} = options; return ( menu((ctl) => [ ...filters.map((filterInfo) => ( menuItemAsync( - () => turnOnAndOpenFilter(filterInfo.fieldOrColumn, viewSection, popupControls), + () => openFilter(filterInfo, popupControls), 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'), ) )), @@ -62,25 +86,30 @@ export function addFilterMenu(filters: FilterInfo[], viewSection: ViewSectionRec ctl.close(); ev.stopPropagation(); }), - ], options) + ], menuOptions) ); } -function turnOnAndOpenFilter(fieldOrColumn: ViewFieldRec|ColumnRec, viewSection: ViewSectionRec, - popupControls: WeakMap) { - viewSection.setFilter(fieldOrColumn.origCol().origColRef(), allInclusive); +function openFilter( + {fieldOrColumn, isFiltered, viewSection}: FilterInfo, + popupControls: WeakMap, +) { + viewSection.setFilter(fieldOrColumn.origCol().origColRef(), { + filter: isFiltered.peek() ? undefined : NEW_FILTER_JSON, + pinned: true, + }); popupControls.get(fieldOrColumn.origCol())?.open(); } function makePlusButton(viewSectionRec: ViewSectionRec, popupControls: WeakMap) { return dom.domComputed((use) => { const filters = use(viewSectionRec.filters); - const anyFilter = use(viewSectionRec.activeFilters).length > 0; return cssPlusButton( cssBtn.cls('-grayed'), cssIcon('Plus'), - addFilterMenu(filters, viewSectionRec, popupControls), - anyFilter ? null : cssPlusLabel(t('AddFilter')), + addFilterMenu(filters, popupControls, { + allowedColumns: 'unpinned-or-unfiltered', + }), testId('add-filter-btn') ); }); @@ -96,12 +125,16 @@ const cssFilterBar = styled('div.filter_bar', ` &::-webkit-scrollbar { display: none; } + &-hidden { + display: none; + } `); -const cssFilterBarItem = styled(cssButtonGroup, ` +const cssFilterBarItem = styled('div', ` + border-radius: ${vars.controlBorderRadius}; flex-shrink: 0; margin: 0 4px; - & > .${cssButton.className}:first-child { - border-right: 0.5px solid white; + &-unpinned { + display: none; } `); const cssMenuTextLabel = styled('span', ` @@ -134,12 +167,6 @@ const primaryButton = (...args: IDomArgs) => ( dom('div', cssButton.cls(''), cssButton.cls('-primary'), cssBtn.cls(''), ...args) ); -const deleteButton = styled(primaryButton, ` - padding: 3px 4px; -`); const cssPlusButton = styled(primaryButton, ` padding: 3px 3px `); -const cssPlusLabel = styled('span', ` - margin: 0 12px 0 4px; -`); diff --git a/app/client/ui/FilterConfig.ts b/app/client/ui/FilterConfig.ts new file mode 100644 index 00000000..07a97178 --- /dev/null +++ b/app/client/ui/FilterConfig.ts @@ -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; +`); diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index e3baa4a3..a86dcbd1 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -28,6 +28,7 @@ import {GridOptions} from 'app/client/ui/GridOptions'; import {attachPageWidgetPicker, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; import {linkId, selectBy} from 'app/client/ui/selectBy'; import {CustomSectionConfig} from 'app/client/ui/CustomSectionConfig'; +import {cssLabel} from 'app/client/ui/RightPanelStyles'; import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig'; import {IWidgetType, widgetTypes} from 'app/client/ui/widgetTypes'; import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; @@ -429,14 +430,7 @@ export class RightPanel extends Disposable { private _buildPageSortFilterConfig(owner: MultiHolder) { const viewConfigTab = this._createViewConfigTab(owner); - return [ - cssLabel(t('Sort')), - dom.maybe(viewConfigTab, (vct) => vct.buildSortDom()), - cssSeparator(), - - cssLabel(t('Filter')), - dom.maybe(viewConfigTab, (vct) => dom('div', vct._buildFilterDom())), - ]; + return dom.maybe(viewConfigTab, (vct) => vct.buildSortFilterDom()); } private _buildPageDataConfig(owner: MultiHolder, activeSection: ViewSectionRec) { @@ -635,13 +629,6 @@ const cssBottomText = styled('span', ` 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', ` color: ${theme.text}; display: flex; diff --git a/app/client/ui/RightPanelStyles.ts b/app/client/ui/RightPanelStyles.ts index 4e47f133..89e1e1bc 100644 --- a/app/client/ui/RightPanelStyles.ts +++ b/app/client/ui/RightPanelStyles.ts @@ -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', ` &, & * { cursor: not-allowed !important; @@ -51,3 +63,23 @@ export const cssSeparator = styled('div', ` border-bottom: 1px solid ${theme.pagePanelsBorder}; 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}; + } +`); diff --git a/app/client/ui/SortConfig.ts b/app/client/ui/SortConfig.ts new file mode 100644 index 00000000..2d235654 --- /dev/null +++ b/app/client/ui/SortConfig.ts @@ -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 = 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, + columns: Computed + ) { + 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) { + 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; +`); diff --git a/app/client/ui/SortFilterConfig.ts b/app/client/ui/SortFilterConfig.ts new file mode 100644 index 00000000..37f09796 --- /dev/null +++ b/app/client/ui/SortFilterConfig.ts @@ -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 = 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; +`); diff --git a/app/client/ui/ViewSectionMenu.ts b/app/client/ui/ViewSectionMenu.ts index f20b0daf..9a8c97e7 100644 --- a/app/client/ui/ViewSectionMenu.ts +++ b/app/client/ui/ViewSectionMenu.ts @@ -1,20 +1,18 @@ +import {GristDoc} from 'app/client/components/GristDoc'; import {makeT} from 'app/client/lib/localization'; import {reportError} from 'app/client/models/AppModel'; -import {ColumnRec, DocModel, ViewSectionRec} from 'app/client/models/DocModel'; -import {FilterInfo} from 'app/client/models/entities/ViewSectionRec'; -import {CustomComputed} from 'app/client/models/modelUtil'; -import {attachColumnFilterMenu} from 'app/client/ui/ColumnFilterMenu'; -import {addFilterMenu} from 'app/client/ui/FilterBar'; +import {DocModel, ViewSectionRec} from 'app/client/models/DocModel'; +import {FilterConfig} from 'app/client/ui/FilterConfig'; +import {cssLabel, cssSaveButtonsRow} from 'app/client/ui/RightPanelStyles'; import {hoverTooltip} from 'app/client/ui/tooltips'; +import {SortConfig} from 'app/client/ui/SortConfig'; import {makeViewLayoutMenu} from 'app/client/ui/ViewLayoutMenu'; import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; import {theme, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {menu} from 'app/client/ui2018/menus'; -import {Sort} from 'app/common/SortSpec'; -import {Computed, dom, fromKo, IDisposableOwner, makeTestId, Observable, styled} from 'grainjs'; -import {PopupControl} from 'popweasel'; -import difference = require('lodash/difference'); +import {Computed, dom, IDisposableOwner, makeTestId, styled} from 'grainjs'; +import {defaultMenuOptions} from 'popweasel'; const testId = makeTestId('test-section-menu-'); const t = makeT('ViewSectionMenu'); @@ -24,7 +22,6 @@ async function doSave(docModel: DocModel, viewSection: ViewSectionRec): Promise< await docModel.docData.bundleActions(t("UpdateSortFilterSettings"), () => Promise.all([ viewSection.activeSortJson.save(), // Save sort viewSection.saveFilters(), // Save filter - viewSection.activeFilterBar.save(), // Save bar viewSection.activeCustomOptions.save(), // Save widget options ])); } @@ -33,24 +30,24 @@ async function doSave(docModel: DocModel, viewSection: ViewSectionRec): Promise< function doRevert(viewSection: ViewSectionRec) { viewSection.activeSortJson.revert(); // Revert sort viewSection.revertFilters(); // Revert filter - viewSection.activeFilterBar.revert(); // Revert bar viewSection.activeCustomOptions.revert(); // Revert widget options } -// [Filter Icon] (v) (x) - Filter toggle and all the components in the menu. -export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, viewSection: ViewSectionRec, - isReadonly: Observable) { +// [Filter Icon] - Filter toggle and all the components in the menu. +export function viewSectionMenu( + owner: IDisposableOwner, + gristDoc: GristDoc, + viewSection: ViewSectionRec, +) { + const {docModel, isReadonly} = gristDoc; - const popupControls = new WeakMap(); + // If there is any filter (should [Filter Icon] background be filled). + const anyFilter = Computed.create(owner, (use) => Boolean(use(viewSection.activeFilters).length)); - // If there is any filter (should [Filter Icon] be green). - 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 = Computed.create(owner, (use) => ( use(viewSection.filterSpecChanged) || !use(viewSection.activeSortJson.isSaved) - || !use(viewSection.activeFilterBar.isSaved) || !use(viewSection.activeCustomOptions.isSaved) )); @@ -64,189 +61,111 @@ export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, vie testId('wrapper'), cssMenu( testId('sortAndFilter'), - // [Filter icon] grey or green + // [Filter icon] cssFilterIconWrapper( 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), cssFilterIcon('Filter'), hoverTooltip('Sort and filter', {key: 'sortFilterBtnTooltip'}), ), - menu(ctl => [ - // Sorted by section. - dom.domComputed(use => { - use(viewSection.activeSortJson.isSaved); // Rebuild sort panel if sort gets saved. A little hacky. - return makeSortPanel(viewSection, use(viewSection.activeSortSpec), - (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 - dom.maybe(use => use(viewSection.parentKey) === 'custom', () => - makeCustomOptions(viewSection) - ), - // [Save] [Revert] buttons - dom.domComputed(displaySaveObs, displaySave => [ - displaySave ? cssMenuInfoHeader( - cssSaveButton(t('Save'), testId('btn-save'), - dom.on('click', () => { save(); ctl.close(); }), - dom.boolAttr('disabled', isReadonly)), - basicButton(t('Revert'), testId('btn-revert'), - dom.on('click', () => { revert(); ctl.close(); })) - ) : null, - ]), - ]), ), - // Two icons (v) (x) left to the toggle, when there are unsaved filters or sort options. - // Those buttons are equivalent of the [Save] [Revert] buttons in the menu. - dom.maybe(displaySaveObs, () => cssSaveIconsWrapper( - // (v) - cssSmallIconWrapper( - cssIcon('Tick'), cssSmallIconWrapper.cls('-green'), + // [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), ), - // (x) - cssSmallIconWrapper( - cssIcon('CrossSmall'), cssSmallIconWrapper.cls('-gray'), + cssRevertIconButton( + cssRevertIcon('Revert', cssRevertIcon.cls('-normal')), dom.on('click', revert), hoverTooltip('Revert sort & filter settings', {key: 'sortFilterBtnTooltip'}), testId('small-btn-revert'), ), )), + menu(ctl => [ + // Sort section. + makeSortPanel(viewSection, gristDoc), + // Filter section. + makeFilterPanel(viewSection), + // Widget options + dom.maybe(use => use(viewSection.parentKey) === 'custom', () => + makeCustomOptions(viewSection) + ), + // [Save] [Revert] buttons + dom.domComputed(displaySaveObs, displaySave => [ + displaySave ? cssSaveButtonsRow( + cssSaveButton(t('Save'), testId('btn-save'), + dom.on('click', () => { ctl.close(); save(); }), + dom.boolAttr('disabled', isReadonly)), + basicButton(t('Revert'), testId('btn-revert'), + dom.on('click', () => { ctl.close(); revert(); })) + ) : 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 + // the menu growing beyond the boundaries of the viewport. To mitigate this, + // we subscribe to changes to the sort/filters and manually update the popup's + // position, which will re-position the popup if necessary so that it's fully + // visible. + dom.autoDispose(viewSection.activeFilters.addListener(() => ctl.update())), + dom.autoDispose(viewSection.activeSortJson.subscribe(() => ctl.update())), + ], {...defaultMenuOptions, placement: 'bottom-end', trigger: [ + // Toggle the menu whenever the filter icon button is clicked. + (el, ctl) => dom.onMatchElem(el, '.test-section-menu-sortAndFilter', 'click', () => { + ctl.toggle(); + }), + // Close the menu whenever the save or revert button is clicked. + (el, ctl) => dom.onMatchElem(el, '.test-section-menu-small-btn-save', 'click', () => { + ctl.close(); + }), + (el, ctl) => dom.onMatchElem(el, '.test-section-menu-small-btn-revert', 'click', () => { + ctl.close(); + }), + ]}), ), cssMenu( testId('viewLayout'), cssFixHeight.cls(''), 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') - ); - }); - +function makeSortPanel(section: ViewSectionRec, gristDoc: GristDoc) { return [ - cssMenuInfoHeader(t('SortedBy'), testId('heading-sorted')), - sortColumns.length > 0 ? sortColumns : cssGrayedMenuText('(Default)') + cssLabel(t('Sort'), testId('heading-sort')), + 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}, + }), ]; } -// [+] Add Filter. -export function makeAddFilterButton(viewSectionRec: ViewSectionRec, popupControls: WeakMap) { - 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) { - 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, - 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') - ); - }); - +function makeFilterPanel(section: ViewSectionRec) { return [ - cssMenuInfoHeader(t('FilteredBy'), {style: 'margin-top: 4px'}, testId('heading-filtered')), - activeFilters.length > 0 ? filters : cssGrayedMenuText('(Not filtered)') + 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 // (empty)|(customized)|(modified) [Remove Icon] 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 => { if (use(section.activeCustomOptions)) { return use(section.activeCustomOptions.isSaved) ? t("Customized") : t("Modified"); @@ -348,7 +267,7 @@ const cssIcon = styled(icon, ` background-color: ${theme.controlPrimaryFg}; } - &-green { + &-accent { background-color: ${theme.accentIcon}; } `); @@ -363,14 +282,18 @@ const cssDotsIconWrapper = styled(cssIconWrapper, ` const cssFilterIconWrapper = styled(cssIconWrapper, ` border-radius: 2px 0px 0px 2px; + &-any { + border-radius: 2px; + background-color: ${theme.controlSecondaryFg}; + } .${cssFilterMenuWrapper.className}-unsaved & { - background-color: ${theme.accentIcon}; + background-color: ${theme.controlPrimaryBg}; } `); const cssFilterIcon = styled(cssIcon, ` .${cssFilterIconWrapper.className}-any & { - background-color: ${theme.accentIcon}; + background-color: ${theme.controlPrimaryFg}; } .${cssFilterMenuWrapper.className}-unsaved & { background-color: ${theme.controlPrimaryFg}; @@ -390,51 +313,48 @@ const cssMenuText = styled('div', ` padding: 0px 24px 8px 24px; cursor: default; white-space: nowrap; - &-green { + &-accent { color: ${theme.accentText}; } - &-gray { + &-normal { color: ${theme.lightText}; } `); -const cssGrayedMenuText = styled(cssMenuText, ` - color: ${theme.lightText}; +const cssSaveButton = styled(primaryButton, ` + margin-right: 8px; `); -const cssMenuTextLabel = styled('span', ` - color: ${theme.menuItemFg}; - flex-grow: 1; - padding: 0 4px; - overflow: hidden; - text-overflow: ellipsis; +const cssSaveTextButton = styled('div', ` + display: flex; + align-items: center; + cursor: pointer; + font-size: ${vars.mediumFontSize}; + padding: 0px 5px; + border-right: 1px solid ${theme.accentBorder}; + + &-accent { + color: ${theme.accentText}; + } `); -const cssSaveButton = styled(primaryButton, ` - margin-right: 8px; +const cssRevertIconButton = styled('div', ` + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; `); -const cssSmallIconWrapper = styled('div', ` - width: 16px; - height: 16px; - border-radius: 8px; +const cssRevertIcon = styled(icon, ` + --icon-color: ${theme.accentIcon}; margin: 0 5px 0 5px; - - &-green { - background-color: ${theme.accentIcon}; - } - &-gray { - background-color: ${theme.lightText}; - } - & > .${cssIcon.className} { - background-color: ${theme.controlPrimaryFg}; - } `); -const cssSaveIconsWrapper = styled('div', ` +const cssSectionSaveButtonsWrapper = styled('div', ` padding: 0 1px 0 1px; display: flex; justify-content: space-between; + align-self: normal; `); const cssSpacer = styled('div', ` diff --git a/app/client/ui2018/IconList.ts b/app/client/ui2018/IconList.ts index b11c5b06..a9014e28 100644 --- a/app/client/ui2018/IconList.ts +++ b/app/client/ui2018/IconList.ts @@ -90,6 +90,7 @@ export type IconName = "ChartArea" | "Pencil" | "PinBig" | "PinSmall" | + "PinTilted" | "Pivot" | "PivotLight" | "Plus" | @@ -101,6 +102,7 @@ export type IconName = "ChartArea" | "Remove" | "Repl" | "ResizePanel" | + "Revert" | "RightAlign" | "Script" | "Search" | @@ -220,6 +222,7 @@ export const IconList: IconName[] = ["ChartArea", "Pencil", "PinBig", "PinSmall", + "PinTilted", "Pivot", "PivotLight", "Plus", @@ -231,6 +234,7 @@ export const IconList: IconName[] = ["ChartArea", "Remove", "Repl", "ResizePanel", + "Revert", "RightAlign", "Script", "Search", diff --git a/app/client/ui2018/buttons.ts b/app/client/ui2018/buttons.ts index 6c4dd985..83378ac2 100644 --- a/app/client/ui2018/buttons.ts +++ b/app/client/ui2018/buttons.ts @@ -22,6 +22,7 @@ export const cssButton = styled('button', ` outline: none; border-style: none; line-height: normal; + user-select: none; /* Vars */ font-size: ${vars.mediumFontSize}; diff --git a/app/common/schema.ts b/app/common/schema.ts index d4564fcc..6142e47d 100644 --- a/app/common/schema.ts +++ b/app/common/schema.ts @@ -4,7 +4,7 @@ import { GristObjCode } from "app/plugin/GristData"; // tslint:disable:object-literal-key-quotes -export const SCHEMA_VERSION = 33; +export const SCHEMA_VERSION = 34; export const schema = { @@ -194,6 +194,7 @@ export const schema = { viewSectionRef : "Ref:_grist_Views_section", colRef : "Ref:_grist_Tables_column", filter : "Text", + pinned : "Bool", }, "_grist_Cells": { @@ -397,6 +398,7 @@ export interface SchemaTypes { viewSectionRef: number; colRef: number; filter: string; + pinned: boolean; }; "_grist_Cells": { diff --git a/app/server/lib/initialDocSql.ts b/app/server/lib/initialDocSql.ts index 8c43cb89..8f4ce689 100644 --- a/app/server/lib/initialDocSql.ts +++ b/app/server/lib/initialDocSql.ts @@ -6,7 +6,7 @@ export const GRIST_DOC_SQL = ` PRAGMA foreign_keys=OFF; 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 ''); -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_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); @@ -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(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_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 INDEX _grist_Attachments_fileIdent ON _grist_Attachments(fileIdent); COMMIT; @@ -43,7 +43,7 @@ export const GRIST_DOC_WITH_TABLE1_SQL = ` PRAGMA foreign_keys=OFF; 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 ''); -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); 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); @@ -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(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_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 "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); diff --git a/sandbox/grist/migrations.py b/sandbox/grist/migrations.py index 99842769..9fc6f025 100644 --- a/sandbox/grist/migrations.py +++ b/sandbox/grist/migrations.py @@ -1112,3 +1112,45 @@ def migration33(tdset): ] 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) diff --git a/sandbox/grist/schema.py b/sandbox/grist/schema.py index 682f561d..2ab9a460 100644 --- a/sandbox/grist/schema.py +++ b/sandbox/grist/schema.py @@ -15,7 +15,7 @@ import six import actions -SCHEMA_VERSION = 33 +SCHEMA_VERSION = 34 def make_column(col_id, col_type, formula='', isFormula=False): return { @@ -315,7 +315,10 @@ def schema_create_actions(): # `excluded` string to an array of column values: # Ex1: { included: ['foo', 'bar'] } # 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 diff --git a/sandbox/grist/test_useractions.py b/sandbox/grist/test_useractions.py index 236bc7d7..19f3710f 100644 --- a/sandbox/grist/test_useractions.py +++ b/sandbox/grist/test_useractions.py @@ -954,14 +954,16 @@ class TestUserActions(test_engine.EngineTestCase): self.apply_user_action(['BulkAddRecord', '_grist_Filters', [None], { "viewSectionRef": [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) self.apply_user_action(['BulkAddRecord', '_grist_Filters', [None], { "viewSectionRef": [1], "colRef": [2], - "filter": [json.dumps({"included": ["b", "c"]})] + "filter": [json.dumps({"included": ["b", "c"]})], + "pinned": [False], }]) # Rename choices @@ -971,9 +973,9 @@ class TestUserActions(test_engine.EngineTestCase): # Test filters self.assertTableData('_grist_Filters', data=[ - ["id", "colRef", "filter", "setAutoRemove", "viewSectionRef"], - [1, 1, json.dumps({"included": ["z", "b"]}), None, 1], - [2, 2, json.dumps({"included": ["b", "c"]}), None, 1] + ["id", "colRef", "filter", "setAutoRemove", "viewSectionRef", "pinned"], + [1, 1, json.dumps({"included": ["z", "b"]}), None, 1, True], + [2, 2, json.dumps({"included": ["b", "c"]}), None, 1, False] ]) def test_add_or_update(self): diff --git a/static/icons/icons.css b/static/icons/icons.css index f91caa6f..447c9dfa 100644 --- a/static/icons/icons.css +++ b/static/icons/icons.css @@ -91,6 +91,7 @@ --icon-Pencil: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9Ii0yIC0yIDIwIDIwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxnIGNsYXNzPSJuYy1pY29uLXdyYXBwZXIiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzIxMjEyMSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xMyAuNUwxNS41IDMgNy41IDExIDQgMTIgNSA4LjV6TTExIDIuNUwxMy41IDUiLz48cGF0aCBkPSJNMTMuNSw5LjV2NSBjMCwwLjU1Mi0wLjQ0OCwxLTEsMWgtMTFjLTAuNTUyLDAtMS0wLjQ0OC0xLTF2LTExYzAtMC41NTIsMC40NDgtMSwxLTFoNSIvPjwvZz48L3N2Zz4='); --icon-PinBig: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjgyOTcyODEsLTcuNjAyODA3MjdlLTEzIEw0LjE5Mjk3NDkxLC03LjU3NjE2MTkyZS0xMyBDMy42MjcyODk0OCwtNy41MzE3NTNlLTEzIDMuMjAzMDI1NDEsMC40MTAyNTY0MSAzLjIwMzAyNTQxLDAuOTU3MjY0OTU3IEMzLjIwMzAyNTQxLDEuNTA0MjczNSAzLjYyNzI4OTQ4LDEuOTE0NTI5OTEgNC4xOTI5NzQ5MSwxLjkxNDUyOTkxIEw1LjA0MTUwMzA0LDEuOTE0NTI5OTEgTDIuODQ5NDcyMDIsOS4wOTQwMTcwOSBMMS40MzUyNTg0Niw5LjA5NDAxNzA5IEMwLjg2OTU3MzAzNiw5LjA5NDAxNzA5IDAuNDQ1MzA4OTY3LDkuNTA0MjczNSAwLjQ0NTMwODk2NywxMC4wNTEyODIxIEMwLjQ0NTMwODk2NywxMC41OTgyOTA2IDAuODY5NTczMDM2LDExLjAwODU0NyAxLjQzNTI1ODQ2LDExLjAwODU0NyBMMy41NTY1Nzg4LDExLjAwODU0NyBMNi45NTA2OTEzNSwxMS4wMDg1NDcgTDcuMDIxNDAyMDMsMTYgTDkuMDAxMzAxMDIsMTYgTDkuMDAxMzAxMDIsMTAuOTQwMTcwOSBMMTQuNjU4MTU1MywxMC45NDAxNzA5IEMxNS4zNjUyNjIxLDEwLjk0MDE3MDkgMTUuNjQ4MTA0OCwxMC4yNTY0MTAzIDE1LjY0ODEwNDgsOS45ODI5MDU5OCBDMTUuNjQ4MTA0OCw5LjQzNTg5NzQ0IDE1LjIyMzg0MDcsOS4wMjU2NDEwMyAxNC42NTgxNTUzLDkuMDI1NjQxMDMgTDEzLjI0Mzk0MTcsOS4wMjU2NDEwMyBMMTEuMTIyNjIxNCwxLjkxNDUyOTkxIEwxMS45NzExNDk1LDEuOTE0NTI5OTEgQzEyLjgxOTY3NzYsMS45MTQ1Mjk5MSAxMi45NjEwOTksMS4yMzA3NjkyMyAxMi45NjEwOTksMC45NTcyNjQ5NTcgQzEyLjg5MDM4ODMsMC40Nzg2MzI0NzkgMTIuMzk1NDEzNiwtNy42NjQ5Nzk3NmUtMTMgMTEuODI5NzI4MSwtNy42MDI4MDcyN2UtMTMgWiBNMTEuMDUxOTEwNyw5LjA5NDAxNzA5IEw0Ljk3MDc5MjM3LDkuMDk0MDE3MDkgTDcuMDkyMTEyNzEsMS45ODI5MDU5OCBMOC45MzA1OTAzNCwxLjk4MjkwNTk4IEwxMS4wNTE5MTA3LDkuMDk0MDE3MDkgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+'); --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-PivotLight: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cmVjdCBzdHJva2U9IiM5Nzk3OTciIHg9Ii41IiB5PSIuNSIgd2lkdGg9IjE1IiBoZWlnaHQ9IjE1IiByeD0iMiIvPjxwYXRoIGZpbGw9IiM5Nzk3OTciIGQ9Ik01LjM3NSA0LjVMMTAuNjI1IDQuNSAxMC42MjUgNi4wOTUzMTcwNyA5Ljc3NDMxMjEzIDQuOTg3MzUzNjYgNi45MTk3MzE2MSA0Ljk5MDg1MzY2IDguOTAyODMzIDcuNjE1ODUzNjYgNi45MTk3MzE2MSAxMC40MTY5NjM0IDYuOTE5NzMxNjEgMTAuNzI2NDE0NiA5LjgyNjQ5OTAxIDEwLjcyNjQxNDYgMTAuNjI1IDEwLjAzMjgxNzEgMTAuNjI1IDExLjUgNS4zNzUgMTEuNSA1LjM3NSAxMS4wOTQ1MTIyIDcuNjM0NjUwMSA4LjAyMjcwNzMyIDUuMzc1IDQuNzg1NDYzNDF6Ii8+PC9nPjwvc3ZnPg=='); --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-Repl: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE0LjI5Mjg5MzIsMTIgTDEyLjE0NjQ0NjYsOS44NTM1NTMzOSBDMTEuOTUxMTg0NSw5LjY1ODI5MTI0IDExLjk1MTE4NDUsOS4zNDE3MDg3NiAxMi4xNDY0NDY2LDkuMTQ2NDQ2NjEgQzEyLjM0MTcwODgsOC45NTExODQ0NiAxMi42NTgyOTEyLDguOTUxMTg0NDYgMTIuODUzNTUzNCw5LjE0NjQ0NjYxIEwxNS44NTM1NTM0LDEyLjE0NjQ0NjYgQzE2LjA0ODgxNTUsMTIuMzQxNzA4OCAxNi4wNDg4MTU1LDEyLjY1ODI5MTIgMTUuODUzNTUzNCwxMi44NTM1NTM0IEwxMi44NTM1NTM0LDE1Ljg1MzU1MzQgQzEyLjY1ODI5MTIsMTYuMDQ4ODE1NSAxMi4zNDE3MDg4LDE2LjA0ODgxNTUgMTIuMTQ2NDQ2NiwxNS44NTM1NTM0IEMxMS45NTExODQ1LDE1LjY1ODI5MTIgMTEuOTUxMTg0NSwxNS4zNDE3MDg4IDEyLjE0NjQ0NjYsMTUuMTQ2NDQ2NiBMMTQuMjkyODkzMiwxMyBMMTIuMjk3LDEzIEMxMS4wMTE0MDE0LDEzIDkuNzg3MzEwNDYsMTIuNDUwMDg5NiA4LjkzMzM4NDQzLDExLjQ4OTI4MjEgTDcuOTA0Mzg0NDMsMTAuMzMyMjgyMSBDNy43MjA4NzAwOSwxMC4xMjU5Mzk5IDcuNzM5Mzc1Nyw5LjgwOTg5ODc4IDcuOTQ1NzE3ODgsOS42MjYzODQ0MyBDOC4xNTIwNjAwNiw5LjQ0Mjg3MDA5IDguNDY4MTAxMjIsOS40NjEzNzU3IDguNjUxNjE1NTcsOS42Njc3MTc4OCBMOS42ODA3Mjg4LDEwLjgyNDg0NTIgQzEwLjM0NTAyNTUsMTEuNTcyMjg4NCAxMS4yOTcxMDQsMTIgMTIuMjk3LDEyIEwxNC4yOTI4OTMyLDEyIFogTTAuNSw0IEMwLjIyMzg1NzYyNSw0IC01LjE5NTg0Mzc2ZS0xNCwzLjc3NjE0MjM3IC01LjE5NTg0Mzc2ZS0xNCwzLjUgQy01LjE5NTg0Mzc2ZS0xNCwzLjIyMzg1NzYzIDAuMjIzODU3NjI1LDMgMC41LDMgTDAuNzA0LDMgQzEuOTg5NTk4NTUsMyAzLjIxMzY4OTU0LDMuNTQ5OTEwNDIgNC4wNjc3NzU5NSw0LjUxMDg5ODMgTDUuMDk1Nzc1OTUsNS42Njc4OTgzIEM1LjI3OTE5MDY1LDUuODc0MzI5MDUgNS4yNjA1MzI0Niw2LjE5MDM2MTI0IDUuMDU0MTAxNyw2LjM3Mzc3NTk1IEM0Ljg0NzY3MDk1LDYuNTU3MTkwNjUgNC41MzE2Mzg3Niw2LjUzODUzMjQ2IDQuMzQ4MjI0MDUsNi4zMzIxMDE3IEwzLjMyMDI3MTIsNS4xNzUxNTQ3NiBDMi42NTU5NzQ1Miw0LjQyNzcxMTU5IDEuNzAzODk1OTksNCAwLjcwNCw0IEwwLjUsNCBaIE0xNC4yOTE4OTMyLDIuOTk5IEwxMi4xNDY0NDY2LDAuODUzNTUzMzkxIEMxMS45NTExODQ1LDAuNjU4MjkxMjQ1IDExLjk1MTE4NDUsMC4zNDE3MDg3NTUgMTIuMTQ2NDQ2NiwwLjE0NjQ0NjYwOSBDMTIuMzQxNzA4OCwtMC4wNDg4MTU1MzY1IDEyLjY1ODI5MTIsLTAuMDQ4ODE1NTM2NSAxMi44NTM1NTM0LDAuMTQ2NDQ2NjA5IEwxNS44MzQxOTg2LDMuMTI3MDkxODMgQzE1LjkzMTE4OSwzLjIxNDMwNTE3IDE1Ljk5Mzg4MDQsMy4zMzg5MTI3OCAxNS45OTk1NzU5LDMuNDc4MjE3NzQgQzE1Ljk5OTgzNzEsMy40ODUzNzc4MyAxNS45OTk5OTI4LDMuNDkyNDM5ODEgMTUuOTk5OTk5OCwzLjQ5OTUwMTkyIEMxNS45OTk5ODg0LDMuNTExMDYxMTMgMTUuOTk5NTg0OCwzLjUyMjUyODQ5IDE1Ljk5ODgwMTUsMy41MzM4OTE0MyBDMTUuOTkwOTk0NCwzLjY1MDMzMjU2IDE1Ljk0MjU1OTgsMy43NjQ1NDcwMiAxNS44NTM1NTM0LDMuODUzNTUzMzkgTDEyLjg1MzU1MzQsNi44NTM1NTMzOSBDMTIuNjU4MjkxMiw3LjA0ODgxNTU0IDEyLjM0MTcwODgsNy4wNDg4MTU1NCAxMi4xNDY0NDY2LDYuODUzNTUzMzkgQzExLjk1MTE4NDUsNi42NTgyOTEyNCAxMS45NTExODQ1LDYuMzQxNzA4NzYgMTIuMTQ2NDQ2Niw2LjE0NjQ0NjYxIEwxNC4yOTM4OTMyLDMuOTk5IEwxMi4yOTcsMy45OTkgQzExLjI5NzEwNCwzLjk5OSAxMC4zNDUwMjU1LDQuNDI2NzExNTkgOS42ODA3MTQ0NSw1LjE3NDE3MDkgTDQuMDY3NzI4OCwxMS40ODkxNTQ4IEMzLjIxMzY4OTU0LDEyLjQ1MDA4OTYgMS45ODk1OTg1NSwxMyAwLjcwNCwxMyBMMC41LDEzIEMwLjIyMzg1NzYyNSwxMyAtMi4yNjQ4NTQ5N2UtMTMsMTIuNzc2MTQyNCAtMi4yNjQ4NTQ5N2UtMTMsMTIuNSBDLTIuMjY0ODU0OTdlLTEzLDEyLjIyMzg1NzYgMC4yMjM4NTc2MjUsMTIgMC41LDEyIEwwLjcwNCwxMiBDMS43MDM4OTU5OSwxMiAyLjY1NTk3NDUyLDExLjU3MjI4ODQgMy4zMjAyODU1NSwxMC44MjQ4MjkxIEw4LjkzMzI3MTIsNC41MDk4NDUyNCBDOS43ODczMTA0NiwzLjU0ODkxMDQyIDExLjAxMTQwMTQsMi45OTkgMTIuMjk3LDIuOTk5IEwxNC4yOTE4OTMyLDIuOTk5IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --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-Script: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0iZmVhdGhlciBmZWF0aGVyLWRhdGFiYXNlIj48cmVjdCB3aWR0aD0iMTkuMzI4IiBoZWlnaHQ9IjE5LjMyOCIgeD0iMi4zMzYiIHk9IjIuMzM2IiByeT0iNC4wMjYiIHJ4PSIzLjc0NSIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJidXR0Ii8+PGcgc3Ryb2tlLXdpZHRoPSIxLjU0MyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0iYnV0dCIgc3Ryb2tlLWxpbmVqb2luPSJtaXRlciI+PHBhdGggZD0iTTE4LjczNDQ0OSA5LjAyMTI1NTZMNS4yNjU1NTEyIDkuMDA5NTM2OE0xOC43MzQ0NDkgMTQuNzM4NTg2TDUuMjY1NTUxMiAxNC43MjY4NjciIHRyYW5zZm9ybT0ibWF0cml4KC45Mzk2OSAwIDAgMS4wMDUzMSAuNzI0IC0uMDYzKSIvPjwvZz48L3N2Zz4='); --icon-Search: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjQzNjIxMjcsMTAuNzI5MTA1OSBMMTQuODUzNTUzNCwxNC4xNDY0NDY2IEMxNS4wNDg4MTU1LDE0LjM0MTcwODggMTUuMDQ4ODE1NSwxNC42NTgyOTEyIDE0Ljg1MzU1MzQsMTQuODUzNTUzNCBDMTQuNjU4MjkxMiwxNS4wNDg4MTU1IDE0LjM0MTcwODgsMTUuMDQ4ODE1NSAxNC4xNDY0NDY2LDE0Ljg1MzU1MzQgTDEwLjcyOTEwNTksMTEuNDM2MjEyNyBDOS41OTIzMzg0OCwxMi40MTEwNDg3IDguMTE0OTQ3NzEsMTMgNi41LDEzIEMyLjkxMDE0OTEzLDEzIDAsMTAuMDg5ODUwOSAwLDYuNSBDMCwyLjkxMDE0OTEzIDIuOTEwMTQ5MTMsMCA2LjUsMCBDMTAuMDg5ODUwOSwwIDEzLDIuOTEwMTQ5MTMgMTMsNi41IEMxMyw4LjExNDk0NzcxIDEyLjQxMTA0ODcsOS41OTIzMzg0OCAxMS40MzYyMTI3LDEwLjcyOTEwNTkgWiBNMTAuNDA5NTc0NywxMC4zNjg0OTIxIEMxMS4zOTI4MzI1LDkuMzc0ODU3OCAxMiw4LjAwODMzNDY4IDEyLDYuNSBDMTIsMy40NjI0MzM4OCA5LjUzNzU2NjEyLDEgNi41LDEgQzMuNDYyNDMzODgsMSAxLDMuNDYyNDMzODggMSw2LjUgQzEsOS41Mzc1NjYxMiAzLjQ2MjQzMzg4LDEyIDYuNSwxMiBDOC4wMDgzMzQ2OCwxMiA5LjM3NDg1NzgsMTEuMzkyODMyNSAxMC4zNjg0OTIxLDEwLjQwOTU3NDcgQzEwLjM3NDkwMDEsMTAuNDAyMzg3OSAxMC4zODE1NTE2LDEwLjM5NTM0MTYgMTAuMzg4NDQ2NiwxMC4zODg0NDY2IEMxMC4zOTUzNDE2LDEwLjM4MTU1MTYgMTAuNDAyMzg3OSwxMC4zNzQ5MDAxIDEwLjQwOTU3NDcsMTAuMzY4NDkyMSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4='); diff --git a/static/locales/en.client.json b/static/locales/en.client.json index b2bd8434..e8d5e2ab 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -240,8 +240,8 @@ "Settings_savecommon": "Save as common settings", "Settings_revertcommon": "Revert to common settings" }, - "FilterBar": { - "AddFilter": "Add Filter" + "FilterConfig":{ + "AddColumn": "Add Column" }, "GridOptions": { "GridOptions": "Grid Options", @@ -469,6 +469,20 @@ "SwitchSites":"Switch Sites", "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": { "Appearance": "Appearance ", "SyncWithOS": "Switch appearance automatically to match system" @@ -533,7 +547,9 @@ "Customized":"(customized)", "Modified":"(modified)", "Empty":"(empty)", - "CustomOptions":"Custom options" + "CustomOptions":"Custom options", + "Sort": "SORT", + "Filter": "FILTER" }, "aclui": { "AccessRules": { @@ -687,13 +703,6 @@ "Rows": "Rows" }, "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?", "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.", @@ -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.", "AdvancedSettings": "Advanced settings", "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", "Compact": "Compact", "Blocks": "Blocks", diff --git a/static/locales/fr.client.json b/static/locales/fr.client.json index 23d0e9ae..e59e5f5c 100644 --- a/static/locales/fr.client.json +++ b/static/locales/fr.client.json @@ -238,8 +238,8 @@ "Settings_savecommon": "Save common settings", "Settings_revertcommon": "Revert common settings" }, - "FilterBar": { - "AddFilter": "Ajouter un filtre" + "FilterConfig": { + "AddColumn": "Ajouter une colonne" }, "GridOptions": { "GridOptions": "Options de la grille", @@ -466,6 +466,20 @@ "SwitchSites": "Changer d’espace", "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": { "Appearance": "Apparence ", "SyncWithOS": "Adapter l'apparence au système" @@ -530,7 +544,9 @@ "Customized": "(personnalisé)", "Modified": "(modifié)", "Empty": "(vide)", - "CustomOptions": "Options personnalisées" + "CustomOptions": "Options personnalisées", + "Sort": "TRI", + "Filter": "FILTRE" }, "aclui": { "AccessRules": { @@ -684,13 +700,6 @@ "Rows": "Lignes" }, "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?", "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.", @@ -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.", "AdvancedSettings": "Paramètres avancés", "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", "Compact": "Compact", "Blocks": "Blocs", diff --git a/static/ui-icons/UI/PinTilted.svg b/static/ui-icons/UI/PinTilted.svg new file mode 100644 index 00000000..134f814d --- /dev/null +++ b/static/ui-icons/UI/PinTilted.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/static/ui-icons/UI/Revert.svg b/static/ui-icons/UI/Revert.svg new file mode 100644 index 00000000..cd79d2e8 --- /dev/null +++ b/static/ui-icons/UI/Revert.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/client/models/ColumnFilter.ts b/test/client/models/ColumnFilter.ts index e96d3605..6d401ecc 100644 --- a/test/client/models/ColumnFilter.ts +++ b/test/client/models/ColumnFilter.ts @@ -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 {CellValue} from 'app/common/DocActions'; import {assert} from 'chai'; @@ -57,10 +57,10 @@ describe('ColumnFilter', function() { 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 defaultJson = filter.makeFilterJson(); - assert.equal(defaultJson, allInclusive); + assert.equal(defaultJson, ALL_INCLUSIVE_FILTER_JSON); filter.clear(); assert.equal(filter.makeFilterJson(), '{"included":[]}'); @@ -69,7 +69,10 @@ describe('ColumnFilter', function() { assert.equal(filter.makeFilterJson(), defaultJson); // 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() { diff --git a/test/fixtures/docs/Hello.grist b/test/fixtures/docs/Hello.grist index 63c17756..ff0579d6 100644 Binary files a/test/fixtures/docs/Hello.grist and b/test/fixtures/docs/Hello.grist differ diff --git a/test/fixtures/docs/World-v33.grist b/test/fixtures/docs/World-v33.grist new file mode 100644 index 00000000..0c648de2 Binary files /dev/null and b/test/fixtures/docs/World-v33.grist differ diff --git a/test/nbrowser/ReferenceList.ts b/test/nbrowser/ReferenceList.ts index 4ab1e1e6..ce5ce710 100644 --- a/test/nbrowser/ReferenceList.ts +++ b/test/nbrowser/ReferenceList.ts @@ -345,9 +345,8 @@ describe('ReferenceList', function() { await driver.find('.test-config-sortAndFilter').click(); // Sort the Favorite Film column. - await driver.find('.test-vconfigtab-sort-add').click(); - await driver.findContent('.test-vconfigtab-sort-add-menu-row', /Favorite_Film/).click(); - await driver.find('.test-vconfigtab-sort-save').click(); + await gu.addColumnToSort('Favorite Film'); + await gu.saveSortConfig(); // Check that the records are sorted by display value. assert.deepEqual( diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index d010defa..c83278e4 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -1103,6 +1103,13 @@ export async function selectWidget( 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 * -selected when selected. @@ -1329,6 +1336,14 @@ export async function openWidgetPanel() { 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. * Needs a visible Creator panel. @@ -1448,25 +1463,6 @@ export async function closeRawTable() { 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. */ @@ -1521,13 +1517,17 @@ export async function deleteColumn(col: IColHeader|string) { /** * 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 driver.find('.test-right-tab-field').click(); await driver.find('.test-fbuilder-type-select').click(); type = typeof type === 'string' ? exactMatch(type) : type; 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. export async function addColumnToSort(colName: RegExp|string) { - await driver.find(".test-vconfigtab-sort-add").click(); - await driver.findContent(".test-vconfigtab-sort-add-menu-row", colName).click(); - await driver.findContentWait(".test-vconfigtab-sort-row", colName, 100); + await driver.find(".test-sort-config-add").click(); + await driver.findContent(".test-sort-config-add-menu-row", colName).click(); + await driver.findContentWait(".test-sort-config-row", colName, 100); } // Remove column from sort. 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. export async function toggleSortOrder(colName: RegExp|string) { - await findSortRow(colName).find(".test-vconfigtab-sort-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(); + await findSortRow(colName).find(".test-sort-config-order").click(); } // Reset the sort to the last saved sort. export async function revertSortConfig() { - await driver.find(".test-vconfigtab-sort-reset").click(); + await driver.find(".test-sort-filter-config-revert").click(); } // Save the sort. export async function saveSortConfig() { - await driver.find(".test-vconfigtab-sort-save").click(); + await driver.find(".test-sort-filter-config-save").click(); await waitForServer(); } // Update the data positions to the given sort. export async function updateRowsBySort() { - await driver.find(".test-vconfigtab-sort-update").click(); + await driver.find(".test-sort-config-update").click(); await waitForServer(10000); } // Returns a WebElementPromise for the sort row of the given col name. 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 export async function openMoreSortOptions(colName: RegExp|string) { 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. 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 waitForServer(); } @@ -2572,7 +2566,7 @@ export const SortOptions: ReadonlyArray = ["orderByChoice", "emptyLa export async function getSortOptions(): Promise { const options: SortOption[] = []; 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) { options.push(option); } @@ -2585,7 +2579,7 @@ export async function getSortOptions(): Promise { export async function getEnabledOptions(): Promise { const options: SortOption[] = []; 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) { options.push(option); } @@ -2647,6 +2641,48 @@ export async function filterBy(col: IColHeader|string, save: boolean, values: (s 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 { + 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 { + 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). */