mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Adding sort options for columns.
Summary: Adding sort options for columns. - Sort menu has a new option "More sort options" that opens up Sort left menu - Each sort entry has an additional menu with 3 options -- Order by choice index (for the Choice column, orders by choice position) -- Empty last (puts empty values last in ascending order, first in descending order) -- Natural sort (for Text column, compares strings with numbers as numbers) Updated also CSV/Excel export and api sorting. Most of the changes in this diff is a sort expression refactoring. Pulling out all the methods that works on sortExpression array into a single namespace. Test Plan: Browser tests Reviewers: alexmojaki Reviewed By: alexmojaki Subscribers: dsagal, alexmojaki Differential Revision: https://phab.getgrist.com/D3077
This commit is contained in:
@@ -6,13 +6,14 @@ var ko = require('knockout');
|
||||
var gutil = require('app/common/gutil');
|
||||
var BinaryIndexedTree = require('app/common/BinaryIndexedTree');
|
||||
var MANUALSORT = require('app/common/gristTypes').MANUALSORT;
|
||||
const {Sort} = require('app/common/SortSpec');
|
||||
|
||||
var dom = require('../lib/dom');
|
||||
var kd = require('../lib/koDom');
|
||||
var kf = require('../lib/koForm');
|
||||
var koDomScrolly = require('../lib/koDomScrolly');
|
||||
var tableUtil = require('../lib/tableUtil');
|
||||
var {addToSort} = require('../lib/sortUtil');
|
||||
var {addToSort, sortBy} = require('../lib/sortUtil');
|
||||
|
||||
var commands = require('./commands');
|
||||
var viewCommon = require('./viewCommon');
|
||||
@@ -260,16 +261,16 @@ GridView.gridCommands = {
|
||||
paste: function(pasteObj, cutCallback) { return this.paste(pasteObj, cutCallback); },
|
||||
cancel: function() { this.clearSelection(); },
|
||||
sortAsc: function() {
|
||||
this.viewSection.activeSortSpec.assign([this.currentColumn().getRowId()]);
|
||||
sortBy(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.ASC);
|
||||
},
|
||||
sortDesc: function() {
|
||||
this.viewSection.activeSortSpec.assign([-this.currentColumn().getRowId()]);
|
||||
sortBy(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.DESC);
|
||||
},
|
||||
addSortAsc: function() {
|
||||
addToSort(this.viewSection.activeSortSpec, this.currentColumn().getRowId());
|
||||
addToSort(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.ASC);
|
||||
},
|
||||
addSortDesc: function() {
|
||||
addToSort(this.viewSection.activeSortSpec, -this.currentColumn().getRowId());
|
||||
addToSort(this.viewSection.activeSortSpec, this.currentColumn().getRowId(), Sort.DESC);
|
||||
},
|
||||
toggleFreeze: function() {
|
||||
// get column selection
|
||||
|
||||
@@ -9,19 +9,21 @@ var SummaryConfig = require('./SummaryConfig');
|
||||
var commands = require('./commands');
|
||||
var {CustomSectionElement} = require('../lib/CustomSectionElement');
|
||||
const {ChartConfig} = require('./ChartView');
|
||||
const {Computed, dom: grainjsDom, makeTestId, Observable, styled} = require('grainjs');
|
||||
const {Computed, dom: grainjsDom, makeTestId, Observable, styled, MultiHolder} = require('grainjs');
|
||||
|
||||
const {addToSort, flipColDirection, parseSortColRefs} = require('app/client/lib/sortUtil');
|
||||
const {reorderSortRefs, updatePositions} = require('app/client/lib/sortUtil');
|
||||
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/RightPanel');
|
||||
const {VisibleFieldsConfig} = require('app/client/ui/VisibleFieldsConfig');
|
||||
const {basicButton, primaryButton} = require('app/client/ui2018/buttons');
|
||||
const {labeledLeftSquareCheckbox} = require("app/client/ui2018/checkbox");
|
||||
const {colors} = require('app/client/ui2018/cssVars');
|
||||
const {cssDragger} = require('app/client/ui2018/draggableList');
|
||||
const {menu, menuItem, 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');
|
||||
|
||||
@@ -207,7 +209,7 @@ ViewConfigTab.prototype.buildSortDom = function() {
|
||||
|
||||
// Computed to indicate if sort has changed from saved.
|
||||
const hasChanged = Computed.create(null, (use) =>
|
||||
!isEqual(use(section.activeSortSpec), parseSortColRefs(use(section.sortColRefs))));
|
||||
!isEqual(use(section.activeSortSpec), Sort.parseSortColRefs(use(section.sortColRefs))));
|
||||
|
||||
// Computed array of sortable columns.
|
||||
const columns = Computed.create(null, (use) => {
|
||||
@@ -217,26 +219,36 @@ ViewConfigTab.prototype.buildSortDom = function() {
|
||||
.map(col => ({
|
||||
label: use(col.colId),
|
||||
value: col.getRowId(),
|
||||
icon: 'FieldColumn'
|
||||
icon: 'FieldColumn',
|
||||
type: col.type()
|
||||
}));
|
||||
});
|
||||
|
||||
// KoArray of sortRows used to create the draggableList.
|
||||
const sortRows = koArray.syncedKoArray(section.activeSortSpec);
|
||||
// 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);
|
||||
}
|
||||
})
|
||||
|
||||
// Sort row create function for each sort row in the draggableList.
|
||||
const rowCreateFn = sortRef =>
|
||||
this._buildSortRow(sortRef, section.activeSortSpec.peek(), columns);
|
||||
const rowCreateFn = colRef =>
|
||||
this._buildSortRow(colRef, section.activeSortSpec, columns);
|
||||
|
||||
// Reorder function called when sort rows are reordered via dragging.
|
||||
const reorder = (...args) => {
|
||||
const spec = reorderSortRefs(section.activeSortSpec.peek(), ...args);
|
||||
const spec = Sort.reorderSortRefs(section.activeSortSpec.peek(), ...args);
|
||||
this._saveSort(spec);
|
||||
};
|
||||
|
||||
return grainjsDom('div',
|
||||
grainjsDom.autoDispose(hasChanged),
|
||||
grainjsDom.autoDispose(columns),
|
||||
grainjsDom.autoDispose(colRefs),
|
||||
grainjsDom.autoDispose(sortRows),
|
||||
// Sort rows.
|
||||
kf.draggableList(sortRows, rowCreateFn, {
|
||||
@@ -280,46 +292,101 @@ ViewConfigTab.prototype.buildSortDom = function() {
|
||||
};
|
||||
|
||||
// Builds a single row of the sort dom
|
||||
// Takes the sortRef (signed colRef), current sortSpec and array of column select options to show
|
||||
// Takes the colRef, current sortSpec and array of column select options to show
|
||||
// in the column select dropdown.
|
||||
ViewConfigTab.prototype._buildSortRow = function(sortRef, sortSpec, columns) {
|
||||
// sortRef is a rowId of a column or its negative value (indicating descending order).
|
||||
const colRef = Math.abs(sortRef);
|
||||
// Computed to show the selected column at the sortSpec index and to update the
|
||||
// sortSpec on write.
|
||||
const col = Computed.create(null, () => colRef);
|
||||
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) => {
|
||||
const idx = sortSpec.findIndex(_sortRef => _sortRef === sortRef);
|
||||
const swapIdx = sortSpec.findIndex(_sortRef => Math.abs(_sortRef) === newRef);
|
||||
// If the selected ref is already present, swap it with the old ref.
|
||||
// Maintain sort order in each case for simplicity.
|
||||
if (swapIdx > -1) { sortSpec.splice(swapIdx, 1, sortSpec[swapIdx] > 0 ? colRef : -colRef); }
|
||||
if (colRef !== newRef) { sortSpec.splice(idx, 1, sortRef > 0 ? newRef : -newRef); }
|
||||
this._saveSort(sortSpec);
|
||||
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'], 'Use choice position');
|
||||
const naturalSort = computedFlag('naturalSort', ['Text'], 'Natural sort');
|
||||
const emptyLast = computedFlag('emptyLast', null, 'Empty values last');
|
||||
const flags = [orderByChoice, emptyLast, naturalSort];
|
||||
|
||||
const column = columns.get().find(col => col.value === Sort.getColRef(colRef));
|
||||
|
||||
return cssSortRow(
|
||||
grainjsDom.autoDispose(col),
|
||||
grainjsDom.autoDispose(holder),
|
||||
cssSortSelect(
|
||||
select(col, columns)
|
||||
),
|
||||
cssSortIconPrimaryBtn('Sort',
|
||||
grainjsDom.style('transform', sortRef < 0 ? 'none' : 'scaleY(-1)'),
|
||||
grainjsDom.on('click', () => {
|
||||
this._saveSort(flipColDirection(sortSpec, sortRef));
|
||||
}),
|
||||
testId('sort-order'),
|
||||
testId(sortRef < 0 ? 'sort-order-desc' : 'sort-order-asc')
|
||||
// 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 _idx = sortSpec.findIndex(c => c === sortRef);
|
||||
if (_idx !== -1) {
|
||||
sortSpec.splice(_idx, 1);
|
||||
this._saveSort(sortSpec);
|
||||
const specs = sortSpec.peek();
|
||||
if (Sort.findCol(specs, colRef)) {
|
||||
this._saveSort(Sort.removeCol(specs, colRef));
|
||||
}
|
||||
}),
|
||||
testId('sort-remove')
|
||||
),
|
||||
cssMenu(
|
||||
cssBigIconWrapper(
|
||||
cssIcon('Dots', grainjsDom.cls(cssBgLightGreen.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')
|
||||
);
|
||||
};
|
||||
@@ -329,38 +396,42 @@ ViewConfigTab.prototype._buildSortRow = function(sortRef, sortSpec, columns) {
|
||||
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))
|
||||
});
|
||||
return [
|
||||
// Add column button.
|
||||
cssRow(
|
||||
grainjsDom.autoDispose(showAddNew),
|
||||
grainjsDom.autoDispose(available),
|
||||
cssTextBtn(
|
||||
cssPlusIcon('Plus'), 'Add Column',
|
||||
testId('sort-add')
|
||||
),
|
||||
grainjsDom.hide(showAddNew),
|
||||
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(columns), _columns => {
|
||||
grainjsDom.maybe((use) => use(showAddNew) && use(available), _columns => {
|
||||
const col = Observable.create(null, 0);
|
||||
const currentSection = this.activeSectionData().section;
|
||||
const currentSortSpec = currentSection.activeSortSpec();
|
||||
const specRowIds = new Set(currentSortSpec.map(_sortRef => Math.abs(_sortRef)));
|
||||
// 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);
|
||||
addToSort(currentSection.activeSortSpec, _col.value, 1);
|
||||
};
|
||||
const menuCols = _columns
|
||||
.filter(_col => !specRowIds.has(_col.value))
|
||||
.map(_col =>
|
||||
menuItem(() => onClick(_col),
|
||||
cssMenuIcon(_col.icon),
|
||||
_col.label,
|
||||
testId('sort-add-menu-row')
|
||||
)
|
||||
);
|
||||
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(
|
||||
@@ -380,7 +451,8 @@ ViewConfigTab.prototype._buildAddToSortBtn = function(columns) {
|
||||
cssSortIconPrimaryBtn('Sort',
|
||||
grainjsDom.style('transform', 'scaleY(-1)')
|
||||
),
|
||||
cssSortIconBtn('Remove')
|
||||
cssSortIconBtn('Remove'),
|
||||
cssBigIconWrapper(cssIcon('Dots')),
|
||||
));
|
||||
})
|
||||
];
|
||||
@@ -820,4 +892,39 @@ const cssNoMarginLeft = styled('div', `
|
||||
|
||||
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: ${colors.mediumGrey};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssBgLightGreen = styled(`div`, `
|
||||
background: ${colors.lightGreen}
|
||||
`)
|
||||
|
||||
const cssOptionMenuItem = styled('div', `
|
||||
&:hover {
|
||||
background-color: ${colors.mediumGrey};
|
||||
}
|
||||
& label {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
&.disabled * {
|
||||
color: ${colors.darkGrey} important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`)
|
||||
|
||||
module.exports = ViewConfigTab;
|
||||
|
||||
@@ -1,66 +1,34 @@
|
||||
import {GristDoc} from 'app/client/components/GristDoc';
|
||||
import {ClientColumnGetters} from 'app/client/models/ClientColumnGetters';
|
||||
import {ViewSectionRec} from 'app/client/models/entities/ViewSectionRec';
|
||||
import { GristDoc } from 'app/client/components/GristDoc';
|
||||
import { ClientColumnGetters } from 'app/client/models/ClientColumnGetters';
|
||||
import { ViewSectionRec } from 'app/client/models/entities/ViewSectionRec';
|
||||
import * as rowset from 'app/client/models/rowset';
|
||||
import {MANUALSORT} from 'app/common/gristTypes';
|
||||
import {SortFunc} from 'app/common/SortFunc';
|
||||
import { MANUALSORT } from 'app/common/gristTypes';
|
||||
import { SortFunc } from 'app/common/SortFunc';
|
||||
import { Sort } from 'app/common/SortSpec';
|
||||
import * as ko from 'knockout';
|
||||
import range = require('lodash/range');
|
||||
|
||||
|
||||
/**
|
||||
* Adds a column to the given sort spec, replacing its previous occurrence if
|
||||
* it's already in the sort spec.
|
||||
*/
|
||||
export function addToSort(sortSpecObs: ko.Observable<number[]>, colRef: number) {
|
||||
export function addToSort(sortSpecObs: ko.Observable<Sort.SortSpec>, colRef: number, direction: -1|1) {
|
||||
const spec = sortSpecObs.peek();
|
||||
const index = spec.findIndex((colRefSpec) => Math.abs(colRefSpec) === Math.abs(colRef));
|
||||
const index = Sort.findColIndex(spec, colRef);
|
||||
if (index !== -1) {
|
||||
spec.splice(index, 1, colRef);
|
||||
spec.splice(index, 1, colRef * direction);
|
||||
} else {
|
||||
spec.push(colRef);
|
||||
spec.push(colRef * direction);
|
||||
}
|
||||
sortSpecObs(spec);
|
||||
}
|
||||
|
||||
|
||||
// Takes an activeSortSpec and sortRef to flip (negative sortRefs signify descending order) and returns a new
|
||||
// activeSortSpec with that sortRef flipped (or original spec if sortRef not found).
|
||||
export function flipColDirection(spec: number[], sortRef: number): number[] {
|
||||
const idx = spec.findIndex(c => c === sortRef);
|
||||
if (idx !== -1) {
|
||||
const newSpec = Array.from(spec);
|
||||
newSpec[idx] *= -1;
|
||||
return newSpec;
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
// Parses the sortColRefs string, defaulting to an empty array on invalid input.
|
||||
export function parseSortColRefs(sortColRefs: string): number[] {
|
||||
try {
|
||||
return JSON.parse(sortColRefs);
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Given the current sort spec, moves sortRef to be immediately before nextSortRef. Moves sortRef
|
||||
// to the end of the sort spec if nextSortRef is null.
|
||||
// If the given sortRef or nextSortRef cannot be found, return sortSpec unchanged.
|
||||
export function reorderSortRefs(spec: number[], sortRef: number, nextSortRef: number|null): number[] {
|
||||
const updatedSpec = spec.slice();
|
||||
|
||||
// Remove sortRef from sortSpec.
|
||||
const _idx = updatedSpec.findIndex(c => c === sortRef);
|
||||
if (_idx === -1) { return spec; }
|
||||
updatedSpec.splice(_idx, 1);
|
||||
|
||||
// Add sortRef to before nextSortRef
|
||||
const _nextIdx = nextSortRef ? updatedSpec.findIndex(c => c === nextSortRef) : updatedSpec.length;
|
||||
if (_nextIdx === -1) { return spec; }
|
||||
updatedSpec.splice(_nextIdx, 0, sortRef);
|
||||
|
||||
return updatedSpec;
|
||||
export function sortBy(sortSpecObs: ko.Observable<Sort.SortSpec>, colRef: number, direction: -1|1) {
|
||||
let spec = sortSpecObs.peek();
|
||||
const colSpec = Sort.findCol(spec, colRef) ?? colRef;
|
||||
spec = [Sort.setColDirection(colSpec, direction)];
|
||||
sortSpecObs(spec);
|
||||
}
|
||||
|
||||
// Updates the manual sort positions to the positions currently displayed in the view, sets the
|
||||
@@ -72,21 +40,27 @@ export async function updatePositions(gristDoc: GristDoc, section: ViewSectionRe
|
||||
|
||||
// Build a sorted array of rowIds the way a view would, using the active sort spec. We just need
|
||||
// the sorted list, and can dispose the observable array immediately.
|
||||
const sortFunc = new SortFunc(new ClientColumnGetters(tableModel, {unversioned: true}));
|
||||
const sortFunc = new SortFunc(new ClientColumnGetters(tableModel, { unversioned: true }));
|
||||
sortFunc.updateSpec(section.activeDisplaySortSpec.peek());
|
||||
const sortedRows = rowset.SortedRowSet.create(null, (a: rowset.RowId, b: rowset.RowId) =>
|
||||
sortFunc.compare(a as number, b as number), tableModel.tableData);
|
||||
const sortedRows = rowset.SortedRowSet.create(
|
||||
null,
|
||||
(a: rowset.RowId, b: rowset.RowId) => sortFunc.compare(a as number, b as number),
|
||||
tableModel.tableData
|
||||
);
|
||||
sortedRows.subscribeTo(tableModel);
|
||||
const sortedRowIds = sortedRows.getKoArray().peek().slice(0);
|
||||
sortedRows.dispose();
|
||||
|
||||
// The action just assigns consecutive positions to the sorted rows.
|
||||
const colInfo = {[MANUALSORT]: range(0, sortedRowIds.length)};
|
||||
await gristDoc.docData.sendActions([
|
||||
// Update row positions and clear the saved sort spec as a single action bundle.
|
||||
['BulkUpdateRecord', tableId, sortedRowIds, colInfo],
|
||||
['UpdateRecord', '_grist_Views_section', section.getRowId(), {sortColRefs: '[]'}]
|
||||
], `Updated table ${tableId} row positions.`);
|
||||
await gristDoc.docData.sendActions(
|
||||
[
|
||||
// Update row positions and clear the saved sort spec as a single action bundle.
|
||||
['BulkUpdateRecord', tableId, sortedRowIds, colInfo],
|
||||
['UpdateRecord', '_grist_Views_section', section.getRowId(), {sortColRefs: '[]'}],
|
||||
],
|
||||
`Updated table ${tableId} row positions.`
|
||||
);
|
||||
// Finally clear out the local sort spec.
|
||||
section.activeSortJson.revert();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as DataTableModel from 'app/client/models/DataTableModel';
|
||||
import {ColumnGetters} from 'app/common/ColumnGetters';
|
||||
import { ColumnGetter, ColumnGetters } from 'app/common/ColumnGetters';
|
||||
import * as gristTypes from 'app/common/gristTypes';
|
||||
import { choiceGetter } from 'app/common/SortFunc';
|
||||
import { Sort } from 'app/common/SortSpec';
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -18,13 +20,15 @@ export class ClientColumnGetters implements ColumnGetters {
|
||||
unversioned?: boolean} = {}) {
|
||||
}
|
||||
|
||||
public getColGetter(colRef: number): ((rowId: number) => any) | null {
|
||||
const colId = this._tableModel.docModel.columns.getRowModel(Math.abs(colRef)).colId();
|
||||
const getter = this._tableModel.tableData.getRowPropFunc(colId);
|
||||
if (!getter) { return getter || null; }
|
||||
public getColGetter(colSpec: Sort.ColSpec): ColumnGetter | null {
|
||||
const rowModel = this._tableModel.docModel.columns.getRowModel(Sort.getColRef(colSpec));
|
||||
const colId = rowModel.colId();
|
||||
let getter: ColumnGetter|undefined = this._tableModel.tableData.getRowPropFunc(colId);
|
||||
if (!getter) { return null; }
|
||||
if (this._options.unversioned && this._tableModel.tableData.mayHaveVersions()) {
|
||||
return (rowId) => {
|
||||
const value = getter(rowId);
|
||||
const valueGetter = getter;
|
||||
getter = (rowId) => {
|
||||
const value = valueGetter(rowId);
|
||||
if (value && gristTypes.isVersions(value)) {
|
||||
const versions = value[1];
|
||||
return ('parent' in versions) ? versions.parent :
|
||||
@@ -33,6 +37,13 @@ export class ClientColumnGetters implements ColumnGetters {
|
||||
return value;
|
||||
};
|
||||
}
|
||||
const details = Sort.specToDetails(colSpec);
|
||||
if (details.orderByChoice) {
|
||||
if (rowModel.pureType() === 'Choice') {
|
||||
const choices: string[] = rowModel.widgetOptionsJson.peek()?.choices || [];
|
||||
getter = choiceGetter(getter, choices);
|
||||
}
|
||||
}
|
||||
return getter;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import * as BaseView from 'app/client/components/BaseView';
|
||||
import {CursorPos} from 'app/client/components/Cursor';
|
||||
import {KoArray} from 'app/client/lib/koArray';
|
||||
import {ColumnRec, TableRec, ViewFieldRec, ViewRec} from 'app/client/models/DocModel';
|
||||
import {DocModel, IRowModel, recordSet, refRecord} from 'app/client/models/DocModel';
|
||||
import { ColumnRec, TableRec, ViewFieldRec, ViewRec } from 'app/client/models/DocModel';
|
||||
import * as modelUtil from 'app/client/models/modelUtil';
|
||||
import {RowId} from 'app/client/models/rowset';
|
||||
import {getWidgetTypes} from 'app/client/ui/widgetTypes';
|
||||
import {Computed} from 'grainjs';
|
||||
import * as ko from 'knockout';
|
||||
import { CursorPos, } from 'app/client/components/Cursor';
|
||||
import { KoArray, } from 'app/client/lib/koArray';
|
||||
import { DocModel, IRowModel, recordSet, refRecord, } from 'app/client/models/DocModel';
|
||||
import { RowId, } from 'app/client/models/rowset';
|
||||
import { getWidgetTypes, } from 'app/client/ui/widgetTypes';
|
||||
import { Sort, } from 'app/common/SortSpec';
|
||||
import { Computed, } from 'grainjs';
|
||||
import defaults = require('lodash/defaults');
|
||||
|
||||
// Represents a section of user views, now also known as a "page widget" (e.g. a view may contain
|
||||
@@ -44,10 +45,10 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> {
|
||||
|
||||
// is an array (parsed from JSON) of colRefs (i.e. rowIds into the columns table), with a
|
||||
// twist: a rowId may be positive or negative, for ascending or descending respectively.
|
||||
activeSortSpec: modelUtil.ObjObservable<number[]>;
|
||||
activeSortSpec: modelUtil.ObjObservable<Sort.SortSpec>;
|
||||
|
||||
// Modified sort spec to take into account any active display columns.
|
||||
activeDisplaySortSpec: ko.Computed<number[]>;
|
||||
activeDisplaySortSpec: ko.Computed<Sort.SortSpec>;
|
||||
|
||||
// Evaluates to an array of column models, which are not referenced by anything in viewFields.
|
||||
hiddenColumns: ko.Computed<ColumnRec[]>;
|
||||
@@ -209,9 +210,9 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
||||
// twist: a rowId may be positive or negative, for ascending or descending respectively.
|
||||
// TODO: This method of ignoring columns which are deleted is inefficient and may cause conflicts
|
||||
// with sharing.
|
||||
this.activeSortSpec = modelUtil.jsonObservable(this.activeSortJson, (obj: any) => {
|
||||
return (obj || []).filter((sortRef: number) => {
|
||||
const colModel = docModel.columns.getRowModel(Math.abs(sortRef));
|
||||
this.activeSortSpec = modelUtil.jsonObservable(this.activeSortJson, (obj: Sort.SortSpec|null) => {
|
||||
return (obj || []).filter((sortRef: Sort.ColSpec) => {
|
||||
const colModel = docModel.columns.getRowModel(Sort.getColRef(sortRef));
|
||||
return !colModel._isDeleted() && colModel.getRowId();
|
||||
});
|
||||
});
|
||||
@@ -219,10 +220,10 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
||||
// Modified sort spec to take into account any active display columns.
|
||||
this.activeDisplaySortSpec = this.autoDispose(ko.computed(() => {
|
||||
return this.activeSortSpec().map(directionalColRef => {
|
||||
const colRef = Math.abs(directionalColRef);
|
||||
const colRef = Sort.getColRef(directionalColRef);
|
||||
const field = this.viewFields().all().find(f => f.column().origColRef() === colRef);
|
||||
const effectiveColRef = field ? field.displayColRef() : colRef;
|
||||
return directionalColRef > 0 ? effectiveColRef : -effectiveColRef;
|
||||
return Sort.swapColRef(directionalColRef, effectiveColRef);
|
||||
});
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {allCommands} from 'app/client/components/commands';
|
||||
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
|
||||
import {testId, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {menuDivider, menuItem, menuItemCmd} from 'app/client/ui2018/menus';
|
||||
import {dom, DomElementArg, styled} from 'grainjs';
|
||||
import { allCommands } from 'app/client/components/commands';
|
||||
import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
|
||||
import { testId, vars } from 'app/client/ui2018/cssVars';
|
||||
import { icon } from 'app/client/ui2018/icons';
|
||||
import { menuDivider, menuItem, menuItemCmd } from 'app/client/ui2018/menus';
|
||||
import { Sort } from 'app/common/SortSpec';
|
||||
import { dom, DomElementArg, styled } from 'grainjs';
|
||||
import isEqual = require('lodash/isEqual');
|
||||
|
||||
interface IView {
|
||||
@@ -47,7 +48,7 @@ interface IMultiColumnContextMenu {
|
||||
|
||||
interface IColumnContextMenu extends IMultiColumnContextMenu {
|
||||
filterOpenFunc: () => void;
|
||||
sortSpec: number[];
|
||||
sortSpec: Sort.SortSpec;
|
||||
colId: number;
|
||||
}
|
||||
|
||||
@@ -75,19 +76,18 @@ export function ColumnContextMenu(options: IColumnContextMenu) {
|
||||
icon('Sort', dom.style('transform', 'scaley(-1)')),
|
||||
'A-Z',
|
||||
dom.style('flex', ''),
|
||||
cssCustomMenuItem.cls('-selected', isEqual(sortSpec, [colId])),
|
||||
cssCustomMenuItem.cls('-selected', Sort.containsOnly(sortSpec, colId, Sort.ASC)),
|
||||
testId('sort-asc'),
|
||||
),
|
||||
customMenuItem(
|
||||
allCommands.sortDesc.run,
|
||||
icon('Sort'),
|
||||
'Z-A',
|
||||
cssCustomMenuItem.cls('-selected', isEqual(sortSpec, [-colId])),
|
||||
cssCustomMenuItem.cls('-selected', Sort.containsOnly(sortSpec, colId, Sort.DESC)),
|
||||
testId('sort-dsc'),
|
||||
),
|
||||
testId('sort'),
|
||||
),
|
||||
menuDivider({style: 'margin-bottom: 0; margin-top: 0;'}),
|
||||
addToSortLabel ? [
|
||||
cssRowMenuItem(
|
||||
customMenuItem(
|
||||
@@ -95,20 +95,22 @@ export function ColumnContextMenu(options: IColumnContextMenu) {
|
||||
cssRowMenuLabel(addToSortLabel, testId('add-to-sort-label')),
|
||||
icon('Sort', dom.style('transform', 'scaley(-1)')),
|
||||
'A-Z',
|
||||
cssCustomMenuItem.cls('-selected', sortSpec.includes(colId)),
|
||||
cssCustomMenuItem.cls('-selected', Sort.contains(sortSpec, colId, Sort.ASC)),
|
||||
testId('add-to-sort-asc'),
|
||||
),
|
||||
customMenuItem(
|
||||
allCommands.addSortDesc.run,
|
||||
icon('Sort'),
|
||||
'Z-A',
|
||||
cssCustomMenuItem.cls('-selected', sortSpec.includes(-colId)),
|
||||
cssCustomMenuItem.cls('-selected', Sort.contains(sortSpec, colId, Sort.DESC)),
|
||||
testId('add-to-sort-dsc'),
|
||||
),
|
||||
testId('add-to-sort'),
|
||||
),
|
||||
menuDivider({style: 'margin-top: 0;'}),
|
||||
] : null,
|
||||
menuDivider({style: 'margin-bottom: 0; margin-top: 0;'}),
|
||||
menuItem(allCommands.sortFilterTabOpen.run, 'More sort options ...', testId('more-sort-options')),
|
||||
menuDivider({style: 'margin-top: 0;'}),
|
||||
menuItemCmd(allCommands.renameField, 'Rename column', disableForReadonlyColumn),
|
||||
menuItemCmd(allCommands.hideField, 'Hide column', disableForReadonlyView),
|
||||
freezeMenuItemCmd(options),
|
||||
@@ -269,8 +271,8 @@ function freezeMenuItemCmd(options: IMultiColumnContextMenu) {
|
||||
// Returns 'Add to sort' is there are columns in the sort spec but colId is not part of it. Returns
|
||||
// undefined if colId is the only column in the spec. Otherwise returns `Sorted (#N)` where #N is
|
||||
// the position (1 based) of colId in the spec.
|
||||
function getAddToSortLabel(sortSpec: number[], colId: number): string|undefined {
|
||||
const columnsInSpec = sortSpec.map((n) => Math.abs(n));
|
||||
function getAddToSortLabel(sortSpec: Sort.SortSpec, colId: number): string|undefined {
|
||||
const columnsInSpec = sortSpec.map((n) =>Sort.getColRef(n));
|
||||
if (sortSpec.length !== 0 && !isEqual(columnsInSpec, [colId])) {
|
||||
const index = columnsInSpec.indexOf(colId);
|
||||
if (index > -1) {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import {flipColDirection, parseSortColRefs} from 'app/client/lib/sortUtil';
|
||||
import {reportError} from 'app/client/models/AppModel';
|
||||
import {ColumnRec, DocModel, ViewFieldRec, ViewRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||
import {CustomComputed} from 'app/client/models/modelUtil';
|
||||
import {attachColumnFilterMenu} from 'app/client/ui/ColumnFilterMenu';
|
||||
import {addFilterMenu} from 'app/client/ui/FilterBar';
|
||||
import {hoverTooltip} from 'app/client/ui/tooltips';
|
||||
import {makeViewLayoutMenu} from 'app/client/ui/ViewLayoutMenu';
|
||||
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
||||
import {colors, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {menu} from 'app/client/ui2018/menus';
|
||||
import {Computed, dom, fromKo, IDisposableOwner, makeTestId, Observable, styled} from 'grainjs';
|
||||
import { reportError } from 'app/client/models/AppModel';
|
||||
import { ColumnRec, DocModel, ViewFieldRec, ViewRec, ViewSectionRec } from 'app/client/models/DocModel';
|
||||
import { CustomComputed } from 'app/client/models/modelUtil';
|
||||
import { attachColumnFilterMenu } from 'app/client/ui/ColumnFilterMenu';
|
||||
import { addFilterMenu } from 'app/client/ui/FilterBar';
|
||||
import { hoverTooltip } from 'app/client/ui/tooltips';
|
||||
import { makeViewLayoutMenu } from 'app/client/ui/ViewLayoutMenu';
|
||||
import { basicButton, primaryButton } from 'app/client/ui2018/buttons';
|
||||
import { colors, vars } from 'app/client/ui2018/cssVars';
|
||||
import { icon } from 'app/client/ui2018/icons';
|
||||
import { menu } 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 {PopupControl} from 'popweasel';
|
||||
|
||||
const testId = makeTestId('test-section-menu-');
|
||||
|
||||
@@ -105,29 +105,27 @@ export function viewSectionMenu(owner: IDisposableOwner, docModel: DocModel, vie
|
||||
];
|
||||
}
|
||||
|
||||
function makeSortPanel(section: ViewSectionRec, sortSpec: number[], getColumn: (row: number) => ColumnRec) {
|
||||
const changedColumns = difference(sortSpec, parseSortColRefs(section.sortColRefs.peek()));
|
||||
const sortColumns = sortSpec.map(colRef => {
|
||||
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(Math.abs(colRef));
|
||||
const col = getColumn(Sort.getColRef(colSpec));
|
||||
return cssMenuText(
|
||||
cssMenuIconWrapper(
|
||||
cssMenuIconWrapper.cls('-changed', changedColumns.includes(colRef)),
|
||||
cssMenuIconWrapper.cls(colRef < 0 ? '-desc' : '-asc'),
|
||||
cssMenuIconWrapper.cls('-changed', changedColumns.includes(colSpec)),
|
||||
cssMenuIconWrapper.cls(Sort.isAscending(colSpec) ? '-asc' : '-desc'),
|
||||
cssIcon('Sort',
|
||||
dom.style('transform', colRef < 0 ? 'none' : 'scaleY(-1)'),
|
||||
dom.style('transform', Sort.isAscending(colSpec) ? 'scaleY(-1)' : 'none'),
|
||||
dom.on('click', () => {
|
||||
section.activeSortSpec(flipColDirection(sortSpec, colRef));
|
||||
section.activeSortSpec(Sort.flipSort(sortSpec, colSpec));
|
||||
})
|
||||
)
|
||||
),
|
||||
cssMenuTextLabel(col.colId()),
|
||||
cssMenuIconWrapper(
|
||||
cssIcon('Remove', testId('btn-remove-sort'), dom.on('click', () => {
|
||||
const idx = sortSpec.findIndex(c => c === colRef);
|
||||
if (idx !== -1) {
|
||||
sortSpec.splice(idx, 1);
|
||||
section.activeSortSpec(sortSpec);
|
||||
if (Sort.findCol(sortSpec, colSpec)) {
|
||||
section.activeSortSpec(Sort.removeCol(sortSpec, colSpec));
|
||||
}
|
||||
}))
|
||||
),
|
||||
|
||||
@@ -120,33 +120,40 @@ export const cssLabelText = styled('span', `
|
||||
type CheckboxArg = DomArg<HTMLInputElement>;
|
||||
|
||||
function checkbox(
|
||||
obs: Observable<boolean>, cssCheckbox: typeof cssCheckboxSquare, label: DomArg = '', ...domArgs: CheckboxArg[]
|
||||
obs: Observable<boolean>, cssCheckbox: typeof cssCheckboxSquare,
|
||||
label: DomArg, right: boolean, ...domArgs: CheckboxArg[]
|
||||
) {
|
||||
return cssLabel(
|
||||
cssCheckbox(
|
||||
const field = cssCheckbox(
|
||||
{ type: 'checkbox' },
|
||||
dom.prop('checked', obs),
|
||||
dom.on('change', (ev, el) => obs.set(el.checked)),
|
||||
...domArgs
|
||||
),
|
||||
label ? cssLabelText(label) : null
|
||||
);
|
||||
);
|
||||
const text = label ? cssLabelText(label) : null;
|
||||
if (right) {
|
||||
return cssReversedLabel([text, cssInlineRelative(field)]);
|
||||
}
|
||||
return cssLabel(field, text);
|
||||
}
|
||||
|
||||
export function squareCheckbox(obs: Observable<boolean>, ...domArgs: CheckboxArg[]) {
|
||||
return checkbox(obs, cssCheckboxSquare, '', ...domArgs);
|
||||
return checkbox(obs, cssCheckboxSquare, '', false, ...domArgs);
|
||||
}
|
||||
|
||||
export function circleCheckbox(obs: Observable<boolean>, ...domArgs: CheckboxArg[]) {
|
||||
return checkbox(obs, cssCheckboxCircle, '', ...domArgs);
|
||||
return checkbox(obs, cssCheckboxCircle, '', false, ...domArgs);
|
||||
}
|
||||
|
||||
export function labeledSquareCheckbox(obs: Observable<boolean>, label: DomArg, ...domArgs: CheckboxArg[]) {
|
||||
return checkbox(obs, cssCheckboxSquare, label, ...domArgs);
|
||||
return checkbox(obs, cssCheckboxSquare, label, false, ...domArgs);
|
||||
}
|
||||
|
||||
export function labeledLeftSquareCheckbox(obs: Observable<boolean>, label: DomArg, ...domArgs: CheckboxArg[]) {
|
||||
return checkbox(obs, cssCheckboxSquare, label, true, ...domArgs);
|
||||
}
|
||||
|
||||
export function labeledCircleCheckbox(obs: Observable<boolean>, label: DomArg, ...domArgs: CheckboxArg[]) {
|
||||
return checkbox(obs, cssCheckboxCircle, label, ...domArgs);
|
||||
return checkbox(obs, cssCheckboxCircle, label, false, ...domArgs);
|
||||
}
|
||||
|
||||
export const Indeterminate = 'indeterminate';
|
||||
@@ -158,7 +165,7 @@ function triStateCheckbox(
|
||||
const checkboxObs = Computed.create(null, obs, (_use, state) => state === true)
|
||||
.onWrite((checked) => obs.set(checked));
|
||||
return checkbox(
|
||||
checkboxObs, cssCheckbox, label,
|
||||
checkboxObs, cssCheckbox, label, false,
|
||||
dom.prop('indeterminate', (use) => use(obs) === 'indeterminate'),
|
||||
dom.autoDispose(checkboxObs),
|
||||
...domArgs
|
||||
@@ -172,3 +179,17 @@ export function triStateSquareCheckbox(obs: Observable<TriState>, ...domArgs: Ch
|
||||
export function labeledTriStateSquareCheckbox(obs: Observable<TriState>, label: string, ...domArgs: CheckboxArg[]) {
|
||||
return triStateCheckbox(obs, cssCheckboxSquare, label, ...domArgs);
|
||||
}
|
||||
|
||||
const cssInlineRelative = styled('div', `
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
height: 16px;
|
||||
`);
|
||||
|
||||
const cssReversedLabel = styled(cssLabel, `
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
& .${cssLabelText.className} {
|
||||
margin: 0px;
|
||||
}
|
||||
`);
|
||||
|
||||
Reference in New Issue
Block a user