From 2b1b586ecd0ccaaa3ada70c0027153659d5b41ee Mon Sep 17 00:00:00 2001 From: Cyprien P Date: Wed, 14 Apr 2021 17:17:45 +0200 Subject: [PATCH] (core) add new filter bar Summary: - add new filterBar option to views section - add toggle to the section menu - add filter bar - shows Save/Revert btn when unsaved change - shows all filered fields witch edit and delete buttons Test Plan: Add new FilterBar nbrowser test Reviewers: paulfitz, dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2769 --- app/client/components/ViewLayout.ts | 2 + app/client/models/entities/ViewSectionRec.ts | 4 + app/client/ui/ColumnFilterMenu.ts | 24 ++++- app/client/ui/FilterBar.ts | 102 +++++++++++++++++++ app/client/ui/ViewSectionMenu.ts | 73 ++++++++----- 5 files changed, 179 insertions(+), 26 deletions(-) create mode 100644 app/client/ui/FilterBar.ts diff --git a/app/client/components/ViewLayout.ts b/app/client/components/ViewLayout.ts index 793f8ed3..0b23bf00 100644 --- a/app/client/components/ViewLayout.ts +++ b/app/client/components/ViewLayout.ts @@ -12,6 +12,7 @@ import {Delay} from 'app/client/lib/Delay'; import {createObsArray} from 'app/client/lib/koArrayWrap'; import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel'; import {reportError} from 'app/client/models/errors'; +import {filterBar} from 'app/client/ui/FilterBar'; import {viewSectionMenu} from 'app/client/ui/ViewSectionMenu'; import {colors, mediaSmall, testId} from 'app/client/ui2018/cssVars'; import {editableLabel} from 'app/client/ui2018/editableLabel'; @@ -202,6 +203,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { viewSectionMenu(this.docModel, vs, this.viewModel, this.gristDoc.isReadonly, this.gristDoc.app.useNewUI) ) )), + dom.maybe(vs.activeFilterBar, () => dom.create(filterBar, vs)), dom.maybe(vs.viewInstance, (viewInstance) => dom('div.view_data_pane_container.flexvbox', cssResizing.cls('', this._isResizing), diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts index f39269c9..4922064d 100644 --- a/app/client/models/entities/ViewSectionRec.ts +++ b/app/client/models/entities/ViewSectionRec.ts @@ -88,6 +88,7 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> { isSorted: ko.Computed; disableDragRows: ko.Computed; + activeFilterBar: modelUtil.CustomComputed; // Save all filters of fields in the section. saveFilters(): Promise; @@ -131,6 +132,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): horizontalGridlines: true, zebraStripes: false, customView: '', + filterBar: false, }; this.optionsObj = modelUtil.jsonObservable(this.options, (obj: any) => defaults(obj || {}, defaultOptions)); @@ -272,4 +274,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')); } diff --git a/app/client/ui/ColumnFilterMenu.ts b/app/client/ui/ColumnFilterMenu.ts index c4b2f057..8b8265f6 100644 --- a/app/client/ui/ColumnFilterMenu.ts +++ b/app/client/ui/ColumnFilterMenu.ts @@ -5,7 +5,7 @@ */ import {allInclusive, ColumnFilter, isEquivalentFilter} from 'app/client/models/ColumnFilter'; -import {ViewFieldRec} from 'app/client/models/DocModel'; +import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel'; import {FilteredRowSource} from 'app/client/models/rowset'; import {SectionFilter} from 'app/client/models/SectionFilter'; import {TableData} from 'app/client/models/TableData'; @@ -15,9 +15,9 @@ import {colors, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {menuCssClass, menuDivider} from 'app/client/ui2018/menus'; import {CellValue} from 'app/common/DocActions'; -import {Computed, Disposable, dom, IDisposableOwner, input, makeTestId, styled} from 'grainjs'; +import {Computed, Disposable, dom, DomElementMethod, IDisposableOwner, input, makeTestId, styled} from 'grainjs'; import identity = require('lodash/identity'); -import {IOpenController} from 'popweasel'; +import {IOpenController, IPopupOptions, setPopupToCreateDom} from 'popweasel'; import {ColumnFilterMenuModel, IFilterCount} from '../models/ColumnFilterMenuModel'; @@ -313,6 +313,24 @@ function getCount(values: Array<[CellValue, IFilterCount]>) { return values.reduce((acc, val) => acc + val[1].count, 0); } +const defaultPopupOptions: IPopupOptions = { + placement: 'bottom-start', + boundaries: 'viewport', + trigger: ['click'], +}; + +// Helper to attach the column filter menu. +export function attachColumnFilterMenu(viewSection: ViewSectionRec, field: ViewFieldRec, + popupOptions: IPopupOptions): DomElementMethod { + const options = {...defaultPopupOptions, ...popupOptions}; + return (elem) => { + const instance = viewSection.viewInstance(); + if (instance && instance.createFilterMenu) { // Should be set if using BaseView + setPopupToCreateDom(elem, ctl => instance.createFilterMenu(ctl, field), options); + } + }; +} + const cssMenu = styled('div', ` display: flex; flex-direction: column; diff --git a/app/client/ui/FilterBar.ts b/app/client/ui/FilterBar.ts new file mode 100644 index 00000000..2737a5cf --- /dev/null +++ b/app/client/ui/FilterBar.ts @@ -0,0 +1,102 @@ +import { ViewFieldRec, ViewSectionRec } from "app/client/models/DocModel"; +import { attachColumnFilterMenu } from "app/client/ui/ColumnFilterMenu"; +import { cssButton, cssButtonGroup } from "app/client/ui2018/buttons"; +import { colors, testId } from "app/client/ui2018/cssVars"; +import { icon } from "app/client/ui2018/icons"; +import { dom, IDisposableOwner, IDomArgs, styled } from "grainjs"; + +export function filterBar(_owner: IDisposableOwner, viewSection: ViewSectionRec) { + return cssFilterBar( + testId('filter-bar'), + dom.forEach(viewSection.filteredFields, (field) => makeFilterField(viewSection, field)), + cssSpacer(), + dom.maybe(viewSection.filterSpecChanged, () => [ + primaryButton( + 'Save', testId('btn'), + dom.on('click', async () => await viewSection.saveFilters()), + ), + basicButton( + 'Revert', testId('btn'), + dom.on('click', () => viewSection.revertFilters()), + ) + ]) + ); +} + +function makeFilterField(viewSection: ViewSectionRec, field: ViewFieldRec) { + return cssFilterBarItem( + testId('filter-field'), + primaryButton( + testId('btn'), + cssIcon('FilterSimple'), + cssMenuTextLabel(dom.text(field.label)), + cssBtn.cls('-disabled', field.activeFilter.isSaved), + attachColumnFilterMenu(viewSection, field, {placement: 'bottom-start', attach: 'body'}), + ), + deleteButton( + testId('delete'), + cssIcon('CrossSmall'), + cssBtn.cls('-disabled', field.activeFilter.isSaved), + dom.on('click', () => field.activeFilter('')), + ) + ); +} + +const cssFilterBar = styled('div', ` + display: flex; + flex-direction: row; + margin-bottom: 8px; + margin-left: -4px; + overflow-x: scroll; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +`); +const cssFilterBarItem = styled(cssButtonGroup, ` + flex-shrink: 0; + margin: 0 4px; + & > .${cssButton.className}:first-child { + border-right: 0.5px solid white; + } +`); +const cssMenuTextLabel = styled('span', ` + flex-grow: 1; + padding: 0 4px; + overflow: hidden; + text-overflow: ellipsis; +`); +const cssIcon = styled(icon, ` + margin-top: -3px; +`); +const cssBtn = styled('div', ` + height: 24px; + padding: 3px 8px; + .${cssFilterBar.className} > & { + margin: 0 4px; + } + &-disabled { + color: ${colors.light}; + --icon-color: ${colors.light}; + background-color: ${colors.slate}; + border-color: ${colors.slate}; + } + &-disabled:hover { + background-color: ${colors.darkGrey}; + border-color: ${colors.darkGrey}; + } +`); +const primaryButton = (...args: IDomArgs) => ( + dom('div', cssButton.cls(''), cssButton.cls('-primary'), + cssBtn.cls(''), ...args) +); +const basicButton = (...args: IDomArgs) => ( + dom('div', cssButton.cls(''), cssBtn.cls(''), ...args) +); +const deleteButton = styled(primaryButton, ` + padding: 3px 4px; +`); +const cssSpacer = styled('div', ` + width: 8px; + flex-shrink: 0; +`); diff --git a/app/client/ui/ViewSectionMenu.ts b/app/client/ui/ViewSectionMenu.ts index 274f4e57..3ac9ba94 100644 --- a/app/client/ui/ViewSectionMenu.ts +++ b/app/client/ui/ViewSectionMenu.ts @@ -1,13 +1,14 @@ import {flipColDirection, parseSortColRefs} from 'app/client/lib/sortUtil'; import {ColumnRec, DocModel, ViewFieldRec, ViewRec, ViewSectionRec} from 'app/client/models/DocModel'; +import {CustomComputed} from 'app/client/models/modelUtil'; +import {attachColumnFilterMenu} from 'app/client/ui/ColumnFilterMenu'; +import {makeViewLayoutMenu} from 'app/client/ui/ViewLayoutMenu'; +import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; import {colors, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {menu, menuDivider} from 'app/client/ui2018/menus'; import {Computed, dom, fromKo, makeTestId, Observable, styled} from 'grainjs'; import difference = require('lodash/difference'); -import {setPopupToCreateDom} from 'popweasel'; -import {makeViewLayoutMenu} from '../ui/ViewLayoutMenu'; -import {basicButton, primaryButton} from '../ui2018/buttons'; const testId = makeTestId('test-section-menu-'); @@ -23,7 +24,8 @@ export function viewSectionMenu(docModel: DocModel, viewSection: ViewSectionRec, // it started in the "unsaved" state (in which a dynamic use()-based subscription to // emptySortFilterObs wouldn't be active, which could result in a wrong order of evaluation). const iconSuffixObs: Computed = Computed.create(null, emptySortFilterObs, (use, empty) => { - if (use(viewSection.filterSpecChanged) || !use(viewSection.activeSortJson.isSaved)) { + if (use(viewSection.filterSpecChanged) || !use(viewSection.activeSortJson.isSaved) + || !use(viewSection.activeFilterBar.isSaved)) { return '-unsaved'; } else if (!empty) { return '-saved'; @@ -49,21 +51,26 @@ export function viewSectionMenu(docModel: DocModel, viewSection: ViewSectionRec, }), dom.domComputed(viewSection.filteredFields, fields => makeFilterPanel(viewSection, fields)), + makeFilterBarToggle(viewSection.activeFilterBar), dom.domComputed(iconSuffixObs, iconSuffix => { const displaySave = iconSuffix === '-unsaved'; return [ displaySave ? cssMenuInfoHeader( cssSaveButton('Save', testId('btn-save'), dom.on('click', async () => { - await viewSection.activeSortJson.save(); // Save sort - await viewSection.saveFilters(); // Save filter + await docModel.docData.bundleActions("Update Sort&Filter settings", () => Promise.all([ + viewSection.activeSortJson.save(), // Save sort + viewSection.saveFilters(), // Save filter + viewSection.activeFilterBar.save(), // Save bar + ])); }), dom.boolAttr('disabled', isReadonly), ), basicButton('Revert', testId('btn-revert'), dom.on('click', () => { - viewSection.activeSortJson.revert(); // Revert sort - viewSection.revertFilters(); // Revert filter + viewSection.activeSortJson.revert(); // Revert sort + viewSection.revertFilters(); // Revert filter + viewSection.activeFilterBar.revert(); // Revert bar }) ) ) : null, @@ -108,10 +115,29 @@ function makeSortPanel(section: ViewSectionRec, sortSpec: number[], getColumn: ( return [ cssMenuInfoHeader('Sorted by', testId('heading-sorted')), - sortColumns.length > 0 ? sortColumns : cssMenuText('(Default)') + sortColumns.length > 0 ? sortColumns : cssGrayedMenuText('(Default)') ]; } +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())), + cssMenuTextLabel("Toggle Filter Bar"), + ); +} + + function makeFilterPanel(section: ViewSectionRec, filteredFields: ViewFieldRec[]) { const fields = filteredFields.map(field => { const fieldChanged = Computed.create(null, fromKo(field.activeFilter.isSaved), (_use, isSaved) => !isSaved); @@ -120,26 +146,18 @@ function makeFilterPanel(section: ViewSectionRec, filteredFields: ViewFieldRec[] cssMenuIconWrapper( cssMenuIconWrapper.cls('-changed', fieldChanged), cssIcon('FilterSimple'), - (elem) => { - const instance = section.viewInstance(); - if (instance && instance.createFilterMenu) { // Should be set if using BaseView - setPopupToCreateDom(elem, ctl => instance.createFilterMenu(ctl, field), { - placement: 'bottom-end', - boundaries: 'viewport', - trigger: ['click'] - }); - } - } + attachColumnFilterMenu(section, field, {placement: 'bottom-end'}), + testId('filter-icon'), ), cssMenuTextLabel(field.label()), - cssMenuIconWrapper(cssIcon('Remove'), dom.on('click', () => field.activeFilter(''))), + cssMenuIconWrapper(cssIcon('Remove', testId('btn-remove-filter')), dom.on('click', () => field.activeFilter(''))), testId('filter-col') ); }); return [ cssMenuInfoHeader('Filtered by', {style: 'margin-top: 4px'}, testId('heading-filtered')), - filteredFields.length > 0 ? fields : cssMenuText('(Not filtered)') + filteredFields.length > 0 ? fields : cssGrayedMenuText('(Not filtered)') ]; } @@ -184,7 +202,7 @@ const cssIconWrapper = styled('div', ` `); const cssMenuIconWrapper = styled(cssIconWrapper, ` - padding: 3px; + display: flex; margin: -3px 0; width: 22px; height: 22px; @@ -212,6 +230,10 @@ const cssIcon = styled(icon, ` .${clsOldUI.className} & { background-color: white; } + + &-green { + background-color: ${colors.lightGreen}; + } `); const cssDotsIconWrapper = styled(cssIconWrapper, ` @@ -251,9 +273,14 @@ const cssMenuInfoHeader = styled('div', ` const cssMenuText = styled('div', ` display: flex; align-items: center; - color: ${colors.slate}; padding: 0px 24px 8px 24px; cursor: default; + white-space: nowrap; +`); + +const cssGrayedMenuText = styled(cssMenuText, ` + color: ${colors.slate}; + padding-left: 24px; `); const cssMenuTextLabel = styled('span', `