Skip to content

Commit

Permalink
[ADD] pivot: introduce spreadsheet pivot table
Browse files Browse the repository at this point in the history
This commit introduces the spreadsheet pivot table. It is a new type of
pivot table that is based on values from the spreadsheet itself.

The pivot table is created by selecting a range of cells in the spreadsheet.
The first row of the range is used as the available fields, and the rest
of the range is used as the data.

The type of the data is automatically detected based on the content of
the cells in the column. It could be `date` if all the cells in the column
are dates, `boolean` if all the cells are boolean, `number` if all the
cells are numbers, or `char` otherwise.

Each field can be used as a dimension (row or column) or as a measure
(value), based on its type. The user can drag and drop the fields to
the corresponding area in the pivot table side panel, select the order,
select the granularity if the field is a date. Each measure can be
aggregated using different functions (sum, average, count, etc.).

For now, the pivot table is updated in real-time when the user changes
the range of cells in the spreadsheet. This could be heavy for large
spreadsheets, but a flag will be implemented to delay the update in a
future task (3897841).

This commit is a first version of the pivot table. Features are missing
compared to the pivot table implemented in Odoo, for example the
possibility to use `PIVOT.HEADER` and `PIVOT.VALUE` functions (and all
the features that come with it, autocompletion of formulas, positional
arguments, etc.). This will be implemented in a future task (3897857).

Task: 3748717
Part-of: #4025
Co-authored-by: rrahir <rar@odoo.com>
Co-authored-by: Pierre Rousseau <pro@odoo.com>
  • Loading branch information
pro-odoo and rrahir committed May 15, 2024
1 parent fecb914 commit 6e447f0
Show file tree
Hide file tree
Showing 79 changed files with 4,433 additions and 282 deletions.
610 changes: 610 additions & 0 deletions demo/data.js

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions demo/main.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Don't remove unused import
// organize-imports-ignore
import { demoData, makeLargeDataset } from "./data.js";
import { makePivotDataset } from "./pivot.js";
import { currenciesData } from "./currencies.js";
import { WebsocketTransport } from "./transport.js";
import { FileStore } from "./file_store.js";
Expand Down Expand Up @@ -257,6 +258,7 @@ class Demo extends Component {
this.stateUpdateMessages = [];
}
this.createModel(data || demoData);
// this.createModel(makePivotDataset(10_000));
// this.createModel(makeLargeDataset(26, 10_000, ["numbers"]));
// this.createModel({});
}
Expand Down
138 changes: 138 additions & 0 deletions demo/pivot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
const salesperson = [
"Perceval",
"Arthur",
"Dame Séli",
"Léodagan",
"Karadoc",
"Lancelot du Lac",
"Guenièvre",
"Bohort",
"Père Blaise",
"Yvain",
"Merlin",
"Gauvin",
"Mevanwi",
"Roi Loth",
"Uther Pendragon",
"Goustan le Cruel",
"Ygeme de Tintagel",
];

const stages = ["New", "Qualified", "Proposal", "Negotiation", "Won", "Lost"];

const sources = ["Web", "Phone", "Email", "In person", "Other"];

const customers = Array(30)
.fill(0)
.map((_, i) => `Customer ${i + 1}`);

const columns = [
"Salesperson",
"Stage",
"Customer",
"Email",
"Amount",
"Probability",
"Created on",
"Source",
"Active",
];

function randomIntFromInterval(min, max) {
return Math.floor(Math.random() * (max - min + 1) + min);
}

