diff --git a/src/components/side_panel/pivot/pivot_layout_configurator/pivot_layout_configurator.ts b/src/components/side_panel/pivot/pivot_layout_configurator/pivot_layout_configurator.ts index d74de4477c..48559e2369 100644 --- a/src/components/side_panel/pivot/pivot_layout_configurator/pivot_layout_configurator.ts +++ b/src/components/side_panel/pivot/pivot_layout_configurator/pivot_layout_configurator.ts @@ -27,6 +27,7 @@ import { PivotDimension } from "./pivot_dimension/pivot_dimension"; import { PivotDimensionGranularity } from "./pivot_dimension_granularity/pivot_dimension_granularity"; import { PivotDimensionOrder } from "./pivot_dimension_order/pivot_dimension_order"; import { PivotMeasureEditor } from "./pivot_measure/pivot_measure"; +import { PivotSortSection } from "./pivot_sort_section/pivot_sort_section"; interface Props { definition: PivotRuntimeDefinition; @@ -53,6 +54,7 @@ export class PivotLayoutConfigurator extends Component = { measures: measures.map((m) => (m.id === measure.id ? newMeasure : m)), - }); + }; + if (this.props.definition.sortedColumn?.measure === measure.id) { + update.sortedColumn = { ...this.props.definition.sortedColumn, measure: newMeasure.id }; + } + this.props.onDimensionsUpdated(update); } private getMeasureId(fieldName: string, aggregator?: string) { diff --git a/src/components/side_panel/pivot/pivot_layout_configurator/pivot_layout_configurator.xml b/src/components/side_panel/pivot/pivot_layout_configurator/pivot_layout_configurator.xml index b1836d99b6..3ca2197a40 100644 --- a/src/components/side_panel/pivot/pivot_layout_configurator/pivot_layout_configurator.xml +++ b/src/components/side_panel/pivot/pivot_layout_configurator/pivot_layout_configurator.xml @@ -84,5 +84,6 @@ + diff --git a/src/components/side_panel/pivot/pivot_layout_configurator/pivot_sort_section/pivot_sort_section.ts b/src/components/side_panel/pivot/pivot_layout_configurator/pivot_sort_section/pivot_sort_section.ts new file mode 100644 index 0000000000..c196373230 --- /dev/null +++ b/src/components/side_panel/pivot/pivot_layout_configurator/pivot_sort_section/pivot_sort_section.ts @@ -0,0 +1,86 @@ +import { Component } from "@odoo/owl"; +import { SpreadsheetChildEnv } from "../../../../.."; +import { GRAY_100, GRAY_300, PRIMARY_BUTTON_BG } from "../../../../../constants"; +import { formatValue } from "../../../../../helpers"; +import { + getFieldDisplayName, + isSortedColumnValid, +} from "../../../../../helpers/pivot/pivot_helpers"; +import { PivotRuntimeDefinition } from "../../../../../helpers/pivot/pivot_runtime_definition"; +import { _t } from "../../../../../translation"; +import { PivotDomain, UID } from "../../../../../types"; +import { css } from "../../../../helpers"; +import { Section } from "../../../components/section/section"; + +interface Props { + definition: PivotRuntimeDefinition; + pivotId: UID; +} + +css/* scss */ ` + .o-pivot-sort { + .o-sort-card { + width: fit-content; + background-color: ${GRAY_100}; + border: 1px solid ${GRAY_300}; + + .o-sort-value { + color: ${PRIMARY_BUTTON_BG}; + } + } + } +`; + +export class PivotSortSection extends Component { + static template = "o-spreadsheet-PivotSortSection"; + static components = { + Section, + }; + static props = { + definition: Object, + pivotId: String, + }; + + get hasValidSort() { + const pivot = this.env.model.getters.getPivot(this.props.pivotId); + return ( + !!this.props.definition.sortedColumn && + isSortedColumnValid(this.props.definition.sortedColumn, pivot) + ); + } + + get sortDescription() { + const sortOrder = + this.props.definition.sortedColumn?.order === "asc" ? _t("ascending") : _t("descending"); + return _t("Sorted on column (%(ascOrDesc)s):", { + ascOrDesc: sortOrder, + }); + } + + get sortValuesAndFields() { + const sortedColumn = this.props.definition.sortedColumn; + if (!sortedColumn) { + return []; + } + const pivot = this.env.model.getters.getPivot(this.props.pivotId); + const locale = this.env.model.getters.getLocale(); + + const currentDomain: PivotDomain = []; + const sortValues: { field?: string; value: string }[] = []; + for (const domainItem of sortedColumn.domain) { + currentDomain.push(domainItem); + const { value, format } = pivot.getPivotHeaderValueAndFormat(currentDomain); + const label = formatValue(value, { format, locale }); + const field = pivot.definition.getDimension(domainItem.field); + sortValues.push({ field: getFieldDisplayName(field), value: label }); + } + + if (sortedColumn.domain.length === 0) { + sortValues.push({ value: _t("Total") }); + } + const measureLabel = pivot.getMeasure(sortedColumn.measure).displayName; + sortValues.push({ value: measureLabel, field: _t("Measure") }); + + return sortValues; + } +} diff --git a/src/components/side_panel/pivot/pivot_layout_configurator/pivot_sort_section/pivot_sort_section.xml b/src/components/side_panel/pivot/pivot_layout_configurator/pivot_sort_section/pivot_sort_section.xml new file mode 100644 index 0000000000..7cbc6b09e3 --- /dev/null +++ b/src/components/side_panel/pivot/pivot_layout_configurator/pivot_sort_section/pivot_sort_section.xml @@ -0,0 +1,19 @@ + + +
+ Sorting +
+
+ +
+ + + = + + +
+
+
+
+
+
diff --git a/src/components/side_panel/pivot/pivot_side_panel/pivot_side_panel_store.ts b/src/components/side_panel/pivot/pivot_side_panel/pivot_side_panel_store.ts index a5fa62d4dd..e335396544 100644 --- a/src/components/side_panel/pivot/pivot_side_panel/pivot_side_panel_store.ts +++ b/src/components/side_panel/pivot/pivot_side_panel/pivot_side_panel_store.ts @@ -195,6 +195,7 @@ export class PivotSidePanelStore extends SpreadsheetStore { format: measure.format, display: measure.display, })), + sortedColumn: this.shouldKeepSortedColumn(definition) ? definition.sortedColumn : undefined, }; if (!this.draft && deepEquals(coreDefinition, cleanedDefinition)) { return; @@ -265,4 +266,20 @@ export class PivotSidePanelStore extends SpreadsheetStore { } return granularitiesPerFields; } + + /** + * Check if we want to keep the sorted column when updating the pivot definition. We should remove it if either + * the measure is not in the new definition or the columns have changed. + */ + private shouldKeepSortedColumn(newDefinition: PivotCoreDefinition) { + const { sortedColumn } = newDefinition; + if (!sortedColumn) { + return true; + } + const oldDefinition = this.getters.getPivotCoreDefinition(this.pivotId); + return ( + newDefinition.measures.find((measure) => measure.id === sortedColumn.measure) && + deepEquals(oldDefinition.columns, newDefinition.columns) + ); + } } diff --git a/src/helpers/pivot/pivot_domain_helpers.ts b/src/helpers/pivot/pivot_domain_helpers.ts index f0e1c9aaa8..d78a4a79d0 100644 --- a/src/helpers/pivot/pivot_domain_helpers.ts +++ b/src/helpers/pivot/pivot_domain_helpers.ts @@ -253,3 +253,18 @@ export function getRunningTotalDomainKey( } return domainToString([...domain.slice(0, index), ...domain.slice(index + 1)]); } + +export function sortPivotTree( + tree: DimensionTree, + baseDomain: PivotDomain, + sortFn: (a: PivotDomain, b: PivotDomain) => number +) { + const sortedTree = [...tree]; + const domain = [...baseDomain]; + sortedTree.sort((node1, node2) => sortFn([...domain, node1], [...domain, node2])); + for (const node of tree) { + const children = sortPivotTree(node.children, [...domain, node], sortFn); + node.children = children; + } + return sortedTree; +} diff --git a/src/helpers/pivot/pivot_helpers.ts b/src/helpers/pivot/pivot_helpers.ts index b4171652a2..6bdd784f26 100644 --- a/src/helpers/pivot/pivot_helpers.ts +++ b/src/helpers/pivot/pivot_helpers.ts @@ -19,6 +19,7 @@ import { PivotDimension, PivotDomain, PivotField, + PivotSortedColumn, PivotTableCell, } from "../../types/pivot"; import { domainToColRowDomain } from "./pivot_domain_helpers"; @@ -299,3 +300,27 @@ export function addIndentAndAlignToPivotHeader( format: `${" ".repeat(indent)}${format}* `, }; } + +export function isSortedColumnValid(sortedColumn: PivotSortedColumn, pivot: Pivot): boolean { + try { + if (!pivot.getMeasure(sortedColumn.measure)) { + return false; + } + + const columns = pivot.definition.columns; + for (let i = 0; i < sortedColumn.domain.length; i++) { + if (columns[i].nameWithGranularity !== sortedColumn.domain[i].field) { + return false; + } + const possibleValues: (CellValue | null)[] = pivot + .getPossibleFieldValues(columns[i]) + .map((v) => v.value); + if (!possibleValues.includes(sortedColumn.domain[i].value)) { + return false; + } + } + return true; + } catch (e) { + return false; + } +} diff --git a/src/helpers/pivot/pivot_menu_items.ts b/src/helpers/pivot/pivot_menu_items.ts index ad989f49ef..2530d81fc1 100644 --- a/src/helpers/pivot/pivot_menu_items.ts +++ b/src/helpers/pivot/pivot_menu_items.ts @@ -1,6 +1,9 @@ +import { SortDirection, SpreadsheetChildEnv } from "../.."; import { ActionSpec } from "../../actions/action"; import { _t } from "../../translation"; import { CellValueType } from "../../types"; +import { deepEquals } from "../misc"; +import { domainToColRowDomain } from "./pivot_domain_helpers"; export const pivotProperties: ActionSpec = { name: _t("See pivot properties"), @@ -18,6 +21,24 @@ export const pivotProperties: ActionSpec = { icon: "o-spreadsheet-Icon.PIVOT", }; +export const pivotSortingAsc: ActionSpec = { + name: _t("Ascending"), + execute: (env) => sortPivot(env, "asc"), + isActive: (env) => isPivotSortMenuItemActive(env, "asc"), +}; + +export const pivotSortingDesc: ActionSpec = { + name: _t("Descending"), + execute: (env) => sortPivot(env, "desc"), + isActive: (env) => isPivotSortMenuItemActive(env, "desc"), +}; + +export const noPivotSorting: ActionSpec = { + name: _t("No sorting"), + execute: (env) => sortPivot(env, "none"), + isActive: (env) => isPivotSortMenuItemActive(env, "none"), +}; + export const FIX_FORMULAS: ActionSpec = { name: _t("Convert to individual formulas"), execute(env) { @@ -56,3 +77,75 @@ export const FIX_FORMULAS: ActionSpec = { }, icon: "o-spreadsheet-Icon.PIVOT", }; + +export function canSortPivot(env: SpreadsheetChildEnv): boolean { + const position = env.model.getters.getActivePosition(); + const pivotId = env.model.getters.getPivotIdFromPosition(position); + if ( + !pivotId || + !env.model.getters.isExistingPivot(pivotId) || + !env.model.getters.isSpillPivotFormula(position) + ) { + return false; + } + const pivot = env.model.getters.getPivot(pivotId); + if (!pivot.isValid()) { + return false; + } + const pivotCell = env.model.getters.getPivotCellFromPosition(position); + return pivotCell.type === "VALUE" || pivotCell.type === "MEASURE_HEADER"; +} + +function sortPivot(env: SpreadsheetChildEnv, order: SortDirection | "none") { + const position = env.model.getters.getActivePosition(); + const pivotId = env.model.getters.getPivotIdFromPosition(position); + const pivotCell = env.model.getters.getPivotCellFromPosition(position); + if (pivotCell.type === "EMPTY" || pivotCell.type === "HEADER" || !pivotId) { + return; + } + + if (order === "none") { + env.model.dispatch("UPDATE_PIVOT", { + pivotId: pivotId, + pivot: { + ...env.model.getters.getPivotCoreDefinition(pivotId), + sortedColumn: undefined, + }, + }); + return; + } + + const pivot = env.model.getters.getPivot(pivotId); + const colDomain = domainToColRowDomain(pivot, pivotCell.domain).colDomain; + env.model.dispatch("UPDATE_PIVOT", { + pivotId: pivotId, + pivot: { + ...env.model.getters.getPivotCoreDefinition(pivotId), + sortedColumn: { domain: colDomain, order, measure: pivotCell.measure }, + }, + }); +} + +function isPivotSortMenuItemActive( + env: SpreadsheetChildEnv, + order: SortDirection | "none" +): boolean { + const position = env.model.getters.getActivePosition(); + const pivotId = env.model.getters.getPivotIdFromPosition(position); + const pivotCell = env.model.getters.getPivotCellFromPosition(position); + if (pivotCell.type === "EMPTY" || pivotCell.type === "HEADER" || !pivotId) { + return false; + } + const pivot = env.model.getters.getPivot(pivotId); + const colDomain = domainToColRowDomain(pivot, pivotCell.domain).colDomain; + const sortedColumn = pivot.definition.sortedColumn; + + if (order === "none") { + return !sortedColumn; + } + + if (!sortedColumn || sortedColumn.order !== order) { + return false; + } + return sortedColumn.measure === pivotCell.measure && deepEquals(sortedColumn.domain, colDomain); +} diff --git a/src/helpers/pivot/pivot_presentation.ts b/src/helpers/pivot/pivot_presentation.ts index 7abe07636e..b11afc52b8 100644 --- a/src/helpers/pivot/pivot_presentation.ts +++ b/src/helpers/pivot/pivot_presentation.ts @@ -34,8 +34,9 @@ import { isFieldInDomain, replaceFieldValueInDomain, } from "./pivot_domain_helpers"; -import { AGGREGATORS_FN, toNormalizedPivotValue } from "./pivot_helpers"; +import { AGGREGATORS_FN, isSortedColumnValid, toNormalizedPivotValue } from "./pivot_helpers"; import { PivotParams, PivotUIConstructor } from "./pivot_registry"; +import { SpreadsheetPivotTable } from "./table_spreadsheet_pivot"; const PERCENT_FORMAT = "0.00%"; @@ -147,7 +148,7 @@ export default function (PivotClass: PivotUIConstructor) { private getValuesToAggregate(measure: PivotMeasure, domain: PivotDomain) { const { rowDomain, colDomain } = domainToColRowDomain(this, domain); - const table = this.getTableStructure(); + const table = super.getTableStructure(); const values: FunctionResultObject[] = []; if ( colDomain.length === 0 && @@ -730,6 +731,25 @@ export default function (PivotClass: PivotUIConstructor) { } throw new Error(`Value ${result.value} is not a number`); } + + getTableStructure(): SpreadsheetPivotTable { + const table = super.getTableStructure(); + this.sortTableStructure(table); + return table; + } + + private sortTableStructure(table: SpreadsheetPivotTable) { + if (!this.definition.sortedColumn || table.isSorted) { + return; + } + const measure = this.definition.sortedColumn.measure; + const isSortValid = isSortedColumnValid(this.definition.sortedColumn, this); + if (isSortValid) { + table.sort(measure, this.definition.sortedColumn, (measure, domain) => + this._getPivotCellValueAndFormat(measure, domain) + ); + } + } } return PivotPresentationLayer; } diff --git a/src/helpers/pivot/pivot_runtime_definition.ts b/src/helpers/pivot/pivot_runtime_definition.ts index 02ebd9b871..52d0f452c2 100644 --- a/src/helpers/pivot/pivot_runtime_definition.ts +++ b/src/helpers/pivot/pivot_runtime_definition.ts @@ -7,6 +7,7 @@ import { PivotDimension, PivotFields, PivotMeasure, + PivotSortedColumn, } from "../../types/pivot"; import { isDateOrDatetimeField } from "./pivot_helpers"; @@ -19,11 +20,13 @@ export class PivotRuntimeDefinition { readonly measures: PivotMeasure[]; readonly columns: PivotDimension[]; readonly rows: PivotDimension[]; + readonly sortedColumn?: PivotSortedColumn; constructor(definition: CommonPivotCoreDefinition, fields: PivotFields) { this.measures = definition.measures.map((measure) => createMeasure(fields, measure)); this.columns = definition.columns.map((dimension) => createPivotDimension(fields, dimension)); this.rows = definition.rows.map((dimension) => createPivotDimension(fields, dimension)); + this.sortedColumn = definition.sortedColumn; } getDimension(nameWithGranularity: string): PivotDimension { diff --git a/src/helpers/pivot/spreadsheet_pivot/data_entry_spreadsheet_pivot.ts b/src/helpers/pivot/spreadsheet_pivot/data_entry_spreadsheet_pivot.ts index 429443e28f..8997a01d70 100644 --- a/src/helpers/pivot/spreadsheet_pivot/data_entry_spreadsheet_pivot.ts +++ b/src/helpers/pivot/spreadsheet_pivot/data_entry_spreadsheet_pivot.ts @@ -107,6 +107,7 @@ function dataEntriesToColumnsTree( value: groups[key]?.[0]?.[column.nameWithGranularity]?.value ?? null, field: colName, children: dataEntriesToColumnsTree(groups[key] || [], columns, index + 1), + type: column.type, width: 0, }; }); diff --git a/src/helpers/pivot/table_spreadsheet_pivot.ts b/src/helpers/pivot/table_spreadsheet_pivot.ts index 21034b90c1..c77364fcf5 100644 --- a/src/helpers/pivot/table_spreadsheet_pivot.ts +++ b/src/helpers/pivot/table_spreadsheet_pivot.ts @@ -1,13 +1,15 @@ -import { Lazy } from "../../types"; +import { FunctionResultObject, Lazy } from "../../types"; import { DimensionTree, DimensionTreeNode, PivotDomain, + PivotSortedColumn, PivotTableCell, PivotTableColumn, PivotTableRow, } from "../../types/pivot"; import { lazy } from "../misc"; +import { sortPivotTree } from "./pivot_domain_helpers"; import { parseDimension, toNormalizedPivotValue } from "./pivot_helpers"; /** @@ -55,7 +57,7 @@ import { parseDimension, toNormalizedPivotValue } from "./pivot_helpers"; export class SpreadsheetPivotTable { readonly columns: PivotTableColumn[][]; - readonly rows: PivotTableRow[]; + rows: PivotTableRow[]; readonly measures: string[]; readonly fieldsType: Record; readonly maxIndent: number; @@ -63,6 +65,8 @@ export class SpreadsheetPivotTable { private rowTree: Lazy; private colTree: Lazy; + isSorted = false; + constructor( columns: PivotTableColumn[][], rows: PivotTableRow[], @@ -260,6 +264,7 @@ export class SpreadsheetPivotTable { value, field: row.fields[rowDepth], children: [], + type: this.fieldsType[fieldName] || "char", width: 0, // not used }; treesAtDepth[depth].push(node); @@ -286,6 +291,7 @@ export class SpreadsheetPivotTable { field: leaf.fields[depth], children: [], width: leaf.width, + type: this.fieldsType[fieldName] || "char", }; if (treesAtDepth[depth]?.at(-1)?.value !== value) { treesAtDepth[depth + 1] = []; @@ -305,6 +311,42 @@ export class SpreadsheetPivotTable { fieldsType: this.fieldsType, }; } + + sort( + measure: string, + sortedColumn: PivotSortedColumn, + getValue: (measure: string, domain: PivotDomain) => FunctionResultObject + ) { + if (this.isSorted) { + return; + } + const getSortValue = (measure: string, domain: PivotDomain): number => { + const rawValue = getValue(measure, domain).value; + return typeof rawValue === "number" ? rawValue : -Infinity; + }; + const sortColDomain = sortedColumn.domain; + + const sortFn = (rowDomain1: PivotDomain, rowDomain2: PivotDomain) => { + const value1 = getSortValue(measure, [...rowDomain1, ...sortColDomain]); + const value2 = getSortValue(measure, [...rowDomain2, ...sortColDomain]); + return sortedColumn.order === "asc" ? value1 - value2 : value2 - value1; + }; + const sortedRowTree = sortPivotTree(this.rowTree(), [], sortFn); + this.rowTree = lazy(sortedRowTree); + this.rows = [...this.rowTreeToRows(sortedRowTree), this.rows[this.rows.length - 1]]; + this.isSorted = true; + } + + private rowTreeToRows(tree: DimensionTree, parentRow?: PivotTableRow): PivotTableRow[] { + return tree.flatMap((node) => { + const row: PivotTableRow = { + indent: parentRow ? parentRow.indent + 1 : 0, + fields: [...(parentRow?.fields || []), node.field], + values: [...(parentRow?.values || []), node.value], + }; + return [row, ...this.rowTreeToRows(node.children, row)]; + }); + } } export const EMPTY_PIVOT_CELL = { type: "EMPTY" } as const; diff --git a/src/registries/menus/cell_menu_registry.ts b/src/registries/menus/cell_menu_registry.ts index f093d4be15..346eac0c5e 100644 --- a/src/registries/menus/cell_menu_registry.ts +++ b/src/registries/menus/cell_menu_registry.ts @@ -106,11 +106,30 @@ cellMenuRegistry sequence: 150, separator: true, }) + .add("pivot_sorting", { + name: _t("Sort pivot"), + sequence: 155, + icon: "o-spreadsheet-Icon.SORT_RANGE", + isVisible: ACTIONS_PIVOT.canSortPivot, + }) .add("pivot_fix_formulas", { ...ACTIONS_PIVOT.FIX_FORMULAS, - sequence: 155, + sequence: 160, }) .add("pivot_properties", { ...ACTIONS_PIVOT.pivotProperties, sequence: 170, + separator: true, + }) + .addChild("pivot_sorting_asc", ["pivot_sorting"], { + ...ACTIONS_PIVOT.pivotSortingAsc, + sequence: 10, + }) + .addChild("pivot_sorting_desc", ["pivot_sorting"], { + ...ACTIONS_PIVOT.pivotSortingDesc, + sequence: 20, + }) + .addChild("pivot_sorting_none", ["pivot_sorting"], { + ...ACTIONS_PIVOT.noPivotSorting, + sequence: 30, }); diff --git a/src/types/pivot.ts b/src/types/pivot.ts index 92f4cd557e..36deac6d8c 100644 --- a/src/types/pivot.ts +++ b/src/types/pivot.ts @@ -57,6 +57,13 @@ export interface CommonPivotCoreDefinition { measures: PivotCoreMeasure[]; name: string; deferUpdates?: boolean; + sortedColumn?: PivotSortedColumn; +} + +export interface PivotSortedColumn { + order: SortDirection; + domain: PivotDomain; + measure: string; } export interface SpreadsheetPivotCoreDefinition extends CommonPivotCoreDefinition { @@ -197,6 +204,7 @@ export type PivotMeasureDisplayType = export interface DimensionTreeNode { value: CellValue; field: string; + type: string; children: DimensionTree; width: number; } diff --git a/tests/pivots/pivot_helpers.test.ts b/tests/pivots/pivot_helpers.test.ts index 514bc4cb58..5d34716655 100644 --- a/tests/pivots/pivot_helpers.test.ts +++ b/tests/pivots/pivot_helpers.test.ts @@ -1,7 +1,11 @@ +import { PivotSortedColumn } from "../../src"; import { + isSortedColumnValid, toFunctionPivotValue, toNormalizedPivotValue, } from "../../src/helpers/pivot/pivot_helpers"; +import { createModelFromGrid } from "../test_helpers/helpers"; +import { addPivot } from "../test_helpers/pivot_helpers"; describe("toNormalizedPivotValue", () => { test("parse values of char field", () => { @@ -218,3 +222,49 @@ describe("ToFunctionValue", () => { expect(toFunctionPivotValue(true, dimension)).toBe("TRUE"); }); }); + +test("isSortedColumnValid", () => { + // prettier-ignore + const grid = { + A1: "Customer", B1: "Price", C1: "Date", + A2: "Alice", B2: "10", C2: "10/10/2020", + A3: "Bob", B3: "30", C3: "10/10/2022", + }; + const model = createModelFromGrid(grid); + addPivot(model, "A1:C3", { + columns: [{ fieldName: "Date", granularity: "year" }], + rows: [{ fieldName: "Customer" }], + measures: [{ id: "Price:sum", fieldName: "Price", aggregator: "sum" }], + }); + + const pivotId = model.getters.getPivotIds()[0]; + + // Total column + const sortedColumn: PivotSortedColumn = { measure: "Price:sum", order: "asc", domain: [] }; + expect(isSortedColumnValid(sortedColumn, model.getters.getPivot(pivotId))).toBe(true); + + // Valid column + sortedColumn.domain = [{ field: "Date:year", value: 2020, type: "char" }]; + expect(isSortedColumnValid(sortedColumn, model.getters.getPivot(pivotId))).toBe(true); + + // Invalid column value + sortedColumn.domain = [{ field: "Customer", value: "Random Person", type: "char" }]; + expect(isSortedColumnValid(sortedColumn, model.getters.getPivot(pivotId))).toBe(false); + + // Invalid column field + sortedColumn.domain = [{ field: "Random Field", value: "Alice", type: "char" }]; + expect(isSortedColumnValid(sortedColumn, model.getters.getPivot(pivotId))).toBe(false); + + // Invalid column granularity + sortedColumn.domain = [{ field: "Date:quarter", value: 2020, type: "char" }]; + expect(isSortedColumnValid(sortedColumn, model.getters.getPivot(pivotId))).toBe(false); + + // Row dimension as sorted column + sortedColumn.domain = [{ field: "Customer", value: "Alice", type: "char" }]; + expect(isSortedColumnValid(sortedColumn, model.getters.getPivot(pivotId))).toBe(false); + + // Invalid measure + sortedColumn.measure = "Random Measure"; + sortedColumn.domain = []; + expect(isSortedColumnValid(sortedColumn, model.getters.getPivot(pivotId))).toBe(false); +}); diff --git a/tests/pivots/pivot_measure/pivot_measure_display_model.test.ts b/tests/pivots/pivot_measure/pivot_measure_display_model.test.ts index 2c8111013f..dae1e9f87b 100644 --- a/tests/pivots/pivot_measure/pivot_measure_display_model.test.ts +++ b/tests/pivots/pivot_measure/pivot_measure_display_model.test.ts @@ -1,51 +1,20 @@ -import { CellErrorType, PivotMeasureDisplay, SpreadsheetPivotCoreDefinition } from "../../../src"; +import { CellErrorType, PivotMeasureDisplay } from "../../../src"; import { NEXT_VALUE, PREVIOUS_VALUE } from "../../../src/helpers/pivot/pivot_domain_helpers"; import { setCellContent } from "../../test_helpers/commands_helpers"; import { getCell, getEvaluatedCell } from "../../test_helpers/getters_helpers"; -import { createModelFromGrid, getFormattedGrid, getGrid } from "../../test_helpers/helpers"; -import { addPivot, updatePivot, updatePivotMeasureDisplay } from "../../test_helpers/pivot_helpers"; +import { getFormattedGrid, getGrid } from "../../test_helpers/helpers"; +import { + createModelWithTestPivotDataset, + updatePivot, + updatePivotMeasureDisplay, +} from "../../test_helpers/pivot_helpers"; const pivotId = "pivotId"; -const measureId = "m1"; - -function createModelWithTestPivot(pivotDefinition?: Partial) { - // prettier-ignore - const grid = { - A1:"Created on", B1: "Salesperson", C1: "Expected Revenue", D1: "Stage", E1: "Active", - A2: "04/02/2024", B2: "Bob", C2: "2000", D2: "Won", E2: "TRUE", - A3: "03/28/2024", B3: "Bob", C3: "11000", D3: "New", E3: "TRUE", - A4: "04/02/2024", B4: "Alice", C4: "4500", D4: "Won", E4: "TRUE", - A5: "04/02/2024", B5: "Alice", C5: "9000", D5: "New", E5: "TRUE", - A6: "03/27/2024", B6: "Alice", C6: "19800", D6: "Won", E6: "TRUE", - A7: "04/01/2024", B7: "Alice", C7: "3800", D7: "Won", E7: "TRUE", - A8: "04/02/2024", B8: "Bob", C8: "24000", D8: "New", E8: "TRUE", - A9: "02/03/2024", B9: "Alice", C9: "22500", D9: "Won", E9: "FALSE", - A10: "03/03/2024", B10: "Alice", C10: "40000", D10: "New", E10: "FALSE", - A11: "03/26/2024", B11: "Alice", C11: "5600", D11: "New", E11: "FALSE", - A12: "03/27/2024", B12: "Bob", C12: "15000", D12: "New", E12: "FALSE", - A13: "03/27/2024", B13: "Bob", C13: "35000", D13: "Won", E13: "FALSE", - A14: "03/31/2024", B14: "Bob", C14: "1000", D14: "Won", E14: "FALSE", - A15: "04/02/2024", B15: "Alice", C15: "25000", D15: "Won", E15: "FALSE", - A16: "04/02/2024", B16: "Alice", C16: "40000", D16: "New", E16: "FALSE", - A17: "03/27/2024", B17: "Alice", C17: "60000", D17: "New", E17: "FALSE", - A18: "03/27/2024", B18: "Bob", C18: "2000", D18: "Won", E18: "FALSE", - }; - const model = createModelFromGrid(grid); - - pivotDefinition = { - columns: [{ fieldName: "Salesperson", order: "asc" }], - rows: [{ fieldName: "Created on", granularity: "month_number", order: "asc" }], - measures: [{ fieldName: "Expected Revenue", aggregator: "sum", id: measureId }], - ...pivotDefinition, - }; - addPivot(model, "A1:E18", pivotDefinition, pivotId); - setCellContent(model, "A20", "=PIVOT(1)"); - return model; -} +const measureId = "measureId"; describe("Measure display", () => { test("Can display measures with no calculations", () => { - const model = createModelWithTestPivot(); + const model = createModelWithTestPivotDataset(); updatePivotMeasureDisplay(model, pivotId, measureId, { type: "no_calculations" }); expect(model.getters.getPivotCoreDefinition(pivotId).measures[0].display).toEqual({ @@ -63,7 +32,7 @@ describe("Measure display", () => { }); test("%_of_grand_total display type", () => { - const model = createModelWithTestPivot(); + const model = createModelWithTestPivotDataset(); updatePivotMeasureDisplay(model, pivotId, measureId, { type: "%_of_grand_total" }); // prettier-ignore @@ -77,7 +46,7 @@ describe("Measure display", () => { }); test("Displayed measure are updated when changing the aggregator", () => { - const model = createModelWithTestPivot(); + const model = createModelWithTestPivotDataset(); updatePivot(model, pivotId, { measures: [ { @@ -100,7 +69,7 @@ describe("Measure display", () => { }); test("%_of_col_total display type", () => { - const model = createModelWithTestPivot(); + const model = createModelWithTestPivotDataset(); updatePivotMeasureDisplay(model, pivotId, measureId, { type: "%_of_col_total" }); // prettier-ignore @@ -114,7 +83,7 @@ describe("Measure display", () => { }); test("%_of_row_total display type", () => { - const model = createModelWithTestPivot(); + const model = createModelWithTestPivotDataset(); updatePivotMeasureDisplay(model, pivotId, measureId, { type: "%_of_row_total" }); // prettier-ignore @@ -128,7 +97,7 @@ describe("Measure display", () => { }); test("%_of_parent_row_total display type", () => { - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ rows: [ { fieldName: "Created on", granularity: "month_number", order: "asc" }, { fieldName: "Active", order: "asc" }, @@ -159,7 +128,7 @@ describe("Measure display", () => { }); test("%_of_parent_col_total display type", () => { - const model = createModelWithTestPivot(); + const model = createModelWithTestPivotDataset(); updatePivot(model, pivotId, { columns: [ { fieldName: "Salesperson", order: "asc" }, @@ -192,7 +161,7 @@ describe("Measure display", () => { type: "%_of_parent_total", fieldNameWithGranularity: "Created on:month_number", }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ rows: [ { fieldName: "Created on", granularity: "month_number", order: "asc" }, { fieldName: "Active", order: "asc" }, @@ -237,7 +206,7 @@ describe("Measure display", () => { type: "%_of_parent_total", fieldNameWithGranularity: "Active", }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ rows: [ { fieldName: "Created on", granularity: "month_number", order: "asc" }, { fieldName: "Active", order: "asc" }, @@ -282,7 +251,7 @@ describe("Measure display", () => { type: "%_of_parent_total", fieldNameWithGranularity: "Salesperson", }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ columns: [ { fieldName: "Salesperson", order: "asc" }, { fieldName: "Stage", order: "asc" }, @@ -313,7 +282,7 @@ describe("Measure display", () => { type: "%_of_parent_total", fieldNameWithGranularity: "Active", }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ measures: [ { fieldName: "Expected Revenue", @@ -345,7 +314,7 @@ describe("Measure display", () => { fieldNameWithGranularity: "Created on:month_number", value: 2, }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ measures: [ { fieldName: "Expected Revenue", @@ -372,7 +341,7 @@ describe("Measure display", () => { fieldNameWithGranularity: "Salesperson", value: "Alice", }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ measures: [ { fieldName: "Expected Revenue", @@ -399,7 +368,7 @@ describe("Measure display", () => { fieldNameWithGranularity: "Active", value: false, }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ rows: [ { fieldName: "Created on", granularity: "month_number", order: "asc" }, { fieldName: "Active", order: "asc" }, @@ -445,7 +414,7 @@ describe("Measure display", () => { fieldNameWithGranularity: "Created on:month_number", value: PREVIOUS_VALUE, }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ measures: [ { fieldName: "Expected Revenue", @@ -472,7 +441,7 @@ describe("Measure display", () => { fieldNameWithGranularity: "Salesperson", value: NEXT_VALUE, }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ measures: [ { fieldName: "Expected Revenue", @@ -499,7 +468,7 @@ describe("Measure display", () => { fieldNameWithGranularity: "Active", value: PREVIOUS_VALUE, }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ rows: [ { fieldName: "Created on", granularity: "month_number", order: "asc" }, { fieldName: "Active", order: "asc" }, @@ -545,7 +514,7 @@ describe("Measure display", () => { fieldNameWithGranularity: "Created on:month_number", value: PREVIOUS_VALUE, }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ rows: [{ fieldName: "Created on", granularity: "month_number", order: "desc" }], measures: [ { @@ -573,7 +542,7 @@ describe("Measure display", () => { fieldNameWithGranularity: "Stages", value: "Won", }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ measures: [ { fieldName: "Expected Revenue", @@ -600,7 +569,7 @@ describe("Measure display", () => { fieldNameWithGranularity: "Salesperson", value: "Annette", }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ measures: [ { fieldName: "Expected Revenue", @@ -627,7 +596,7 @@ describe("Measure display", () => { fieldNameWithGranularity: "Salesperson", value: "Bob", }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ measures: [ { fieldName: "Expected Revenue", @@ -660,7 +629,7 @@ describe("Measure display", () => { fieldNameWithGranularity: "Created on:month_number", value: 2, }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ measures: [ { fieldName: "Expected Revenue", @@ -687,7 +656,7 @@ describe("Measure display", () => { fieldNameWithGranularity: "Salesperson", value: "Alice", }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ measures: [ { fieldName: "Expected Revenue", @@ -714,7 +683,7 @@ describe("Measure display", () => { fieldNameWithGranularity: "Active", value: true, }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ rows: [ { fieldName: "Created on", granularity: "month_number", order: "asc" }, { fieldName: "Active", order: "asc" }, @@ -760,7 +729,7 @@ describe("Measure display", () => { fieldNameWithGranularity: "Active", value: PREVIOUS_VALUE, }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ rows: [ { fieldName: "Created on", granularity: "month_number", order: "asc" }, { fieldName: "Active", order: "asc" }, @@ -808,7 +777,7 @@ describe("Measure display", () => { fieldNameWithGranularity: "Created on:month_number", value: 2, }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ measures: [ { fieldName: "Expected Revenue", @@ -835,7 +804,7 @@ describe("Measure display", () => { fieldNameWithGranularity: "Salesperson", value: "Alice", }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ measures: [ { fieldName: "Expected Revenue", @@ -862,7 +831,7 @@ describe("Measure display", () => { fieldNameWithGranularity: "Active", value: true, }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ rows: [ { fieldName: "Created on", granularity: "month_number", order: "asc" }, { fieldName: "Active", order: "asc" }, @@ -908,7 +877,7 @@ describe("Measure display", () => { fieldNameWithGranularity: "Active", value: PREVIOUS_VALUE, }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ rows: [ { fieldName: "Created on", granularity: "month_number", order: "asc" }, { fieldName: "Active", order: "asc" }, @@ -954,7 +923,7 @@ describe("Measure display", () => { fieldNameWithGranularity: "Salesperson", value: "Bob", }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ measures: [ { fieldName: "Expected Revenue", @@ -982,7 +951,7 @@ describe("Measure display", () => { describe("index", () => { test("Can display measure as index with simple grouping", () => { - const model = createModelWithTestPivot(); + const model = createModelWithTestPivotDataset(); updatePivotMeasureDisplay(model, pivotId, measureId, { type: "index" }); // prettier-ignore @@ -996,7 +965,7 @@ describe("Measure display", () => { }); test("Can display measure as index with multi-level grouping", () => { - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ rows: [ { fieldName: "Created on", granularity: "month_number", order: "asc" }, { fieldName: "Active", order: "asc" }, @@ -1039,7 +1008,7 @@ describe("Measure display", () => { describe("rank_asc", () => { test("Can display measure as ascending ranking on a row field", () => { - const model = createModelWithTestPivot(); + const model = createModelWithTestPivotDataset(); updatePivotMeasureDisplay(model, pivotId, measureId, { type: "rank_asc", fieldNameWithGranularity: "Created on:month_number", @@ -1060,7 +1029,7 @@ describe("Measure display", () => { type: "rank_asc", fieldNameWithGranularity: "Created on:month_number", }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ rows: [ { fieldName: "Created on", granularity: "month_number", order: "asc" }, { fieldName: "Active", order: "asc" }, @@ -1148,7 +1117,7 @@ describe("Measure display", () => { }); test("Can display measure as ascending ranking on a column field", () => { - const model = createModelWithTestPivot(); + const model = createModelWithTestPivotDataset(); updatePivotMeasureDisplay(model, pivotId, measureId, { type: "rank_asc", fieldNameWithGranularity: "Salesperson", @@ -1167,7 +1136,7 @@ describe("Measure display", () => { describe("rank_desc", () => { test("Can display measure as descending ranking on a row field", () => { - const model = createModelWithTestPivot(); + const model = createModelWithTestPivotDataset(); updatePivotMeasureDisplay(model, pivotId, measureId, { type: "rank_desc", fieldNameWithGranularity: "Created on:month_number", @@ -1188,7 +1157,7 @@ describe("Measure display", () => { type: "rank_desc", fieldNameWithGranularity: "Active", }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ rows: [ { fieldName: "Created on", granularity: "month_number", order: "asc" }, { fieldName: "Active", order: "asc" }, @@ -1222,7 +1191,7 @@ describe("Measure display", () => { }); test("Can display measure as descending ranking on a column field", () => { - const model = createModelWithTestPivot(); + const model = createModelWithTestPivotDataset(); updatePivotMeasureDisplay(model, pivotId, measureId, { type: "rank_desc", fieldNameWithGranularity: "Salesperson", @@ -1241,7 +1210,7 @@ describe("Measure display", () => { describe("running_total", () => { test("Can display measure as running total of a row field", () => { - const model = createModelWithTestPivot(); + const model = createModelWithTestPivotDataset(); updatePivotMeasureDisplay(model, pivotId, measureId, { type: "running_total", fieldNameWithGranularity: "Created on:month_number", @@ -1262,7 +1231,7 @@ describe("Measure display", () => { type: "running_total", fieldNameWithGranularity: "Created on:month_number", }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ rows: [ { fieldName: "Created on", granularity: "month_number", order: "asc" }, { fieldName: "Active", order: "asc" }, @@ -1352,7 +1321,7 @@ describe("Measure display", () => { }); test("Can display measure as running_total of a column field", () => { - const model = createModelWithTestPivot(); + const model = createModelWithTestPivotDataset(); updatePivotMeasureDisplay(model, pivotId, measureId, { type: "running_total", fieldNameWithGranularity: "Salesperson", @@ -1373,7 +1342,7 @@ describe("Measure display", () => { type: "running_total", fieldNameWithGranularity: "Created on:month_number", }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ rows: [{ fieldName: "Created on", granularity: "month_number", order: "desc" }], measures: [{ fieldName: "Expected Revenue", aggregator: "sum", id: measureId, display }], }); @@ -1389,7 +1358,7 @@ describe("Measure display", () => { }); test("Running total with multiple measures", () => { - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ measures: [ { fieldName: "Expected Revenue", @@ -1424,7 +1393,7 @@ describe("Measure display", () => { describe("%_running_total", () => { test("Can display measure as percentage of running total of a row field", () => { - const model = createModelWithTestPivot(); + const model = createModelWithTestPivotDataset(); updatePivotMeasureDisplay(model, pivotId, measureId, { type: "%_running_total", fieldNameWithGranularity: "Created on:month_number", @@ -1445,7 +1414,7 @@ describe("Measure display", () => { type: "%_running_total", fieldNameWithGranularity: "Created on:month_number", }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ rows: [ { fieldName: "Created on", granularity: "month_number", order: "asc" }, { fieldName: "Active", order: "asc" }, @@ -1535,7 +1504,7 @@ describe("Measure display", () => { }); test("Can display measure as percentage of running total of a column field", () => { - const model = createModelWithTestPivot(); + const model = createModelWithTestPivotDataset(); updatePivotMeasureDisplay(model, pivotId, measureId, { type: "%_running_total", fieldNameWithGranularity: "Salesperson", @@ -1558,7 +1527,7 @@ describe("Measure display", () => { fieldNameWithGranularity: "Created on:month_number", value: 2, }; - const model = createModelWithTestPivot({ + const model = createModelWithTestPivotDataset({ measures: [ { fieldName: "Expected Revenue", @@ -1595,7 +1564,7 @@ describe("Measure display", () => { fieldNameWithGranularity: "Created on:month_number", value: 2, }; - const model = createModelWithTestPivot(); + const model = createModelWithTestPivotDataset(); const sheetId = model.getters.getActiveSheetId(); updatePivot(model, pivotId, { measures: [ @@ -1603,7 +1572,7 @@ describe("Measure display", () => { fieldName: "Expected Revenue", userDefinedName: "m1", aggregator: "sum", - id: measureId, + id: "m1", display: measureDisplay, }, { diff --git a/tests/pivots/pivot_menu_items.test.ts b/tests/pivots/pivot_menu_items.test.ts index b6900e8e05..aad4bf84fb 100644 --- a/tests/pivots/pivot_menu_items.test.ts +++ b/tests/pivots/pivot_menu_items.test.ts @@ -1,4 +1,5 @@ -import { Model, SpreadsheetChildEnv } from "../../src"; +import { Model, SortDirection, SpreadsheetChildEnv } from "../../src"; +import { Action } from "../../src/actions/action"; import { PIVOT_TABLE_CONFIG } from "../../src/constants"; import { toCartesian, toZone } from "../../src/helpers"; import { cellMenuRegistry, topbarMenuRegistry } from "../../src/registries"; @@ -16,11 +17,16 @@ import { getCellContent, getCellText, getCoreTable, + getEvaluatedCell, getEvaluatedGrid, getTable, } from "../test_helpers/getters_helpers"; import { createModelFromGrid, doAction, getNode, makeTestEnv } from "../test_helpers/helpers"; -import { addPivot, createModelWithPivot } from "../test_helpers/pivot_helpers"; +import { + addPivot, + createModelWithPivot, + createModelWithTestPivotDataset, +} from "../test_helpers/pivot_helpers"; const reinsertDynamicPivotPath = ["data", "reinsert_dynamic_pivot", "reinsert_dynamic_pivot_1"]; const reinsertStaticPivotPath = ["data", "reinsert_static_pivot", "reinsert_static_pivot_1"]; @@ -606,3 +612,137 @@ describe("Pivot reinsertion menu item", () => { }); }); }); + +describe("Pivot sorting menu item", () => { + let model: Model; + let env: SpreadsheetChildEnv; + let sortAction: Action; + + function sortPivot(order: SortDirection | "none") { + const path = ["pivot_sorting"]; + if (order === "asc") { + path.push("pivot_sorting_asc"); + } else if (order === "desc") { + path.push("pivot_sorting_desc"); + } else { + path.push("pivot_sorting_none"); + } + doAction(path, env, cellMenuRegistry); + } + + function getPivotSortedColumn() { + const pivotId = model.getters.getPivotIds()[0]; + return model.getters.getPivot(pivotId).definition.sortedColumn; + } + + beforeEach(() => { + model = createModelWithTestPivotDataset(); + env = makeTestEnv({ model }); + sortAction = getNode(["pivot_sorting"], env, cellMenuRegistry); + }); + + test("Can sort and un-sort the pivot when clicking on a value cell", () => { + selectCell(model, "B23"); // Cell of "Alice" column + sortPivot("asc"); + expect(getPivotSortedColumn()).toEqual({ + measure: "measureId", + order: "asc", + domain: [{ field: "Salesperson", value: "Alice", type: "char" }], + }); + + selectCell(model, "C25"); // Cell of "Bob" column + sortPivot("desc"); + expect(getPivotSortedColumn()).toEqual({ + measure: "measureId", + order: "desc", + domain: [{ field: "Salesperson", value: "Bob", type: "char" }], + }); + + sortPivot("none"); + expect(getPivotSortedColumn()).toBeUndefined(); + }); + + test("Can sort the pivot with a measure header", () => { + selectCell(model, "D21"); // "Expected Revenue" measure header from the total column + sortPivot("asc"); + expect(getPivotSortedColumn()).toEqual({ + measure: "measureId", + order: "asc", + domain: [], + }); + }); + + test("Cannot sort if the pivot is in error", () => { + setCellContent(model, "A1", "Not Created On"); + expect(getEvaluatedCell(model, "A20").value).toEqual("#ERROR"); + selectCell(model, "A20"); + expect(sortAction.isVisible(env)).toBe(false); + }); + + test("Sort menu item is only visible on pivot values and measure header", () => { + selectCell(model, "A1"); // Random cell + expect(sortAction.isVisible(env)).toBe(false); + + selectCell(model, "A20"); // Pivot header + expect(sortAction.isVisible(env)).toBe(false); + + selectCell(model, "A21"); // Empty pivot cell + expect(sortAction.isVisible(env)).toBe(false); + + selectCell(model, "A22"); // Pivot row header + expect(sortAction.isVisible(env)).toBe(false); + + selectCell(model, "B20"); // Pivot col header + expect(sortAction.isVisible(env)).toBe(false); + + selectCell(model, "B21"); // Pivot measure header + expect(sortAction.isVisible(env)).toBe(true); + + selectCell(model, "B23"); // Pivot value cell + expect(sortAction.isVisible(env)).toBe(true); + }); + + test("Sort menu item is not visible on static pivot formulas", () => { + const pivotId = model.getters.getPivotIds()[0]; + model.dispatch("SPLIT_PIVOT_FORMULA", { + sheetId: model.getters.getActiveSheetId(), + col: 0, + row: 91, + pivotId, + }); + + selectCell(model, "A21"); + expect(sortAction.isVisible(env)).toBe(false); + + selectCell(model, "A22"); + expect(sortAction.isVisible(env)).toBe(false); + }); + + test("Menu item corresponding to the current sorting of the pivot is active", () => { + function getActiveSortOrder() { + const activeNodes: string[] = []; + if (getNode(["pivot_sorting", "pivot_sorting_asc"], env, cellMenuRegistry).isActive?.(env)) { + activeNodes.push("pivot_sorting_asc"); + } + if (getNode(["pivot_sorting", "pivot_sorting_desc"], env, cellMenuRegistry).isActive?.(env)) { + activeNodes.push("pivot_sorting_desc"); + } + if (getNode(["pivot_sorting", "pivot_sorting_none"], env, cellMenuRegistry).isActive?.(env)) { + activeNodes.push("pivot_sorting_none"); + } + return activeNodes; + } + + selectCell(model, "B21"); // Pivot measure header + expect(getActiveSortOrder()).toEqual(["pivot_sorting_none"]); + + sortPivot("asc"); + expect(getActiveSortOrder()).toEqual(["pivot_sorting_asc"]); + + sortPivot("desc"); + expect(getActiveSortOrder()).toEqual(["pivot_sorting_desc"]); + + selectCell(model, "C21"); // Other column + expect(getActiveSortOrder()).toEqual([]); + }); +}); diff --git a/tests/pivots/pivot_side_panel.test.ts b/tests/pivots/pivot_side_panel.test.ts index c1d9d35149..dedde15cc1 100644 --- a/tests/pivots/pivot_side_panel.test.ts +++ b/tests/pivots/pivot_side_panel.test.ts @@ -9,7 +9,7 @@ import { nextTick, setGrid, } from "../test_helpers/helpers"; -import { SELECTORS, addPivot, removePivot } from "../test_helpers/pivot_helpers"; +import { SELECTORS, addPivot, removePivot, updatePivot } from "../test_helpers/pivot_helpers"; describe("Pivot side panel", () => { let model: Model; @@ -94,4 +94,36 @@ describe("Pivot side panel", () => { await nextTick(); expect(getHighlightsFromStore(env).map((h) => zoneToXc(h.zone))).toEqual(["A5:A7"]); }); + + test("Renaming the computed measure the pivot is sorted on keep the sorting", async () => { + // prettier-ignore + setGrid(model, { + A1: "Partner", B1: "Amount", + A2: "Alice", B2: "10", + A5: "=PIVOT(1)" + }); + + const sheetId = model.getters.getActiveSheetId(); + updatePivot(model, "1", { + measures: [ + { id: "Price", fieldName: "Amount", aggregator: "sum" }, + { + id: "Amount times 2", + fieldName: "Amount times 2", + aggregator: "sum", + computedBy: { formula: "=Amount*2", sheetId }, + }, + ], + sortedColumn: { domain: [], order: "asc", measure: "Amount times 2" }, + }); + env.openSidePanel("PivotSidePanel", { pivotId: "1" }); + await nextTick(); + + const measureEl = fixture.querySelectorAll(".pivot-measure")[1]; + await setInputValueAndTrigger(measureEl.querySelector("input")!, "renamed"); + + const definition = model.getters.getPivotCoreDefinition("1") as SpreadsheetPivotCoreDefinition; + expect(definition.measures[1].id).toBe("renamed:sum"); + expect(definition.sortedColumn?.measure).toBe("renamed:sum"); + }); }); diff --git a/tests/pivots/pivot_sorting.test.ts b/tests/pivots/pivot_sorting.test.ts new file mode 100644 index 0000000000..c2afb742b9 --- /dev/null +++ b/tests/pivots/pivot_sorting.test.ts @@ -0,0 +1,313 @@ +import { Model, PivotSortedColumn, SpreadsheetPivotCoreDefinition } from "../../src"; +import { PREVIOUS_VALUE } from "../../src/helpers/pivot/pivot_domain_helpers"; +import { getFormattedGrid, getGrid } from "../test_helpers/helpers"; +import { createModelWithTestPivotDataset, updatePivot } from "../test_helpers/pivot_helpers"; + +// prettier-ignore +const unsortedGrid = { + A20: "(#1) Pivot", B20: "Alice", C20: "Bob", D20: "Total", + A22: "February", B22: 22500, C22: "", D22: 22500, + A23: "March", B23: 125400, C23: 64000, D23: 189400, + A24: "April", B24: 82300, C24: 26000, D24: 108300, + A25: "Total", B25: 230200, C25: 90000, D25: 320200, +} + +describe("Pivot sorting", () => { + test("Can sort the pivot on any column", () => { + const model = createModelWithTestPivotDataset(); + expect(getGrid(model)).toMatchObject(unsortedGrid); + + updatePivot(model, "pivotId", { + sortedColumn: { + measure: "measureId", + order: "asc", + domain: [{ field: "Salesperson", value: "Alice", type: "char" }], + }, + }); + // prettier-ignore + expect(getGrid(model)).toMatchObject({ + A20: "(#1) Pivot", B20: "Alice", C20: "Bob", D20: "Total", + A22: "February", B22: 22500, C22: "", D22: 22500, + A23: "April", B23: 82300, C23: 26000, D23: 108300, + A24: "March", B24: 125400, C24: 64000, D24: 189400, + A25: "Total", B25: 230200, C25: 90000, D25: 320200, + }); + + updatePivot(model, "pivotId", { + sortedColumn: { + measure: "measureId", + order: "asc", + domain: [{ field: "Salesperson", value: "Bob", type: "char" }], + }, + }); + // prettier-ignore + expect(getGrid(model)).toMatchObject({ + A20:"(#1) Pivot", B20: "Alice", C20: "Bob", D20: "Total", + A22: "February", B22: 22500, C22: "", D22: 22500, + A23: "April", B23: 82300, C23: 26000, D23: 108300, + A24: "March", B24: 125400, C24: 64000, D24: 189400, + A25: "Total", B25: 230200, C25: 90000, D25: 320200, + }); + + updatePivot(model, "pivotId", { + sortedColumn: { measure: "measureId", order: "desc", domain: [] }, + }); + // prettier-ignore + expect(getGrid(model)).toMatchObject({ + A20:"(#1) Pivot", B20: "Alice", C20: "Bob", D20: "Total", + A22: "March", B22: 125400, C22: 64000, D22: 189400, + A23: "April", B23: 82300, C23: 26000, D23: 108300, + A24: "February", B24: 22500, C24: "", D24: 22500, + A25: "Total", B25: 230200, C25: 90000, D25: 320200, + }); + }); + + test("Empty values are sorted as the smallest value", () => { + const model = createModelWithTestPivotDataset(); + const bobColumn: PivotSortedColumn = { + measure: "measureId", + order: "asc", + domain: [{ field: "Salesperson", value: "Bob", type: "char" }], + }; + + updatePivot(model, "pivotId", { sortedColumn: { ...bobColumn, order: "asc" } }); + // prettier-ignore + expect(getGrid(model)).toMatchObject({ + A20:"(#1) Pivot", B20: "Alice", C20: "Bob", D20: "Total", + A22: "February", B22: 22500, C22: "", D22: 22500, + A23: "April", B23: 82300, C23: 26000, D23: 108300, + A24: "March", B24: 125400, C24: 64000, D24: 189400, + A25: "Total", B25: 230200, C25: 90000, D25: 320200, + }); + + updatePivot(model, "pivotId", { sortedColumn: { ...bobColumn, order: "desc" } }); + // prettier-ignore + expect(getGrid(model)).toMatchObject({ + A20:"(#1) Pivot", B20: "Alice", C20: "Bob", D20: "Total", + A22: "March", B22: 125400, C22: 64000, D22: 189400, + A23: "April", B23: 82300, C23: 26000, D23: 108300, + A24: "February", B24: 22500, C24: "", D24: 22500, + A25: "Total", B25: 230200, C25: 90000, D25: 320200, + }); + }); + + test("Can sort on pivot with a datetime column group by", () => { + const model = createModelWithTestPivotDataset({ + rows: [{ fieldName: "Salesperson" }], + columns: [{ fieldName: "Created on", granularity: "month_number", order: "asc" }], + sortedColumn: { + measure: "measureId", + order: "desc", + domain: [{ field: "Created on:month_number", value: /*April*/ 4, type: "datetime" }], + }, + }); + // prettier-ignore + expect(getGrid(model)).toMatchObject({ + A20: '(#1) Pivot', B20: 'February', C20: 'March', D20: 'April', E20: 'Total', + A22: 'Alice', B22: 22500, C22: 125400, D22: 82300, E22: 230200, + A23: 'Bob', B23: "", C23: 64000, D23: 26000, E23: 90000, + A24: 'Total', B24: 22500, C24: 189400, D24: 108300, E24: 320200, + + }); + }); + + test("Can sort on pivot with multiple group by", () => { + const model = createModelWithTestPivotDataset({ + rows: [ + { fieldName: "Created on", granularity: "month_number", order: "asc" }, + { fieldName: "Active", order: "asc" }, + ], + sortedColumn: { measure: "measureId", order: "asc", domain: [] }, + }); + + // prettier-ignore + expect(getGrid(model)).toMatchObject({ + A20:"(#1) Pivot", B20: "Alice", C20: "Bob", D20: "Total", + A22: "February", B22: 22500, C22: "", D22: 22500, + A23: "FALSE", B23: 22500, C23: "", D23: 22500, + A24: "April", B24: 82300, C24: 26000, D24: 108300, + A25: "TRUE", B25: 17300, C25: 26000, D25: 43300, + A26: "FALSE", B26: 65000, C26: "", D26: 65000, + A27: "March", B27: 125400, C27: 64000, D27: 189400, + A28: "TRUE", B28: 19800, C28: 11000, D28: 30800, + A29: "FALSE", B29: 105600, C29: 53000, D29: 158600, + A30: "Total", B30: 230200, C30: 90000, D30: 320200, + }); + + updatePivot(model, "pivotId", { + sortedColumn: { + measure: "measureId", + order: "desc", + domain: [{ field: "Salesperson", value: "Alice", type: "char" }], + }, + }); + // prettier-ignore + expect(getGrid(model)).toMatchObject({ + A20:"(#1) Pivot", B20: "Alice", C20: "Bob", D20: "Total", + A22: "March", B22: 125400, C22: 64000, D22: 189400, + A23: "FALSE", B23: 105600, C23: 53000, D23: 158600, + A24: "TRUE", B24: 19800, C24: 11000, D24: 30800, + A25: "April", B25: 82300, C25: 26000, D25: 108300, + A26: "FALSE", B26: 65000, C26: "", D26: 65000, + A27: "TRUE", B27: 17300, C27: 26000, D27: 43300, + A28: "February", B28: 22500, C28: "", D28: 22500, + A29: "FALSE", B29: 22500, C29: "", D29: 22500, + A30: "Total", B30: 230200, C30: 90000, D30: 320200, + }); + }); + + test("Trying to sort the pivot on an invalid column or measure does nothing", () => { + const model = createModelWithTestPivotDataset({ + sortedColumn: { + measure: "measureId", + order: "asc", + domain: [{ field: "Salesperson", value: "Random Pouilleux", type: "char" }], + }, + }); + expect(getGrid(model)).toMatchObject(unsortedGrid); + + updatePivot(model, "pivotId", { + sortedColumn: { + measure: "measureId", + order: "asc", + domain: [{ field: "Not a real field", value: "Alice", type: "char" }], + }, + }); + expect(getGrid(model)).toMatchObject(unsortedGrid); + + updatePivot(model, "pivotId", { + sortedColumn: { + measure: "Not a real measure", + order: "asc", + domain: [{ field: "Salesperson", value: "Alice", type: "char" }], + }, + }); + expect(getGrid(model)).toMatchObject(unsortedGrid); + }); + + test("Can sort on a calculated measure", () => { + const model = createModelWithTestPivotDataset(); + const sheetId = model.getters.getActiveSheetId(); + updatePivot(model, "pivotId", { + measures: [ + { + id: "Expected Revenue", + fieldName: "Expected Revenue", + aggregator: "sum", + isHidden: true, + }, + { + id: "Twice the revenue", + fieldName: "Twice the revenue", + aggregator: "sum", + computedBy: { formula: "='Expected Revenue'*2", sheetId }, + }, + ], + sortedColumn: { + measure: "Twice the revenue", + order: "asc", + domain: [], + }, + }); + // prettier-ignore + expect(getGrid(model)).toMatchObject({ + A20:"(#1) Pivot", B20: "Alice", C20: "Bob", D20: "Total", + A22: "February", B22: 45000, C22: 0, D22: 45000, + A23: "April", B23: 164600, C23: 52000, D23: 216600, + A24: "March", B24: 250800, C24: 128000, D24: 378800, + A25: "Total", B25: 460400, C25: 180000, D25: 640400, + }); + }); + + test("Sorting is applied before measure display", () => { + const model = createModelWithTestPivotDataset({ + measures: [ + { + id: "measureId", + fieldName: "Expected Revenue", + aggregator: "sum", + display: { + type: "running_total", + fieldNameWithGranularity: "Created on:month_number", + }, + }, + ], + sortedColumn: { + measure: "measureId", + order: "asc", + domain: [], + }, + }); + + // prettier-ignore + expect(getGrid(model)).toMatchObject({ + A20:"(#1) Pivot", B20: "Alice", C20: "Bob", D20: "Total", + A22: "February", B22: 22500, C22: 0, D22: 22500, + A23: "April", B23: 104800, C23: 26000, D23: 130800, + A24: "March", B24: 230200, C24: 90000, D24: 320200, + A25: "Total", B25: "", C25: "", D25: "", + }); + }); + + test("(previous) in measure display take sorting into account", () => { + // Note: this is explicitly disable in Excel. + const model = createModelWithTestPivotDataset({ + sortedColumn: { + measure: "measureId", + order: "asc", + domain: [], + }, + }); + + // prettier-ignore + expect(getGrid(model)).toMatchObject({ + A20:"(#1) Pivot", B20: "Alice", C20: "Bob", D20: "Total", + A22: "February", B22: 22500, C22: "", D22: 22500, + A23: "April", B23: 82300, C23: 26000, D23: 108300, + A24: "March", B24: 125400, C24: 64000, D24: 189400, + A25: "Total", B25: 230200, C25: 90000, D25: 320200, + }); + + updatePivot(model, "pivotId", { + measures: [ + { + id: "measureId", + fieldName: "Expected Revenue", + aggregator: "sum", + display: { + type: "%_of", + fieldNameWithGranularity: "Created on:month_number", + value: PREVIOUS_VALUE, + }, + }, + ], + }); + + // prettier-ignore + expect(getFormattedGrid(model)).toMatchObject({ + A20:"(#1) Pivot", B20: "Alice", C20: "Bob", D20: "Total", + A22: "February", B22: "100.00%", C22: "", D22: "100.00%", + A23: "April", B23: "365.78%", C23: "", D23: "481.33%", + A24: "March", B24: "152.37%", C24: "246.15%", D24: "174.88%", + A25: "Total", B25: "", C25: "", D25: "", + }); + }); + + test("Can import/export sorted pivot ", () => { + const pivotDefinition: Partial = { + columns: [{ fieldName: "Salesperson" }], + sortedColumn: { + measure: "measureId", + order: "asc", + domain: [{ field: "Salesperson", value: "Alice", type: "char" }], + }, + }; + const model = createModelWithTestPivotDataset(pivotDefinition); + + const exported = model.exportData(); + expect(exported.pivots).toMatchObject({ pivotId: pivotDefinition }); + + const importedModel = new Model(exported); + expect(importedModel.getters.getPivotCoreDefinition("pivotId")).toMatchObject(pivotDefinition); + }); +}); diff --git a/tests/pivots/spreadsheet_pivot/__snapshots__/spreadsheet_pivot_side_panel.test.ts.snap b/tests/pivots/spreadsheet_pivot/__snapshots__/spreadsheet_pivot_side_panel.test.ts.snap index f9e085fc70..498bc664f9 100644 --- a/tests/pivots/spreadsheet_pivot/__snapshots__/spreadsheet_pivot_side_panel.test.ts.snap +++ b/tests/pivots/spreadsheet_pivot/__snapshots__/spreadsheet_pivot_side_panel.test.ts.snap @@ -187,6 +187,7 @@ exports[`Spreadsheet pivot side panel It should correctly be displayed 1`] = ` +
{ beforeEach(async () => { ({ env, model, fixture } = await mountSpreadsheet()); - setCellContent(model, "A1", "Customer"); - setCellContent(model, "B1", "Product"); - setCellContent(model, "C1", "Amount"); - setCellContent(model, "A2", "Alice"); - setCellContent(model, "B2", "Chair"); - setCellContent(model, "C2", "10"); - setCellContent(model, "A3", "Bob"); - setCellContent(model, "B3", "Table"); - setCellContent(model, "C3", "20"); + // prettier-ignore + const grid = { + A1: "Customer", B1: "Product", C1: "Amount", + A2: "Alice", B2: "Chair", C2: "10", + A3: "Bob", B3: "Table", C3: "20", + }; + setGrid(model, grid); + addPivot(model, "A1:C3", {}, "1"); env.openSidePanel("PivotSidePanel", { pivotId: "1" }); await nextTick(); @@ -628,4 +632,59 @@ describe("Spreadsheet pivot side panel", () => { { fieldName: "Amount", order: "desc" }, ]); }); + + describe("Pivot sorting", () => { + const sortedColumn: PivotSortedColumn = { + order: "asc", + measure: "Amount", + domain: [{ field: "Customer", value: "Bob", type: "char" }], + }; + + beforeEach(async () => { + addPivot( + model, + "A1:C3", + { + columns: [{ fieldName: "Customer" }], + measures: [{ id: "Amount", fieldName: "Amount", aggregator: "sum" }], + sortedColumn, + }, + "1" + ); + env.openSidePanel("PivotSidePanel", { pivotId: "1" }); + await nextTick(); + }); + + test("Pivot sorting is displayed in the side panel", async () => { + expect(".o-sidePanel .o-pivot-sort").toHaveCount(1); + const sortValues = [...fixture.querySelectorAll(".o-sort-card")].map((s) => s.textContent); + expect(sortValues).toEqual(["Customer = Bob", "Measure = Amount"]); + }); + + test("Does not display sorting for pivot with no sorting or invalid sorting ", async () => { + updatePivot(model, "1", { sortedColumn: undefined }); + env.openSidePanel("PivotSidePanel", { pivotId: "1" }); + await nextTick(); + expect(".o-sidePanel .o-pivot-sort").toHaveCount(0); + + updatePivot(model, "1", { + sortedColumn: { order: "asc", measure: "Yolo", domain: [] }, + }); + await nextTick(); + expect(".o-sidePanel .o-pivot-sort").toHaveCount(0); + }); + + test("Pivot sorting is removed when removing the sorted measure", async () => { + expect(model.getters.getPivotCoreDefinition("1").sortedColumn).toEqual(sortedColumn); + click(fixture, ".pivot-measure .fa-trash"); + expect(model.getters.getPivotCoreDefinition("1").sortedColumn).toBeUndefined(); + }); + + test("Pivot sorting is removed when removing a column", async () => { + expect(model.getters.getPivotCoreDefinition("1").sortedColumn).toEqual(sortedColumn); + const column = fixture.querySelectorAll(".pivot-dimension")[0]; + click(column, ".fa-trash"); + expect(model.getters.getPivotCoreDefinition("1").sortedColumn).toBeUndefined(); + }); + }); }); diff --git a/tests/test_helpers/pivot_helpers.ts b/tests/test_helpers/pivot_helpers.ts index 476da58ac0..743ad5dcc0 100644 --- a/tests/test_helpers/pivot_helpers.ts +++ b/tests/test_helpers/pivot_helpers.ts @@ -2,6 +2,8 @@ import { DispatchResult, Model, UID } from "../../src"; import { deepCopy, toZone } from "../../src/helpers"; import { PivotMeasureDisplay, SpreadsheetPivotCoreDefinition } from "../../src/types/pivot"; import { pivotModelData } from "../pivots/pivot_data"; +import { setCellContent } from "./commands_helpers"; +import { createModelFromGrid } from "./helpers"; export function createModelWithPivot(range: string): Model { return new Model(pivotModelData(range)); @@ -80,3 +82,42 @@ export function updatePivotMeasureDisplay( measure.display = display; updatePivot(model, pivotId, { measures }); } + +export function createModelWithTestPivotDataset( + pivotDefinition?: Partial, + pivotId = "pivotId", + measureId = "measureId" +) { + // prettier-ignore + const grid = { + A1:"Created on", B1: "Salesperson", C1: "Expected Revenue", D1: "Stage", E1: "Active", + A2: "04/02/2024", B2: "Bob", C2: "2000", D2: "Won", E2: "TRUE", + A3: "03/28/2024", B3: "Bob", C3: "11000", D3: "New", E3: "TRUE", + A4: "04/02/2024", B4: "Alice", C4: "4500", D4: "Won", E4: "TRUE", + A5: "04/02/2024", B5: "Alice", C5: "9000", D5: "New", E5: "TRUE", + A6: "03/27/2024", B6: "Alice", C6: "19800", D6: "Won", E6: "TRUE", + A7: "04/01/2024", B7: "Alice", C7: "3800", D7: "Won", E7: "TRUE", + A8: "04/02/2024", B8: "Bob", C8: "24000", D8: "New", E8: "TRUE", + A9: "02/03/2024", B9: "Alice", C9: "22500", D9: "Won", E9: "FALSE", + A10: "03/03/2024", B10: "Alice", C10: "40000", D10: "New", E10: "FALSE", + A11: "03/26/2024", B11: "Alice", C11: "5600", D11: "New", E11: "FALSE", + A12: "03/27/2024", B12: "Bob", C12: "15000", D12: "New", E12: "FALSE", + A13: "03/27/2024", B13: "Bob", C13: "35000", D13: "Won", E13: "FALSE", + A14: "03/31/2024", B14: "Bob", C14: "1000", D14: "Won", E14: "FALSE", + A15: "04/02/2024", B15: "Alice", C15: "25000", D15: "Won", E15: "FALSE", + A16: "04/02/2024", B16: "Alice", C16: "40000", D16: "New", E16: "FALSE", + A17: "03/27/2024", B17: "Alice", C17: "60000", D17: "New", E17: "FALSE", + A18: "03/27/2024", B18: "Bob", C18: "2000", D18: "Won", E18: "FALSE", + }; + const model = createModelFromGrid(grid); + + pivotDefinition = { + columns: [{ fieldName: "Salesperson", order: "asc" }], + rows: [{ fieldName: "Created on", granularity: "month_number", order: "asc" }], + measures: [{ fieldName: "Expected Revenue", aggregator: "sum", id: measureId }], + ...pivotDefinition, + }; + addPivot(model, "A1:E18", pivotDefinition, pivotId); + setCellContent(model, "A20", "=PIVOT(1)"); + return model; +}