mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Update sort and filter UI
Summary: The sort and filter UI now has a more unified UI, with similar capabilities that are accessible from different parts of Grist. It's now also possible to pin individual filters to the filter bar, which replaces the old toggle for showing all filters in the filter bar. Test Plan: Various tests (browser, migration, project). Reviewers: jarek, dsagal Reviewed By: jarek, dsagal Subscribers: dsagal Differential Revision: https://phab.getgrist.com/D3669
This commit is contained in:
@@ -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<CellValue, IFilterCount>;
|
||||
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<CellValue, HTMLInputElement> = 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<CellValue,
|
||||
return new Map(values.map((v) => [v, {label: String(v), count: 0, displayValue: v}]));
|
||||
}
|
||||
|
||||
export interface IColumnFilterMenuOptions {
|
||||
// Callback for when the filter menu is closed.
|
||||
onClose?: () => void;
|
||||
// If true, shows a button that opens the sort & filter widget menu.
|
||||
showAllFiltersButton?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns content for the newly created columnFilterMenu; for use with setPopupToCreateDom().
|
||||
*/
|
||||
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};
|
||||
|
||||
@@ -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<ColumnRec, PopupControl>();
|
||||
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<ColumnRec, PopupControl>) {
|
||||
function makeFilterField(filterInfo: FilterInfo, popupControls: WeakMap<ColumnRec, PopupControl>) {
|
||||
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<ColumnRec, PopupControl>, 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<ColumnRec, PopupControl>,
|
||||
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<ColumnRec, PopupControl>) {
|
||||
viewSection.setFilter(fieldOrColumn.origCol().origColRef(), allInclusive);
|
||||
function openFilter(
|
||||
{fieldOrColumn, isFiltered, viewSection}: FilterInfo,
|
||||
popupControls: WeakMap<ColumnRec, PopupControl>,
|
||||
) {
|
||||
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<ColumnRec, PopupControl>) {
|
||||
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<HTMLDivElement>) => (
|
||||
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;
|
||||
`);
|
||||
|
||||
149
app/client/ui/FilterConfig.ts
Normal file
149
app/client/ui/FilterConfig.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {attachColumnFilterMenu} from 'app/client/ui/ColumnFilterMenu';
|
||||
import {addFilterMenu} from 'app/client/ui/FilterBar';
|
||||
import {cssIcon, cssPinButton, cssRow, cssSortFilterColumn} from 'app/client/ui/RightPanelStyles';
|
||||
import {theme} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {Computed, Disposable, dom, makeTestId, styled} from 'grainjs';
|
||||
import {IMenuOptions} from 'popweasel';
|
||||
|
||||
const testId = makeTestId('test-filter-config-');
|
||||
|
||||
const t = makeT('SortConfig');
|
||||
|
||||
export interface FilterConfigOptions {
|
||||
/** Options to pass to the menu and popup components. */
|
||||
menuOptions?: IMenuOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that renders controls for managing filters for a view section.
|
||||
*
|
||||
* Active filters (i.e. columns that have non-blank filters set) are displayed in
|
||||
* a vertical list of pill-shaped buttons. These buttons can be clicked to open their
|
||||
* respective filter menu. Additionally, there are buttons to the right of each filter
|
||||
* for removing and pinning them.
|
||||
*/
|
||||
export class FilterConfig extends Disposable {
|
||||
private _popupControls = new WeakMap();
|
||||
|
||||
private _canAddFilter = Computed.create(this, (use) => {
|
||||
return use(this._section.filters).some(f => !use(f.isFiltered));
|
||||
});
|
||||
|
||||
constructor(private _section: ViewSectionRec, private _options: FilterConfigOptions = {}) {
|
||||
super();
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
const {menuOptions} = this._options;
|
||||
return dom('div',
|
||||
dom.forEach(this._section.activeFilters, (filterInfo) => {
|
||||
const {fieldOrColumn, filter, pinned, isPinned} = filterInfo;
|
||||
return cssRow(
|
||||
cssSortFilterColumn(
|
||||
cssIconWrapper(
|
||||
cssFilterIcon('FilterSimple',
|
||||
cssFilterIcon.cls('-accent', use => !use(filter.isSaved) || !use(pinned.isSaved)),
|
||||
testId('filter-icon'),
|
||||
),
|
||||
),
|
||||
cssLabel(dom.text(fieldOrColumn.label)),
|
||||
attachColumnFilterMenu(filterInfo, {
|
||||
popupOptions: {
|
||||
placement: 'bottom-end',
|
||||
...menuOptions,
|
||||
trigger: [
|
||||
'click',
|
||||
(_el, popupControl) => this._popupControls.set(fieldOrColumn.origCol(), popupControl)
|
||||
],
|
||||
},
|
||||
}),
|
||||
testId('column'),
|
||||
),
|
||||
cssPinFilterButton(
|
||||
icon('PinTilted'),
|
||||
dom.on('click', () => this._section.setFilter(fieldOrColumn.origCol().origColRef(), {
|
||||
pinned: !isPinned.peek()
|
||||
})),
|
||||
cssPinButton.cls('-pinned', isPinned),
|
||||
testId('pin-filter'),
|
||||
),
|
||||
cssIconWrapper(
|
||||
cssRemoveFilterButton('Remove',
|
||||
dom.on('click',
|
||||
() => this._section.setFilter(fieldOrColumn.origCol().origColRef(), {
|
||||
filter: '',
|
||||
pinned: false,
|
||||
})),
|
||||
testId('remove-filter'),
|
||||
),
|
||||
),
|
||||
testId('filter'),
|
||||
);
|
||||
}),
|
||||
cssRow(
|
||||
dom.domComputed((use) => {
|
||||
const filters = use(this._section.filters);
|
||||
return cssTextBtn(
|
||||
t('AddColumn'),
|
||||
addFilterMenu(filters, this._popupControls, {
|
||||
menuOptions: {
|
||||
placement: 'bottom-end',
|
||||
...this._options.menuOptions,
|
||||
},
|
||||
}),
|
||||
dom.on('click', (ev) => ev.stopPropagation()),
|
||||
dom.hide(u => !u(this._canAddFilter)),
|
||||
testId('add-filter-btn'),
|
||||
);
|
||||
}),
|
||||
),
|
||||
testId('container'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cssIconWrapper = styled('div', ``);
|
||||
|
||||
const cssLabel = styled('div', `
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
`);
|
||||
|
||||
const cssTextBtn = styled('div', `
|
||||
color: ${theme.controlFg};
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: ${theme.controlHoverFg};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssFilterIcon = styled(cssIcon, `
|
||||
flex: none;
|
||||
margin: 0px 6px 0px 0px;
|
||||
background-color: ${theme.controlSecondaryFg};
|
||||
|
||||
&-accent {
|
||||
background-color: ${theme.accentIcon};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssRemoveFilterButton = styled(cssIcon, `
|
||||
flex: none;
|
||||
margin: 0 6px;
|
||||
background-color: ${theme.controlSecondaryFg};
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.controlSecondaryHoverFg};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssPinFilterButton = styled(cssPinButton, `
|
||||
margin-left: 6px;
|
||||
`);
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
`);
|
||||
|
||||
372
app/client/ui/SortConfig.ts
Normal file
372
app/client/ui/SortConfig.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import koArray from 'app/client/lib/koArray';
|
||||
import * as kf from 'app/client/lib/koForm';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {addToSort, updatePositions} from 'app/client/lib/sortUtil';
|
||||
import {ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {ObjObservable} from 'app/client/models/modelUtil';
|
||||
import {cssIcon, cssRow, cssSortFilterColumn} from 'app/client/ui/RightPanelStyles';
|
||||
import {labeledLeftSquareCheckbox} from 'app/client/ui2018/checkbox';
|
||||
import {theme} from 'app/client/ui2018/cssVars';
|
||||
import {cssDragger} from 'app/client/ui2018/draggableList';
|
||||
import {menu, menuItem} from 'app/client/ui2018/menus';
|
||||
import {Sort} from 'app/common/SortSpec';
|
||||
import {Computed, Disposable, dom, makeTestId, MultiHolder, styled} from 'grainjs';
|
||||
import difference = require('lodash/difference');
|
||||
import isEqual = require('lodash/isEqual');
|
||||
import {cssMenuItem, IMenuOptions} from 'popweasel';
|
||||
|
||||
interface SortableColumn {
|
||||
label: string;
|
||||
value: number;
|
||||
icon: 'FieldColumn';
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface SortConfigOptions {
|
||||
/** Options to pass to all menus created by `SortConfig`. */
|
||||
menuOptions?: IMenuOptions;
|
||||
}
|
||||
|
||||
const testId = makeTestId('test-sort-config-');
|
||||
|
||||
const t = makeT('SortConfig');
|
||||
|
||||
/**
|
||||
* Component that renders controls for managing sorting for a view section.
|
||||
*
|
||||
* Sorted columns are displayed in a vertical list of pill-shaped buttons. These
|
||||
* buttons can be clicked to toggle their sort direction, and can be clicked and
|
||||
* dragged to re-arrange their order. Additionally, there are buttons to the right
|
||||
* of each sorted column for removing them, and opening a menu with advanced sort
|
||||
* options.
|
||||
*/
|
||||
export class SortConfig extends Disposable {
|
||||
// Computed array of sortable columns.
|
||||
private _columns: Computed<SortableColumn[]> = Computed.create(this, (use) => {
|
||||
// Columns is an observable holding an observable array - must call 'use' on it 2x.
|
||||
const cols = use(use(use(this._section.table).columns).getObservable());
|
||||
return cols.filter(col => !use(col.isHiddenCol)).map(col => ({
|
||||
label: use(col.label),
|
||||
value: col.getRowId(),
|
||||
icon: 'FieldColumn',
|
||||
type: col.type(),
|
||||
}));
|
||||
});
|
||||
|
||||
// We only want to recreate rows, when the actual columns change.
|
||||
private _colRefs = Computed.create(this, (use) => {
|
||||
return use(this._section.activeSortSpec).map(col => Sort.getColRef(col));
|
||||
});
|
||||
private _sortRows = this.autoDispose(koArray(this._colRefs.get()));
|
||||
|
||||
private _changedColRefs = Computed.create(this, (use) => {
|
||||
const changedSpecs = difference(
|
||||
use(this._section.activeSortSpec),
|
||||
Sort.parseSortColRefs(use(this._section.sortColRefs))
|
||||
);
|
||||
return new Set(changedSpecs.map(spec => Sort.getColRef(spec)));
|
||||
});
|
||||
|
||||
constructor(private _section: ViewSectionRec, private _gristDoc: GristDoc, private _options: SortConfigOptions = {}) {
|
||||
super();
|
||||
|
||||
this.autoDispose(this._colRefs.addListener((curr, prev) => {
|
||||
if (!isEqual(curr, prev)){
|
||||
this._sortRows.assign(curr);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
return dom('div',
|
||||
// Sort rows.
|
||||
kf.draggableList(this._sortRows, (colRef: number) => this._createRow(colRef), {
|
||||
reorder: (colRef: number, nextColRef: number | null) => this._reorder(colRef, nextColRef),
|
||||
removeButton: false,
|
||||
drag_indicator: cssDragger,
|
||||
itemClass: cssDragRow.className,
|
||||
}),
|
||||
// Add to sort btn & menu.
|
||||
this._buildAddToSortButton(this._columns),
|
||||
this._buildUpdateDataButton(),
|
||||
testId('container'),
|
||||
);
|
||||
}
|
||||
|
||||
private _createRow(colRef: number) {
|
||||
return this._buildSortRow(colRef, this._section.activeSortSpec, this._columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a single row of the sort dom.
|
||||
* Takes the colRef, current sortSpec and array of column select options to show
|
||||
* in the column select dropdown.
|
||||
*/
|
||||
private _buildSortRow(
|
||||
colRef: number,
|
||||
sortSpec: ObjObservable<Sort.SortSpec>,
|
||||
columns: Computed<SortableColumn[]>
|
||||
) {
|
||||
const holder = new MultiHolder();
|
||||
const {menuOptions} = this._options;
|
||||
|
||||
const col = Computed.create(holder, () => colRef);
|
||||
const details = Computed.create(holder, (use) => Sort.specToDetails(Sort.findCol(use(sortSpec), colRef)!));
|
||||
const hasSpecs = Computed.create(holder, details, (_, specDetails) => Sort.hasOptions(specDetails));
|
||||
const isAscending = Computed.create(holder, details, (_, specDetails) => specDetails.direction === Sort.ASC);
|
||||
|
||||
col.onWrite((newRef) => {
|
||||
let specs = sortSpec.peek();
|
||||
const colSpec = Sort.findCol(specs, colRef);
|
||||
const newSpec = Sort.findCol(specs, newRef);
|
||||
if (newSpec) {
|
||||
// this column is already there so only swap order
|
||||
specs = Sort.swap(specs, colRef, newRef);
|
||||
// but keep the directions
|
||||
specs = Sort.setSortDirection(specs, colRef, Sort.direction(newSpec));
|
||||
specs = Sort.setSortDirection(specs, newRef, Sort.direction(colSpec!));
|
||||
} else {
|
||||
specs = Sort.replace(specs, colRef, Sort.createColSpec(newRef, Sort.direction(colSpec!)));
|
||||
}
|
||||
this._saveSort(specs);
|
||||
});
|
||||
|
||||
const computedFlag = (
|
||||
flag: keyof Sort.ColSpecDetails,
|
||||
allowedTypes: string[] | null,
|
||||
label: string
|
||||
) => {
|
||||
const computed = Computed.create(holder, details, (_, d) => d[flag] || false);
|
||||
computed.onWrite(value => {
|
||||
const specs = sortSpec.peek();
|
||||
// Get existing details
|
||||
const specDetails = Sort.specToDetails(Sort.findCol(specs, colRef)!) as any;
|
||||
// Update flags
|
||||
specDetails[flag] = value;
|
||||
// Replace the colSpec at the index
|
||||
this._saveSort(Sort.replace(specs, Sort.getColRef(colRef), specDetails));
|
||||
});
|
||||
return {computed, allowedTypes, flag, label};
|
||||
};
|
||||
const orderByChoice = computedFlag('orderByChoice', ['Choice'], t('UseChoicePosition'));
|
||||
const naturalSort = computedFlag('naturalSort', ['Text'], t('NaturalSort'));
|
||||
const emptyLast = computedFlag('emptyLast', null, t('EmptyValuesLast'));
|
||||
const flags = [orderByChoice, emptyLast, naturalSort];
|
||||
|
||||
const column = columns.get().find(c => c.value === Sort.getColRef(colRef));
|
||||
|
||||
return cssSortRow(
|
||||
dom.autoDispose(holder),
|
||||
cssSortFilterColumn(
|
||||
dom.domComputed(isAscending, ascending =>
|
||||
cssSortIcon(
|
||||
"Sort",
|
||||
cssSortIcon.cls('-accent', use => use(this._changedColRefs).has(column!.value)),
|
||||
dom.style("transform", ascending ? "scaleY(-1)" : "none"),
|
||||
testId('order'),
|
||||
testId(ascending ? "sort-order-asc" : "sort-order-desc"),
|
||||
)
|
||||
),
|
||||
cssLabel(column!.label),
|
||||
dom.on("click", () => {
|
||||
this._saveSort(Sort.flipSort(sortSpec.peek(), colRef));
|
||||
}),
|
||||
testId('column'),
|
||||
),
|
||||
cssMenu(
|
||||
cssBigIconWrapper(
|
||||
cssIcon('Dots', dom.cls(cssBgAccent.className, hasSpecs)),
|
||||
testId('options-icon'),
|
||||
),
|
||||
menu(_ctl => flags.map(({computed, allowedTypes, flag, label}) => {
|
||||
// when allowedTypes is null, flag can be used for every column
|
||||
const enabled = !allowedTypes || allowedTypes.includes(column!.type);
|
||||
return cssMenuItem(
|
||||
labeledLeftSquareCheckbox(
|
||||
computed as any,
|
||||
label,
|
||||
dom.prop('disabled', !enabled),
|
||||
),
|
||||
dom.cls(cssOptionMenuItem.className),
|
||||
dom.cls('disabled', !enabled),
|
||||
testId('option'),
|
||||
testId(`option-${flag}`),
|
||||
);
|
||||
},
|
||||
), menuOptions),
|
||||
),
|
||||
cssSortIconBtn('Remove',
|
||||
dom.on('click', () => {
|
||||
const specs = sortSpec.peek();
|
||||
if (Sort.findCol(specs, colRef)) {
|
||||
this._saveSort(Sort.removeCol(specs, colRef));
|
||||
}
|
||||
}),
|
||||
testId('remove')
|
||||
),
|
||||
testId('row'),
|
||||
);
|
||||
}
|
||||
|
||||
private _buildAddToSortButton(columns: Computed<SortableColumn[]>) {
|
||||
const available = Computed.create(null, (use) => {
|
||||
const currentSection = this._section;
|
||||
const currentSortSpec = use(currentSection.activeSortSpec);
|
||||
const specRowIds = new Set(currentSortSpec.map(_sortRef => Sort.getColRef(_sortRef)));
|
||||
return use(columns).filter(_col => !specRowIds.has(_col.value));
|
||||
});
|
||||
const {menuOptions} = this._options;
|
||||
return cssButtonRow(
|
||||
dom.autoDispose(available),
|
||||
dom.domComputed(use => {
|
||||
const cols = use(available);
|
||||
return cssTextBtn(
|
||||
t('AddColumn'),
|
||||
menu((ctl) => [
|
||||
...cols.map((col) => (
|
||||
menuItem(
|
||||
() => addToSort(this._section.activeSortSpec, col.value, 1),
|
||||
col.label,
|
||||
testId('add-menu-row')
|
||||
)
|
||||
)),
|
||||
// We need to stop click event to propagate otherwise it would cause view section menu to
|
||||
// close.
|
||||
dom.on('click', (ev) => {
|
||||
ctl.close();
|
||||
ev.stopPropagation();
|
||||
}),
|
||||
], menuOptions),
|
||||
dom.on('click', (ev) => { ev.stopPropagation(); }),
|
||||
testId('add'),
|
||||
);
|
||||
}),
|
||||
dom.hide(use => !use(available).length),
|
||||
);
|
||||
}
|
||||
|
||||
private _buildUpdateDataButton() {
|
||||
return dom.maybe(this._section.isSorted, () =>
|
||||
cssButtonRow(
|
||||
cssTextBtn(t('UpdateData'),
|
||||
dom.on('click', () => updatePositions(this._gristDoc, this._section)),
|
||||
testId('update'),
|
||||
dom.show((use) => (
|
||||
use(use(this._section.table).supportsManualSort)
|
||||
&& !use(this._gristDoc.isReadonly)
|
||||
)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private _reorder(colRef: number, nextColRef: number | null) {
|
||||
const activeSortSpec = this._section.activeSortSpec.peek();
|
||||
const colSpec = Sort.findCol(activeSortSpec, colRef);
|
||||
if (colSpec === undefined) {
|
||||
throw new Error(`Col ${colRef} not found in active sort spec`);
|
||||
}
|
||||
|
||||
const newSpec = Sort.reorderSortRefs(this._section.activeSortSpec.peek(), colSpec, nextColRef);
|
||||
this._saveSort(newSpec);
|
||||
}
|
||||
|
||||
private _saveSort(sortSpec: Sort.SortSpec) {
|
||||
this._section.activeSortSpec(sortSpec);
|
||||
}
|
||||
}
|
||||
|
||||
const cssDragRow = styled('div', `
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
margin: 0 16px 0px 0px;
|
||||
& > .kf_draggable_content {
|
||||
margin: 4px 0;
|
||||
flex: 1 1 0px;
|
||||
min-width: 0px;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssLabel = styled('div', `
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
`);
|
||||
|
||||
const cssSortRow = styled('div', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
`);
|
||||
|
||||
const cssTextBtn = styled('div', `
|
||||
color: ${theme.controlFg};
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: ${theme.controlHoverFg};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssSortIconBtn = styled(cssIcon, `
|
||||
flex: none;
|
||||
margin: 0 6px;
|
||||
cursor: pointer;
|
||||
background-color: ${theme.controlSecondaryFg};
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.controlSecondaryHoverFg};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssSortIcon = styled(cssIcon, `
|
||||
flex: none;
|
||||
margin: 0px 6px 0px 0px;
|
||||
background-color: ${theme.controlSecondaryFg};
|
||||
|
||||
&-accent {
|
||||
background-color: ${theme.accentIcon};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssBigIconWrapper = styled('div', `
|
||||
padding: 3px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
`);
|
||||
|
||||
const cssBgAccent = styled(`div`, `
|
||||
background: ${theme.accentIcon}
|
||||
`);
|
||||
|
||||
const cssMenu = styled('div', `
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
border: 1px solid transparent;
|
||||
margin-left: 6px;
|
||||
&:hover, &.weasel-popup-open {
|
||||
background-color: ${theme.hover};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssOptionMenuItem = styled('div', `
|
||||
&:hover {
|
||||
background-color: ${theme.hover};
|
||||
}
|
||||
& label {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
&.disabled * {
|
||||
color: ${theme.menuItemDisabledFg} important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssButtonRow = styled(cssRow, `
|
||||
margin-top: 4px;
|
||||
`);
|
||||
68
app/client/ui/SortFilterConfig.ts
Normal file
68
app/client/ui/SortFilterConfig.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {FilterConfig} from 'app/client/ui/FilterConfig';
|
||||
import {cssLabel, cssSaveButtonsRow} from 'app/client/ui/RightPanelStyles';
|
||||
import {SortConfig} from 'app/client/ui/SortConfig';
|
||||
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
||||
import {Computed, Disposable, dom, makeTestId, styled} from 'grainjs';
|
||||
|
||||
const testId = makeTestId('test-sort-filter-config-');
|
||||
|
||||
const t = makeT('SortFilterConfig');
|
||||
|
||||
export class SortFilterConfig extends Disposable {
|
||||
private _docModel = this._gristDoc.docModel;
|
||||
private _isReadonly = this._gristDoc.isReadonly;
|
||||
|
||||
private _hasChanges: Computed<boolean> = Computed.create(this, (use) => (
|
||||
use(this._section.filterSpecChanged) || !use(this._section.activeSortJson.isSaved)
|
||||
));
|
||||
|
||||
constructor(private _section: ViewSectionRec, private _gristDoc: GristDoc) {
|
||||
super();
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
return [
|
||||
cssLabel(t('Sort')),
|
||||
dom.create(SortConfig, this._section, this._gristDoc, {
|
||||
menuOptions: {attach: 'body'},
|
||||
}),
|
||||
cssLabel(t('Filter')),
|
||||
dom.create(FilterConfig, this._section, {
|
||||
menuOptions: {attach: 'body'},
|
||||
}),
|
||||
dom.maybe(this._hasChanges, () => [
|
||||
cssSaveButtonsRow(
|
||||
cssSaveButton(t('Save'),
|
||||
dom.on('click', () => this._save()),
|
||||
dom.boolAttr('disabled', this._isReadonly),
|
||||
testId('save'),
|
||||
),
|
||||
basicButton(t('Revert'),
|
||||
dom.on('click', () => this._revert()),
|
||||
testId('revert'),
|
||||
),
|
||||
testId('save-btns'),
|
||||
),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
private async _save() {
|
||||
await this._docModel.docData.bundleActions(t('UpdateSortFilterSettings'), () => Promise.all([
|
||||
this._section.activeSortJson.save(),
|
||||
this._section.saveFilters(),
|
||||
]));
|
||||
}
|
||||
|
||||
private _revert() {
|
||||
this._section.activeSortJson.revert();
|
||||
this._section.revertFilters();
|
||||
}
|
||||
}
|
||||
|
||||
const cssSaveButton = styled(primaryButton, `
|
||||
margin-right: 8px;
|
||||
`);
|
||||
@@ -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<boolean>) {
|
||||
// [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<ColumnRec, PopupControl>();
|
||||
// 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<boolean> = 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<ColumnRec, PopupControl>) {
|
||||
return dom.domComputed((use) => {
|
||||
const filters = use(viewSectionRec.filters);
|
||||
return cssMenuText(
|
||||
cssMenuIconWrapper(
|
||||
cssIcon('Plus'),
|
||||
addFilterMenu(filters, viewSectionRec, popupControls, {
|
||||
placement: 'bottom-end',
|
||||
// Attach content to triggerElem's parent, which is needed to prevent view section menu to
|
||||
// close when clicking an item of the add filter menu.
|
||||
attach: null
|
||||
}),
|
||||
testId('plus-button'),
|
||||
dom.on('click', (ev) => ev.stopPropagation()),
|
||||
),
|
||||
cssMenuTextLabel(t('AddFilter')),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// [v] or [x] Toggle Filter Bar.
|
||||
export function makeFilterBarToggle(activeFilterBar: CustomComputed<boolean>) {
|
||||
return cssMenuText(
|
||||
cssMenuIconWrapper(
|
||||
testId('btn'),
|
||||
cssMenuIconWrapper.cls('-changed', (use) => !use(activeFilterBar.isSaved)),
|
||||
dom.domComputed((use) => {
|
||||
const filterBar = use(activeFilterBar);
|
||||
const isSaved = use(activeFilterBar.isSaved);
|
||||
return cssIcon(filterBar ? "Tick" : (isSaved ? "Plus" : "CrossSmall"),
|
||||
cssIcon.cls('-green', Boolean(filterBar)),
|
||||
testId('icon'));
|
||||
}),
|
||||
),
|
||||
dom.on('click', () => activeFilterBar(!activeFilterBar.peek())),
|
||||
cssMenuTextLabel(t("ToggleFilterBar")),
|
||||
);
|
||||
}
|
||||
|
||||
// Filtered by - section in the menu (contains all filtered columns or (Not filtered) label).
|
||||
function makeFilterPanel(section: ViewSectionRec, activeFilters: FilterInfo[],
|
||||
popupControls: WeakMap<ColumnRec, PopupControl>,
|
||||
onCloseContent: () => void) {
|
||||
const filters = activeFilters.map(filterInfo => {
|
||||
const filterChanged = Computed.create(null, fromKo(filterInfo.filter.isSaved), (_use, isSaved) => !isSaved);
|
||||
return cssMenuText(
|
||||
cssMenuIconWrapper(
|
||||
cssMenuIconWrapper.cls('-changed', filterChanged),
|
||||
cssIcon('FilterSimple'),
|
||||
attachColumnFilterMenu(section, filterInfo, {
|
||||
placement: 'bottom-end',
|
||||
trigger: [
|
||||
'click',
|
||||
(_el, popupControl) => popupControls.set(filterInfo.fieldOrColumn.origCol(), popupControl)
|
||||
],
|
||||
onCloseContent,
|
||||
}),
|
||||
testId('filter-icon'),
|
||||
),
|
||||
cssMenuTextLabel(filterInfo.fieldOrColumn.label()),
|
||||
cssMenuIconWrapper(cssIcon('Remove',
|
||||
dom.on('click', () => section.setFilter(filterInfo.fieldOrColumn.origCol().origColRef(), ''))),
|
||||
testId('btn-remove-filter')
|
||||
),
|
||||
testId('filter-col')
|
||||
);
|
||||
});
|
||||
|
||||
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 cssMenuTextLabel = styled('span', `
|
||||
color: ${theme.menuItemFg};
|
||||
flex-grow: 1;
|
||||
padding: 0 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`);
|
||||
|
||||
const cssSaveButton = styled(primaryButton, `
|
||||
margin-right: 8px;
|
||||
`);
|
||||
|
||||
const cssSmallIconWrapper = styled('div', `
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
margin: 0 5px 0 5px;
|
||||
const cssSaveTextButton = styled('div', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-size: ${vars.mediumFontSize};
|
||||
padding: 0px 5px;
|
||||
border-right: 1px solid ${theme.accentBorder};
|
||||
|
||||
&-green {
|
||||
background-color: ${theme.accentIcon};
|
||||
}
|
||||
&-gray {
|
||||
background-color: ${theme.lightText};
|
||||
}
|
||||
& > .${cssIcon.className} {
|
||||
background-color: ${theme.controlPrimaryFg};
|
||||
&-accent {
|
||||
color: ${theme.accentText};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssSaveIconsWrapper = styled('div', `
|
||||
const cssRevertIconButton = styled('div', `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
`);
|
||||
|
||||
const cssRevertIcon = styled(icon, `
|
||||
--icon-color: ${theme.accentIcon};
|
||||
margin: 0 5px 0 5px;
|
||||
`);
|
||||
|
||||
const cssSectionSaveButtonsWrapper = styled('div', `
|
||||
padding: 0 1px 0 1px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-self: normal;
|
||||
`);
|
||||
|
||||
const cssSpacer = styled('div', `
|
||||
|
||||
Reference in New Issue
Block a user