mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(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:
		
							parent
							
								
									9e8e895abd
								
							
						
					
					
						commit
						2b1b586ecd
					
				@ -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<BaseView|null>(vs.viewInstance, (viewInstance) =>
 | 
			
		||||
        dom('div.view_data_pane_container.flexvbox',
 | 
			
		||||
          cssResizing.cls('', this._isResizing),
 | 
			
		||||
 | 
			
		||||
@ -88,6 +88,7 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> {
 | 
			
		||||
 | 
			
		||||
  isSorted: ko.Computed<boolean>;
 | 
			
		||||
  disableDragRows: ko.Computed<boolean>;
 | 
			
		||||
  activeFilterBar: modelUtil.CustomComputed<boolean>;
 | 
			
		||||
 | 
			
		||||
  // Save all filters of fields in the section.
 | 
			
		||||
  saveFilters(): Promise<void>;
 | 
			
		||||
@ -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'));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										102
									
								
								app/client/ui/FilterBar.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								app/client/ui/FilterBar.ts
									
									
									
									
									
										Normal 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;
 | 
			
		||||
`);
 | 
			
		||||
@ -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<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';
 | 
			
		||||
    } 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<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[]) {
 | 
			
		||||
  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', `
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user