(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
pull/345/head
George Gevoian 1 year ago
parent af462fc938
commit 1a6d427339

@ -430,7 +430,7 @@ BaseView.prototype.filterByThisCellValue = function() {
}
filterValues = [value];
}
this.viewSection.setFilter(col.getRowId(), JSON.stringify({included: filterValues}));
this.viewSection.setFilter(col.getRowId(), {filter: JSON.stringify({included: filterValues})});
};
/**
@ -732,9 +732,9 @@ BaseView.prototype.getLastDataRowIndex = function() {
/**
* Creates and opens ColumnFilterMenu for a given field/column, and returns its PopupControl.
*/
BaseView.prototype.createFilterMenu = function(openCtl, filterInfo, onClose) {
BaseView.prototype.createFilterMenu = function(openCtl, filterInfo, options) {
return createFilterMenu(openCtl, this._sectionFilter, filterInfo, this._mainRowSource,
this.tableModel.tableData, onClose);
this.tableModel.tableData, options);
};
/**

@ -46,6 +46,7 @@ const {mouseDragMatchElem} = require('app/client/ui/mouseDrag');
const {menuToggle} = require('app/client/ui/MenuToggle');
const {showTooltip} = require('app/client/ui/tooltips');
const {parsePasteForView} = require("./BaseView2");
const {NEW_FILTER_JSON} = require('app/client/models/ColumnFilter');
const {CombinedStyle} = require("app/client/models/Styles");
// A threshold for interpreting a motionless click as a click rather than a drag.
@ -1073,12 +1074,16 @@ GridView.prototype.buildDom = function() {
// Select the column if it's not part of a multiselect.
dom.on('click', (ev) => this.maybeSelectColumn(ev.currentTarget.parentNode, field)),
(elem) => {
filterTriggerCtl = setPopupToCreateDom(elem, ctl => this._columnFilterMenu(ctl, field), {
attach: 'body',
placement: 'bottom-start',
boundaries: 'viewport',
trigger: [],
});
filterTriggerCtl = setPopupToCreateDom(
elem,
ctl => this._columnFilterMenu(ctl, field, {showAllFiltersButton: true}),
{
attach: 'body',
placement: 'bottom-start',
boundaries: 'viewport',
trigger: [],
}
);
},
menu(ctl => this.columnContextMenu(ctl, this.getSelection(), field, filterTriggerCtl)),
testId('column-menu-trigger'),
@ -1623,11 +1628,18 @@ GridView.prototype._getColumnMenuOptions = function(copySelection) {
};
}
GridView.prototype._columnFilterMenu = function(ctl, field) {
GridView.prototype._columnFilterMenu = function(ctl, field, options) {
this.ctxMenuHolder.autoDispose(ctl);
const filterInfo = this.viewSection.filters()
.find(({fieldOrColumn}) => fieldOrColumn.origCol().origColRef() === field.column().origColRef());
return this.createFilterMenu(ctl, filterInfo);
if (!filterInfo.isFiltered.peek()) {
// This is a new filter - initialize its spec and pin it.
this.viewSection.setFilter(filterInfo.fieldOrColumn.origCol().origColRef(), {
filter: NEW_FILTER_JSON,
pinned: true,
});
}
return this.createFilterMenu(ctl, filterInfo, options);
};
GridView.prototype.maybeSelectColumn = function (elem, field) {

@ -8,22 +8,13 @@ var koArray = require('../lib/koArray');
var commands = require('./commands');
var {CustomSectionElement} = require('../lib/CustomSectionElement');
const {ChartConfig} = require('./ChartView');
const {Computed, dom: grainjsDom, makeTestId, Observable, styled, MultiHolder} = require('grainjs');
const {Computed, dom: grainjsDom, makeTestId} = require('grainjs');
const {addToSort} = require('app/client/lib/sortUtil');
const {updatePositions} = require('app/client/lib/sortUtil');
const {attachColumnFilterMenu} = require('app/client/ui/ColumnFilterMenu');
const {addFilterMenu} = require('app/client/ui/FilterBar');
const {cssIcon, cssRow} = require('app/client/ui/RightPanelStyles');
const {basicButton, primaryButton} = require('app/client/ui2018/buttons');
const {labeledLeftSquareCheckbox} = require("app/client/ui2018/checkbox");
const {theme} = require('app/client/ui2018/cssVars');
const {cssDragger} = require('app/client/ui2018/draggableList');
const {menu, menuItem, select} = require('app/client/ui2018/menus');
const {cssRow} = require('app/client/ui/RightPanelStyles');
const {SortFilterConfig} = require('app/client/ui/SortFilterConfig');
const {primaryButton} = require('app/client/ui2018/buttons');
const {select} = require('app/client/ui2018/menus');
const {confirmModal} = require('app/client/ui2018/modals');
const {Sort} = require('app/common/SortSpec');
const isEqual = require('lodash/isEqual');
const {cssMenuItem} = require('popweasel');
const {makeT} = require('app/client/lib/localization');
const testId = makeTestId('test-vconfigtab-');
@ -56,282 +47,40 @@ function ViewConfigTab(options) {
.setAutoDisposeValues()
);
this.activeSectionData = this.autoDispose(ko.computed(function() {
return _.find(self.viewSectionData.all(), function(sectionData) {
return sectionData.section &&
sectionData.section.getRowId() === self.viewModel.activeSectionId();
}) || self.viewSectionData.at(0);
}));
this.isDetail = this.autoDispose(ko.computed(function() {
return ['detail', 'single'].includes(this.viewModel.activeSection().parentKey());
}, this));
this.isChart = this.autoDispose(ko.computed(function() {
return this.viewModel.activeSection().parentKey() === 'chart';}, this));
return this.viewModel.activeSection().parentKey() === 'chart';}, this));
this.isGrid = this.autoDispose(ko.computed(function() {
return this.viewModel.activeSection().parentKey() === 'record';}, this));
return this.viewModel.activeSection().parentKey() === 'record';}, this));
this.isCustom = this.autoDispose(ko.computed(function() {
return this.viewModel.activeSection().parentKey() === 'custom';}, this));
}
dispose.makeDisposable(ViewConfigTab);
ViewConfigTab.prototype.buildSortDom = function() {
return grainjsDom.maybe(this.activeSectionData, (sectionData) => {
const section = sectionData.section;
// Computed to indicate if sort has changed from saved.
const hasChanged = Computed.create(null, (use) =>
!isEqual(use(section.activeSortSpec), Sort.parseSortColRefs(use(section.sortColRefs))));
// Computed array of sortable columns.
const columns = Computed.create(null, (use) => {
// Columns is an observable holding an observable array - must call 'use' on it 2x.
const cols = use(use(use(section.table).columns));
return cols.filter(col => !use(col.isHiddenCol))
.map(col => ({
label: use(col.colId),
value: col.getRowId(),
icon: 'FieldColumn',
type: col.type()
}));
});
// We only want to recreate rows, when the actual columns change.
const colRefs = Computed.create(null, (use) => {
return use(section.activeSortSpec).map(col => Sort.getColRef(col));
});
const sortRows = koArray(colRefs.get());
colRefs.addListener((curr, prev) => {
if (!isEqual(curr, prev)){
sortRows.assign(curr);
}
})
return this.viewModel.activeSection().parentKey() === 'custom';}, this));
this.isRaw = this.autoDispose(ko.computed(function() {
return this.viewModel.activeSection().isRaw();}, this));
// Sort row create function for each sort row in the draggableList.
const rowCreateFn = colRef =>
this._buildSortRow(colRef, section.activeSortSpec, columns);
// Reorder function called when sort rows are reordered via dragging.
const reorder = (...args) => {
const spec = Sort.reorderSortRefs(section.activeSortSpec.peek(), ...args);
this._saveSort(spec);
};
this.activeRawSectionData = this.autoDispose(ko.computed(function() {
return self.isRaw() ? ViewSectionData.create(self.viewModel.activeSection()) : null;
}));
return grainjsDom('div',
grainjsDom.autoDispose(hasChanged),
grainjsDom.autoDispose(columns),
grainjsDom.autoDispose(colRefs),
grainjsDom.autoDispose(sortRows),
// Sort rows.
kf.draggableList(sortRows, rowCreateFn, {
reorder,
removeButton: false,
drag_indicator: cssDragger,
itemClass: cssDragRow.className
}),
// Add to sort btn & menu & fake sort row.
this._buildAddToSortBtn(columns),
// Update/save/reset buttons visible when the sort has changed.
cssRow(
cssExtraMarginTop.cls(''),
grainjsDom.maybe(hasChanged, () => [
primaryButton(t('Save'), {style: 'margin-right: 8px;'},
grainjsDom.on('click', () => { section.activeSortJson.save(); }),
testId('sort-save'),
grainjsDom.boolAttr('disabled', this.gristDoc.isReadonly),
),
// Let's use same label (revert) as the similar button which appear in the view section.
// menu.
basicButton(t('Revert'),
grainjsDom.on('click', () => { section.activeSortJson.revert(); }),
testId('sort-reset')
)
]),
cssFlex(),
grainjsDom.maybe(section.isSorted, () =>
basicButton(t('UpdateData'), {style: 'margin-left: 8px; white-space: nowrap;'},
grainjsDom.on('click', () => { updatePositions(this.gristDoc, section); }),
testId('sort-update'),
grainjsDom.show((use) => use(use(section.table).supportsManualSort)),
grainjsDom.boolAttr('disabled', this.gristDoc.isReadonly),
)
),
grainjsDom.show((use) => use(hasChanged) || use(section.isSorted))
),
testId('sort-menu')
this.activeSectionData = this.autoDispose(ko.computed(function() {
return (
_.find(self.viewSectionData.all(), function(sectionData) {
return sectionData.section &&
sectionData.section.getRowId() === self.viewModel.activeSectionId();
})
|| self.activeRawSectionData()
|| self.viewSectionData.at(0)
);
});
};
// Builds a single row of the sort dom
// Takes the colRef, current sortSpec and array of column select options to show
// in the column select dropdown.
ViewConfigTab.prototype._buildSortRow = function(colRef, sortSpec, columns) {
const holder = new MultiHolder();
const col = Computed.create(holder, () => colRef);
const details = Computed.create(holder, (use) => Sort.specToDetails(Sort.findCol(use(sortSpec), colRef)));
const hasSpecs = Computed.create(holder, details, (_, details) => Sort.hasOptions(details));
const isAscending = Computed.create(holder, details, (_, details) => details.direction === Sort.ASC);
col.onWrite((newRef) => {
let specs = sortSpec.peek();
const colSpec = Sort.findCol(specs, colRef);
const newSpec = Sort.findCol(specs, newRef);
if (newSpec) {
// this column is already there so only swap order
specs = Sort.swap(specs, colRef, newRef);
// but keep the directions
specs = Sort.setSortDirection(specs, colRef, Sort.direction(newSpec))
specs = Sort.setSortDirection(specs, newRef, Sort.direction(colSpec))
} else {
specs = Sort.replace(specs, colRef, Sort.createColSpec(newRef, Sort.direction(colSpec)));
}
this._saveSort(specs);
});
const computedFlag = (flag, allowedTypes, label) => {
const computed = Computed.create(holder, details, (_, details) => details[flag] || false);
computed.onWrite(value => {
const specs = sortSpec.peek();
// Get existing details
const details = Sort.specToDetails(Sort.findCol(specs, colRef));
// Update flags
details[flag] = value;
// Replace the colSpec at the index
this._saveSort(Sort.replace(specs, Sort.getColRef(colRef), details));
});
return {computed, allowedTypes, flag, label};
}
const orderByChoice = computedFlag('orderByChoice', ['Choice'], t('UseChoicePosition'));
const naturalSort = computedFlag('naturalSort', ['Text'], t('NaturalSort'));
const emptyLast = computedFlag('emptyLast', null, t('EmptyValuesLast'));
const flags = [orderByChoice, emptyLast, naturalSort];
const column = columns.get().find(col => col.value === Sort.getColRef(colRef));
}));
}
dispose.makeDisposable(ViewConfigTab);
return cssSortRow(
grainjsDom.autoDispose(holder),
cssSortSelect(
select(col, columns)
),
// Use domComputed method for this icon, for dynamic testId, otherwise
// we are not able add it dynamically.
grainjsDom.domComputed(isAscending, isAscending =>
cssSortIconPrimaryBtn(
"Sort",
grainjsDom.style("transform", isAscending ? "scaleY(-1)" : "none"),
grainjsDom.on("click", () => {
this._saveSort(Sort.flipSort(sortSpec.peek(), colRef));
}),
testId("sort-order"),
testId(isAscending ? "sort-order-asc" : "sort-order-desc")
)
),
cssSortIconBtn('Remove',
grainjsDom.on('click', () => {
const specs = sortSpec.peek();
if (Sort.findCol(specs, colRef)) {
this._saveSort(Sort.removeCol(specs, colRef));
}
}),
testId('sort-remove')
),
cssMenu(
cssBigIconWrapper(
cssIcon('Dots', grainjsDom.cls(cssBgAccent.className, hasSpecs)),
testId('sort-options-icon'),
),
menu(_ctl => flags.map(({computed, allowedTypes, flag, label}) => {
// when allowedTypes is null, flag can be used for every column
const enabled = !allowedTypes || allowedTypes.includes(column.type);
return cssMenuItem(
labeledLeftSquareCheckbox(
computed,
label,
grainjsDom.prop('disabled', !enabled),
),
grainjsDom.cls(cssOptionMenuItem.className),
grainjsDom.cls('disabled', !enabled),
testId('sort-option'),
testId(`sort-option-${flag}`),
);
},
))
),
testId('sort-row')
);
};
// Build the button to open the menu to add a sort item to the sort dom.
// Takes the full array of sortable column select options.
ViewConfigTab.prototype._buildAddToSortBtn = function(columns) {
// Observable indicating whether the add new column row is visible.
const showAddNew = Observable.create(null, false);
const available = Computed.create(null, (use) => {
const currentSection = use(this.activeSectionData).section;
const currentSortSpec = use(currentSection.activeSortSpec);
const specRowIds = new Set(currentSortSpec.map(_sortRef => Sort.getColRef(_sortRef)));
return use(columns)
.filter(_col => !specRowIds.has(_col.value))
ViewConfigTab.prototype.buildSortFilterDom = function() {
return grainjsDom.maybe(this.activeSectionData, ({section}) => {
return grainjsDom.create(SortFilterConfig, section, this.gristDoc);
});
return [
// Add column button.
cssRow(
grainjsDom.autoDispose(showAddNew),
grainjsDom.autoDispose(available),
cssTextBtn(
cssPlusIcon('Plus'), t('AddColumn'),
testId('sort-add')
),
grainjsDom.hide((use) => use(showAddNew) || !use(available).length),
grainjsDom.on('click', () => { showAddNew.set(true); }),
),
// Fake add column row that appears only when the menu is open to select a new column
// to add to the sort. Immediately destroyed when menu is closed.
grainjsDom.maybe((use) => use(showAddNew) && use(available), _columns => {
const col = Observable.create(null, 0);
const currentSection = this.activeSectionData().section;
// Function called when a column select value is clicked.
const onClick = (_col) => {
showAddNew.set(false); // Remove add row ASAP to prevent flickering
addToSort(currentSection.activeSortSpec, _col.value, 1);
};
const menuCols = _columns.map(_col =>
menuItem(() => onClick(_col),
cssMenuIcon(_col.icon),
_col.label,
testId('sort-add-menu-row')
)
);
return cssRow(cssSortRow(
dom.autoDispose(col),
cssSortSelect(
select(col, [], {defaultLabel: t('AddColumn')}),
menu(() => [
menuCols,
grainjsDom.onDispose(() => { showAddNew.set(false); })
], {
// Trigger to make menu open immediately
trigger: [(elem, ctl) => {
ctl.open();
grainjsDom.onElem(elem, 'click', () => { ctl.close(); });
}],
stretchToSelector: `.${cssSortSelect.className}`
})
),
cssSortIconPrimaryBtn('Sort',
grainjsDom.style('transform', 'scaleY(-1)')
),
cssSortIconBtn('Remove'),
cssBigIconWrapper(cssIcon('Dots')),
));
})
];
};
ViewConfigTab.prototype._saveSort = function(sortSpec) {
this.activeSectionData().section.activeSortSpec(sortSpec);
};
ViewConfigTab.prototype._makeOnDemand = function(table) {
@ -394,90 +143,6 @@ ViewConfigTab.prototype._buildAdvancedSettingsDom = function() {
});
};
ViewConfigTab.prototype._buildFilterDom = function() {
return grainjsDom.maybe(this.activeSectionData, (sectionData) => {
const section = sectionData.section;
const docModel = this.gristDoc.docModel;
const popupControls = new WeakMap();
const activeFilterBar = section.activeFilterBar;
const hasChangedObs = Computed.create(null, (use) => use(section.filterSpecChanged) || !use(section.activeFilterBar.isSaved))
async function save() {
await docModel.docData.bundleActions(t("UpdateFilterSettings"), () => Promise.all([
section.saveFilters(), // Save filter
section.activeFilterBar.save(), // Save bar
]));
}
function revert() {
section.revertFilters(); // Revert filter
section.activeFilterBar.revert(); // Revert bar
}
return [
grainjsDom.forEach(section.activeFilters, (filterInfo) => {
return cssRow(
cssIconWrapper(
cssFilterIcon('FilterSimple', cssNoMarginLeft.cls('')),
attachColumnFilterMenu(section, filterInfo, {
placement: 'bottom-end', attach: 'body',
trigger: [
'click',
(_el, popupControl) => popupControls.set(filterInfo.fieldOrColumn.origCol(), popupControl)
],
}),
),
cssLabel(grainjsDom.text(filterInfo.fieldOrColumn.label)),
cssIconWrapper(
cssFilterIcon('Remove',
dom.on('click', () => section.setFilter(filterInfo.fieldOrColumn.origCol().origColRef(), '')),
testId('remove-filter')
),
),
testId('filter'),
);
}),
cssRow(
grainjsDom.domComputed((use) => {
const filters = use(section.filters);
return cssTextBtn(
cssPlusIcon('Plus'), t('AddFilter'),
addFilterMenu(filters, section, popupControls, {placement: 'bottom-end'}),
testId('add-filter-btn'),
);
}),
),
grainjsDom.maybe((use) => !use(section.isRaw),
() => cssRow(cssTextBtn(
testId('toggle-filter-bar'),
grainjsDom.domComputed((use) => {
const filterBar = use(activeFilterBar);
return cssPlusIcon(
filterBar ? "Tick" : "Plus",
cssIcon.cls('-green', Boolean(filterBar)),
testId('toggle-filter-bar-icon'),
);
}),
grainjsDom.on('click', () => activeFilterBar(!activeFilterBar.peek())),
'Toggle Filter Bar',
))),
grainjsDom.maybe(hasChangedObs, () => cssRow(
cssExtraMarginTop.cls(''),
testId('save-filter-btns'),
primaryButton(
t('Save'), {style: 'margin-right: 8px'},
grainjsDom.on('click', save),
grainjsDom.boolAttr('disabled', this.gristDoc.isReadonly),
),
basicButton(
t('Revert'),
grainjsDom.on('click', revert),
)
))
];
});
};
ViewConfigTab.prototype._buildThemeDom = function() {
return kd.maybe(this.activeSectionData, (sectionData) => {
var section = sectionData.section;
@ -570,132 +235,4 @@ ViewConfigTab.prototype._buildCustomTypeItems = function() {
}];
};
const cssMenuIcon = styled(cssIcon, `
margin: 0 8px 0 0;
.${cssMenuItem.className}-sel > & {
background-color: ${theme.iconButtonFg};
}
`);
// Note that the width is set to 0 so that flex-shrink works properly with long text values.
const cssSortSelect = styled('div', `
flex: 1 1 0px;
margin: 0 6px 0 0;
min-width: 0;
`);
const cssSortIconBtn = styled(cssIcon, `
flex: none;
margin: 0 6px;
cursor: pointer;
background-color: ${theme.controlSecondaryFg};
&:hover {
background-color: ${theme.controlSecondaryHoverFg};
}
`);
const cssSortIconPrimaryBtn = styled(cssSortIconBtn, `
background-color: ${theme.controlFg};
&:hover {
background-color: ${theme.controlHoverFg};
}
`);
const cssTextBtn = styled('div', `
color: ${theme.controlFg};
cursor: pointer;
&:hover {
color: ${theme.controlHoverFg};
}
`);
const cssPlusIcon = styled(cssIcon, `
background-color: ${theme.controlFg};
cursor: pointer;
margin: 0px 4px 3px 0;
.${cssTextBtn.className}:hover > & {
background-color: ${theme.controlHoverFg};
}
`);
const cssDragRow = styled('div', `
display: flex !important;
align-items: center;
margin: 0 16px 0px 0px;
& > .kf_draggable_content {
margin: 6px 0;
flex: 1 1 0px;
min-width: 0px;
}
`);
const cssSortRow = styled('div', `
display: flex;
align-items: center;
width: 100%;
`);
const cssFlex = styled('div', `
flex: 1 1 0;
`);
const cssLabel = styled('div', `
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex-grow: 1;
`);
const cssExtraMarginTop = styled('div', `
margin-top: 28px;
`);
const cssFilterIcon = cssSortIconBtn;
const cssNoMarginLeft = styled('div', `
margin-left: 0;
`);
const cssIconWrapper = styled('div', ``);
const cssBigIconWrapper = styled('div', `
padding: 3px;
border-radius: 3px;
cursor: pointer;
user-select: none;
`);
const cssMenu = styled('div', `
display: inline-flex;
cursor: pointer;
border-radius: 3px;
border: 1px solid transparent;
&:hover, &.weasel-popup-open {
background-color: ${theme.hover};
}
`);
const cssBgAccent = styled(`div`, `
background: ${theme.accentIcon}
`)
const cssOptionMenuItem = styled('div', `
&:hover {
background-color: ${theme.hover};
}
& label {
flex: 1;
cursor: pointer;
}
&.disabled * {
color: ${theme.menuItemDisabledFg} important;
cursor: not-allowed;
}
`)
module.exports = ViewConfigTab;

@ -155,6 +155,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
nextSection: () => { this._otherSection(+1); },
prevSection: () => { this._otherSection(-1); },
printSection: () => { printViewSection(this._layout, this.viewModel.activeSection()).catch(reportError); },
sortFilterMenuOpen: (sectionId?: number) => { this._openSortFilterMenu(sectionId); },
};
this.autoDispose(commands.createGroup(commandGroup, this, true));
}
@ -267,6 +268,20 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
this.gristDoc.viewModel.activeSectionId(layoutBox.leafId.peek());
}
}
/**
* Opens the sort and filter menu of the active view section.
*
* Optionally accepts a `sectionId` for opening a specific section's menu.
*/
private _openSortFilterMenu(sectionId?: number) {
const id = sectionId ?? this.viewModel.activeSectionId();
const leafBoxDom = this._layout.getLeafBox(id)?.dom;
if (!leafBoxDom) { return; }
const menu: HTMLElement | null = leafBoxDom.querySelector('.test-section-menu-sortAndFilter');
menu?.click();
}
}
export function buildViewSectionDom(options: {
@ -305,11 +320,10 @@ export function buildViewSectionDom(options: {
buildWidgetTitle(vs, options, testId('viewsection-title'), cssTestClick(testId("viewsection-blank"))),
viewInstance.buildTitleControls(),
dom('span.viewsection_buttons',
dom.create(viewSectionMenu, gristDoc.docModel, vs, gristDoc.isReadonly)
dom.create(viewSectionMenu, gristDoc, vs)
)
)),
dom.maybe((use) => use(vs.activeFilterBar) || use(vs.isRaw) && use(vs.activeFilters).length,
() => dom.create(filterBar, vs)),
dom.create(filterBar, vs),
dom.maybe<BaseView|null>(vs.viewInstance, (viewInstance) => [
dom('div.view_data_pane_container.flexvbox',
cssResizing.cls('', isResizing),

@ -66,11 +66,6 @@ exports.groups = [{
keys: ['Esc'],
desc: null, // Shortcut to close active menu
},
{
name: 'filterMenuOpen',
keys: [],
desc: 'Shortcut to open filter menu'
},
{
name: 'docTabOpen',
keys: [],
@ -91,6 +86,11 @@ exports.groups = [{
keys: [],
desc: 'Shortcut to sort & filter tab'
},
{
name: 'sortFilterMenuOpen',
keys: [],
desc: 'Shortcut to open sort & filter menu'
},
{
name: 'dataSelectionTabOpen',
keys: [],

@ -39,6 +39,7 @@ declare module "app/client/components/BaseView" {
import {ViewFieldRec, ViewSectionRec} from "app/client/models/DocModel";
import {FilterInfo} from 'app/client/models/entities/ViewSectionRec';
import {SortedRowSet} from 'app/client/models/rowset';
import {IColumnFilterMenuOptions} from 'app/client/ui/ColumnFilterMenu';
import {FieldBuilder} from "app/client/widgets/FieldBuilder";
import {DomArg} from 'grainjs';
import {IOpenController} from 'popweasel';
@ -66,7 +67,8 @@ declare module "app/client/components/BaseView" {
constructor(gristDoc: GristDoc, viewSectionModel: any, options?: {addNewRow?: boolean, isPreview?: boolean});
public setCursorPos(cursorPos: CursorPos): void;
public createFilterMenu(ctl: IOpenController, filterInfo: FilterInfo, onClose?: () => void): HTMLElement;
public createFilterMenu(ctl: IOpenController, filterInfo: FilterInfo,
options?: IColumnFilterMenuOptions): HTMLElement;
public buildTitleControls(): DomArg;
public getLoadingDonePromise(): Promise<void>;
public activateEditorAtCursor(options?: Options): void;
@ -95,10 +97,9 @@ declare module "app/client/components/ViewConfigTab" {
class ViewConfigTab extends Disposable {
constructor(options: {gristDoc: GristDoc, viewModel: ViewRec});
public buildSortDom(): DomContents;
public buildSortFilterDom(): DomContents;
// TODO: these should be made private or renamed.
public _buildAdvancedSettingsDom(): DomArg;
public _buildFilterDom(): DomArg;
public _buildThemeDom(): DomArg;
public _buildChartConfigDom(): DomContents;
public _buildLayoutDom(): DomArg;

@ -40,6 +40,10 @@ export class ColumnFilter extends Disposable {
return this._columnType;
}
public get initialFilterJson() {
return this._initialFilterJson;
}
public setState(filterJson: string|FilterSpec) {
const state = makeFilterState(filterJson);
if (isRangeFilter(state)) {
@ -138,4 +142,18 @@ export class ColumnFilter extends Disposable {
}
}
export const allInclusive = '{"excluded":[]}';
/**
* A JSON-encoded filter spec that includes every value.
*/
export const ALL_INCLUSIVE_FILTER_JSON = '{"excluded":[]}';
/**
* A blank JSON-encoded filter spec.
*
* This is interpreted the same as `ALL_INCLUSIVE_FILTER_JSON` in the context
* of parsing filters. However, it's still useful in scenarios where it's
* necessary to discern between new filters and existing filters; initializing
* a `ColumnFilter` with `NEW_FIlTER_JSON` makes it clear that a new filter
* is being created.
*/
export const NEW_FILTER_JSON = '{}';

@ -1,4 +1,5 @@
import { ColumnFilter } from "app/client/models/ColumnFilter";
import { FilterInfo } from "app/client/models/entities/ViewSectionRec";
import { CellValue } from "app/plugin/GristData";
import { Computed, Disposable, Observable } from "grainjs";
import escapeRegExp = require("lodash/escapeRegExp");
@ -23,7 +24,21 @@ type ICompare<T> = (a: T, b: T) => number
const localeCompare = new Intl.Collator('en-US', {numeric: true}).compare;
interface ColumnFilterMenuModelParams {
columnFilter: ColumnFilter;
filterInfo: FilterInfo;
valueCount: Array<[CellValue, IFilterCount]>;
limitShow?: number;
}
export class ColumnFilterMenuModel extends Disposable {
public readonly columnFilter = this._params.columnFilter;
public readonly filterInfo = this._params.filterInfo;
public readonly initialPinned = this.filterInfo.isPinned.peek();
public readonly limitShown = this._params.limitShow ?? MAXIMUM_SHOWN_FILTER_ITEMS;
public readonly searchValue = Observable.create(this, '');
@ -34,7 +49,7 @@ export class ColumnFilterMenuModel extends Disposable {
const searchRegex = new RegExp(escapeRegExp(searchValue), 'i');
const showAllOptions = ['Bool', 'Choice', 'ChoiceList'].includes(this.columnFilter.columnType);
return new Set(
this._valueCount
this._params.valueCount
.filter(([_, {label, count}]) => (showAllOptions ? true : count) && searchRegex.test(label))
.map(([key]) => key)
);
@ -56,7 +71,7 @@ export class ColumnFilterMenuModel extends Disposable {
return localeCompare(a, b);
};
return this._valueCount
return this._params.valueCount
.filter(([key]) => filter.has(key))
.sort((a, b) => comparator(a[1][prop], b[1][prop]));
}
@ -64,12 +79,12 @@ export class ColumnFilterMenuModel extends Disposable {
// computes the array of all values that does NOT matches the search text
public readonly otherValues = Computed.create(this, this.filterSet, (_use, filter) => {
return this._valueCount.filter(([key]) => !filter.has(key));
return this._params.valueCount.filter(([key]) => !filter.has(key));
});
// computes the array of keys that matches the search text
public readonly filteredKeys = Computed.create(this, this.filterSet, (_use, filter) => {
return this._valueCount
return this._params.valueCount
.filter(([key]) => filter.has(key))
.map(([key]) => key);
});
@ -78,8 +93,7 @@ export class ColumnFilterMenuModel extends Disposable {
return filteredValues.slice(this.limitShown);
});
constructor(public columnFilter: ColumnFilter, private _valueCount: Array<[CellValue, IFilterCount]>,
public limitShown: number = MAXIMUM_SHOWN_FILTER_ITEMS) {
constructor(private _params: ColumnFilterMenuModelParams) {
super();
}
}

@ -19,6 +19,7 @@ import {RowId} from 'app/client/models/rowset';
import {LinkConfig} from 'app/client/ui/selectBy';
import {getWidgetTypes} from 'app/client/ui/widgetTypes';
import {AccessLevel, ICustomWidget} from 'app/common/CustomWidget';
import {UserAction} from 'app/common/DocActions';
import {arrayRepeat} from 'app/common/gutil';
import {Sort} from 'app/common/SortSpec';
import {ColumnsToMap, WidgetColumnMap} from 'app/plugin/CustomSectionAPI';
@ -72,7 +73,7 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
*
* NOTE: See `filters`, where `_unsavedFilters` is merged with `savedFilters`.
*/
_unsavedFilters: Map<number, string>;
_unsavedFilters: Map<number, Partial<Filter>>;
/**
* Filter information for all fields/section in the section.
@ -86,6 +87,9 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
// Subset of `filters` containing non-blank active filters.
activeFilters: Computed<FilterInfo[]>;
// Subset of `activeFilters` that are pinned.
pinnedActiveFilters: Computed<FilterInfo[]>;
// Helper metadata item which indicates whether any of the section's fields/columns have unsaved
// changes to their filters. (True indicates unsaved changes)
filterSpecChanged: Computed<boolean>;
@ -146,7 +150,6 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
isSorted: ko.Computed<boolean>;
disableDragRows: ko.Computed<boolean>;
activeFilterBar: modelUtil.CustomComputed<boolean>;
// Number of frozen columns
rawNumFrozen: modelUtil.CustomComputed<number>;
// Number for frozen columns to display.
@ -191,8 +194,11 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
// Revert all filters of fields/columns in the section.
revertFilters(): void;
// Apply `filter` to the field or column identified by `colRef`.
setFilter(colRef: number, filter: string): void;
// Set `filter` for the field or column identified by `colRef`.
setFilter(colRef: number, filter: Partial<Filter>): void;
// Revert the filter of the field or column identified by `colRef`.
revertFilter(colRef: number): void;
// Saves custom definition (bundles change)
saveCustomDef(): Promise<void>;
@ -236,14 +242,25 @@ export interface CustomViewSectionDef {
sectionId: modelUtil.KoSaveableObservable<string>;
}
// Information about filters for a field or hidden column.
/** Information about filters for a field or hidden column. */
export interface FilterInfo {
// The field or column associated with this filter info (field if column is visible, else column).
/** The section that's being filtered. */
viewSection: ViewSectionRec;
/** The field or column that's being filtered. (Field if column is visible.) */
fieldOrColumn: ViewFieldRec|ColumnRec;
// Filter that applies to this field/column, if any.
/** Filter that applies to this field/column, if any. */
filter: modelUtil.CustomComputed<string>;
// True if `filter` has a non-blank value.
/** Whether this filter is pinned to the filter bar. */
pinned: modelUtil.CustomComputed<boolean>;
/** True if `filter` has a non-blank value. */
isFiltered: ko.PureComputed<boolean>;
/** True if `pinned` is true. */
isPinned: ko.PureComputed<boolean>;
}
export interface Filter {
filter: string;
pinned: boolean;
}
export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): void {
@ -262,7 +279,6 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
horizontalGridlines: true,
zebraStripes: false,
customView: '',
filterBar: false,
numFrozen: 0
};
this.optionsObj = modelUtil.jsonObservable(this.options,
@ -365,7 +381,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
this._unsavedFilters = new Map();
/**
* Filter information for all fields/section in the section.
* Filter information for all fields/columns in the section.
*
* Re-computed on changes to `savedFilters`, as well as any changes to `viewFields` or `columns`. Any
* unsaved filters saved in `_unsavedFilters` are applied on computation, taking priority over saved
@ -377,30 +393,43 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
return this.columns().map(column => {
const savedFilter = savedFiltersByColRef.get(column.origColRef());
// Initialize with a saved filter, if one exists. Otherwise, use a blank filter.
const filter = modelUtil.customComputed({
// Initialize with a saved filter, if one exists. Otherwise, use a blank filter.
read: () => { return savedFilter ? savedFilter.activeFilter() : ''; },
});
const pinned = modelUtil.customComputed({
read: () => { return savedFilter ? savedFilter.pinned() : false; },
});
// If an unsaved filter exists, overwrite `filter` with it.
// If an unsaved filter exists, overwrite the filter with it.
const unsavedFilter = this._unsavedFilters.get(column.origColRef());
if (unsavedFilter !== undefined) { filter(unsavedFilter); }
if (unsavedFilter) {
const {filter: f, pinned: p} = unsavedFilter;
if (f !== undefined) { filter(f); }
if (p !== undefined) { pinned(p); }
}
return {
viewSection: this,
filter,
pinned,
fieldOrColumn: viewFieldsByColRef.get(column.origColRef()) ?? column,
isFiltered: ko.pureComputed(() => filter() !== '')
isFiltered: ko.pureComputed(() => filter() !== ''),
isPinned: ko.pureComputed(() => pinned()),
};
});
}));
// List of `filters` that have non-blank active filters.
this.activeFilters = Computed.create(this, use => use(this.filters).filter(col => use(col.isFiltered)));
this.activeFilters = Computed.create(this, use => use(this.filters).filter(f => use(f.isFiltered)));
// List of `activeFilters` that are pinned.
this.pinnedActiveFilters = Computed.create(this, use => use(this.activeFilters).filter(f => use(f.isPinned)));
// Helper metadata item which indicates whether any of the section's fields/columns have unsaved
// changes to their filters. (True indicates unsaved changes)
this.filterSpecChanged = Computed.create(this, use => {
return use(this.filters).some(col => !use(col.filter.isSaved));
return use(this.filters).some(col => !use(col.filter.isSaved) || !use(col.pinned.isSaved));
});
// Save all filters of fields/columns in the section.
@ -408,52 +437,72 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
return docModel.docData.bundleActions(`Save all filters in ${this.titleDef()}`,
async () => {
const savedFiltersByColRef = new Map(this._savedFilters().all().map(f => [f.colRef(), f]));
const updatedFilters: [number, string][] = []; // Pairs of row ids and filters to update.
const updatedFilters: [number, Filter][] = []; // Pairs of row ids and filters to update.
const removedFilterIds: number[] = []; // Row ids of filters to remove.
const newFilters: [number, string][] = []; // Pairs of column refs and filters to add.
const newFilters: [number, Filter][] = []; // Pairs of column refs and filters to add.
for (const c of this.filters()) {
for (const f of this.filters()) {
const {fieldOrColumn, filter, pinned} = f;
// Skip saved filters (i.e. filters whose local values are unchanged from server).
if (c.filter.isSaved()) { continue; }
if (filter.isSaved() && pinned.isSaved()) { continue; }
const savedFilter = savedFiltersByColRef.get(c.fieldOrColumn.origCol().origColRef());
const savedFilter = savedFiltersByColRef.get(fieldOrColumn.origCol().origColRef());
if (!savedFilter) {
// Never save blank filters. (This is primarily a sanity check.)
if (filter() === '') { continue; }
// Since no saved filter exists, we must add a new record to the filters table.
newFilters.push([c.fieldOrColumn.origCol().origColRef(), c.filter()]);
} else if (c.filter() === '') {
newFilters.push([fieldOrColumn.origCol().origColRef(), {
filter: filter(),
pinned: pinned(),
}]);
} else if (filter() === '') {
// Mark the saved filter for removal from the filters table.
removedFilterIds.push(savedFilter.id());
} else {
// Mark the saved filter for update in the filters table.
updatedFilters.push([savedFilter.id(), c.filter()]);
updatedFilters.push([savedFilter.id(), {
filter: filter(),
pinned: pinned(),
}]);
}
}
const actions: UserAction[] = [];
// Remove records of any deleted filters.
if (removedFilterIds.length > 0) {
await docModel.filters.sendTableAction(['BulkRemoveRecord', removedFilterIds]);
actions.push(['BulkRemoveRecord', removedFilterIds]);
}
// Update existing filter records with new filter values.
if (updatedFilters.length > 0) {
await docModel.filters.sendTableAction(['BulkUpdateRecord',
actions.push(['BulkUpdateRecord',
updatedFilters.map(([id]) => id),
{filter: updatedFilters.map(([, filter]) => filter)}
{
filter: updatedFilters.map(([, {filter}]) => filter),
pinned: updatedFilters.map(([, {pinned}]) => pinned),
}
]);
}
// Add new filter records.
if (newFilters.length > 0) {
await docModel.filters.sendTableAction(['BulkAddRecord',
actions.push(['BulkAddRecord',
arrayRepeat(newFilters.length, null),
{
viewSectionRef: arrayRepeat(newFilters.length, this.id()),
colRef: newFilters.map(([colRef]) => colRef),
filter: newFilters.map(([, filter]) => filter),
filter: newFilters.map(([, {filter}]) => filter),
pinned: newFilters.map(([, {pinned}]) => pinned),
}
]);
}
if (actions.length > 0) {
await docModel.filters.sendTableActions(actions);
}
// Reset client filter state.
this.revertFilters();
}
@ -462,15 +511,32 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
// Revert all filters of fields/columns in the section.
this.revertFilters = () => {
this._unsavedFilters = new Map();
this.filters().forEach(c => { c.filter.revert(); });
this._unsavedFilters.clear();
this.filters().forEach(c => {
c.filter.revert();
c.pinned.revert();
});
};
// Set `filter` for the field or column identified by `colRef`.
this.setFilter = (colRef: number, filter: Partial<Filter>) => {
this._unsavedFilters.set(colRef, {...this._unsavedFilters.get(colRef), ...filter});
const filterInfo = this.filters().find(c => c.fieldOrColumn.origCol().origColRef() === colRef);
if (!filterInfo) { return; }
const {filter: newFilter, pinned: newPinned} = filter;
if (newFilter !== undefined) { filterInfo.filter(newFilter); }
if (newPinned !== undefined) { filterInfo.pinned(newPinned); }
};
// Apply `filter` to the field or column identified by `colRef`.
this.setFilter = (colRef: number, filter: string) => {
this._unsavedFilters.set(colRef, filter);
// Revert the filter of the field or column identified by `colRef`.
this.revertFilter = (colRef: number) => {
this._unsavedFilters.delete(colRef);
const filterInfo = this.filters().find(c => c.fieldOrColumn.origCol().origColRef() === colRef);
filterInfo?.filter(filter);
if (!filterInfo) { return; }
filterInfo.filter.revert();
filterInfo.pinned.revert();
};
// Customizable version of the JSON-stringified sort spec. It may diverge from the saved one.
@ -571,8 +637,6 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
this.isSorted = ko.pureComputed(() => this.activeSortSpec().length > 0);
this.disableDragRows = ko.pureComputed(() => this.isSorted() || !this.table().supportsManualSort());
this.activeFilterBar = modelUtil.customValue(this.optionsObj.prop('filterBar'));
// Number of frozen columns
this.rawNumFrozen = modelUtil.customValue(this.optionsObj.prop('numFrozen'));
// Number for frozen columns to display

@ -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;
`);

@ -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};
}
`);

@ -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;
`);

@ -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 cssSaveButton = styled(primaryButton, `
margin-right: 8px;
`);
const cssMenuTextLabel = styled('span', `
color: ${theme.menuItemFg};
flex-grow: 1;
padding: 0 4px;
overflow: hidden;
text-overflow: ellipsis;
const cssSaveTextButton = styled('div', `
display: flex;
align-items: center;
cursor: pointer;
font-size: ${vars.mediumFontSize};
padding: 0px 5px;
border-right: 1px solid ${theme.accentBorder};
&-accent {
color: ${theme.accentText};
}
`);
const cssSaveButton = styled(primaryButton, `
margin-right: 8px;
const cssRevertIconButton = styled('div', `
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
`);
const cssSmallIconWrapper = styled('div', `
width: 16px;
height: 16px;
border-radius: 8px;
const cssRevertIcon = styled(icon, `
--icon-color: ${theme.accentIcon};
margin: 0 5px 0 5px;
&-green {
background-color: ${theme.accentIcon};
}
&-gray {
background-color: ${theme.lightText};
}
& > .${cssIcon.className} {
background-color: ${theme.controlPrimaryFg};
}
`);
const cssSaveIconsWrapper = styled('div', `
const cssSectionSaveButtonsWrapper = styled('div', `
padding: 0 1px 0 1px;
display: flex;
justify-content: space-between;
align-self: normal;
`);
const cssSpacer = styled('div', `

@ -90,6 +90,7 @@ export type IconName = "ChartArea" |
"Pencil" |
"PinBig" |
"PinSmall" |
"PinTilted" |
"Pivot" |
"PivotLight" |
"Plus" |
@ -101,6 +102,7 @@ export type IconName = "ChartArea" |
"Remove" |
"Repl" |
"ResizePanel" |
"Revert" |
"RightAlign" |
"Script" |
"Search" |
@ -220,6 +222,7 @@ export const IconList: IconName[] = ["ChartArea",
"Pencil",
"PinBig",
"PinSmall",
"PinTilted",
"Pivot",
"PivotLight",
"Plus",
@ -231,6 +234,7 @@ export const IconList: IconName[] = ["ChartArea",
"Remove",
"Repl",
"ResizePanel",
"Revert",
"RightAlign",
"Script",
"Search",

@ -22,6 +22,7 @@ export const cssButton = styled('button', `
outline: none;
border-style: none;
line-height: normal;
user-select: none;
/* Vars */
font-size: ${vars.mediumFontSize};

@ -4,7 +4,7 @@ import { GristObjCode } from "app/plugin/GristData";
// tslint:disable:object-literal-key-quotes
export const SCHEMA_VERSION = 33;
export const SCHEMA_VERSION = 34;
export const schema = {
@ -194,6 +194,7 @@ export const schema = {
viewSectionRef : "Ref:_grist_Views_section",
colRef : "Ref:_grist_Tables_column",
filter : "Text",
pinned : "Bool",
},
"_grist_Cells": {
@ -397,6 +398,7 @@ export interface SchemaTypes {
viewSectionRef: number;
colRef: number;
filter: string;
pinned: boolean;
};
"_grist_Cells": {

@ -6,7 +6,7 @@ export const GRIST_DOC_SQL = `
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',33,'','');
INSERT INTO _grist_DocInfo VALUES(1,'','','',34,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0);
@ -33,7 +33,7 @@ INSERT INTO _grist_ACLPrincipals VALUES(2,'group','','','Admins','');
INSERT INTO _grist_ACLPrincipals VALUES(3,'group','','','Editors','');
INSERT INTO _grist_ACLPrincipals VALUES(4,'group','','','Viewers','');
CREATE TABLE IF NOT EXISTS "_grist_ACLMemberships" (id INTEGER PRIMARY KEY, "parent" INTEGER DEFAULT 0, "child" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Filters" (id INTEGER PRIMARY KEY, "viewSectionRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Filters" (id INTEGER PRIMARY KEY, "viewSectionRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '', "pinned" BOOLEAN DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Cells" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "rowId" INTEGER DEFAULT 0, "root" BOOLEAN DEFAULT 0, "parentId" INTEGER DEFAULT 0, "type" INTEGER DEFAULT 0, "content" TEXT DEFAULT '', "userRef" TEXT DEFAULT '');
CREATE INDEX _grist_Attachments_fileIdent ON _grist_Attachments(fileIdent);
COMMIT;
@ -43,7 +43,7 @@ export const GRIST_DOC_WITH_TABLE1_SQL = `
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',33,'','');
INSERT INTO _grist_DocInfo VALUES(1,'','','',34,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0);
INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
@ -86,7 +86,7 @@ INSERT INTO _grist_ACLPrincipals VALUES(2,'group','','','Admins','');
INSERT INTO _grist_ACLPrincipals VALUES(3,'group','','','Editors','');
INSERT INTO _grist_ACLPrincipals VALUES(4,'group','','','Viewers','');
CREATE TABLE IF NOT EXISTS "_grist_ACLMemberships" (id INTEGER PRIMARY KEY, "parent" INTEGER DEFAULT 0, "child" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Filters" (id INTEGER PRIMARY KEY, "viewSectionRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Filters" (id INTEGER PRIMARY KEY, "viewSectionRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '', "pinned" BOOLEAN DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Cells" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "colRef" INTEGER DEFAULT 0, "rowId" INTEGER DEFAULT 0, "root" BOOLEAN DEFAULT 0, "parentId" INTEGER DEFAULT 0, "type" INTEGER DEFAULT 0, "content" TEXT DEFAULT '', "userRef" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "Table1" (id INTEGER PRIMARY KEY, "manualSort" NUMERIC DEFAULT 1e999, "A" BLOB DEFAULT NULL, "B" BLOB DEFAULT NULL, "C" BLOB DEFAULT NULL);
CREATE INDEX _grist_Attachments_fileIdent ON _grist_Attachments(fileIdent);

@ -1112,3 +1112,45 @@ def migration33(tdset):
]
return tdset.apply_doc_actions(doc_actions)
@migration(schema_version=34)
def migration34(tdset):
""""
Add pinned column to _grist_Filters and populate based on existing sections.
When populating, pinned will be set to true for filters that either belong to
a section where the filter bar is toggled or a raw view section.
From this version on, _grist_Views_section.options.filterBar is deprecated.
"""
doc_actions = [add_column('_grist_Filters', 'pinned', 'Bool')]
tables = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Tables']))
sections = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Views_section']))
filters = list(actions.transpose_bulk_action(tdset.all_tables['_grist_Filters']))
raw_section_ids = set(t.rawViewSectionRef for t in tables)
filter_bar_by_section_id = {
# Pre-migration, raw sections always showed the filter bar in the UI. Since we want
# existing raw section filters to continue appearing in the filter bar, we'll pretend
# here that raw sections have a filterBar value of True. Note that after this migration
# it will be possible for raw sections to have unpinned filters.
s.id: bool(s.id in raw_section_ids or safe_parse(s.options).get('filterBar', False))
for s in sections
}
# List of (filter_rec, pinned) pairs.
filter_updates = []
for filter_rec in filters:
filter_updates.append((
filter_rec,
filter_bar_by_section_id.get(filter_rec.viewSectionRef, False)
))
if filter_updates:
doc_actions.append(actions.BulkUpdateRecord(
'_grist_Filters',
[filter_rec.id for filter_rec, _ in filter_updates],
{'pinned': [pinned for _, pinned in filter_updates]},
))
return tdset.apply_doc_actions(doc_actions)

@ -15,7 +15,7 @@ import six
import actions
SCHEMA_VERSION = 33
SCHEMA_VERSION = 34
def make_column(col_id, col_type, formula='', isFormula=False):
return {
@ -315,7 +315,10 @@ def schema_create_actions():
# `excluded` string to an array of column values:
# Ex1: { included: ['foo', 'bar'] }
# Ex2: { excluded: ['apple', 'orange'] }
make_column("filter", "Text")
make_column("filter", "Text"),
# Filters can be pinned to the filter bar, which causes a button to be displayed
# that opens the filter menu when clicked.
make_column("pinned", "Bool"),
]),
# Additional metadata for cells

@ -954,14 +954,16 @@ class TestUserActions(test_engine.EngineTestCase):
self.apply_user_action(['BulkAddRecord', '_grist_Filters', [None], {
"viewSectionRef": [1],
"colRef": [1],
"filter": [json.dumps({"included": ["b", "c"]})]
"filter": [json.dumps({"included": ["b", "c"]})],
"pinned": [True],
}])
# Add the same filter for second column (to make sure it is not renamed)
self.apply_user_action(['BulkAddRecord', '_grist_Filters', [None], {
"viewSectionRef": [1],
"colRef": [2],
"filter": [json.dumps({"included": ["b", "c"]})]
"filter": [json.dumps({"included": ["b", "c"]})],
"pinned": [False],
}])
# Rename choices
@ -971,9 +973,9 @@ class TestUserActions(test_engine.EngineTestCase):
# Test filters
self.assertTableData('_grist_Filters', data=[
["id", "colRef", "filter", "setAutoRemove", "viewSectionRef"],
[1, 1, json.dumps({"included": ["z", "b"]}), None, 1],
[2, 2, json.dumps({"included": ["b", "c"]}), None, 1]
["id", "colRef", "filter", "setAutoRemove", "viewSectionRef", "pinned"],
[1, 1, json.dumps({"included": ["z", "b"]}), None, 1, True],
[2, 2, json.dumps({"included": ["b", "c"]}), None, 1, False]
])
def test_add_or_update(self):

@ -91,6 +91,7 @@
--icon-Pencil: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9Ii0yIC0yIDIwIDIwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxnIGNsYXNzPSJuYy1pY29uLXdyYXBwZXIiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzIxMjEyMSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xMyAuNUwxNS41IDMgNy41IDExIDQgMTIgNSA4LjV6TTExIDIuNUwxMy41IDUiLz48cGF0aCBkPSJNMTMuNSw5LjV2NSBjMCwwLjU1Mi0wLjQ0OCwxLTEsMWgtMTFjLTAuNTUyLDAtMS0wLjQ0OC0xLTF2LTExYzAtMC41NTIsMC40NDgtMSwxLTFoNSIvPjwvZz48L3N2Zz4=');
--icon-PinBig: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjgyOTcyODEsLTcuNjAyODA3MjdlLTEzIEw0LjE5Mjk3NDkxLC03LjU3NjE2MTkyZS0xMyBDMy42MjcyODk0OCwtNy41MzE3NTNlLTEzIDMuMjAzMDI1NDEsMC40MTAyNTY0MSAzLjIwMzAyNTQxLDAuOTU3MjY0OTU3IEMzLjIwMzAyNTQxLDEuNTA0MjczNSAzLjYyNzI4OTQ4LDEuOTE0NTI5OTEgNC4xOTI5NzQ5MSwxLjkxNDUyOTkxIEw1LjA0MTUwMzA0LDEuOTE0NTI5OTEgTDIuODQ5NDcyMDIsOS4wOTQwMTcwOSBMMS40MzUyNTg0Niw5LjA5NDAxNzA5IEMwLjg2OTU3MzAzNiw5LjA5NDAxNzA5IDAuNDQ1MzA4OTY3LDkuNTA0MjczNSAwLjQ0NTMwODk2NywxMC4wNTEyODIxIEMwLjQ0NTMwODk2NywxMC41OTgyOTA2IDAuODY5NTczMDM2LDExLjAwODU0NyAxLjQzNTI1ODQ2LDExLjAwODU0NyBMMy41NTY1Nzg4LDExLjAwODU0NyBMNi45NTA2OTEzNSwxMS4wMDg1NDcgTDcuMDIxNDAyMDMsMTYgTDkuMDAxMzAxMDIsMTYgTDkuMDAxMzAxMDIsMTAuOTQwMTcwOSBMMTQuNjU4MTU1MywxMC45NDAxNzA5IEMxNS4zNjUyNjIxLDEwLjk0MDE3MDkgMTUuNjQ4MTA0OCwxMC4yNTY0MTAzIDE1LjY0ODEwNDgsOS45ODI5MDU5OCBDMTUuNjQ4MTA0OCw5LjQzNTg5NzQ0IDE1LjIyMzg0MDcsOS4wMjU2NDEwMyAxNC42NTgxNTUzLDkuMDI1NjQxMDMgTDEzLjI0Mzk0MTcsOS4wMjU2NDEwMyBMMTEuMTIyNjIxNCwxLjkxNDUyOTkxIEwxMS45NzExNDk1LDEuOTE0NTI5OTEgQzEyLjgxOTY3NzYsMS45MTQ1Mjk5MSAxMi45NjEwOTksMS4yMzA3NjkyMyAxMi45NjEwOTksMC45NTcyNjQ5NTcgQzEyLjg5MDM4ODMsMC40Nzg2MzI0NzkgMTIuMzk1NDEzNiwtNy42NjQ5Nzk3NmUtMTMgMTEuODI5NzI4MSwtNy42MDI4MDcyN2UtMTMgWiBNMTEuMDUxOTEwNyw5LjA5NDAxNzA5IEw0Ljk3MDc5MjM3LDkuMDk0MDE3MDkgTDcuMDkyMTEyNzEsMS45ODI5MDU5OCBMOC45MzA1OTAzNCwxLjk4MjkwNTk4IEwxMS4wNTE5MTA3LDkuMDk0MDE3MDkgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--icon-PinSmall: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcuNSwxMSBMMi41LDExIEMyLjIyMzg1NzYzLDExIDIsMTAuNzc2MTQyNCAyLDEwLjUgQzIsMTAuMjIzODU3NiAyLjIyMzg1NzYzLDEwIDIuNSwxMCBMNC4yNSwxMCBMNS43NSw0IEw0LjUsNCBDNC4yMjM4NTc2Myw0IDQsMy43NzYxNDIzNyA0LDMuNSBDNCwzLjIyMzg1NzYzIDQuMjIzODU3NjMsMyA0LjUsMyBMMTEuNSwzIEMxMS43NzYxNDI0LDMgMTIsMy4yMjM4NTc2MyAxMiwzLjUgQzEyLDMuNzc2MTQyMzcgMTEuNzc2MTQyNCw0IDExLjUsNCBMMTAuMjUsNCBMMTEuNzUsMTAgTDEzLjUsMTAgQzEzLjc3NjE0MjQsMTAgMTQsMTAuMjIzODU3NiAxNCwxMC41IEMxNCwxMC43NzYxNDI0IDEzLjc3NjE0MjQsMTEgMTMuNSwxMSBMOC41LDExIEw4LjUsMTQgTDcuNSwxNCBMNy41LDExIFogTTUuMjgwNzc2NDEsMTAgTDEwLjcxOTIyMzYsMTAgTDkuMjE5MjIzNTksNCBMNi43ODA3NzY0MSw0IEw1LjI4MDc3NjQxLDEwIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-PinTilted: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIgNi4wMDAyNEwxMCAxNC4wMDAyTTkuMjAwMiAyTDE0LjAwMDIgNi44TTMuMzE2ODkgNy4zMTY2MUwxMC41MTQ1IDMuMzE0MjFNOC42ODMxMSAxMi42ODNMMTIuNjg1NSA1LjQ4NTM1TTQuOCAxMS4yMDAyTDIgMTQuMDAwMiIgc3Ryb2tlPSIjMTZCMzc4IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=');
--icon-Pivot: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMsMSBMMTMsMSBDMTQuMTA0NTY5NSwxIDE1LDEuODk1NDMwNSAxNSwzIEwxNSwxMyBDMTUsMTQuMTA0NTY5NSAxNC4xMDQ1Njk1LDE1IDEzLDE1IEwzLDE1IEMxLjg5NTQzMDUsMTUgMSwxNC4xMDQ1Njk1IDEsMTMgTDEsMyBDMSwxLjg5NTQzMDUgMS44OTU0MzA1LDEgMywxIFogTTUuMzc1LDQuNSBMNS4zNzUsNC43ODU0NjM0MSBMNy42MzQ2NTAxLDguMDIyNzA3MzIgTDUuMzc1LDExLjA5NDUxMjIgTDUuMzc1LDExLjUgTDEwLjYyNSwxMS41IEwxMC42MjUsMTAuMDMyODE3MSBMOS44MjY0OTkwMSwxMC43MjY0MTQ2IEw2LjkxOTczMTYxLDEwLjcyNjQxNDYgTDYuOTE5NzMxNjEsMTAuNDE2OTYzNCBMOC45MDI4MzMsNy42MTU4NTM2NiBMNi45MTk3MzE2MSw0Ljk5MDg1MzY2IEw5Ljc3NDMxMjEzLDQuOTg3MzUzNjYgTDEwLjYyNSw2LjA5NTMxNzA3IEwxMC42MjUsNC41IEw1LjM3NSw0LjUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--icon-PivotLight: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48cmVjdCBzdHJva2U9IiM5Nzk3OTciIHg9Ii41IiB5PSIuNSIgd2lkdGg9IjE1IiBoZWlnaHQ9IjE1IiByeD0iMiIvPjxwYXRoIGZpbGw9IiM5Nzk3OTciIGQ9Ik01LjM3NSA0LjVMMTAuNjI1IDQuNSAxMC42MjUgNi4wOTUzMTcwNyA5Ljc3NDMxMjEzIDQuOTg3MzUzNjYgNi45MTk3MzE2MSA0Ljk5MDg1MzY2IDguOTAyODMzIDcuNjE1ODUzNjYgNi45MTk3MzE2MSAxMC40MTY5NjM0IDYuOTE5NzMxNjEgMTAuNzI2NDE0NiA5LjgyNjQ5OTAxIDEwLjcyNjQxNDYgMTAuNjI1IDEwLjAzMjgxNzEgMTAuNjI1IDExLjUgNS4zNzUgMTEuNSA1LjM3NSAxMS4wOTQ1MTIyIDcuNjM0NjUwMSA4LjAyMjcwNzMyIDUuMzc1IDQuNzg1NDYzNDF6Ii8+PC9nPjwvc3ZnPg==');
--icon-Plus: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTguNSw3LjUgTDEzLDcuNSBDMTMuMjc2MTQyNCw3LjUgMTMuNSw3LjcyMzg1NzYzIDEzLjUsOCBDMTMuNSw4LjI3NjE0MjM3IDEzLjI3NjE0MjQsOC41IDEzLDguNSBMOC41LDguNSBMOC41LDEzIEM4LjUsMTMuMjc2MTQyNCA4LjI3NjE0MjM3LDEzLjUgOCwxMy41IEM3LjcyMzg1NzYzLDEzLjUgNy41LDEzLjI3NjE0MjQgNy41LDEzIEw3LjUsOC41IEwzLDguNSBDMi43MjM4NTc2Myw4LjUgMi41LDguMjc2MTQyMzcgMi41LDggQzIuNSw3LjcyMzg1NzYzIDIuNzIzODU3NjMsNy41IDMsNy41IEw3LjUsNy41IEw3LjUsMyBDNy41LDIuNzIzODU3NjMgNy43MjM4NTc2MywyLjUgOCwyLjUgQzguMjc2MTQyMzcsMi41IDguNSwyLjcyMzg1NzYzIDguNSwzIEw4LjUsNy41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
@ -102,6 +103,7 @@
--icon-Remove: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYsNCBMNiwyLjUgQzYsMi4yMjM4NTc2MyA2LjIyMzg1NzYzLDIgNi41LDIgTDkuNSwyIEM5Ljc3NjE0MjM3LDIgMTAsMi4yMjM4NTc2MyAxMCwyLjUgTDEwLDQgTDEzLjUsNCBDMTMuNzc2MTQyNCw0IDE0LDQuMjIzODU3NjMgMTQsNC41IEMxNCw0Ljc3NjE0MjM3IDEzLjc3NjE0MjQsNSAxMy41LDUgTDIuNSw1IEMyLjIyMzg1NzYzLDUgMiw0Ljc3NjE0MjM3IDIsNC41IEMyLDQuMjIzODU3NjMgMi4yMjM4NTc2Myw0IDIuNSw0IEw2LDQgWiBNNyw0IEw5LDQgTDksMyBMNywzIEw3LDQgWiBNMTEsNi41IEMxMSw2LjIyMzg1NzYzIDExLjIyMzg1NzYsNiAxMS41LDYgQzExLjc3NjE0MjQsNiAxMiw2LjIyMzg1NzYzIDEyLDYuNSBMMTIsMTIuNSBDMTIsMTMuMzI4NDI3MSAxMS4zMjg0MjcxLDE0IDEwLjUsMTQgTDUuNSwxNCBDNC42NzE1NzI4OCwxNCA0LDEzLjMyODQyNzEgNCwxMi41IEw0LDYuNSBDNCw2LjIyMzg1NzYzIDQuMjIzODU3NjMsNiA0LjUsNiBDNC43NzYxNDIzNyw2IDUsNi4yMjM4NTc2MyA1LDYuNSBMNSwxMi41IEM1LDEyLjc3NjE0MjQgNS4yMjM4NTc2MywxMyA1LjUsMTMgTDEwLjUsMTMgQzEwLjc3NjE0MjQsMTMgMTEsMTIuNzc2MTQyNCAxMSwxMi41IEwxMSw2LjUgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
--icon-Repl: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE0LjI5Mjg5MzIsMTIgTDEyLjE0NjQ0NjYsOS44NTM1NTMzOSBDMTEuOTUxMTg0NSw5LjY1ODI5MTI0IDExLjk1MTE4NDUsOS4zNDE3MDg3NiAxMi4xNDY0NDY2LDkuMTQ2NDQ2NjEgQzEyLjM0MTcwODgsOC45NTExODQ0NiAxMi42NTgyOTEyLDguOTUxMTg0NDYgMTIuODUzNTUzNCw5LjE0NjQ0NjYxIEwxNS44NTM1NTM0LDEyLjE0NjQ0NjYgQzE2LjA0ODgxNTUsMTIuMzQxNzA4OCAxNi4wNDg4MTU1LDEyLjY1ODI5MTIgMTUuODUzNTUzNCwxMi44NTM1NTM0IEwxMi44NTM1NTM0LDE1Ljg1MzU1MzQgQzEyLjY1ODI5MTIsMTYuMDQ4ODE1NSAxMi4zNDE3MDg4LDE2LjA0ODgxNTUgMTIuMTQ2NDQ2NiwxNS44NTM1NTM0IEMxMS45NTExODQ1LDE1LjY1ODI5MTIgMTEuOTUxMTg0NSwxNS4zNDE3MDg4IDEyLjE0NjQ0NjYsMTUuMTQ2NDQ2NiBMMTQuMjkyODkzMiwxMyBMMTIuMjk3LDEzIEMxMS4wMTE0MDE0LDEzIDkuNzg3MzEwNDYsMTIuNDUwMDg5NiA4LjkzMzM4NDQzLDExLjQ4OTI4MjEgTDcuOTA0Mzg0NDMsMTAuMzMyMjgyMSBDNy43MjA4NzAwOSwxMC4xMjU5Mzk5IDcuNzM5Mzc1Nyw5LjgwOTg5ODc4IDcuOTQ1NzE3ODgsOS42MjYzODQ0MyBDOC4xNTIwNjAwNiw5LjQ0Mjg3MDA5IDguNDY4MTAxMjIsOS40NjEzNzU3IDguNjUxNjE1NTcsOS42Njc3MTc4OCBMOS42ODA3Mjg4LDEwLjgyNDg0NTIgQzEwLjM0NTAyNTUsMTEuNTcyMjg4NCAxMS4yOTcxMDQsMTIgMTIuMjk3LDEyIEwxNC4yOTI4OTMyLDEyIFogTTAuNSw0IEMwLjIyMzg1NzYyNSw0IC01LjE5NTg0Mzc2ZS0xNCwzLjc3NjE0MjM3IC01LjE5NTg0Mzc2ZS0xNCwzLjUgQy01LjE5NTg0Mzc2ZS0xNCwzLjIyMzg1NzYzIDAuMjIzODU3NjI1LDMgMC41LDMgTDAuNzA0LDMgQzEuOTg5NTk4NTUsMyAzLjIxMzY4OTU0LDMuNTQ5OTEwNDIgNC4wNjc3NzU5NSw0LjUxMDg5ODMgTDUuMDk1Nzc1OTUsNS42Njc4OTgzIEM1LjI3OTE5MDY1LDUuODc0MzI5MDUgNS4yNjA1MzI0Niw2LjE5MDM2MTI0IDUuMDU0MTAxNyw2LjM3Mzc3NTk1IEM0Ljg0NzY3MDk1LDYuNTU3MTkwNjUgNC41MzE2Mzg3Niw2LjUzODUzMjQ2IDQuMzQ4MjI0MDUsNi4zMzIxMDE3IEwzLjMyMDI3MTIsNS4xNzUxNTQ3NiBDMi42NTU5NzQ1Miw0LjQyNzcxMTU5IDEuNzAzODk1OTksNCAwLjcwNCw0IEwwLjUsNCBaIE0xNC4yOTE4OTMyLDIuOTk5IEwxMi4xNDY0NDY2LDAuODUzNTUzMzkxIEMxMS45NTExODQ1LDAuNjU4MjkxMjQ1IDExLjk1MTE4NDUsMC4zNDE3MDg3NTUgMTIuMTQ2NDQ2NiwwLjE0NjQ0NjYwOSBDMTIuMzQxNzA4OCwtMC4wNDg4MTU1MzY1IDEyLjY1ODI5MTIsLTAuMDQ4ODE1NTM2NSAxMi44NTM1NTM0LDAuMTQ2NDQ2NjA5IEwxNS44MzQxOTg2LDMuMTI3MDkxODMgQzE1LjkzMTE4OSwzLjIxNDMwNTE3IDE1Ljk5Mzg4MDQsMy4zMzg5MTI3OCAxNS45OTk1NzU5LDMuNDc4MjE3NzQgQzE1Ljk5OTgzNzEsMy40ODUzNzc4MyAxNS45OTk5OTI4LDMuNDkyNDM5ODEgMTUuOTk5OTk5OCwzLjQ5OTUwMTkyIEMxNS45OTk5ODg0LDMuNTExMDYxMTMgMTUuOTk5NTg0OCwzLjUyMjUyODQ5IDE1Ljk5ODgwMTUsMy41MzM4OTE0MyBDMTUuOTkwOTk0NCwzLjY1MDMzMjU2IDE1Ljk0MjU1OTgsMy43NjQ1NDcwMiAxNS44NTM1NTM0LDMuODUzNTUzMzkgTDEyLjg1MzU1MzQsNi44NTM1NTMzOSBDMTIuNjU4MjkxMiw3LjA0ODgxNTU0IDEyLjM0MTcwODgsNy4wNDg4MTU1NCAxMi4xNDY0NDY2LDYuODUzNTUzMzkgQzExLjk1MTE4NDUsNi42NTgyOTEyNCAxMS45NTExODQ1LDYuMzQxNzA4NzYgMTIuMTQ2NDQ2Niw2LjE0NjQ0NjYxIEwxNC4yOTM4OTMyLDMuOTk5IEwxMi4yOTcsMy45OTkgQzExLjI5NzEwNCwzLjk5OSAxMC4zNDUwMjU1LDQuNDI2NzExNTkgOS42ODA3MTQ0NSw1LjE3NDE3MDkgTDQuMDY3NzI4OCwxMS40ODkxNTQ4IEMzLjIxMzY4OTU0LDEyLjQ1MDA4OTYgMS45ODk1OTg1NSwxMyAwLjcwNCwxMyBMMC41LDEzIEMwLjIyMzg1NzYyNSwxMyAtMi4yNjQ4NTQ5N2UtMTMsMTIuNzc2MTQyNCAtMi4yNjQ4NTQ5N2UtMTMsMTIuNSBDLTIuMjY0ODU0OTdlLTEzLDEyLjIyMzg1NzYgMC4yMjM4NTc2MjUsMTIgMC41LDEyIEwwLjcwNCwxMiBDMS43MDM4OTU5OSwxMiAyLjY1NTk3NDUyLDExLjU3MjI4ODQgMy4zMjAyODU1NSwxMC44MjQ4MjkxIEw4LjkzMzI3MTIsNC41MDk4NDUyNCBDOS43ODczMTA0NiwzLjU0ODkxMDQyIDExLjAxMTQwMTQsMi45OTkgMTIuMjk3LDIuOTk5IEwxNC4yOTE4OTMyLDIuOTk5IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg==');
--icon-ResizePanel: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiB4PSI0IiB5PSIyIiB3aWR0aD0iMiIgaGVpZ2h0PSIxMiIgcng9IjEiLz48L3N2Zz4=');
--icon-Revert: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTMuMTIzMTcgMy4wMDMzTDMuMDg3MjggNi45MTEwM0w2LjgxMzM2IDUuNjY0MzYiIHN0cm9rZT0iIzE2QjM3OCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTEzLjYxNTggMTAuMDc5OUMxNC4wMzY0IDguNjg0NzggMTMuODg1NSA3LjE3OTc0IDEzLjE5NjUgNS44OTU4NiAxMi41MDc0IDQuNjExOTggMTEuMzM2NSAzLjY1NDQzIDkuOTQxMzcgMy4yMzM4NSA4LjU0NjI3IDIuODEzMjcgNy4wNDEyMyAyLjk2NDExIDUuNzU3MzUgMy42NTMyIDQuNDczNDcgNC4zNDIzIDMuNTE1OTIgNS41MTMxOCAzLjA5NTM0IDYuOTA4MjhNNi40Mjk0MyAxMy41ODc2QzUuODQ4OCAxMy4zNjgzIDUuMzA5NjIgMTMuMDUyIDQuODM0ODQgMTIuNjUyMk0xMC4wMzk4IDEzLjY4MTZDOS40OTk1MyAxMy44NTM0IDguOTM1OTUgMTMuOTQwNyA4LjM2OTAzIDEzLjk0MDJIOC4yNDMwNE0xMi44ODY4IDExLjU3MjhDMTIuNTQ4MiAxMi4wNjA4IDEyLjEzMzIgMTIuNDkxMiAxMS42NTc3IDEyLjg0NzNNMy42NjIxNSAxMS4yODA0QzMuMzUxNDggMTAuNzY2NiAzLjEyNzc2IDEwLjIwNTEgMyA5LjYxODQxIiBzdHJva2U9IiMxNkIzNzgiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg==');
--icon-RightAlign: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNSw4LjUgQzIuMjIzODU3NjMsOC41IDIsOC4yNzYxNDIzNyAyLDggQzIsNy43MjM4NTc2MyAyLjIyMzg1NzYzLDcuNSAyLjUsNy41IEwxMy41LDcuNSBDMTMuNzc2MTQyNCw3LjUgMTQsNy43MjM4NTc2MyAxNCw4IEMxNCw4LjI3NjE0MjM3IDEzLjc3NjE0MjQsOC41IDEzLjUsOC41IEwyLjUsOC41IFogTTIuNSw0IEMyLjIyMzg1NzYzLDQgMiwzLjc3NjE0MjM3IDIsMy41IEMyLDMuMjIzODU3NjMgMi4yMjM4NTc2MywzIDIuNSwzIEwxMy41LDMgQzEzLjc3NjE0MjQsMyAxNCwzLjIyMzg1NzYzIDE0LDMuNSBDMTQsMy43NzYxNDIzNyAxMy43NzYxNDI0LDQgMTMuNSw0IEwyLjUsNCBaIE04LjUsMTMgQzguMjIzODU3NjMsMTMgOCwxMi43NzYxNDI0IDgsMTIuNSBDOCwxMi4yMjM4NTc2IDguMjIzODU3NjMsMTIgOC41LDEyIEwxMy41LDEyIEMxMy43NzYxNDI0LDEyIDE0LDEyLjIyMzg1NzYgMTQsMTIuNSBDMTQsMTIuNzc2MTQyNCAxMy43NzYxNDI0LDEzIDEzLjUsMTMgTDguNSwxMyBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');
--icon-Script: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0iZmVhdGhlciBmZWF0aGVyLWRhdGFiYXNlIj48cmVjdCB3aWR0aD0iMTkuMzI4IiBoZWlnaHQ9IjE5LjMyOCIgeD0iMi4zMzYiIHk9IjIuMzM2IiByeT0iNC4wMjYiIHJ4PSIzLjc0NSIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJidXR0Ii8+PGcgc3Ryb2tlLXdpZHRoPSIxLjU0MyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0iYnV0dCIgc3Ryb2tlLWxpbmVqb2luPSJtaXRlciI+PHBhdGggZD0iTTE4LjczNDQ0OSA5LjAyMTI1NTZMNS4yNjU1NTEyIDkuMDA5NTM2OE0xOC43MzQ0NDkgMTQuNzM4NTg2TDUuMjY1NTUxMiAxNC43MjY4NjciIHRyYW5zZm9ybT0ibWF0cml4KC45Mzk2OSAwIDAgMS4wMDUzMSAuNzI0IC0uMDYzKSIvPjwvZz48L3N2Zz4=');
--icon-Search: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjQzNjIxMjcsMTAuNzI5MTA1OSBMMTQuODUzNTUzNCwxNC4xNDY0NDY2IEMxNS4wNDg4MTU1LDE0LjM0MTcwODggMTUuMDQ4ODE1NSwxNC42NTgyOTEyIDE0Ljg1MzU1MzQsMTQuODUzNTUzNCBDMTQuNjU4MjkxMiwxNS4wNDg4MTU1IDE0LjM0MTcwODgsMTUuMDQ4ODE1NSAxNC4xNDY0NDY2LDE0Ljg1MzU1MzQgTDEwLjcyOTEwNTksMTEuNDM2MjEyNyBDOS41OTIzMzg0OCwxMi40MTEwNDg3IDguMTE0OTQ3NzEsMTMgNi41LDEzIEMyLjkxMDE0OTEzLDEzIDAsMTAuMDg5ODUwOSAwLDYuNSBDMCwyLjkxMDE0OTEzIDIuOTEwMTQ5MTMsMCA2LjUsMCBDMTAuMDg5ODUwOSwwIDEzLDIuOTEwMTQ5MTMgMTMsNi41IEMxMyw4LjExNDk0NzcxIDEyLjQxMTA0ODcsOS41OTIzMzg0OCAxMS40MzYyMTI3LDEwLjcyOTEwNTkgWiBNMTAuNDA5NTc0NywxMC4zNjg0OTIxIEMxMS4zOTI4MzI1LDkuMzc0ODU3OCAxMiw4LjAwODMzNDY4IDEyLDYuNSBDMTIsMy40NjI0MzM4OCA5LjUzNzU2NjEyLDEgNi41LDEgQzMuNDYyNDMzODgsMSAxLDMuNDYyNDMzODggMSw2LjUgQzEsOS41Mzc1NjYxMiAzLjQ2MjQzMzg4LDEyIDYuNSwxMiBDOC4wMDgzMzQ2OCwxMiA5LjM3NDg1NzgsMTEuMzkyODMyNSAxMC4zNjg0OTIxLDEwLjQwOTU3NDcgQzEwLjM3NDkwMDEsMTAuNDAyMzg3OSAxMC4zODE1NTE2LDEwLjM5NTM0MTYgMTAuMzg4NDQ2NiwxMC4zODg0NDY2IEMxMC4zOTUzNDE2LDEwLjM4MTU1MTYgMTAuNDAyMzg3OSwxMC4zNzQ5MDAxIDEwLjQwOTU3NDcsMTAuMzY4NDkyMSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=');

@ -240,8 +240,8 @@
"Settings_savecommon": "Save as common settings",
"Settings_revertcommon": "Revert to common settings"
},
"FilterBar": {
"AddFilter": "Add Filter"
"FilterConfig":{
"AddColumn": "Add Column"
},
"GridOptions": {
"GridOptions": "Grid Options",
@ -469,6 +469,20 @@
"SwitchSites":"Switch Sites",
"CreateNewTeamSite":"Create new team site"
},
"SortConfig":{
"AddColumn": "Add Column",
"UpdateData": "Update Data",
"UseChoicePosition": "Use choice position",
"NaturalSort": "Natural sort",
"EmptyValuesLast": "Empty values last"
},
"SortFilterConfig":{
"Save": "Save",
"Revert": "Revert",
"Sort": "SORT",
"Filter": "FILTER",
"UpdateSortFilterSettings": "Update Sort & Filter settings"
},
"ThemeConfig": {
"Appearance": "Appearance ",
"SyncWithOS": "Switch appearance automatically to match system"
@ -533,7 +547,9 @@
"Customized":"(customized)",
"Modified":"(modified)",
"Empty":"(empty)",
"CustomOptions":"Custom options"
"CustomOptions":"Custom options",
"Sort": "SORT",
"Filter": "FILTER"
},
"aclui": {
"AccessRules": {
@ -687,13 +703,6 @@
"Rows": "Rows"
},
"ViewConfigTab": {
"Save": "Save",
"Revert": "Revert",
"UpdateData": "Update Data",
"UseChoicePosition": "Use choice position",
"NaturalSort": "Natural sort",
"EmptyValuesLast": "Empty values last",
"AddColumn": "Add Column",
"UnmarkOnDemandTitle": "Unmark table On-Demand?",
"UnmarkOnDemandButton": "Unmark On-Demand",
"UnmarkOnDemandText": "If you unmark table {{- table}}' as On-Demand, its data will be loaded into the calculation engine and will be available for use in formulas. For a big table, this may greatly increase load times.{{- br}}{{-br}}Changing this setting will reload the document for all users.",
@ -702,8 +711,6 @@
"MakeOnDemandText": "If you make table {{table}} On-Demand, its data will no longer be loaded into the calculation engine and will not be available for use in formulas. It will remain available for viewing and editing.",
"AdvancedSettings": "Advanced settings",
"BigTablesMayBeMarked": "Big tables may be marked as \"on-demand\" to avoid loading them into the data engine.",
"UpdateFilterSettings": "Update Filter settings",
"AddFilter": "Add Filter",
"Form": "Form",
"Compact": "Compact",
"Blocks": "Blocks",

@ -238,8 +238,8 @@
"Settings_savecommon": "Save common settings",
"Settings_revertcommon": "Revert common settings"
},
"FilterBar": {
"AddFilter": "Ajouter un filtre"
"FilterConfig": {
"AddColumn": "Ajouter une colonne"
},
"GridOptions": {
"GridOptions": "Options de la grille",
@ -466,6 +466,20 @@
"SwitchSites": "Changer despace",
"CreateNewTeamSite": "Créer un nouvel espace d'équipe"
},
"SortConfig":{
"AddColumn": "Ajouter une colonne",
"UpdateData": "Mettre à jour les données",
"UseChoicePosition": "Use choice position",
"NaturalSort": "Natural sort",
"EmptyValuesLast": "Valeurs vides en dernier"
},
"SortFilterConfig":{
"Save": "Enregistrer",
"Revert": "Restaurer",
"Sort": "TRI",
"Filter": "FILTRE",
"UpdateSortFilterSettings": "Mettre à jour le tri et le filtre"
},
"ThemeConfig": {
"Appearance": "Apparence ",
"SyncWithOS": "Adapter l'apparence au système"
@ -530,7 +544,9 @@
"Customized": "(personnalisé)",
"Modified": "(modifié)",
"Empty": "(vide)",
"CustomOptions": "Options personnalisées"
"CustomOptions": "Options personnalisées",
"Sort": "TRI",
"Filter": "FILTRE"
},
"aclui": {
"AccessRules": {
@ -684,13 +700,6 @@
"Rows": "Lignes"
},
"ViewConfigTab": {
"Save": "Enregistrer",
"Revert": "Restaurer",
"UpdateData": "Mettre à jour les données",
"UseChoicePosition": "Use choice position",
"NaturalSort": "Natural sort",
"EmptyValuesLast": "Valeurs vides en dernier",
"AddColumn": "Ajouter une colonne",
"UnmarkOnDemandTitle": "Unmark table On-Demand?",
"UnmarkOnDemandButton": "Unmark On-Demand",
"UnmarkOnDemandText": "If you unmark table {{- table}}' as On-Demand, its data will be loaded into the calculation engine and will be available for use in formulas. For a big table, this may greatly increase load times.{{- br}}{{-br}}Changing this setting will reload the document for all users.",
@ -699,8 +708,6 @@
"MakeOnDemandText": "If you make table {{table}} On-Demand, its data will no longer be loaded into the calculation engine and will not be available for use in formulas. It will remain available for viewing and editing.",
"AdvancedSettings": "Paramètres avancés",
"BigTablesMayBeMarked": "Big tables may be marked as \"on-demand\" to avoid loading them into the data engine.",
"UpdateFilterSettings": "Régler les filtres",
"AddFilter": "Ajouter un filtre",
"Form": "Formulaire",
"Compact": "Compact",
"Blocks": "Blocs",

@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 6.00024L10 14.0002" stroke="#16B378" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.2002 2L14.0002 6.8" stroke="#16B378" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.31689 7.31661L10.5145 3.31421" stroke="#16B378" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.68311 12.683L12.6855 5.48535" stroke="#16B378" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.8 11.2002L2 14.0002" stroke="#16B378" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 615 B

@ -0,0 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.12317 3.0033L3.08728 6.91103L6.81336 5.66436" stroke="#16B378" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.6158 10.0799C14.0364 8.68478 13.8855 7.17974 13.1965 5.89586C12.5074 4.61198 11.3365 3.65443 9.94137 3.23385C8.54627 2.81327 7.04123 2.96411 5.75735 3.6532C4.47347 4.3423 3.51592 5.51318 3.09534 6.90828" stroke="#16B378" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.42943 13.5876C5.8488 13.3683 5.30962 13.052 4.83484 12.6522" stroke="#16B378" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.0398 13.6816C9.49953 13.8534 8.93595 13.9407 8.36903 13.9402H8.24304" stroke="#16B378" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.8868 11.5728C12.5482 12.0608 12.1332 12.4912 11.6577 12.8473" stroke="#16B378" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.66215 11.2804C3.35148 10.7666 3.12776 10.2051 3 9.61841" stroke="#16B378" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@ -1,4 +1,4 @@
import {allInclusive, ColumnFilter} from 'app/client/models/ColumnFilter';
import {ALL_INCLUSIVE_FILTER_JSON, ColumnFilter} from 'app/client/models/ColumnFilter';
import {GristObjCode} from 'app/plugin/GristData';
import {CellValue} from 'app/common/DocActions';
import {assert} from 'chai';
@ -57,10 +57,10 @@ describe('ColumnFilter', function() {
assert.isTrue(filter.includes('Carol'));
});
it('should generate an all-inclusive filter from empty string or null', async function() {
it('should generate an all-inclusive filter from empty string/object or null', async function() {
const filter = new ColumnFilter('');
const defaultJson = filter.makeFilterJson();
assert.equal(defaultJson, allInclusive);
assert.equal(defaultJson, ALL_INCLUSIVE_FILTER_JSON);
filter.clear();
assert.equal(filter.makeFilterJson(), '{"included":[]}');
@ -69,7 +69,10 @@ describe('ColumnFilter', function() {
assert.equal(filter.makeFilterJson(), defaultJson);
// Check that the string 'null' initializes properly
assert.equal(new ColumnFilter('null').makeFilterJson(), allInclusive);
assert.equal(new ColumnFilter('null').makeFilterJson(), ALL_INCLUSIVE_FILTER_JSON);
// Check that the empty object initializes properly
assert.equal(new ColumnFilter('{}').makeFilterJson(), ALL_INCLUSIVE_FILTER_JSON);
});
it('should generate a proper FilterFunc and JSON string', async function() {

Binary file not shown.

Binary file not shown.

@ -345,9 +345,8 @@ describe('ReferenceList', function() {
await driver.find('.test-config-sortAndFilter').click();
// Sort the Favorite Film column.
await driver.find('.test-vconfigtab-sort-add').click();
await driver.findContent('.test-vconfigtab-sort-add-menu-row', /Favorite_Film/).click();
await driver.find('.test-vconfigtab-sort-save').click();
await gu.addColumnToSort('Favorite Film');
await gu.saveSortConfig();
// Check that the records are sorted by display value.
assert.deepEqual(

@ -1103,6 +1103,13 @@ export async function selectWidget(
await waitForServer();
}
export async function changeWidget(type: string) {
await openWidgetPanel();
await driver.findContent('.test-right-panel button', /Change Widget/).click();
await selectWidget(type);
await waitForServer();
}
/**
* Toggle elem if not selected. Expects elem to be clickable and to have a class ending with
* -selected when selected.
@ -1329,6 +1336,14 @@ export async function openWidgetPanel() {
await driver.find('.test-right-tab-pagewidget').click();
}
/**
* Opens a Creator Panel on Widget/Table settings tab.
*/
export async function openColumnPanel() {
await toggleSidePanel('right', 'open');
await driver.find('.test-right-tab-field').click();
}
/**
* Moves a column from a hidden to visible section.
* Needs a visible Creator panel.
@ -1448,25 +1463,6 @@ export async function closeRawTable() {
await driver.find('.test-raw-data-close-button').click();
}
/**
* Toggles (opens or closes) the filter bar for a section.
*/
export async function toggleFilterBar(goal: 'open'|'close'|'toggle' = 'toggle',
options: {section?: string|WebElement, save?: boolean} = {}) {
const isOpen = await driver.find('.test-filter-bar').isPresent();
if ((goal === 'close') && !isOpen ||
(goal === 'open') && isOpen ) {
return;
}
const menu = await openSectionMenu('sortAndFilter', options.section);
await menu.findContent('.grist-floating-menu > div', /Toggle Filter Bar/).find('.test-section-menu-btn').click();
if (options.save) {
await menu.findContent('.grist-floating-menu button', /Save/).click();
await waitForServer();
}
await menu.sendKeys(Key.ESCAPE);
}
/**
* Opens the section menu for a section, or the active section if no section is given.
*/
@ -1521,13 +1517,17 @@ export async function deleteColumn(col: IColHeader|string) {
/**
* Sets the type of the currently selected field to value.
*/
export async function setType(type: RegExp|string, options: {skipWait?: boolean} = {}) {
export async function setType(type: RegExp|string, options: {skipWait?: boolean, apply?: boolean} = {}) {
await toggleSidePanel('right', 'open');
await driver.find('.test-right-tab-field').click();
await driver.find('.test-fbuilder-type-select').click();
type = typeof type === 'string' ? exactMatch(type) : type;
await driver.findContentWait('.test-select-menu .test-select-row', type, 500).click();
if (!options.skipWait) { await waitForServer(); }
if (!options.skipWait || options.apply) { await waitForServer(); }
if (options.apply) {
await driver.findWait('.test-type-transform-apply', 1000).click();
await waitForServer();
}
}
/**
@ -2504,58 +2504,52 @@ export async function setRefTable(table: string) {
// Add column to sort.
export async function addColumnToSort(colName: RegExp|string) {
await driver.find(".test-vconfigtab-sort-add").click();
await driver.findContent(".test-vconfigtab-sort-add-menu-row", colName).click();
await driver.findContentWait(".test-vconfigtab-sort-row", colName, 100);
await driver.find(".test-sort-config-add").click();
await driver.findContent(".test-sort-config-add-menu-row", colName).click();
await driver.findContentWait(".test-sort-config-row", colName, 100);
}
// Remove column from sort.
export async function removeColumnFromSort(colName: RegExp|string) {
await findSortRow(colName).find(".test-vconfigtab-sort-remove").click();
await findSortRow(colName).find(".test-sort-config-remove").click();
}
// Toggle column sort order from ascending to descending, or vice-versa.
export async function toggleSortOrder(colName: RegExp|string) {
await findSortRow(colName).find(".test-vconfigtab-sort-order").click();
}
// Change the column at the given sort position.
export async function changeSortDropdown(colName: RegExp|string, newColName: RegExp|string) {
await findSortRow(colName).find(".test-select-row").click();
await driver.findContent("li .test-select-row", newColName).click();
await findSortRow(colName).find(".test-sort-config-order").click();
}
// Reset the sort to the last saved sort.
export async function revertSortConfig() {
await driver.find(".test-vconfigtab-sort-reset").click();
await driver.find(".test-sort-filter-config-revert").click();
}
// Save the sort.
export async function saveSortConfig() {
await driver.find(".test-vconfigtab-sort-save").click();
await driver.find(".test-sort-filter-config-save").click();
await waitForServer();
}
// Update the data positions to the given sort.
export async function updateRowsBySort() {
await driver.find(".test-vconfigtab-sort-update").click();
await driver.find(".test-sort-config-update").click();
await waitForServer(10000);
}
// Returns a WebElementPromise for the sort row of the given col name.
export function findSortRow(colName: RegExp|string) {
return driver.findContent(".test-vconfigtab-sort-row", colName);
return driver.findContent(".test-sort-config-row", colName);
}
// Opens more sort options menu
export async function openMoreSortOptions(colName: RegExp|string) {
const row = await findSortRow(colName);
return row.find(".test-vconfigtab-sort-options-icon").click();
return row.find(".test-sort-config-options-icon").click();
}
// Selects one of the options in the more options menu.
export async function toggleSortOption(option: SortOption) {
const label = await driver.find(`.test-vconfigtab-sort-option-${option} label`);
const label = await driver.find(`.test-sort-config-option-${option} label`);
await label.click();
await waitForServer();
}
@ -2572,7 +2566,7 @@ export const SortOptions: ReadonlyArray<SortOption> = ["orderByChoice", "emptyLa
export async function getSortOptions(): Promise<SortOption[]> {
const options: SortOption[] = [];
for(const option of SortOptions) {
const list = await driver.findAll(`.test-vconfigtab-sort-option-${option} input:checked`);
const list = await driver.findAll(`.test-sort-config-option-${option} input:checked`);
if (list.length) {
options.push(option);
}
@ -2585,7 +2579,7 @@ export async function getSortOptions(): Promise<SortOption[]> {
export async function getEnabledOptions(): Promise<SortOption[]> {
const options: SortOption[] = [];
for(const option of SortOptions) {
const list = await driver.findAll(`.test-vconfigtab-sort-option-${option}:not(.disabled)`);
const list = await driver.findAll(`.test-sort-config-option-${option}:not(.disabled)`);
if (list.length) {
options.push(option);
}
@ -2647,6 +2641,48 @@ export async function filterBy(col: IColHeader|string, save: boolean, values: (s
await waitForServer();
}
export interface PinnedFilter {
name: string;
hasUnsavedChanges: boolean;
}
/**
* Returns a list of all pinned filters in the active section.
*/
export async function getPinnedFilters(): Promise<PinnedFilter[]> {
const filterBar = await driver.find('.active_section .test-filter-bar');
const allFilters = await filterBar.findAll('.test-filter-field', async (el) => {
const button = await el.find('.test-btn');
const buttonClass = await button.getAttribute('class');
return {
name: await el.getText(),
isPinned: await el.getCssValue('display') !== 'none',
hasUnsavedChanges: !/\b\w+-grayed\b/.test(buttonClass),
};
});
const pinnedFilters = allFilters.filter(({isPinned}) => isPinned);
return pinnedFilters.map(({name, hasUnsavedChanges}) => ({name, hasUnsavedChanges}));
}
export interface FilterMenuValue {
checked: boolean;
value: string;
count: number;
}
/**
* Returns a list of all values in the filter menu and their associated state.
*/
export async function getFilterMenuState(): Promise<FilterMenuValue[]> {
const items = await driver.findAll('.test-filter-menu-list > *');
return await Promise.all(items.map(async item => {
const checked = (await item.find('input').getAttribute('checked')) === null ? false : true;
const value = await item.find('label').getText();
const count = parseInt(await item.find('label + div').getText(), 10);
return {checked, value, count};
}));
}
/**
* Refresh browser and dismiss alert that is shown (for refreshing during edits).
*/

Loading…
Cancel
Save