export function makePivotDataset(rowsNumber = 10_000) {
const cells = {
A1: { content: "Salesperson" },
B1: { content: "Stage" },
C1: { content: "Customer" },
D1: { content: "Email" },
E1: { content: "Amount" },
F1: { content: "Probability" },
G1: { content: "Created on" },
H1: { content: "Source" },
I1: { content: "Active" },
};
let rowIndex = 2;
const data = [];
for (let i = 0; i < rowsNumber; i++) {
const customer = customers[randomIntFromInterval(0, customers.length - 1)];
cells[`A${rowIndex}`] = {
content: salesperson[randomIntFromInterval(0, salesperson.length - 1)],
};
cells[`B${rowIndex}`] = { content: stages[randomIntFromInterval(0, stages.length - 1)] };
cells[`C${rowIndex}`] = { content: customer };
cells[`D${rowIndex}`] = {
content: `${customer.replace(/\s/g, "").toLowerCase()}@example.com}`,
};
cells[`E${rowIndex}`] = { content: `${randomIntFromInterval(0, 100000)}`, format: 2 };
cells[`F${rowIndex}`] = { content: `${randomIntFromInterval(0, 100)}`, format: 3 };
cells[`G${rowIndex}`] = { content: `${randomIntFromInterval(40179, 45657)}`, format: 1 }; //random date between 1/1/2010 and 31/12/2024
cells[`H${rowIndex}`] = { content: sources[randomIntFromInterval(0, sources.length - 1)] };
cells[`I${rowIndex}`] = { content: Math.random() > 0.5 ? "true" : "false" };
rowIndex++;
}
return {
sheets: [
{
name: "Pivot",
id: "pivot",
colNumber: 256,
rowNumber: rowsNumber + 1,
cells: {
A1: {
content: `=PIVOT("1")`,
},
},
},
{
name: "Data",
id: "data",
colNumber: columns.length,
rowNumber: rowsNumber + 1,
cells,
},
],
formats: {
1: "d/m/yyyy",
2: "[$$]#,##0.00",
3: "0.00%",
},
pivotNextId: 2,
pivots: {
1: {
type: "SPREADSHEET",
columns: [
{
name: "Stage",
},
],
rows: [
{
name: "Created on",
granularity: "year_number",
order: "asc",
},
],
measures: [
{
name: "Amount",
aggregator: "sum",
},
],
name: "My pivot",
dataSet: {
zone: {
top: 0,
bottom: rowsNumber,
left: 0,
right: 8,
},
sheetId: "data",
},
formulaId: "1",
},
},
};
}
26 changes: 26 additions & 0 deletions global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* The only aim of this file is to make ts-jest happy, as it does not support
* Object.groupBy and Map.groupBy at the current version (29.1.2)
* This is a workaround to make it work.
*/

interface ObjectConstructor {
/**
* Groups members of an iterable according to the return value of the passed callback.
* @param items An iterable.
* @param keySelector A callback which will be invoked for each item in items.
*/
groupBy<K extends PropertyKey, T>(
items: Iterable<T>,
keySelector: (item: T, index: number) => K
): Partial<Record<K, T[]>>;
}

interface MapConstructor {
/**
* Groups members of an iterable according to the return value of the passed callback.
* @param items An iterable.
* @param keySelector A callback which will be invoked for each item in items.
*/
groupBy<K, T>(items: Iterable<T>, keySelector: (item: T, index: number) => K): Map<K, T[]>;
}
6 changes: 6 additions & 0 deletions src/actions/insert_actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ export const insertChart: ActionSpec = {
icon: "o-spreadsheet-Icon.INSERT_CHART",
};

export const insertPivot: ActionSpec = {
name: _t("Pivot table"),
execute: ACTIONS.CREATE_PIVOT,
icon: "o-spreadsheet-Icon.PIVOT",
};

export const insertImage: ActionSpec = {
name: _t("Image"),
description: "Ctrl+O",
Expand Down
13 changes: 13 additions & 0 deletions src/actions/menu_items_actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,19 @@ export const CREATE_CHART = (env: SpreadsheetChildEnv) => {
}
};

//------------------------------------------------------------------------------
// Pivots
//------------------------------------------------------------------------------

export const CREATE_PIVOT = (env: SpreadsheetChildEnv) => {
const pivotId = env.model.uuidGenerator.uuidv4();
const newSheetId = env.model.uuidGenerator.uuidv4();
const result = env.model.dispatch("INSERT_NEW_PIVOT", { pivotId, newSheetId });
if (result.isSuccessful) {
env.openSidePanel("PivotSidePanel", { pivotId });
}
};

