From 9f22669f39623ab5a2173d50c0090dd9ffac1749 Mon Sep 17 00:00:00 2001
From: Nick Fitzpatrick <nick@metabase.com>
Date: Sat, 25 Mar 2023 19:19:17 -0300
Subject: [PATCH] keep standalone values when collapsing column (#29283)

* keep standalone values when collapsing column

* adjusting unit test

* PR Feedback

* adding space

* removing only

* Adjusting behavior and tests

* adjusting data_grid unit tests
---
 .../visualizations/pivot_tables.cy.spec.js    |  51 +++++++
 ...tandalone-values-when-collapsed.cy.spec.js |  59 --------
 frontend/src/metabase/lib/data_grid.js        |   2 +-
 .../PivotTable/PivotTable.unit.spec.tsx       |  70 ++++++++--
 .../PivotTable/PivotTableCell.tsx             |   1 +
 .../PivotTable/RowToggleIcon.tsx              |   3 +
 .../test/metabase/lib/data_grid.unit.spec.js  | 130 +++++++++++++-----
 7 files changed, 212 insertions(+), 104 deletions(-)
 delete mode 100644 e2e/test/scenarios/visualizations/reproductions/25250-pivot-no-standalone-values-when-collapsed.cy.spec.js

diff --git a/e2e/test/scenarios/visualizations/pivot_tables.cy.spec.js b/e2e/test/scenarios/visualizations/pivot_tables.cy.spec.js
index 8ad0cac213f..b1e1535f09d 100644
--- a/e2e/test/scenarios/visualizations/pivot_tables.cy.spec.js
+++ b/e2e/test/scenarios/visualizations/pivot_tables.cy.spec.js
@@ -216,6 +216,57 @@ describe("scenarios > visualizations > pivot tables", () => {
     cy.findByText("294").should("not.exist"); // the other one is still hidden
   });
 
+  it("should show standalone values when collapsed to the sub-level grouping (metabase#25250)", () => {
+    const questionDetails = {
+      name: "25250",
+      dataset_query: {
+        type: "query",
+        query: {
+          "source-table": ORDERS_ID,
+          filter: ["<", ["field", ORDERS.CREATED_AT, null], "2016-06-01"],
+          aggregation: [["count"]],
+          breakout: [
+            ["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }],
+            ["field", ORDERS.USER_ID, null],
+            ["field", ORDERS.PRODUCT_ID, null],
+          ],
+        },
+        database: SAMPLE_DB_ID,
+      },
+      display: "pivot",
+      visualization_settings: {
+        "pivot_table.column_split": {
+          rows: [
+            ["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }],
+            ["field", ORDERS.USER_ID, null],
+            ["field", ORDERS.PRODUCT_ID, null],
+          ],
+          columns: [],
+          values: [["aggregation", 0]],
+        },
+        "pivot_table.collapsed_rows": {
+          value: [],
+          rows: [
+            ["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }],
+            ["field", ORDERS.USER_ID, null],
+            ["field", ORDERS.PRODUCT_ID, null],
+          ],
+        },
+      },
+    };
+
+    visitQuestionAdhoc(questionDetails);
+    cy.findByText("1162").should("be.visible");
+    // Collapse "User ID" column
+    cy.findByText("User ID").parent().find(".Icon-dash").click();
+    cy.findByText("Totals for 1162").should("be.visible");
+
+    //Expanding the grouped column should still work
+    cy.findByText("Totals for 1162").parent().find(".Icon-add").click();
+    cy.findByText("1162").should("be.visible");
+    cy.findByText("34").should("be.visible");
+  });
+
   it("should allow hiding subtotals", () => {
     visitQuestionAdhoc({
       dataset_query: testQuery,
diff --git a/e2e/test/scenarios/visualizations/reproductions/25250-pivot-no-standalone-values-when-collapsed.cy.spec.js b/e2e/test/scenarios/visualizations/reproductions/25250-pivot-no-standalone-values-when-collapsed.cy.spec.js
deleted file mode 100644
index 7b9a01192c5..00000000000
--- a/e2e/test/scenarios/visualizations/reproductions/25250-pivot-no-standalone-values-when-collapsed.cy.spec.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import { restore, visitQuestionAdhoc } from "e2e/support/helpers";
-import { SAMPLE_DB_ID } from "e2e/support/cypress_data";
-import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
-
-const { ORDERS_ID, ORDERS } = SAMPLE_DATABASE;
-
-const questionDetails = {
-  name: "25250",
-  dataset_query: {
-    type: "query",
-    query: {
-      "source-table": ORDERS_ID,
-      filter: ["<", ["field", ORDERS.CREATED_AT, null], "2016-06-01"],
-      aggregation: [["count"]],
-      breakout: [
-        ["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }],
-        ["field", ORDERS.USER_ID, null],
-        ["field", ORDERS.PRODUCT_ID, null],
-      ],
-    },
-    database: SAMPLE_DB_ID,
-  },
-  display: "pivot",
-  visualization_settings: {
-    "pivot_table.column_split": {
-      rows: [
-        ["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }],
-        ["field", ORDERS.USER_ID, null],
-        ["field", ORDERS.PRODUCT_ID, null],
-      ],
-      columns: [],
-      values: [["aggregation", 0]],
-    },
-    "pivot_table.collapsed_rows": {
-      value: [],
-      rows: [
-        ["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }],
-        ["field", ORDERS.USER_ID, null],
-        ["field", ORDERS.PRODUCT_ID, null],
-      ],
-    },
-  },
-};
-
-describe.skip("issue 25250", () => {
-  beforeEach(() => {
-    restore();
-    cy.signInAsAdmin();
-
-    visitQuestionAdhoc(questionDetails);
-  });
-
-  it("pivot table should show standalone values when collapsed to the sub-level grouping (metabase#25250)", () => {
-    cy.findByText("1162").should("be.visible");
-    // Collapse "User ID" column
-    cy.findByText("User ID").parent().find(".Icon-dash").click();
-    cy.findByText("1162").should("be.visible");
-  });
-});
diff --git a/frontend/src/metabase/lib/data_grid.js b/frontend/src/metabase/lib/data_grid.js
index 31f1bb2c282..2808032df25 100644
--- a/frontend/src/metabase/lib/data_grid.js
+++ b/frontend/src/metabase/lib/data_grid.js
@@ -430,7 +430,7 @@ function addSubtotal(
       // add subtotals until the last level
       child.children.length > 0
         ? addSubtotal(child, showSubtotalsByColumn, {
-            shouldShowSubtotal: child.children.length > 1,
+            shouldShowSubtotal: child.children.length > 1 || child.isCollapsed,
           })
         : child,
     ),
diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.unit.spec.tsx b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.unit.spec.tsx
index 962a22944ef..4efb6f75000 100644
--- a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.unit.spec.tsx
+++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.unit.spec.tsx
@@ -1,5 +1,6 @@
 import React from "react";
-import { render, screen } from "@testing-library/react";
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
 import _ from "underscore";
 import type { VisualizationSettings } from "metabase-types/api";
 import type { Column } from "metabase-types/types/Dataset";
@@ -45,6 +46,7 @@ const cols = [
 
 const rows = [
   ["foo1", "bar1", "baz1", 0, 111, 222],
+  ["foo1", "bar1", "baz2", 0, 777, 888],
   ["foo2", "bar2", "baz2", 0, 333, 444],
   ["foo3", "bar3", "baz3", 0, 555, 666],
 ];
@@ -156,14 +158,14 @@ describe("Visualizations > PivotTable > PivotTable", () => {
 
     rows.forEach(rowData => {
       columnIndexes.forEach(colIndex => {
-        expect(
-          screen.getByText(rowData[colIndex].toString()),
-        ).toBeInTheDocument();
+        expect(screen.getByTestId("pivot-table")).toHaveTextContent(
+          rowData[colIndex].toString(),
+        );
       });
     });
   });
 
-  it("should not render hidden column values", () => {
+  it("should collapse columns", () => {
     const hiddenSettings = {
       ...settings,
       "pivot_table.collapsed_rows": {
@@ -174,14 +176,58 @@ describe("Visualizations > PivotTable > PivotTable", () => {
 
     setup({ initialSettings: hiddenSettings });
 
-    rows.forEach(rowData => {
-      expect(screen.getByText(rowData[0].toString())).toBeInTheDocument();
+    const COLLAPSED_COLUMN_INDEX = 1;
 
-      [1, 2, 4, 5].forEach(colIndex => {
-        expect(
-          screen.queryByText(rowData[colIndex].toString()),
-        ).not.toBeInTheDocument();
-      });
+    rows.forEach(row => {
+      const totalsElement = screen.getByText(
+        `Totals for ${row[COLLAPSED_COLUMN_INDEX]}`,
+      );
+      expect(totalsElement).toBeInTheDocument();
+
+      const totalsContainer = screen.getByTestId(
+        `${row[COLLAPSED_COLUMN_INDEX]}-toggle-button`,
+      );
+
+      expect(
+        within(totalsContainer).getByRole("img", {
+          name: /add/i,
+        }),
+      ).toBeInTheDocument();
+    });
+  });
+
+  it("expanding collapsed columns", () => {
+    const hiddenSettings = {
+      ...settings,
+      "pivot_table.collapsed_rows": {
+        rows: [cols[0].field_ref, cols[1].field_ref, cols[2].field_ref],
+        value: ["2"],
+      },
+    } as unknown as VisualizationSettings;
+
+    setup({ initialSettings: hiddenSettings });
+
+    const COLLAPSED_COLUMN_INDEX = 1;
+
+    const LAST_ROW = rows[3];
+
+    // Find and click the toggle button to expand the last row
+    // as it's the easiest to make assertions on
+    const toggleButton = screen.getByTestId(
+      `${LAST_ROW[COLLAPSED_COLUMN_INDEX]}-toggle-button`,
+    );
+
+    expect(
+      within(toggleButton).getByRole("img", { name: /add/i }),
+    ).toBeInTheDocument();
+
+    userEvent.click(toggleButton);
+
+    //Ensure that collapsed data is now visible
+    columnIndexes.forEach(columnIndex => {
+      expect(
+        screen.getByText(LAST_ROW[columnIndex].toString()),
+      ).toBeInTheDocument();
     });
   });
 });
diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTableCell.tsx b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTableCell.tsx
index b9b9961ab4b..c29dc2f5bb3 100644
--- a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTableCell.tsx
+++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTableCell.tsx
@@ -176,6 +176,7 @@ export const LeftHeaderCell = ({
       icon={
         (isSubtotal || hasSubtotal) && (
           <RowToggleIcon
+            data-testid={`${item.rawValue}-toggle-button`}
             value={path}
             settings={settings}
             updateSettings={onUpdateVisualizationSettings}
diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/RowToggleIcon.tsx b/frontend/src/metabase/visualizations/visualizations/PivotTable/RowToggleIcon.tsx
index c0fcf9d5bd0..e469142cbbb 100644
--- a/frontend/src/metabase/visualizations/visualizations/PivotTable/RowToggleIcon.tsx
+++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/RowToggleIcon.tsx
@@ -18,6 +18,7 @@ interface RowToggleIconProps {
   updateSettings: (settings: VisualizationSettings) => void;
   hideUnlessCollapsed?: boolean;
   rowIndex?: string[];
+  "data-testid"?: string;
 }
 
 export function RowToggleIcon({
@@ -26,6 +27,7 @@ export function RowToggleIcon({
   updateSettings,
   hideUnlessCollapsed,
   rowIndex = [],
+  "data-testid": testId,
 }: RowToggleIconProps) {
   if (value == null) {
     return null;
@@ -84,6 +86,7 @@ export function RowToggleIcon({
 
   return (
     <RowToggleIconRoot
+      data-testid={testId}
       onClick={e => {
         e.stopPropagation();
         updateSettings({
diff --git a/frontend/test/metabase/lib/data_grid.unit.spec.js b/frontend/test/metabase/lib/data_grid.unit.spec.js
index 8745dcbd4ce..bba06a4a790 100644
--- a/frontend/test/metabase/lib/data_grid.unit.spec.js
+++ b/frontend/test/metabase/lib/data_grid.unit.spec.js
@@ -595,40 +595,106 @@ describe("data_grid", () => {
     });
 
     describe("row collapsing", () => {
-      const cols = [D1, D2, M];
-      const primaryGroup = 0;
-      const subtotalOne = 2;
-      const rows = [
-        ["a", "x", 1, primaryGroup],
-        ["a", "y", 2, primaryGroup],
-        ["b", "x", 3, primaryGroup],
-        ["b", "y", 4, primaryGroup],
-        ["a", null, 3, subtotalOne],
-        ["b", null, 7, subtotalOne],
-      ];
-      const data = {
-        rows,
-        cols: [...cols, { name: "pivot-grouping", base_type: TYPE.Text }],
-      };
-      it("hides single collapsed rows", () => {
-        const { getRowSection, leftHeaderItems, rowCount } =
-          multiLevelPivotForIndexes(data, [], [0, 1], [2], {
-            collapsedRows: ['["a"]'],
-          });
-        expect(rowCount).toEqual(5);
-        expect(leftHeaderItems[0].value).toEqual("Totals for a"); // a is collapsed
-        expect(getRowSection(0, 0)).toEqual([{ isSubtotal: true, value: "3" }]);
+      describe("single level pivot", () => {
+        const cols = [D1, D2, M];
+        const primaryGroup = 0;
+        const subtotalOne = 2;
+        const rows = [
+          ["a", "x", 1, primaryGroup],
+          ["a", "y", 2, primaryGroup],
+          ["b", "x", 3, primaryGroup],
+          ["b", "y", 4, primaryGroup],
+          ["a", null, 3, subtotalOne],
+          ["b", null, 7, subtotalOne],
+        ];
+        const data = {
+          rows,
+          cols: [...cols, { name: "pivot-grouping", base_type: TYPE.Text }],
+        };
+        it("hides single collapsed rows", () => {
+          const { getRowSection, leftHeaderItems, rowCount } =
+            multiLevelPivotForIndexes(data, [], [0, 1], [2], {
+              collapsedRows: ['["a"]'],
+            });
+          expect(rowCount).toEqual(5);
+          expect(leftHeaderItems[0].value).toEqual("Totals for a"); // a is collapsed
+          expect(getRowSection(0, 0)).toEqual([
+            { isSubtotal: true, value: "3" },
+          ]);
+        });
+        it("hides collapsed columns", () => {
+          const { getRowSection, leftHeaderItems, rowCount } =
+            multiLevelPivotForIndexes(data, [], [0, 1], [2], {
+              collapsedRows: ["1"],
+            });
+          expect(rowCount).toEqual(3);
+          expect(leftHeaderItems[0].value).toEqual("Totals for a"); // a is collapsed
+          expect(leftHeaderItems[1].value).toEqual("Totals for b"); // b is also collapsed
+          expect(getRowSection(0, 0)).toEqual([
+            { isSubtotal: true, value: "3" },
+          ]);
+          expect(getRowSection(0, 1)).toEqual([
+            { isSubtotal: true, value: "7" },
+          ]);
+        });
       });
-      it("hides collapsed columns", () => {
-        const { getRowSection, leftHeaderItems, rowCount } =
-          multiLevelPivotForIndexes(data, [], [0, 1], [2], {
-            collapsedRows: ["1"],
+
+      describe("multi level pivot", () => {
+        const cols = [D1, D2, D3];
+        const rows = [
+          ["May", "Affiliate", "Gizmo", 0, 1],
+          ["May", "Facebook", "Gizmo", 0, 1],
+          ["May", "Google", "Doohickey", 0, 2],
+          ["May", "Google", "Gadget", 0, 1],
+          ["May", "Google", "Gizmo", 0, 2],
+          ["May", "Google", "Widget", 0, 1],
+          ["May", "Organic", "Doohickey", 0, 1],
+          ["May", "Organic", "Gadget", 0, 1],
+          ["May", "Organic", "Gizmo", 0, 1],
+          ["May", "Organic", "Widget", 0, 1],
+          ["May", "Twitter", "Doohickey", 0, 2],
+          ["May", "Twitter", "Gadget", 0, 2],
+          ["May", "Twitter", "Widget", 0, 2],
+          ["May", "Affiliate", null, 4, 1],
+          ["May", "Facebook", null, 4, 1],
+          ["May", "Google", null, 4, 6],
+          ["May", "Organic", null, 4, 4],
+          ["May", "Twitter", null, 4, 6],
+          ["May", null, null, 6, 18],
+          [null, null, null, 7, 18],
+        ];
+        const data = {
+          rows,
+          cols: [...cols, { name: "pivot-grouping", base_type: TYPE.Text }, M],
+        };
+
+        it("should convert single rows to subtotal rows when collapsed", () => {
+          // Collapsing the "source" column
+          const { getRowSection, leftHeaderItems, rowCount } =
+            multiLevelPivotForIndexes(data, [3], [0, 1, 2], [4], {
+              collapsedRows: ["2"],
+            });
+          expect(rowCount).toEqual(6);
+          expect(leftHeaderItems[1]).toMatchObject({
+            value: "Totals for Affiliate",
+            isSubtotal: true,
+          });
+          expect(leftHeaderItems[5]).toMatchObject({
+            value: "Totals for Twitter",
+            isSubtotal: true,
           });
-        expect(rowCount).toEqual(3);
-        expect(leftHeaderItems[0].value).toEqual("Totals for a"); // a is collapsed
-        expect(leftHeaderItems[1].value).toEqual("Totals for b"); // b is also collapsed
-        expect(getRowSection(0, 0)).toEqual([{ isSubtotal: true, value: "3" }]);
-        expect(getRowSection(0, 1)).toEqual([{ isSubtotal: true, value: "7" }]);
+          expect(getRowSection(1, 0)).toMatchObject([
+            {
+              value: "1",
+            },
+          ]); //Value for affiliate, but not a subtotal so it has children
+          expect(getRowSection(1, 2)).toEqual([
+            { isSubtotal: true, value: "6" },
+          ]);
+          expect(getRowSection(1, 3)).toEqual([
+            { isSubtotal: true, value: "4" },
+          ]);
+        });
       });
     });
 
-- 
GitLab