(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
This commit is contained in:
Cyprien P 2021-04-14 17:17:45 +02:00
parent 9e8e895abd
commit 2b1b586ecd
5 changed files with 179 additions and 26 deletions

View File

@ -12,6 +12,7 @@ import {Delay} from 'app/client/lib/Delay';
import {createObsArray} from 'app/client/lib/koArrayWrap'; import {createObsArray} from 'app/client/lib/koArrayWrap';
import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel'; import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel';
import {reportError} from 'app/client/models/errors'; import {reportError} from 'app/client/models/errors';
import {filterBar} from 'app/client/ui/FilterBar';
import {viewSectionMenu} from 'app/client/ui/ViewSectionMenu'; import {viewSectionMenu} from 'app/client/ui/ViewSectionMenu';
import {colors, mediaSmall, testId} from 'app/client/ui2018/cssVars'; import {colors, mediaSmall, testId} from 'app/client/ui2018/cssVars';
import {editableLabel} from 'app/client/ui2018/editableLabel'; 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) viewSectionMenu(this.docModel, vs, this.viewModel, this.gristDoc.isReadonly, this.gristDoc.app.useNewUI)
) )
)), )),
dom.maybe(vs.activeFilterBar, () => dom.create(filterBar, vs)),
dom.maybe<BaseView|null>(vs.viewInstance, (viewInstance) => dom.maybe<BaseView|null>(vs.viewInstance, (viewInstance) =>
dom('div.view_data_pane_container.flexvbox', dom('div.view_data_pane_container.flexvbox',
cssResizing.cls('', this._isResizing), cssResizing.cls('', this._isResizing),

View File

@ -88,6 +88,7 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> {
isSorted: ko.Computed<boolean>; isSorted: ko.Computed<boolean>;
disableDragRows: ko.Computed<boolean>; disableDragRows: ko.Computed<boolean>;
activeFilterBar: modelUtil.CustomComputed<boolean>;
// Save all filters of fields in the section. // Save all filters of fields in the section.
saveFilters(): Promise<void>; saveFilters(): Promise<void>;
@ -131,6 +132,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
horizontalGridlines: true, horizontalGridlines: true,
zebraStripes: false, zebraStripes: false,
customView: '', customView: '',
filterBar: false,
}; };
this.optionsObj = modelUtil.jsonObservable(this.options, this.optionsObj = modelUtil.jsonObservable(this.options,
(obj: any) => defaults(obj || {}, defaultOptions)); (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.isSorted = ko.pureComputed(() => this.activeSortSpec().length > 0);
this.disableDragRows = ko.pureComputed(() => this.isSorted() || !this.table().supportsManualSort()); this.disableDragRows = ko.pureComputed(() => this.isSorted() || !this.table().supportsManualSort());
this.activeFilterBar = modelUtil.customValue(this.optionsObj.prop('filterBar'));
} }

View File

@ -5,7 +5,7 @@
*/ */
import {allInclusive, ColumnFilter, isEquivalentFilter} from 'app/client/models/ColumnFilter'; 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 {FilteredRowSource} from 'app/client/models/rowset';
import {SectionFilter} from 'app/client/models/SectionFilter'; import {SectionFilter} from 'app/client/models/SectionFilter';
import {TableData} from 'app/client/models/TableData'; 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 {icon} from 'app/client/ui2018/icons';
import {menuCssClass, menuDivider} from 'app/client/ui2018/menus'; import {menuCssClass, menuDivider} from 'app/client/ui2018/menus';
import {CellValue} from 'app/common/DocActions'; 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 identity = require('lodash/identity');
import {IOpenController} from 'popweasel'; import {IOpenController, IPopupOptions, setPopupToCreateDom} from 'popweasel';
import {ColumnFilterMenuModel, IFilterCount} from '../models/ColumnFilterMenuModel'; 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); 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', ` const cssMenu = styled('div', `
display: flex; display: flex;
flex-direction: column; flex-direction: column;

102
app/client/ui/FilterBar.ts Normal file
View File

@ -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<HTMLDivElement>) => (
dom('div', cssButton.cls(''), cssButton.cls('-primary'),
cssBtn.cls(''), ...args)
);
const basicButton = (...args: IDomArgs<HTMLDivElement>) => (
dom('div', cssButton.cls(''), cssBtn.cls(''), ...args)
);
const deleteButton = styled(primaryButton, `
padding: 3px 4px;
`);
const cssSpacer = styled('div', `
width: 8px;
flex-shrink: 0;
`);

View File

@ -1,13 +1,14 @@
import {flipColDirection, parseSortColRefs} from 'app/client/lib/sortUtil'; import {flipColDirection, parseSortColRefs} from 'app/client/lib/sortUtil';
import {ColumnRec, DocModel, ViewFieldRec, ViewRec, ViewSectionRec} from 'app/client/models/DocModel'; 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 {colors, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {menu, menuDivider} from 'app/client/ui2018/menus'; import {menu, menuDivider} from 'app/client/ui2018/menus';
import {Computed, dom, fromKo, makeTestId, Observable, styled} from 'grainjs'; import {Computed, dom, fromKo, makeTestId, Observable, styled} from 'grainjs';
import difference = require('lodash/difference'); 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-'); 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 // 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). // emptySortFilterObs wouldn't be active, which could result in a wrong order of evaluation).
const iconSuffixObs: Computed<IconSuffix> = Computed.create(null, emptySortFilterObs, (use, empty) => { const iconSuffixObs: Computed<IconSuffix> = 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'; return '-unsaved';
} else if (!empty) { } else if (!empty) {
return '-saved'; return '-saved';
@ -49,21 +51,26 @@ export function viewSectionMenu(docModel: DocModel, viewSection: ViewSectionRec,
}), }),
dom.domComputed(viewSection.filteredFields, fields => dom.domComputed(viewSection.filteredFields, fields =>
makeFilterPanel(viewSection, fields)), makeFilterPanel(viewSection, fields)),
makeFilterBarToggle(viewSection.activeFilterBar),
dom.domComputed(iconSuffixObs, iconSuffix => { dom.domComputed(iconSuffixObs, iconSuffix => {
const displaySave = iconSuffix === '-unsaved'; const displaySave = iconSuffix === '-unsaved';
return [ return [
displaySave ? cssMenuInfoHeader( displaySave ? cssMenuInfoHeader(
cssSaveButton('Save', testId('btn-save'), cssSaveButton('Save', testId('btn-save'),
dom.on('click', async () => { dom.on('click', async () => {
await viewSection.activeSortJson.save(); // Save sort await docModel.docData.bundleActions("Update Sort&Filter settings", () => Promise.all([
await viewSection.saveFilters(); // Save filter viewSection.activeSortJson.save(), // Save sort
viewSection.saveFilters(), // Save filter
viewSection.activeFilterBar.save(), // Save bar
]));
}), }),
dom.boolAttr('disabled', isReadonly), dom.boolAttr('disabled', isReadonly),
), ),
basicButton('Revert', testId('btn-revert'), basicButton('Revert', testId('btn-revert'),
dom.on('click', () => { dom.on('click', () => {
viewSection.activeSortJson.revert(); // Revert sort viewSection.activeSortJson.revert(); // Revert sort
viewSection.revertFilters(); // Revert filter viewSection.revertFilters(); // Revert filter
viewSection.activeFilterBar.revert(); // Revert bar
}) })
) )
) : null, ) : null,
@ -108,10 +115,29 @@ function makeSortPanel(section: ViewSectionRec, sortSpec: number[], getColumn: (
return [ return [
cssMenuInfoHeader('Sorted by', testId('heading-sorted')), cssMenuInfoHeader('Sorted by', testId('heading-sorted')),
sortColumns.length > 0 ? sortColumns : cssMenuText('(Default)') sortColumns.length > 0 ? sortColumns : cssGrayedMenuText('(Default)')
]; ];
} }
export function makeFilterBarToggle(activeFilterBar: CustomComputed<boolean>) {
return cssMenuText(
cssMenuIconWrapper(
testId('btn'),
cssMenuIconWrapper.cls('-changed', (use) => !use(activeFilterBar.isSaved)),
dom.domComputed((use) => {
const filterBar = use(activeFilterBar);
const isSaved = use(activeFilterBar.isSaved);
return cssIcon(filterBar ? "Tick" : (isSaved ? "Plus" : "CrossSmall"),
cssIcon.cls('-green', Boolean(filterBar)),
testId('icon'));
}),
),
dom.on('click', () => activeFilterBar(!activeFilterBar())),
cssMenuTextLabel("Toggle Filter Bar"),
);
}
function makeFilterPanel(section: ViewSectionRec, filteredFields: ViewFieldRec[]) { function makeFilterPanel(section: ViewSectionRec, filteredFields: ViewFieldRec[]) {
const fields = filteredFields.map(field => { const fields = filteredFields.map(field => {
const fieldChanged = Computed.create(null, fromKo(field.activeFilter.isSaved), (_use, isSaved) => !isSaved); const fieldChanged = Computed.create(null, fromKo(field.activeFilter.isSaved), (_use, isSaved) => !isSaved);
@ -120,26 +146,18 @@ function makeFilterPanel(section: ViewSectionRec, filteredFields: ViewFieldRec[]
cssMenuIconWrapper( cssMenuIconWrapper(
cssMenuIconWrapper.cls('-changed', fieldChanged), cssMenuIconWrapper.cls('-changed', fieldChanged),
cssIcon('FilterSimple'), cssIcon('FilterSimple'),
(elem) => { attachColumnFilterMenu(section, field, {placement: 'bottom-end'}),
const instance = section.viewInstance(); testId('filter-icon'),
if (instance && instance.createFilterMenu) { // Should be set if using BaseView
setPopupToCreateDom(elem, ctl => instance.createFilterMenu(ctl, field), {
placement: 'bottom-end',
boundaries: 'viewport',
trigger: ['click']
});
}
}
), ),
cssMenuTextLabel(field.label()), 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') testId('filter-col')
); );
}); });
return [ return [
cssMenuInfoHeader('Filtered by', {style: 'margin-top: 4px'}, testId('heading-filtered')), 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, ` const cssMenuIconWrapper = styled(cssIconWrapper, `
padding: 3px; display: flex;
margin: -3px 0; margin: -3px 0;
width: 22px; width: 22px;
height: 22px; height: 22px;
@ -212,6 +230,10 @@ const cssIcon = styled(icon, `
.${clsOldUI.className} & { .${clsOldUI.className} & {
background-color: white; background-color: white;
} }
&-green {
background-color: ${colors.lightGreen};
}
`); `);
const cssDotsIconWrapper = styled(cssIconWrapper, ` const cssDotsIconWrapper = styled(cssIconWrapper, `
@ -251,9 +273,14 @@ const cssMenuInfoHeader = styled('div', `
const cssMenuText = styled('div', ` const cssMenuText = styled('div', `
display: flex; display: flex;
align-items: center; align-items: center;
color: ${colors.slate};
padding: 0px 24px 8px 24px; padding: 0px 24px 8px 24px;
cursor: default; cursor: default;
white-space: nowrap;
`);
const cssGrayedMenuText = styled(cssMenuText, `
color: ${colors.slate};
padding-left: 24px;
`); `);
const cssMenuTextLabel = styled('span', ` const cssMenuTextLabel = styled('span', `