//------------------------------------------------------------------------------
// Image
//------------------------------------------------------------------------------
Expand Down
50 changes: 48 additions & 2 deletions src/collaborative/ot/ot_specific.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { otRegistry } from "../../registries";
import {
AddColumnsRowsCommand,
AddMergeCommand,
AddPivotCommand,
CreateChartCommand,
CreateSheetCommand,
CreateTableCommand,
Expand Down Expand Up @@ -91,10 +92,55 @@ otRegistry.addTransformation(
otRegistry.addTransformation(
"REMOVE_PIVOT",
["RENAME_PIVOT", "DUPLICATE_PIVOT", "INSERT_PIVOT", "UPDATE_PIVOT"],
pivotTransformation
pivotRemovedTransformation
);

function pivotTransformation(
otRegistry.addTransformation(
"DELETE_SHEET",
["ADD_PIVOT", "UPDATE_PIVOT"],
pivotDeletedSheetTransformation
);

otRegistry.addTransformation(
"ADD_COLUMNS_ROWS",
["ADD_PIVOT", "UPDATE_PIVOT"],
pivotZoneTransformation
);
otRegistry.addTransformation(
"REMOVE_COLUMNS_ROWS",
["ADD_PIVOT", "UPDATE_PIVOT"],
pivotZoneTransformation
);

function pivotZoneTransformation(
toTransform: AddPivotCommand | UpdatePivotCommand,
executed: AddColumnsRowsCommand | RemoveColumnsRowsCommand
): AddPivotCommand | UpdatePivotCommand | undefined {
if (toTransform.pivot.type !== "SPREADSHEET") {
return toTransform;
}
if (toTransform.pivot.dataSet?.sheetId !== executed.sheetId) {
return toTransform;
}
const newZone = transformZone(toTransform.pivot.dataSet.zone, executed);
const dataSet = newZone ? { ...toTransform.pivot.dataSet, zone: newZone } : undefined;
return { ...toTransform, pivot: { ...toTransform.pivot, dataSet } };
}

function pivotDeletedSheetTransformation(
toTransform: AddPivotCommand | UpdatePivotCommand,
executed: DeleteSheetCommand
): AddPivotCommand | UpdatePivotCommand | undefined {
if (toTransform.pivot.type !== "SPREADSHEET") {
return toTransform;
}
if (toTransform.pivot.dataSet?.sheetId === executed.sheetId) {
return { ...toTransform, pivot: { ...toTransform.pivot, dataSet: undefined } };
}
return toTransform;
}

function pivotRemovedTransformation(
toTransform: RenamePivotCommand | DuplicatePivotCommand | InsertPivotCommand | UpdatePivotCommand,
executed: RemovePivotCommand
) {
Expand Down
26 changes: 0 additions & 26 deletions src/components/side_panel/pivot/all_pivots_side_panel.ts

This file was deleted.

13 changes: 0 additions & 13 deletions src/components/side_panel/pivot/all_pivots_side_panel.xml

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,6 @@ css/* scss */ `
.pivot-dimension-search {
background-color: white;
}
.pivot-dimension-field {
background-color: white;
&:hover {
background-color: #f0f0f0;
}
}
`;

export class AddDimensionButton extends Component<Props, SpreadsheetChildEnv> {
Expand Down Expand Up @@ -98,8 +91,9 @@ export class AddDimensionButton extends Component<Props, SpreadsheetChildEnv> {
}

get popoverProps() {
const { x, y, width, height } = this.buttonRef.el!.getBoundingClientRect();
return {
anchorRect: this.buttonRef.el!.getBoundingClientRect(),
anchorRect: { x, y, width, height },
positioning: "BottomLeft",
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<templates>
<t t-name="o-spreadsheet-PivotDimension">
<div class="border py-1 px-2 d-flex flex-column shadow-sm pivot-dimension">
<div
class="border py-1 px-2 d-flex flex-column shadow-sm pivot-dimension"
t-att-class="{'bg-danger': !props.dimension.isValid}">
<div class="d-flex flex-row justify-content-between align-items-center">
<span class="fw-bold" t-esc="props.dimension.displayName"/>
<i
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Component } from "@odoo/owl";
import { SpreadsheetChildEnv } from "../../../../..";
import { PERIODS } from "../../../../../helpers/pivot/pivot_helpers";
import { ALL_PERIODS } from "../../../../../helpers/pivot/pivot_helpers";
import { PivotDimension } from "../../../../../types/pivot";

interface Props {
dimension: PivotDimension;
onUpdated: (dimension: PivotDimension, ev: InputEvent) => void;
availableGranularities: Set<string>;
allGranularities: string[];
}

export class PivotDimensionGranularity extends Component<Props, SpreadsheetChildEnv> {
Expand All @@ -15,7 +16,7 @@ export class PivotDimensionGranularity extends Component<Props, SpreadsheetChild
dimension: Object,
onUpdated: Function,
availableGranularities: Set,
allGranularities: Array,
};
periods = PERIODS;
allGranularities = Object.keys(PERIODS);
periods = ALL_PERIODS;
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
class="o_input flex-basis-50"
t-on-change="(ev) => props.onUpdated(props.dimension, ev.target.value)">
<option
t-foreach="allGranularities"
t-foreach="props.allGranularities"
t-as="granularity"
t-key="granularity"
t-if="props.availableGranularities.has(granularity) || granularity === props.dimension.granularity"
Expand Down

0 comments on commit 6e447f0

Please sign in to comment.