Compare commits

...

52 Commits

Author SHA1 Message Date
Aleksandr Lesnenko
2717d873cc lint 2025-02-26 13:22:16 -05:00
Aleksandr Lesnenko
13c3d74e62 reorg 2025-02-26 13:01:08 -05:00
Aleksandr Lesnenko
aafdd22d5e fix spec 2025-02-25 23:39:26 -05:00
Aleksandr Lesnenko
d5d3a80bf5 fix 2025-02-25 23:39:26 -05:00
Aleksandr Lesnenko
2c368d1dba specs 2025-02-25 23:39:26 -05:00
Aleksandr Lesnenko
488dce1f4c lint 2025-02-25 23:39:26 -05:00
Aleksandr Lesnenko
2d34d288db specs 2025-02-25 23:39:26 -05:00
Aleksandr Lesnenko
5fd299571a cleanup 2025-02-25 23:39:26 -05:00
Aleksandr Lesnenko
9635412745 fixes, specs 2025-02-25 23:39:25 -05:00
Aleksandr Lesnenko
1d6c937463 update e2e specs 2025-02-25 23:39:25 -05:00
Aleksandr Lesnenko
7d5a4216be specs 2025-02-25 23:39:25 -05:00
Aleksandr Lesnenko
faff498d02 specs, polish 2025-02-25 23:39:25 -05:00
Aleksandr Lesnenko
e16b2e1780 polish 2025-02-25 23:39:25 -05:00
Aleksandr Lesnenko
7635aef269 polish 2025-02-25 23:39:25 -05:00
Aleksandr Lesnenko
8c8c68ef03 polish 2025-02-25 23:39:25 -05:00
Aleksandr Lesnenko
1d5519854d polish 2025-02-25 23:39:25 -05:00
Aleksandr Lesnenko
0f162c4ac6 fix bugs 2025-02-25 23:39:25 -05:00
Aleksandr Lesnenko
d044eee043 cleanup 2025-02-25 23:39:25 -05:00
Aleksandr Lesnenko
53fc899587 fix table bugs 2025-02-25 23:39:25 -05:00
Aleksandr Lesnenko
a64d0cf5df more bug fixes 2025-02-25 23:39:25 -05:00
Aleksandr Lesnenko
6a4d87f3b0 fix tabe bugs 2025-02-25 23:39:24 -05:00
Aleksandr Lesnenko
198e898989 fix bugs 2025-02-25 23:39:24 -05:00
Aleksandr Lesnenko
587e863f92 fix middle align 2025-02-25 23:39:24 -05:00
Aleksandr Lesnenko
09de6a3265 add column dynamic padding 2025-02-25 23:39:24 -05:00
Aleksandr Lesnenko
2406546749 fix bugs 2025-02-25 23:39:24 -05:00
Aleksandr Lesnenko
579818de35 types, test ids 2025-02-25 23:39:24 -05:00
Aleksandr Lesnenko
ed7c6227b0 type errors 2025-02-25 23:39:23 -05:00
Aleksandr Lesnenko
5fe9ae35e2 memo 2025-02-25 23:39:23 -05:00
Aleksandr Lesnenko
b7b2c9df43 cleanup 2025-02-25 23:39:23 -05:00
Aleksandr Lesnenko
91f47ccd52 deal with popovers 2025-02-25 23:39:23 -05:00
Aleksandr Lesnenko
4995cbba67 cleanup 2025-02-25 23:39:23 -05:00
Aleksandr Lesnenko
44545f9f12 fix bugs 2025-02-25 23:39:23 -05:00
Aleksandr Lesnenko
3dae6db4bd slice out basic table component, fix bugs 2025-02-25 23:39:23 -05:00
Aleksandr Lesnenko
eafee0e4e7 reorg 2025-02-25 23:39:23 -05:00
Aleksandr Lesnenko
d360d06514 refactor cells 2025-02-25 23:39:23 -05:00
Aleksandr Lesnenko
b1cae109a9 fix conflicting resizing and reordering 2025-02-25 23:39:22 -05:00
Aleksandr Lesnenko
17095a5712 polish, fix bugs 2025-02-25 23:39:22 -05:00
Aleksandr Lesnenko
8f72bd5e82 styles 2025-02-25 23:39:22 -05:00
Aleksandr Lesnenko
98c03ed29b controlled column sizing state, add basic object detail, smooth resizing 2025-02-25 23:39:22 -05:00
Aleksandr Lesnenko
7c2b9019a8 column reordering, add column button, styles 2025-02-25 23:39:22 -05:00
Aleksandr Lesnenko
01b84f9150 reordering, fix missing column settings menu 2025-02-25 23:39:22 -05:00
Aleksandr Lesnenko
afddb6af0b limit popover 2025-02-25 23:39:22 -05:00
Aleksandr Lesnenko
986727575b horizontal scroll, styles 2025-02-25 23:39:22 -05:00
Aleksandr Lesnenko
78552db5f8 fixes, polish 2025-02-25 23:39:22 -05:00
Aleksandr Lesnenko
486176f301 fix layout 2025-02-25 23:39:22 -05:00
Aleksandr Lesnenko
18fc9d1926 improve column resizing, fix bugs, polish ui 2025-02-25 23:39:22 -05:00
Aleksandr Lesnenko
c6accba56c fix initial and scrolling performance issues 2025-02-25 23:39:22 -05:00
Aleksandr Lesnenko
9c195a20e9 long cell auto collapse, minibar cells, sort order in headers, cleanup 2025-02-25 23:39:22 -05:00
Aleksandr Lesnenko
6ffa6ba6fc built-in pivot 2025-02-25 23:39:22 -05:00
Aleksandr Lesnenko
27ea35502b polish 2025-02-25 23:39:22 -05:00
Aleksandr Lesnenko
e7b424c2cf wip polish 2025-02-25 23:39:22 -05:00
Aleksandr Lesnenko
b17d3ae582 build TableInteractive with tanstack table 2025-02-25 23:39:21 -05:00
106 changed files with 4163 additions and 1848 deletions

View File

@@ -311,11 +311,15 @@ export function dashboardCards() {
}
export function tableInteractive() {
return cy.findByTestId("TableInteractive-root");
return cy.findByTestId("table-root");
}
export function tableInteractiveBody() {
return cy.get("#main-data-grid");
return cy.findByTestId("table-body");
}
export function tableInteractiveScrollContainer() {
return cy.findByTestId("table-scroll-container");
}
export function tableAllFieldsHiddenImage() {

View File

@@ -233,11 +233,11 @@ function asNormalUser(callback) {
function visitObjectDetail(modelId, objectId) {
H.visitModel(modelId);
cy.get("main").findByText("Loading...").should("not.exist");
cy.findByTestId("TableInteractive-root").findByText(objectId).click();
H.tableInteractive().findByText(objectId).click();
}
function openObjectDetailModal(objectId) {
cy.findByTestId("TableInteractive-root").findByText(objectId).click();
H.tableInteractive().findByText(objectId).click();
}
function openUpdateObjectModal() {
@@ -287,16 +287,12 @@ function assertDateInputValue(labelText, value) {
function assertUpdatedScoreInTable() {
cy.log("updated quantity should be present in the table");
cy.findByTestId("TableInteractive-root")
.findByText(UPDATED_SCORE_FORMATTED)
.should("exist");
H.tableInteractive().findByText(UPDATED_SCORE_FORMATTED).should("exist");
}
function assertUpdatedScoreNotInTable() {
cy.log("updated quantity should not be present in the table");
cy.findByTestId("TableInteractive-root")
.findByText(UPDATED_SCORE_FORMATTED)
.should("not.exist");
H.tableInteractive().findByText(UPDATED_SCORE_FORMATTED).should("not.exist");
}
function assertSuccessfullUpdateToast() {

View File

@@ -84,7 +84,7 @@ describe("error reporting modal", () => {
{ visitQuestion: true },
);
cy.findByTestId("TableInteractive-root").realClick();
H.tableInteractive().realClick();
cy.realPress(["Control", "F1"]);
H.modal().within(() => {
@@ -138,7 +138,7 @@ describe("error reporting modal", () => {
{ visitQuestion: true },
);
cy.findByTestId("TableInteractive-root").realClick();
H.tableInteractive().realClick();
cy.realPress(["Control", "F1"]);
H.modal().within(() => {

View File

@@ -216,7 +216,7 @@ H.describeWithSnowplow("scenarios > admin > settings", () => {
// Open the orders table
H.openOrdersTable({ limit: 2 });
cy.get("#main-data-grid").within(() => {
H.tableInteractiveBody().within(() => {
// Items in the total column should have a leading dollar sign
cy.findByText("$39.72");
cy.findByText("$117.03");
@@ -864,7 +864,7 @@ describe("scenarios > admin > localization", () => {
{ visitQuestion: true },
);
cy.findByTestId("TableInteractive-root").as("resultTable");
H.tableInteractive().as("resultTable");
cy.get("@resultTable").within(() => {
// The third cell in the first row (CREATED_AT_DAY)
@@ -976,7 +976,7 @@ describe("scenarios > admin > localization", () => {
cy.findByTestId("loading-indicator").should("not.exist");
// verify that the correct row is displayed
cy.findByTestId("TableInteractive-root").within(() => {
H.tableInteractive().within(() => {
cy.findByText("2024/5/15, 19:56");
cy.findByText("127.52");
});

View File

@@ -31,7 +31,7 @@ describe("scenarios > Metabase Analytics Collection (AuditV2) ", () => {
cy.wait("@datasetQuery");
cy.findByTestId("TableInteractive-root").within(() => {
H.tableInteractive().within(() => {
cy.findByTextEnsureVisible("admin@metabase.test");
cy.findByTextEnsureVisible("Robert Tableton");
cy.findByTextEnsureVisible("Read Only Tableton");

View File

@@ -380,7 +380,7 @@ function uploadFileToCollection(testFile) {
cy.wait("@dataset");
cy.url().should("include", "/model/");
cy.findByTestId("TableInteractive-root");
H.tableInteractive();
}
}

View File

@@ -873,18 +873,13 @@ describe("issue 32032", () => {
});
it("should allow quick filter drills on custom columns", () => {
cy.findByTestId("TableInteractive-root")
.findAllByText("xavier")
.eq(1)
.click();
H.tableInteractive().findAllByText("xavier").eq(1).click();
H.popover().findByText("Is xavier").click();
cy.wait("@dataset");
H.main()
.findByText(/There was a problem/i)
.should("not.exist");
cy.findByTestId("TableInteractive-root")
.findAllByText("xavier")
.should("have.length", 2);
H.tableInteractive().findAllByText("xavier").should("have.length", 2);
});
});

View File

@@ -971,9 +971,7 @@ describe("scenarios > dashboard > temporal unit parameters", () => {
cy.findByText("Created At: Year").should("be.visible");
cy.findByText(singleBreakoutQuestionDetails.name).click();
});
cy.findByTestId("TableInteractive-root")
.findByText("Created At: Year")
.should("be.visible");
H.tableInteractive().findByText("Created At: Year").should("be.visible");
});
});

View File

@@ -131,7 +131,7 @@ describe("scenarios > question > view", () => {
cy.get(".test-TableInteractive-cellWrapper--firstColumn").should(
"have.length",
2,
1,
);
cy.get(".CardVisualization").within(() => {
cy.findByText("Widget");

View File

@@ -1104,14 +1104,12 @@ describe("issue 31769", () => {
cy.findAllByTestId("header-cell").should("have.length", 4);
cy.get("@card_id_q2").then(cardId => {
cy.findByTestId("TableInteractive-root")
H.tableInteractive()
.findByText("Q2 - Products → Category → Category")
.should("exist");
});
cy.findByTestId("TableInteractive-root")
.findByText("Products → Category")
.should("exist");
H.tableInteractive().findByText("Products → Category").should("exist");
});
});

View File

@@ -216,7 +216,7 @@ function editTitleMetadata() {
H.openQuestionActions();
H.popover().findByText("Edit metadata").click();
cy.url().should("include", "/metadata");
cy.findByTestId("TableInteractive-root").findByTextEnsureVisible("Title");
H.tableInteractive().findByTextEnsureVisible("Title");
H.openColumnOptions("Title");
}

View File

@@ -224,7 +224,7 @@ describe("scenarios > models metadata", () => {
H.saveMetadataChanges();
cy.log("Revision 1");
cy.findByTestId("TableInteractive-root").within(() => {
H.tableInteractive().within(() => {
cy.findByText("Subtotal ($)").should("be.visible");
cy.findByText("SUBTOTAL").should("not.exist");
});

View File

@@ -112,8 +112,7 @@ describe("scenarios > models query editor", () => {
cy.wait("@dataset");
// FE chooses the scalar visualization to display count of rows for regular questions
// TODO (styles): migrate
cy.get(".test-TableInteractive");
H.tableInteractive();
cy.findByTestId("scalar-value").should("not.exist");
});
});

View File

@@ -221,13 +221,11 @@ describe("scenarios > models", () => {
H.visitQuestion(ORDERS_BY_YEAR_QUESTION_ID);
H.echartsContainer();
// TODO (styles): migrate
cy.get(".test-TableInteractive").should("not.exist");
H.tableInteractive().should("not.exist");
turnIntoModel();
// TODO (styles): migrate
cy.get(".test-TableInteractive");
H.tableInteractive();
H.echartsContainer().should("not.exist");
});
@@ -709,7 +707,7 @@ describe("scenarios > models", () => {
});
cy.findByTestId("native-query-editor-container").icon("play").click();
cy.wait("@query");
cy.findByTestId("TableInteractive-root").within(() => {
H.tableInteractive().within(() => {
cy.findByText("USER_ID");
cy.findByText("PRODUCT_ID");
cy.findByText("TAX");

View File

@@ -46,7 +46,7 @@ describe("issue 19180", () => {
cy.visit(`/model/${QUESTION_ID}/query`);
cy.wait("@cardQuery");
cy.button("Cancel").click();
cy.get(".test-TableInteractive");
H.tableInteractive();
cy.findByText("Here's where your results will appear").should(
"not.exist",
);
@@ -1335,7 +1335,7 @@ describe("issue 31663", () => {
cy.findByLabelText("Move, trash, and more…").click();
H.popover().findByText("Edit metadata").click();
cy.findByTestId("TableInteractive-root").findByText("Product ID").click();
H.tableInteractive().findByText("Product ID").click();
cy.wait("@idFields");
cy.findByLabelText("Foreign key target").click();
H.popover().within(() => {

View File

@@ -1143,9 +1143,7 @@ describe("issue 34514", () => {
H.entityPickerModal().should("not.exist");
cy.button("Save").should("be.enabled");
H.getNotebookStep("data").findByText("Orders").should("be.visible");
cy.findByTestId("TableInteractive-root")
.findByText("39.72")
.should("be.visible");
H.tableInteractive().findByText("39.72").should("be.visible");
}
function assertMetadataTabState() {
@@ -1164,7 +1162,7 @@ describe("issue 34514", () => {
H.getNotebookStep("data")
.findByText("Pick your starting data")
.should("be.visible");
cy.findByTestId("TableInteractive-root").should("not.exist");
H.tableInteractive().should("not.exist");
cy.findByTestId("query-visualization-root").within(() => {
cy.findByText("We're experiencing server issues").should("not.exist");
cy.findByText("Here's where your results will appear").should(

View File

@@ -260,7 +260,10 @@ describe("scenarios > question > native subquery", () => {
);
H.visitQuestion("@toplevelQuestionId");
cy.get("#main-data-grid [data-testid=cell-data]").should("have.text", "41");
H.tableInteractiveBody()
.findAllByTestId("cell-data")
.eq(0)
.should("have.text", "41");
});
it("should be able to reference a nested question (metabase#25988)", () => {

View File

@@ -688,9 +688,7 @@ describe("formatting > sandboxes", () => {
columnAssertion: USERS.sandboxed.login_attributes.attr_uid,
});
cy.findByTestId("TableInteractive-root")
.findByText("Awesome Concrete Shoes")
.click();
H.tableInteractive().findByText("Awesome Concrete Shoes").click();
H.popover()
.findByText(/View details/i)
.click();

View File

@@ -690,7 +690,7 @@ function createAdHocQuestion(questionName) {
function visualizeAndAssertColumns() {
H.visualize();
cy.findByTestId("TableInteractive-root").within(() => {
H.tableInteractive().within(() => {
cy.findByText("ID").should("exist");
cy.findByText("Total").should("not.exist");
});

View File

@@ -557,7 +557,7 @@ describe(
});
it("should be possible to filter by Mongo _id column (metabase#40770, metabase#42010)", () => {
cy.get("#main-data-grid")
H.tableInteractiveBody()
.findAllByRole("gridcell")
.first()
.then($cell => {
@@ -610,7 +610,7 @@ describe(
// The preview should show only one row
const ordersColumns = 10;
cy.findByTestId("preview-root")
.get("#main-data-grid")
.findByTestId("table-body")
.findAllByTestId("cell-data")
.should("have.length.at.most", ordersColumns);

View File

@@ -213,7 +213,7 @@ describe("scenarios > question > new", () => {
cy.log(
"**It should display the table with all orders with the selected quantity.**",
);
cy.get(".test-TableInteractive");
H.tableInteractive();
cy.get(".test-TableInteractive-cellWrapper--firstColumn") // ID (first in the default order for Sample Database)
.eq(1) // first table body cell
@@ -725,7 +725,7 @@ describe(
// strange: we get different behavior when we go to question/new
cy.findAllByTestId("run-button").first().click();
cy.findByTestId("TableInteractive-root").within(() => {
H.tableInteractive().within(() => {
cy.findByText("Rustic Paper Wallet").should("be.visible");
});
});
@@ -741,7 +741,7 @@ describe(
// strange: we get different behavior when we go to question/new
cy.findAllByTestId("run-button").first().click();
cy.findByTestId("TableInteractive-root").within(() => {
H.tableInteractive().within(() => {
cy.findByText(39.72).should("be.visible");
});
});

View File

@@ -172,7 +172,7 @@ describe("scenarios > notebook > link to data source", () => {
cy.findAllByTestId("header-cell")
.should("have.length", "1")
.and("have.text", "FOO");
cy.get("#main-data-grid")
H.tableInteractiveBody()
.findByTestId("cell-data")
.should("have.text", "1");
cy.findByTestId("question-row-count").should(

View File

@@ -136,7 +136,8 @@ describe("scenarios > question > null", () => {
// Total of "39.72", and the next cell is the `discount` (which is empty)
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("39.72")
.closest(".test-TableInteractive-cellWrapper")
.closest("[role=gridcell]")
.parent()
.next()
.find("div")
.should("be.empty")

View File

@@ -52,9 +52,7 @@ describe("scenarios > question > settings", () => {
cy.findByTestId("visualization-root").contains("8833419218504");
// confirm that the table contains the right columns
cy.findByTestId("query-visualization-root")
.get(".test-TableInteractive")
.as("table");
H.tableInteractive().as("table");
cy.get("@table").contains("Product → Category");
cy.get("@table").contains("Product → Ean");
cy.get("@table").contains("Total").should("not.exist");
@@ -254,7 +252,6 @@ describe("scenarios > question > settings", () => {
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Conditional Formatting"); // confirm it's open
// cy.get(".test-TableInteractive").findByText("Subtotal").scrollIntoView();
H.tableHeaderClick("Subtotal"); // open subtotal column header actions
H.popover().icon("gear").click(); // open subtotal column settings
@@ -485,7 +482,7 @@ describe("scenarios > question > settings", () => {
"two",
];
cy.findByTestId("TableInteractive-root").within(() => {
H.tableInteractive().within(() => {
columnNames.forEach(text => cy.findByText(text).should("be.visible"));
});
@@ -508,7 +505,7 @@ describe("scenarios > question > settings", () => {
.should("not.exist");
});
cy.findByTestId("TableInteractive-root").within(() => {
H.tableInteractive().within(() => {
// the query should not have changed
cy.icon("play").should("not.exist");
cy.findByText("Products → Category").should("not.exist");
@@ -523,7 +520,7 @@ describe("scenarios > question > settings", () => {
.should("be.visible");
});
cy.findByTestId("TableInteractive-root").within(() => {
H.tableInteractive().within(() => {
// the query should not have changed
cy.icon("play").should("not.exist");
cy.findByText("Products → Category").should("be.visible");

View File

@@ -996,7 +996,7 @@ describe("issue 32075", () => {
addCountGreaterThan2Filter();
H.visualize();
cy.findByTestId("TableInteractive-root").should("not.exist");
H.tableInteractive().should("not.exist");
cy.get("[data-element-id=pin-map]").should("exist");
});
@@ -1008,7 +1008,7 @@ describe("issue 32075", () => {
H.addSummaryGroupingField({ field: "Birth Date" });
H.visualize();
cy.findByTestId("TableInteractive-root").should("not.exist");
H.tableInteractive().should("not.exist");
cy.get("[data-element-id=pin-map]").should("exist");
});
@@ -1020,7 +1020,7 @@ describe("issue 32075", () => {
H.addSummaryField({ metric: "Average of ...", field: "Longitude" });
H.visualize();
cy.findByTestId("TableInteractive-root").should("not.exist");
H.tableInteractive().should("not.exist");
cy.get("[data-element-id=pin-map]").should("exist");
});

View File

@@ -240,7 +240,9 @@ H.describeWithSnowplow("extract shortcut", () => {
option: "Host",
});
cy.get("#main-data-grid").scrollTo("left", { duration: 2000 / 60 });
H.tableInteractiveScrollContainer().scrollTo("left", {
duration: 2000 / 60,
});
H.tableHeaderClick("ID");
@@ -450,7 +452,7 @@ H.describeWithSnowplow("scenarios > visualizations > combine shortcut", () => {
},
);
cy.findByTestId("TableInteractive-root").should("exist");
H.tableInteractive().should("exist");
combineColumns({
columns: ["Created At: Hour of day", "Count"],
newColumn: "Combined Created At: Hour of day, Count",
@@ -480,7 +482,7 @@ H.describeWithSnowplow("scenarios > visualizations > combine shortcut", () => {
},
);
cy.findByTestId("TableInteractive-root").should("exist");
H.tableInteractive().should("exist");
combineColumns({
columns: ["Created At: Hour of day", "Category"],
newColumn: "Combined Created At: Hour of day, Category",

View File

@@ -113,9 +113,7 @@ describe("scenarios > visualizations > drillthroughs > table_drills", () => {
{ visitQuestion: true },
);
cy.findByTestId("TableInteractive-root")
.findByText("abbey-heidenreich")
.click();
H.tableInteractive().findByText("abbey-heidenreich").click();
H.popover().within(() => {
cy.findByText("Is abbey-heidenreich").should("be.visible");
@@ -398,9 +396,7 @@ describe("scenarios > visualizations > drillthroughs > table_drills", () => {
{ visitQuestion: true },
);
cy.findByTestId("TableInteractive-root")
.findByText("abbey-heidenreich")
.click();
H.tableInteractive().findByText("abbey-heidenreich").click();
H.popover().within(() => {
cy.findByText("Is abbey-heidenreich").should("be.visible");

View File

@@ -319,9 +319,7 @@ describe("scenarios > question > object details", { tags: "@slow" }, () => {
H.openProductsTable({ limit: 5 });
cy.findByTestId("TableInteractive-root")
.findByTextEnsureVisible("Rustic Paper Wallet")
.click();
H.tableInteractive().findByTextEnsureVisible("Rustic Paper Wallet").click();
cy.location("search").should("eq", "?objectId=Rustic%20Paper%20Wallet");
cy.findByTestId("object-detail").contains("Rustic Paper Wallet");
@@ -469,7 +467,7 @@ function changeSorting(columnName, direction) {
cy.visit(`/question#?db=${WRITABLE_DB_ID}&table=${tableId}`);
});
cy.get("#main-data-grid").findByText("Rabbit").trigger("mouseover");
H.tableInteractiveBody().findByText("Rabbit").trigger("mouseover");
cy.icon("expand").first().click();

View File

@@ -360,7 +360,7 @@ describe("scenarios > visualizations > table column settings", () => {
// clicking outside of the popover to close it
cy.findByTestId("app-bar").click();
cy.findByTestId("TableInteractive-root").within(() => {
H.tableInteractive().within(() => {
cy.findByText("prod_id");
});
});
@@ -816,11 +816,11 @@ const openSettings = () => {
};
const visualization = () => {
return cy.findByTestId("TableInteractive-root");
return H.tableInteractive();
};
const scrollVisualization = (position = "right") => {
cy.get("#main-data-grid").scrollTo(position, {
H.tableInteractiveScrollContainer().scrollTo(position, {
force: true,
});
};

View File

@@ -39,9 +39,8 @@ describe("scenarios > visualizations > table", () => {
// This defocuses the input, which triggers the update
cy.findByText("Column title").click();
});
// click somewhere else to close the popover
// eslint-disable-next-line no-unsafe-element-filtering
headerCells().last().click();
cy.realPress("Escape");
headerCells().findAllByText("ID updated").should("have.length", 1);
});

View File

@@ -918,7 +918,7 @@ describe.skip("issue 25415", () => {
cy.findByTestId("qb-filters-panel").should("contain", "Product ID is 1");
// there is a table with data
cy.findByTestId("TableInteractive-root").should("exist");
H.tableInteractive().should("exist");
});
});

View File

@@ -110,7 +110,7 @@ describe("InteractiveQuestion - multiple interactive questions", () => {
screen.queryAllByTestId("loading-indicator"),
);
const tables = screen.getAllByTestId("TableInteractive-root");
const tables = screen.getAllByTestId("table-root");
const gridcells = screen.getAllByRole("gridcell");
expect(tables).toHaveLength(rows.length);

View File

@@ -131,7 +131,7 @@ describe("InteractiveQuestion", () => {
await waitForLoaderToBeRemoved();
expect(
await within(screen.getByTestId("TableInteractive-root")).findByText(
await within(screen.getByTestId("table-root")).findByText(
TEST_COLUMN.display_name,
),
).toBeInTheDocument();
@@ -157,7 +157,7 @@ describe("InteractiveQuestion", () => {
await waitForLoaderToBeRemoved();
expect(
within(screen.getByTestId("TableInteractive-root")).getByText(
within(screen.getByTestId("table-root")).getByText(
TEST_COLUMN.display_name,
),
).toBeInTheDocument();

View File

@@ -0,0 +1,18 @@
.root {
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
background-color: var(--mb-color-bg-white);
width: 36px;
height: 36px;
box-sizing: content-box;
}
.sticky {
position: absolute;
top: 0;
right: 0;
border-left: 1px solid var(--mb-color-border);
border-bottom: 1px solid var(--mb-color-border);
}

View File

@@ -0,0 +1,29 @@
import cx from "classnames";
import { type HTMLAttributes, memo } from "react";
import { t } from "ttag";
import { Button, Icon } from "metabase/ui";
import S from "./AddColumnButton.module.css";
interface AddColumnButtonProps extends HTMLAttributes<HTMLButtonElement> {
isOverflowing?: boolean;
}
export const AddColumnButton = memo(function AddColumnButton({
isOverflowing,
onClick,
}: AddColumnButtonProps) {
return (
<div className={cx(S.root, { [S.sticky]: isOverflowing })}>
<Button
variant="outline"
size="compact-md"
leftSection={<Icon name="add" />}
title={t`Add column`}
aria-label={t`Add column`}
onClick={onClick}
/>
</div>
);
});

View File

@@ -0,0 +1 @@
export * from "./AddColumnButton";

View File

@@ -0,0 +1,26 @@
.root {
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
cursor: default;
font-size: 12.5px;
padding: 0 12px;
}
.leftAligned {
text-align: left;
justify-content: flex-start;
}
.rightAligned {
text-align: right;
justify-content: flex-end;
}
.centerAligned {
text-align: center;
justify-content: center;
}

View File

@@ -0,0 +1,42 @@
import cx from "classnames";
import type React from "react";
import { memo } from "react";
import type { CellAlign } from "metabase/data-grid/types";
import styles from "./BaseCell.module.css";
export type BaseCellProps = {
align?: CellAlign;
children?: React.ReactNode;
className?: string;
backgroundColor?: string;
} & React.HTMLProps<HTMLDivElement>;
export const BaseCell = memo(function BaseCell({
align = "left",
backgroundColor,
className,
children,
...rest
}: BaseCellProps) {
const style = backgroundColor != null ? { backgroundColor } : undefined;
return (
<div
className={cx(
styles.root,
{
[styles.leftAligned]: align === "left",
[styles.rightAligned]: align === "right",
[styles.centerAligned]: align === "middle",
},
className,
)}
style={style}
{...rest}
>
{children}
</div>
);
});

View File

@@ -0,0 +1 @@
export * from "./BaseCell";

View File

@@ -0,0 +1,43 @@
.root {
display: flex;
width: 100%;
height: 100%;
font-size: 12.5px;
line-height: 18px;
font-weight: 700;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--mb-color-border-alpha-30);
cursor: pointer;
}
.content {
overflow: visible;
line-height: 1.4;
}
.noWrap {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.clickable {
cursor: pointer;
}
.pill {
padding: 0 12px;
}
.pill .content {
color: var(--mb-color-brand);
border: 1px solid var(--mb-color-brand-light);
background-color: var(--mb-color-brand-lighter);
border-radius: 99px;
padding: 2px 8px;
width: auto;
min-width: 30px;
text-overflow: ellipsis;
text-align: center;
}

View File

@@ -0,0 +1,73 @@
import cx from "classnames";
import type React from "react";
import { type MouseEventHandler, memo, useCallback } from "react";
import { BaseCell } from "metabase/data-grid/components/BaseCell";
import DataGridS from "metabase/data-grid/components/DataGrid/DataGrid.module.css";
import type { BodyCellBaseProps } from "metabase/data-grid/types";
import { ExpandButton } from "../ExpandButton";
import S from "./BodyCell.module.css";
export interface BodyCellProps<TValue> extends BodyCellBaseProps<TValue> {
variant?: "text" | "pill";
}
export const BodyCell = memo(function BodyCell<TValue>({
value,
formatter,
backgroundColor,
align = "left",
variant = "text",
wrap = false,
canExpand = false,
columnId,
rowIndex,
className,
onExpand,
}: BodyCellProps<TValue>) {
const formattedValue = formatter
? formatter(value, rowIndex, columnId)
: String(value);
const handleExpandClick: MouseEventHandler<HTMLButtonElement> = useCallback(
e => {
e.stopPropagation();
onExpand?.(columnId, formattedValue);
},
[columnId, formattedValue, onExpand],
);
const hasExpandButton = variant === "text" && canExpand;
return (
<BaseCell
role="gridcell"
align={align}
className={cx(S.root, className, {
[S.pill]: variant === "pill",
})}
backgroundColor={backgroundColor}
>
{formattedValue != null ? (
<div
data-grid-cell-content
className={cx(S.content, {
[S.noWrap]: !wrap,
})}
data-testid="cell-data"
>
{formattedValue}
</div>
) : null}
{hasExpandButton && (
<ExpandButton
className={DataGridS.cellHoverVisible}
onClick={handleExpandClick}
/>
)}
</BaseCell>
);
}) as <TValue>(props: BodyCellProps<TValue>) => React.ReactElement;

View File

@@ -0,0 +1,79 @@
import { fireEvent, screen } from "@testing-library/react";
import { renderWithProviders } from "__support__/ui";
import { BodyCell } from "./BodyCell";
describe("BodyCell", () => {
it("renders the value correctly", () => {
renderWithProviders(
<BodyCell value="Cell Value" columnId="col1" rowIndex={0} />,
);
expect(screen.getByText("Cell Value")).toBeInTheDocument();
});
it("formats the value when a formatter is provided", () => {
const formatter = (value: number) => `$${value}`;
renderWithProviders(
<BodyCell
value={100}
formatter={formatter}
columnId="price"
rowIndex={0}
/>,
);
expect(screen.getByText("$100")).toBeInTheDocument();
});
it("does not show an expand button when canExpand is false", () => {
renderWithProviders(
<BodyCell
value="Expandable"
canExpand={false}
columnId="col1"
rowIndex={0}
/>,
);
const expandButton = screen.queryByRole("button");
expect(expandButton).not.toBeInTheDocument();
});
it("does not show expand button when variant is pill", () => {
renderWithProviders(
<BodyCell
value="Pill Variant"
variant="pill"
canExpand={true}
columnId="col1"
rowIndex={0}
/>,
);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("calls onExpand when the expand button is clicked", () => {
const handleExpand = jest.fn();
renderWithProviders(
<BodyCell
value="Expandable"
formatter={value => `formatted:${value}`}
canExpand={true}
columnId="col1"
rowIndex={0}
onExpand={handleExpand}
/>,
);
const expandButton = screen.getByRole("button");
fireEvent.click(expandButton);
expect(handleExpand).toHaveBeenCalledTimes(1);
expect(handleExpand).toHaveBeenCalledWith("col1", "formatted:Expandable");
});
});

View File

@@ -0,0 +1 @@
export * from "./BodyCell";

View File

@@ -0,0 +1,115 @@
.table {
position: relative;
color: var(--mb-color-text-dark);
background-color: var(--mb-color-bg-white);
width: 100%;
height: 100%;
}
.tableGrid {
display: grid;
grid-template-rows: repeat(1, 37px);
width: 100%;
height: 100%;
overflow: auto;
scrollbar-gutter: stable;
}
.headerContainer {
position: sticky;
display: grid;
top: 0;
z-index: 2;
background-color: var(--mb-color-bg-white);
border-bottom: 1px solid var(--mb-color-border);
}
.bodyContainer {
display: grid;
position: relative;
}
.row {
display: flex;
width: 100%;
}
.bodyCell {
position: relative;
background-color: var(--mb-color-bg-white);
cursor: pointer;
}
.bodyCell:hover {
background-color: color-mix(in srgb, var(--mb-color-brand), #fff 90%);
}
.rowHoverContent {
visibility: hidden;
}
.row:hover .rowHoverContent {
visibility: visible;
}
.resizer {
position: absolute;
right: 0;
top: 0;
height: 100%;
width: 5px;
background: transparent;
cursor: col-resize;
user-select: none;
touch-action: none;
opacity: 0;
transition: opacity 0.2s;
}
.resizer:hover {
opacity: 1;
background: var(--mb-color-brand);
}
.resizer:active {
opacity: 1;
background: var(--mb-color-brand);
}
.headerWrapper {
width: 100%;
height: 100%;
outline: none;
}
.headerWrapper:hover {
cursor: grab;
}
.rowHoverVisible {
display: none;
}
.row:hover .rowHoverVisible {
display: unset;
}
.rowHoverHidden {
display: unset;
}
.row:hover .rowHoverHidden {
display: none;
}
.row:hover .headerCell {
box-shadow: inset -1px 0 0 var(--mb-color-border);
}
.cellHoverVisible {
visibility: hidden;
}
.bodyCell:hover .cellHoverVisible {
visibility: visible;
}

View File

@@ -0,0 +1,220 @@
import type { Meta, StoryFn } from "@storybook/react";
import { useCallback, useMemo, useState } from "react";
import { useDataGridInstance } from "metabase/data-grid/hooks/use-data-grid-instance";
import type {
ColumnOptions,
RowIdColumnOptions,
} from "metabase/data-grid/types";
import { DataGrid } from "./DataGrid";
export default {
title: "DataGrid",
component: DataGrid,
parameters: {
docs: {
description: {
component:
"A virtualized table component with support for column reordering, resizing, and sorting",
},
},
},
decorators: [
Story => (
<div style={{ height: "calc(100vh - 2rem)", overflow: "hidden" }}>
<Story />
</div>
),
],
} as Meta<typeof DataGrid>;
type Story = StoryFn<typeof DataGrid>;
const sampleData = Array.from({ length: 2000 }, (_, rowIndex) => {
return {
id: rowIndex + 1,
name: `Item ${rowIndex + 1}`,
category: ["Electronics", "Clothing", "Books", "Food"][rowIndex % 4],
price: Math.round(Math.random() * 1000) / 10,
quantity: Math.round(Math.random() * 100),
description: `This is a sample description for item ${rowIndex + 1}. ${"It can be longer to demonstrate text wrapping. ".repeat(
Math.floor(Math.random() * 18) + 1,
)}`,
};
});
type SampleDataType = (typeof sampleData)[0];
export const BasicGrid: Story = () => {
const columns: ColumnOptions<SampleDataType>[] = useMemo(
() => [
{
id: "id",
name: "ID",
accessorFn: row => row.id,
},
{
id: "name",
name: "Name",
accessorFn: row => row.name,
},
{
id: "category",
name: "Category",
accessorFn: row => row.category,
},
{
id: "price",
name: "Price",
accessorFn: row => row.price,
formatter: value => `$${value}`,
align: "right",
},
{
id: "quantity",
name: "Quantity",
accessorFn: row => row.quantity,
align: "right",
},
{
id: "description",
name: "Description",
accessorFn: row => row.description,
},
],
[],
);
const tableProps = useDataGridInstance({
data: sampleData,
columnsOptions: columns,
});
return <DataGrid {...tableProps} />;
};
export const CombinedFeatures: Story = () => {
const [columnOrder, setColumnOrder] = useState<string[]>([
"id",
"name",
"category",
"price",
"quantity",
"description",
]);
const [columnSizing, setColumnSizing] = useState<Record<string, number>>({
id: 120,
name: 150,
category: 120,
price: 150,
quantity: 200,
description: 600,
});
const columns: ColumnOptions<SampleDataType>[] = useMemo(
() => [
{
id: "id",
name: "ID",
accessorFn: row => row.id,
align: "right",
cellVariant: "pill",
sortDirection: "desc",
},
{
id: "name",
name: "Name",
sortDirection: "asc",
accessorFn: row => row.name,
},
{
id: "category",
name: "Category",
align: "middle",
accessorFn: row => row.category,
getBackgroundColor: value =>
value === "Electronics"
? "#e6f7ff"
: value === "Clothing"
? "#f6ffed"
: value === "Books"
? "#fff7e6"
: "#fff1f0",
},
{
id: "price",
name: "Price",
accessorFn: row => `$${row.price.toFixed(2)}`,
align: "right",
},
{
id: "quantity",
name: "Quantity",
accessorFn: row => row.quantity,
align: "right",
},
{
id: "description",
name: "Description",
accessorFn: row => row.description,
wrap: true,
},
],
[],
);
const rowId: RowIdColumnOptions = useMemo(
() => ({
variant: "indexExpand",
getBackgroundColor: (rowIndex: number) =>
rowIndex % 2 === 0 ? "#f0f0f0" : "transparent",
}),
[],
);
const tableProps = useDataGridInstance({
data: sampleData,
columnsOptions: columns,
columnOrder,
columnSizing,
onColumnReorder: setColumnOrder,
onColumnResize: setColumnSizing,
rowId,
});
const handleAddColumnClick = useCallback(() => {
alert("Add column button clicked");
}, []);
const handleBodyCellClick = useCallback(
(
_: React.MouseEvent<HTMLDivElement>,
rowIndex: number,
columnId: string,
) => {
// eslint-disable-next-line no-console
console.log(`Clicked cell at row ${rowIndex}, column ${columnId}`);
},
[],
);
const handleHeaderCellClick = useCallback(
(_: React.MouseEvent<HTMLDivElement>, columnId?: string) => {
if (columnId) {
// eslint-disable-next-line no-console
console.log(`Clicked header for column ${columnId}`);
}
},
[],
);
return (
<DataGrid
{...tableProps}
onBodyCellClick={handleBodyCellClick}
onHeaderCellClick={handleHeaderCellClick}
onAddColumnClick={handleAddColumnClick}
/>
);
};

View File

@@ -0,0 +1,200 @@
import { DndContext, pointerWithin } from "@dnd-kit/core";
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
import {
SortableContext,
horizontalListSortingStrategy,
} from "@dnd-kit/sortable";
import { flexRender } from "@tanstack/react-table";
import { type Ref, forwardRef, useCallback, useMemo } from "react";
import { AddColumnButton } from "metabase/data-grid/components/AddColumnButton";
import { SortableHeader } from "metabase/data-grid/components/SortableHeader";
import { HEADER_HEIGHT } from "metabase/data-grid/constants";
import type { DataGridInstance } from "metabase/data-grid/types";
import S from "./DataGrid.module.css";
export type DataGridProps<TData> = DataGridInstance<TData>;
export const DataGrid = forwardRef(function DataGrid<TData>(
{
table,
refs,
virtualGrid,
measureRoot,
columnsReordering,
onBodyCellClick,
onHeaderCellClick,
onAddColumnClick,
onScroll,
}: DataGridProps<TData>,
ref: Ref<HTMLDivElement>,
) {
const {
virtualColumns,
virtualRows,
virtualPaddingLeft,
virtualPaddingRight,
rowVirtualizer,
} = virtualGrid;
const dndContextProps = useMemo(
() => ({
collisionDetection: pointerWithin,
modifiers: [restrictToHorizontalAxis],
...columnsReordering,
}),
[columnsReordering],
);
const rowMeasureRef = useCallback(
(element: HTMLElement | null) => {
rowVirtualizer.measureElement(element);
},
[rowVirtualizer],
);
const isAddColumnButtonSticky =
table.getTotalSize() >= (refs.gridRef.current?.offsetWidth ?? Infinity);
const hasAddColumnButton = onAddColumnClick != null;
const addColumnButton = useMemo(
() =>
hasAddColumnButton ? (
<AddColumnButton
isOverflowing={isAddColumnButtonSticky}
onClick={onAddColumnClick}
/>
) : null,
[hasAddColumnButton, isAddColumnButtonSticky, onAddColumnClick],
);
return (
<DndContext {...dndContextProps}>
<div ref={ref} className={S.table} data-testid="table-root">
<div
data-testid="table-scroll-container"
className={S.tableGrid}
ref={refs.gridRef}
style={{
paddingRight: isAddColumnButtonSticky ? "36px" : 0,
}}
onScroll={onScroll}
>
<div data-testid="table-header" className={S.headerContainer}>
{table.getHeaderGroups().map(headerGroup => (
<div
key={headerGroup.id}
className={S.row}
style={{ height: `${HEADER_HEIGHT}px` }}
>
{virtualPaddingLeft ? (
<div style={{ width: virtualPaddingLeft }} />
) : null}
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
{virtualColumns.map(virtualColumn => {
const header = headerGroup.headers[virtualColumn.index];
const headerCell = flexRender(
header.column.columnDef.header,
header.getContext(),
);
return (
<div
key={header.id}
style={{
width: header.getSize(),
position: "relative",
}}
>
<SortableHeader
className={S.headerCell}
header={header}
onClick={onHeaderCellClick}
>
{headerCell}
</SortableHeader>
</div>
);
})}
</SortableContext>
{!isAddColumnButtonSticky ? addColumnButton : null}
{virtualPaddingRight ? (
<div style={{ width: virtualPaddingRight }} />
) : null}
</div>
))}
</div>
<div
data-testid="table-body"
className={S.bodyContainer}
style={{
display: "grid",
position: "relative",
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
{virtualRows.map(virtualRow => {
const row = table.getRowModel().rows[virtualRow.index];
return (
<div
role="row"
key={row.id}
ref={rowMeasureRef}
data-index={virtualRow.index}
className={S.row}
style={{
position: "absolute",
width: "100%",
minHeight: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{virtualPaddingLeft ? (
<div
className={S.bodyCell}
style={{ width: virtualPaddingLeft }}
/>
) : null}
{virtualColumns.map(virtualColumn => {
const cell = row.getVisibleCells()[virtualColumn.index];
return (
<div
key={cell.id}
className={S.bodyCell}
onClick={e =>
onBodyCellClick?.(e, cell.row.index, cell.column.id)
}
style={{
position: "relative",
width: cell.column.getSize(),
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</div>
);
})}
{virtualPaddingRight ? (
<div
className={S.bodyCell}
style={{ width: virtualPaddingRight }}
/>
) : null}
</div>
);
})}
</div>
</div>
{isAddColumnButtonSticky ? addColumnButton : null}
</div>
{measureRoot}
</DndContext>
);
});

View File

@@ -0,0 +1,338 @@
import { type MouseEvent, useMemo } from "react";
import {
act,
fireEvent,
renderWithProviders,
screen,
within,
} from "__support__/ui";
import {
type ColumnOptions,
type RowIdColumnOptions,
useDataGridInstance,
} from "metabase/data-grid";
import { DataGrid } from "./DataGrid";
const sampleData = [
{ id: 1, name: "Item 1", category: "Electronics", price: 99.9 },
{ id: 2, name: "Item 2", category: "Clothing", price: 45.5 },
{ id: 3, name: "Item 3", category: "Books", price: 15.0 },
{ id: 4, name: "Item 4", category: "Food", price: 8.7 },
{ id: 5, name: "Item 5", category: "Electronics", price: 199.9 },
];
const DEFAULT_COLUMN_SIZING = {
id: 100,
name: 150,
category: 120,
price: 120,
};
const DEFAULT_COLUMN_ORDER = ["id", "name", "category", "price"];
type SampleDataType = (typeof sampleData)[0];
type ColumnSizingState = Record<string, number>;
interface TestDataGridProps {
onHeaderCellClick?: (
event: MouseEvent<HTMLDivElement>,
columnId?: string,
) => void;
onBodyCellClick?: (
event: MouseEvent<HTMLDivElement>,
rowIndex: number,
columnId: string,
) => void;
onAddColumnClick?: () => void;
onColumnResize?: (columnSizing: ColumnSizingState) => void;
onColumnReorder?: (columnIds: string[]) => void;
initialColumnSizing?: Record<string, number>;
initialColumnOrder?: string[];
enableRowId?: boolean;
sortableColumns?: boolean;
wrapableColumns?: string[];
}
const TestDataGrid = ({
onHeaderCellClick,
onBodyCellClick,
onAddColumnClick,
onColumnResize,
onColumnReorder,
initialColumnSizing = DEFAULT_COLUMN_SIZING,
initialColumnOrder = DEFAULT_COLUMN_ORDER,
enableRowId = false,
sortableColumns = false,
wrapableColumns = [],
}: TestDataGridProps) => {
const columns: ColumnOptions<SampleDataType>[] = useMemo(
() => [
{
id: "id",
name: "ID",
accessorFn: row => row.id,
align: "right",
cellVariant: "pill",
sortDirection: sortableColumns ? "desc" : undefined,
},
{
id: "name",
name: "Name",
accessorFn: row => row.name,
sortDirection: sortableColumns ? "asc" : undefined,
wrap: wrapableColumns.includes("name"),
},
{
id: "category",
name: "Category",
accessorFn: row => row.category,
wrap: wrapableColumns.includes("category"),
getBackgroundColor: value => {
switch (value) {
case "Electronics":
return "rgb(230, 247, 255)";
case "Clothing":
return "rgb(246, 255, 237)";
case "Books":
return "rgb(255, 247, 230)";
case "Food":
return "rgb(255, 241, 240)";
default:
return "";
}
},
},
{
id: "price",
name: "Price",
accessorFn: row => row.price,
formatter: value => `$${value}`,
align: "right",
wrap: wrapableColumns.includes("price"),
},
],
[sortableColumns, wrapableColumns],
);
const rowId: RowIdColumnOptions | undefined = useMemo(
() =>
enableRowId
? {
variant: "indexExpand",
}
: undefined,
[enableRowId],
);
const tableProps = useDataGridInstance({
data: sampleData,
columnsOptions: columns,
columnOrder: initialColumnOrder,
columnSizing: initialColumnSizing,
onColumnReorder,
onColumnResize,
rowId,
});
return (
<DataGrid
{...tableProps}
onHeaderCellClick={onHeaderCellClick}
onBodyCellClick={onBodyCellClick}
onAddColumnClick={onAddColumnClick}
/>
);
};
describe("DataGrid", () => {
const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
beforeAll(() => {
// Needed for virtual grid to work
Element.prototype.getBoundingClientRect = jest.fn(() => {
return {
width: 800,
height: 400,
top: 0,
left: 0,
bottom: 400,
right: 800,
x: 0,
y: 0,
toJSON: () => ({}),
};
});
});
afterAll(() => {
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
});
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
act(() => {
jest.runAllTimers();
});
jest.useRealTimers();
});
it("renders the grid with expected structure", () => {
renderWithProviders(<TestDataGrid />);
act(() => {
jest.runAllTimers();
});
expect(screen.getByTestId("table-root")).toBeInTheDocument();
expect(screen.getByTestId("table-header")).toBeInTheDocument();
expect(screen.getByTestId("table-body")).toBeInTheDocument();
});
it("calls onBodyCellClick when clicking a cell", () => {
const onBodyCellClick = jest.fn();
renderWithProviders(<TestDataGrid onBodyCellClick={onBodyCellClick} />);
act(() => {
jest.runAllTimers();
});
const bodyCells = screen
.getByTestId("table-body")
.querySelectorAll('[role="gridcell"]');
fireEvent.click(bodyCells[0]);
expect(onBodyCellClick).toHaveBeenCalledTimes(1);
expect(onBodyCellClick).toHaveBeenCalledWith(
expect.anything(), // event
0, // row index
"id", // column ID
);
});
it("displays formatted values using formatters", () => {
renderWithProviders(<TestDataGrid />);
act(() => {
jest.runAllTimers();
});
const visibleCells = screen.getAllByTestId("cell-data");
const priceCell = Array.from(visibleCells).find(cell =>
cell.textContent?.startsWith("$"),
);
expect(priceCell?.textContent).toBe("$99.9");
});
it("calls onAddColumnClick when clicking add column button", () => {
const onAddColumnClick = jest.fn();
renderWithProviders(<TestDataGrid onAddColumnClick={onAddColumnClick} />);
act(() => {
jest.runAllTimers();
});
const addButton = screen.getByRole("button", { name: /add/i });
fireEvent.click(addButton);
expect(onAddColumnClick).toHaveBeenCalledTimes(1);
});
it("applies custom cell background colors", () => {
renderWithProviders(<TestDataGrid />);
act(() => {
jest.runAllTimers();
});
const categoryCells = Array.from(screen.getAllByTestId("cell-data"))
.filter(cell =>
["Electronics", "Clothing", "Books", "Food"].includes(
cell.textContent || "",
),
)
.map(cell => cell.closest('[role="gridcell"]'))
.filter((cell): cell is HTMLElement => cell !== null);
const electronicsCell = categoryCells.find(cell =>
cell.textContent?.includes("Electronics"),
);
const clothingCell = categoryCells.find(cell =>
cell.textContent?.includes("Clothing"),
);
const booksCell = categoryCells.find(cell =>
cell.textContent?.includes("Books"),
);
const foodCell = categoryCells.find(cell =>
cell.textContent?.includes("Food"),
);
expect(
window
.getComputedStyle(electronicsCell!)
.getPropertyValue("background-color"),
).toBe("rgb(230, 247, 255)");
expect(
window
.getComputedStyle(clothingCell!)
.getPropertyValue("background-color"),
).toBe("rgb(246, 255, 237)");
expect(
window.getComputedStyle(booksCell!).getPropertyValue("background-color"),
).toBe("rgb(255, 247, 230)");
expect(
window.getComputedStyle(foodCell!).getPropertyValue("background-color"),
).toBe("rgb(255, 241, 240)");
});
it("renders row ID column when enabled", () => {
renderWithProviders(<TestDataGrid enableRowId={true} />);
act(() => {
jest.runAllTimers();
});
const rows = screen.getAllByRole("row").slice(1); // Skip header row
expect(rows.length).toBeGreaterThan(0);
rows.forEach((row, index) => {
const cells = within(row).getAllByRole("gridcell");
expect(cells[0]).toHaveTextContent(String(index + 2));
});
const firstRow = rows[0];
const cellsInRow = within(firstRow).getAllByRole("gridcell");
expect(cellsInRow.length).toBe(4); // 3 data columns + 1 row ID column
});
it("displays proper sort indicators for sortable columns", () => {
renderWithProviders(<TestDataGrid sortableColumns={true} />);
act(() => {
jest.runAllTimers();
});
const headerCells = screen
.getByTestId("table-header")
.querySelectorAll('[data-testid="header-cell"]');
const idHeader = Array.from(headerCells).find(header =>
header.textContent?.includes("ID"),
);
const nameHeader = Array.from(headerCells).find(header =>
header.textContent?.includes("Name"),
);
expect(idHeader).toBeDefined();
expect(nameHeader).toBeDefined();
const idSortIcon = idHeader!.querySelector('svg[name="chevrondown"]');
const nameSortIcon = nameHeader!.querySelector('svg[name="chevronup"]');
expect(idSortIcon).toBeDefined();
expect(nameSortIcon).toBeDefined();
});
});

View File

@@ -0,0 +1 @@
export * from "./DataGrid";

View File

@@ -0,0 +1,26 @@
.root {
display: flex;
align-items: center;
justify-content: center;
padding-left: 0.25rem;
margin-left: auto;
}
.button {
border: 1px solid color-mix(in srgb, var(--mb-color-brand) 30%, white);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
color: var(--mb-color-brand);
background-color: transparent;
cursor: pointer;
transition: all 0.2s;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.button:hover {
color: var(--mb-color-text-white);
background-color: var(--mb-color-brand);
}

View File

@@ -0,0 +1,24 @@
import cx from "classnames";
import type { MouseEvent } from "react";
import { Icon } from "metabase/ui";
import S from "./ExpandButton.module.css";
interface ExpandButtonProps {
className?: string;
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
}
export const ExpandButton = ({ className, onClick }: ExpandButtonProps) => (
<span className={S.root}>
<button
className={cx(S.button, className)}
onClick={onClick}
data-testid="expand-column"
aria-label="Expand column"
>
<Icon name="ellipsis" size={10} />
</button>
</span>
);

View File

@@ -0,0 +1 @@
export { ExpandButton } from "./ExpandButton";

View File

@@ -0,0 +1,42 @@
.root {
overflow: hidden;
display: flex;
align-items: center;
height: 100%;
width: 100%;
cursor: default;
font-size: 12.5px;
font-weight: 700;
padding: 0 12px;
}
.content {
display: flex;
align-items: center;
padding: 0.25rem 0.625em;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--mb-color-brand);
border-radius: 5px;
overflow: auto;
outline: none;
gap: 0.25rem;
}
.alignRight {
flex-direction: row-reverse;
}
.light .content {
transition: background-color 200ms ease-in-out;
background-color: var(--mb-base-color-brand-10);
cursor: pointer;
}
.light .content:hover {
background-color: var(--mb-base-color-brand-20);
}
.outline .content {
border: 1px solid var(--mb-base-color-brand-20);
}

View File

@@ -0,0 +1,77 @@
import cx from "classnames";
import { type ReactNode, forwardRef, memo } from "react";
import { Ellipsified } from "metabase/core/components/Ellipsified";
import { BaseCell } from "metabase/data-grid/components/BaseCell";
import type {
HeaderCellBaseProps,
HeaderCellVariant,
} from "metabase/data-grid/types";
import { Icon } from "metabase/ui";
import S from "./HeaderCell.module.css";
export interface HeaderCellProps extends HeaderCellBaseProps {
variant?: HeaderCellVariant;
}
export const HeaderCell = memo(function HeaderCell({
name,
align,
sort,
variant = "light",
}: HeaderCellProps) {
return (
<HeaderCellWrapper variant={variant} align={align}>
<HeaderCellPill name={name} sort={sort} align={align} />
</HeaderCellWrapper>
);
});
export const HeaderCellPill = forwardRef<HTMLDivElement, HeaderCellBaseProps>(
function HeaderCellPillInner(
{ name, sort, align }: HeaderCellBaseProps,
ref,
) {
return (
<div
ref={ref}
data-grid-header-cell-content
data-header-click-target
className={cx(S.content, {
[S.alignRight]: align === "right",
})}
data-testid="cell-data"
>
<Ellipsified tooltip={name}>{name}</Ellipsified>
{sort != null ? (
<Icon name={sort === "asc" ? "chevronup" : "chevrondown"} size={10} />
) : null}
</div>
);
},
);
export interface HeaderCellWrapperProp extends HeaderCellProps {
children: ReactNode;
}
export const HeaderCellWrapper = ({
variant,
align,
children,
}: HeaderCellWrapperProp) => {
return (
<BaseCell
className={cx(S.root, {
[S.light]: variant === "light",
[S.outline]: variant === "outline",
})}
align={align}
role="columnheader"
data-testid="header-cell"
>
{children}
</BaseCell>
);
};

View File

@@ -0,0 +1,111 @@
import { render, screen } from "@testing-library/react";
import { HeaderCell, HeaderCellPill, HeaderCellWrapper } from "./HeaderCell";
describe("HeaderCell", () => {
it("renders with the correct text", () => {
render(<HeaderCell name="Column Name" />);
expect(screen.getByText("Column Name")).toBeInTheDocument();
});
it("renders with the sort icon when sort is provided", () => {
render(<HeaderCell name="Column Name" sort="asc" />);
expect(screen.getByText("Column Name")).toBeInTheDocument();
// Sort icon is present (implementation may vary)
const sortIcons = screen.getAllByRole("img", { hidden: true });
expect(sortIcons.length).toBeGreaterThan(0);
});
it("applies alignment classes correctly", () => {
const { rerender } = render(<HeaderCell name="Left" align="left" />);
// BaseCell applies the alignment classes, so we test the align prop propagation
expect(screen.getByTestId("header-cell")).toBeInTheDocument();
rerender(<HeaderCell name="Middle" align="middle" />);
expect(screen.getByTestId("header-cell")).toBeInTheDocument();
rerender(<HeaderCell name="Right" align="right" />);
// Content should have the right alignment class
expect(screen.getByTestId("cell-data")).toHaveClass("alignRight");
});
it("has 'light' variant by default", () => {
render(<HeaderCell name="Default" />);
const headerCell = screen.getByTestId("header-cell");
expect(headerCell).toHaveClass("light");
});
it("applies 'outline' variant class when specified", () => {
render(<HeaderCell name="Outline" variant="outline" />);
const headerCell = screen.getByTestId("header-cell");
expect(headerCell).toHaveClass("outline");
});
});
describe("HeaderCellPill", () => {
it("renders with the correct text", () => {
render(<HeaderCellPill name="Column Name" />);
expect(screen.getByText("Column Name")).toBeInTheDocument();
});
it("shows sort icon when sort is provided", () => {
render(<HeaderCellPill name="Column Name" sort="asc" />);
const sortIcons = screen.getAllByRole("img", { hidden: true });
expect(sortIcons.length).toBeGreaterThan(0);
});
it("applies right alignment class when specified", () => {
render(<HeaderCellPill name="Right" align="right" />);
expect(screen.getByTestId("cell-data")).toHaveClass("alignRight");
});
});
describe("HeaderCellWrapper", () => {
it("renders children correctly", () => {
render(
<HeaderCellWrapper variant="light">
<span data-testid="custom-content">Custom Content</span>
</HeaderCellWrapper>,
);
expect(screen.getByTestId("custom-content")).toBeInTheDocument();
expect(screen.getByText("Custom Content")).toBeInTheDocument();
});
it("applies variant classes correctly", () => {
const { rerender } = render(
<HeaderCellWrapper variant="light">
<span>Content</span>
</HeaderCellWrapper>,
);
expect(screen.getByTestId("header-cell")).toHaveClass("light");
rerender(
<HeaderCellWrapper variant="outline">
<span>Content</span>
</HeaderCellWrapper>,
);
expect(screen.getByTestId("header-cell")).toHaveClass("outline");
});
it("passes alignment to the BaseCell", () => {
render(
<HeaderCellWrapper align="right">
<span>Content</span>
</HeaderCellWrapper>,
);
// BaseCell should receive the alignment prop
expect(screen.getByTestId("header-cell")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1 @@
export * from "./HeaderCell";

View File

@@ -0,0 +1,28 @@
.root {
height: 100%;
padding: 0 0.75em;
text-align: right;
color: var(--mb-color-text-light);
display: flex;
align-items: center;
justify-content: end;
cursor: pointer;
border-bottom: 1px solid var(--mb-color-border-alpha-30);
background-color: transparent;
}
.rowNumber {
display: block;
}
.root:hover > .rowNumber {
display: none;
}
.expandIcon {
display: none;
}
.root:hover > .expandIcon {
display: block;
}

View File

@@ -0,0 +1,47 @@
import cx from "classnames";
import type React from "react";
import { memo } from "react";
import { t } from "ttag";
import { BaseCell } from "metabase/data-grid/components/BaseCell";
import DataGridS from "metabase/data-grid/components/DataGrid/DataGrid.module.css";
import { Icon, Tooltip } from "metabase/ui";
import S from "./RowIdCell.module.css";
export interface RowIdCellProps {
value?: React.ReactNode;
backgroundColor?: string;
}
export const RowIdCell = memo(function RowIdCell({
value,
backgroundColor,
}: RowIdCellProps) {
const hasValue = value != null;
return (
<Tooltip label={t`View details`}>
<span>
<BaseCell
className={cx(S.root)}
backgroundColor={backgroundColor}
align="right"
data-testid="detail-shortcut"
>
{hasValue ? (
<span className={cx(S.rowNumber, DataGridS.rowHoverHidden)}>
{value}
</span>
) : null}
<Icon
className={cx(DataGridS.rowHoverVisible, {})}
name="expand"
size={14}
/>
</BaseCell>
</span>
</Tooltip>
);
});

View File

@@ -0,0 +1 @@
export * from "./RowIdCell";

View File

@@ -0,0 +1,9 @@
.root {
height: 100%;
display: flex;
align-items: center;
/* justify-content: flex-end; */
padding: 0 0.75em;
color: var(--mb-color-text-light);
cursor: default;
}

View File

@@ -0,0 +1,15 @@
import { BaseCell } from "metabase/data-grid/components/BaseCell";
import S from "./RowIdHeaderCell.module.css";
export interface RowIdHeaderCellProps {
name?: string;
}
export const RowIdHeaderCell = ({ name = "" }: RowIdHeaderCellProps) => {
return (
<BaseCell className={S.root} align="right">
{name}{" "}
</BaseCell>
);
};

View File

@@ -0,0 +1 @@
export * from "./RowIdHeaderCell";

View File

@@ -0,0 +1,38 @@
.root {
display: flex;
align-items: center;
height: 36px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background-color: var(--mb-color-bg-white);
position: relative;
user-select: none;
outline: none;
}
.headerContent {
width: 100%;
height: 100%;
outline: none;
}
.resizeHandle {
position: absolute;
right: 0;
top: 0;
height: 100%;
width: 4px;
background: transparent;
cursor: col-resize;
user-select: none;
touch-action: none;
opacity: 0;
transition: opacity 0.2s;
background: var(--mb-color-brand);
}
.resizeHandle:hover,
.resizeHandle:active {
opacity: 1;
}

View File

@@ -0,0 +1,123 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { Header } from "@tanstack/react-table";
import cx from "classnames";
import type React from "react";
import { type CSSProperties, memo, useCallback, useMemo, useRef } from "react";
import S from "./SortableHeader.module.css";
// if header is dragged fewer than than this number of pixels we consider it a click instead of a drag
const HEADER_DRAG_THRESHOLD = 8;
type DragPosition = { x: number; y: number };
export interface SortableHeaderProps<TData, TValue> {
children: React.ReactNode;
className?: string;
header: Header<TData, TValue>;
onClick?: (e: React.MouseEvent<HTMLDivElement>, columnId: string) => void;
}
export const SortableHeader = memo(function SortableHeader<TData, TValue>({
header,
className,
children,
onClick,
}: SortableHeaderProps<TData, TValue>) {
const isPinned = header.column.getIsPinned();
const canResize = header.column.columnDef.enableResizing;
const headerClickTargetSelector =
header.column.columnDef.meta?.headerClickTargetSelector;
const id = header.column.id;
const { attributes, isDragging, listeners, setNodeRef, transform } =
useSortable({
id,
disabled: !!isPinned,
});
const dragStartPosition = useRef<DragPosition | null>(null);
const style = useMemo<CSSProperties>(() => {
if (isPinned) {
return {};
}
return {
position: "relative",
transform: isDragging ? CSS.Translate.toString(transform) : undefined,
transition: "width transform 0.2s ease-in-out",
whiteSpace: "nowrap",
zIndex: isDragging ? 2 : 0,
cursor: isDragging ? "grabbing" : "pointer",
outline: "none",
};
}, [isDragging, transform, isPinned]);
const nodeAttributes = useMemo(() => {
if (isPinned) {
return {};
}
return {
...listeners,
...attributes,
};
}, [attributes, isPinned, listeners]);
const handleDragStart = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
dragStartPosition.current = { x: e.clientX, y: e.clientY };
}, []);
const handleDragEnd = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (dragStartPosition.current) {
const dx = Math.abs(e.clientX - dragStartPosition.current.x);
const dy = Math.abs(e.clientY - dragStartPosition.current.y);
const isClicked = dx + dy < HEADER_DRAG_THRESHOLD;
const isClickTarget = headerClickTargetSelector
? !!(e.target as HTMLElement).closest(headerClickTargetSelector)
: true;
if (isClicked && onClick && isClickTarget) {
onClick(e, id);
}
dragStartPosition.current = null;
}
},
[id, onClick, headerClickTargetSelector],
);
const resizeHandler = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
header.getResizeHandler()(e);
e.stopPropagation();
},
[header],
);
return (
<div
ref={setNodeRef}
className={cx(S.root, className)}
style={style}
onMouseDown={handleDragStart}
onMouseUp={handleDragEnd}
>
<div className={S.headerContent} {...nodeAttributes}>
{children}
</div>
{canResize ? (
<div
className={S.resizeHandle}
onMouseDown={resizeHandler}
onTouchStart={resizeHandler}
onMouseOver={e => e.stopPropagation()}
/>
) : null}
</div>
);
}) as <TData, TValue>(
props: SortableHeaderProps<TData, TValue>,
) => React.ReactElement;

View File

@@ -0,0 +1 @@
export * from "./SortableHeader";

View File

@@ -0,0 +1,5 @@
export const ROW_HEIGHT = 36;
export const HEADER_HEIGHT = 36;
export const MIN_COLUMN_WIDTH = 60;
export const ROW_ID_COLUMN_ID = `\0_INDEX`;
export const TRUNCATE_LONG_CELL_WIDTH = 780;

View File

@@ -0,0 +1,89 @@
import { useCallback, useMemo, useRef } from "react";
import { BodyCell } from "metabase/data-grid/components/BodyCell";
import { EmotionCacheProvider } from "metabase/styled-components/components/EmotionCacheProvider";
import { ThemeProvider } from "metabase/ui";
export type CellMeasurer = (content: string, width?: number) => CellSize;
export interface CellSize {
width: number;
height: number;
}
export const useCellMeasure = (
cell: React.ReactNode,
contentNodeSelector: string,
) => {
const rootRef = useRef<HTMLDivElement>(null);
const measureRoot = useMemo(() => {
return (
<div
ref={rootRef}
style={{
position: "absolute",
top: "-9999px",
left: "-9999px",
visibility: "hidden",
pointerEvents: "none",
zIndex: -999,
}}
>
{cell}
</div>
);
}, [cell]);
const measureDimensions: CellMeasurer = useCallback(
(content: string, containerWidth?: number) => {
const rootEl = rootRef.current;
const contentCell = rootEl?.querySelector(contentNodeSelector);
if (!rootEl || !contentCell) {
throw new Error(
`Trying to measure content "${content}" before measure root mounted`,
);
}
rootEl.style.width =
containerWidth != null ? `${containerWidth}px` : "auto";
contentCell.textContent = content;
const boundingRect = rootEl.getBoundingClientRect();
return {
width: boundingRect.width,
height: boundingRect.height,
};
},
[contentNodeSelector],
);
return {
measureRoot,
measureDimensions,
};
};
export const useBodyCellMeasure = () => {
const bodyCellToMeasure = useMemo(
() => <BodyCell rowIndex={0} columnId="measure" wrap={true} value="" />,
[],
);
const {
measureDimensions: measureBodyCellDimensions,
measureRoot: measureBodyCellRoot,
} = useCellMeasure(bodyCellToMeasure, "[data-grid-cell-content]");
const measureRoot = useMemo(
() => (
<EmotionCacheProvider>
<ThemeProvider>{measureBodyCellRoot}</ThemeProvider>
</EmotionCacheProvider>
),
[measureBodyCellRoot],
);
return {
measureBodyCellDimensions,
measureRoot,
};
};

View File

@@ -0,0 +1,22 @@
import type { TableState } from "@tanstack/table-core";
import { useEffect, useRef } from "react";
export const useColumnResizeObserver = (
state: TableState,
onChange: (columnId: string, columnSize: number) => void,
) => {
const columnResizeRef = useRef<string | false>();
useEffect(() => {
if (
state.columnSizingInfo &&
!state.columnSizingInfo?.isResizingColumn &&
columnResizeRef.current
) {
onChange(
columnResizeRef.current,
state.columnSizing[columnResizeRef.current],
);
}
columnResizeRef.current = state.columnSizingInfo?.isResizingColumn;
}, [onChange, state.columnSizingInfo, state.columnSizing]);
};

View File

@@ -0,0 +1,121 @@
import {
type DragEndEvent,
type DragOverEvent,
type DragStartEvent,
KeyboardSensor,
MouseSensor,
type SensorDescriptor,
type SensorOptions,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { arrayMove } from "@dnd-kit/sortable";
import type { Table as ReactTable } from "@tanstack/react-table";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import _ from "underscore";
import { ROW_ID_COLUMN_ID } from "../constants";
export type ColumnsReordering = {
sensors: SensorDescriptor<SensorOptions>[];
onDragOver: (event: DragOverEvent) => void;
onDragEnd: (event: DragEndEvent) => void;
onDragStart: (event: DragStartEvent) => void;
isDragging: boolean;
};
export const useColumnsReordering = <TData,>(
table: ReactTable<TData>,
gridRef: React.RefObject<HTMLDivElement>,
onColumnReorder?: (columnNames: string[]) => void,
): ColumnsReordering => {
const prevOrder = useRef<string[] | null>(null);
const [isDragging, setIsDragging] = useState(false);
const previousScrollTop = useRef<number>(0);
const sensors = useSensors(
useSensor(MouseSensor),
useSensor(TouchSensor),
useSensor(KeyboardSensor),
);
useEffect(
function preserveScrollWhenDragging() {
if (!isDragging || !gridRef.current) {
return;
}
const gridElement = gridRef.current;
previousScrollTop.current = gridElement.scrollTop;
const handleScroll = () => {
if (gridElement.scrollTop !== previousScrollTop.current) {
gridElement.scrollTop = previousScrollTop.current;
}
};
gridElement.addEventListener("scroll", handleScroll, { passive: false });
return () => {
gridElement.removeEventListener("scroll", handleScroll);
gridElement.style.overflowY = "";
};
},
[isDragging, gridRef],
);
const onDragStart = useCallback(() => {
prevOrder.current = table.getState().columnOrder;
setIsDragging(true);
if (gridRef.current) {
previousScrollTop.current = gridRef.current.scrollTop;
gridRef.current.style.overflowY = "hidden";
}
}, [table, gridRef]);
const onDragOver = useCallback(
(event: DragOverEvent) => {
const { active, over } = event;
if (active && over && active.id !== over.id && !over.disabled) {
table.setColumnOrder(columnOrder => {
const oldIndex = columnOrder.indexOf(active.id as string);
const newIndex = columnOrder.indexOf(over.id as string);
return arrayMove(columnOrder, oldIndex, newIndex);
});
}
},
[table],
);
const onDragEnd = useCallback(() => {
setIsDragging(false);
// Restore scrolling
if (gridRef.current) {
gridRef.current.style.overflowY = "";
}
const newColumnOrder = table.getState().columnOrder;
if (!_.isEqual(newColumnOrder, prevOrder.current)) {
const dataColumns = newColumnOrder.filter(
columnName => columnName !== ROW_ID_COLUMN_ID,
);
onColumnReorder?.(dataColumns);
}
prevOrder.current = null;
}, [onColumnReorder, table, gridRef]);
return useMemo(
() => ({
onDragStart,
onDragOver,
onDragEnd,
sensors,
isDragging,
}),
[onDragEnd, onDragOver, onDragStart, sensors, isDragging],
);
};

View File

@@ -0,0 +1,273 @@
import {
type ColumnSizingState,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useUpdateEffect } from "react-use";
import _ from "underscore";
import type { DataGridProps } from "metabase/data-grid/components/DataGrid";
import {
MIN_COLUMN_WIDTH,
ROW_ID_COLUMN_ID,
TRUNCATE_LONG_CELL_WIDTH,
} from "metabase/data-grid/constants";
import { useBodyCellMeasure } from "metabase/data-grid/hooks/use-body-cell-measure";
import { useColumnResizeObserver } from "metabase/data-grid/hooks/use-column-resize-observer";
import { useColumnsReordering } from "metabase/data-grid/hooks/use-columns-reordering";
import { useMeasureColumnWidths } from "metabase/data-grid/hooks/use-measure-column-widths";
import { useVirtualGrid } from "metabase/data-grid/hooks/use-virtual-grid";
import type {
DataGridOptions,
ExpandedColumnsState,
} from "metabase/data-grid/types";
import { getDataColumn } from "metabase/data-grid/utils/columns/data-column";
import { getRowIdColumn } from "metabase/data-grid/utils/columns/row-id-column";
import { isNotNull } from "metabase/lib/types";
const getColumnOrder = (dataColumnsOrder: string[], hasRowIdColumn: boolean) =>
_.uniq(
hasRowIdColumn ? [ROW_ID_COLUMN_ID, ...dataColumnsOrder] : dataColumnsOrder,
);
export const useDataGridInstance = <TData, TValue>({
data,
columnOrder: controlledColumnOrder,
columnSizing: controlledColumnSizing,
defaultRowHeight = 36,
rowId,
truncateLongCellWidth = TRUNCATE_LONG_CELL_WIDTH,
columnsOptions,
onColumnResize,
onColumnReorder,
}: DataGridOptions<TData, TValue>): DataGridProps<TData> => {
const gridRef = useRef<HTMLDivElement>(null);
const refs = useMemo(() => ({ gridRef }), [gridRef]);
const hasRowIdColumn = rowId != null;
const [columnOrder, setColumnOrder] = useState<string[]>(
getColumnOrder(
controlledColumnOrder ?? columnsOptions.map(column => column.id),
hasRowIdColumn,
),
);
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>(
controlledColumnSizing ?? {},
);
const [measuredColumnSizing, setMeasuredColumnSizing] =
useState<ColumnSizingState>({});
const [expandedColumns, setExpandedColumns] = useState<ExpandedColumnsState>(
() => {
return columnsOptions.reduce(
(acc: ExpandedColumnsState, columnOptions) => {
acc[columnOptions.id] = false;
return acc;
},
{},
);
},
);
const { measureBodyCellDimensions, measureRoot } = useBodyCellMeasure();
useUpdateEffect(() => {
setColumnOrder(getColumnOrder(controlledColumnOrder ?? [], hasRowIdColumn));
}, [controlledColumnOrder, hasRowIdColumn]);
const handleUpdateColumnExpanded = useCallback(
(columnName: string, isExpanded = true) => {
setExpandedColumns(prev => {
return { ...prev, [columnName]: isExpanded };
});
},
[],
);
const handleExpandButtonClick = useCallback(
(columnName: string, content: React.ReactNode) => {
if (typeof content !== "string") {
throw new Error("Columns with rich formatting cannot be truncated");
}
const newColumnWidth = Math.max(
measureBodyCellDimensions(content).width,
measuredColumnSizing[columnName],
);
const newColumnSizing = { ...columnSizing, [columnName]: newColumnWidth };
setColumnSizing(newColumnSizing);
handleUpdateColumnExpanded(columnName);
},
[
columnSizing,
measureBodyCellDimensions,
measuredColumnSizing,
handleUpdateColumnExpanded,
],
);
const columns = useMemo(() => {
const rowIdColumnDefinition =
rowId != null ? getRowIdColumn<TData, TValue>(rowId) : null;
const dataColumns = columnsOptions.map(options =>
getDataColumn<TData, TValue>(
options,
columnSizing,
measuredColumnSizing,
expandedColumns,
truncateLongCellWidth,
handleExpandButtonClick,
),
);
return [rowIdColumnDefinition, ...dataColumns].filter(isNotNull);
}, [
rowId,
columnsOptions,
columnSizing,
measuredColumnSizing,
expandedColumns,
truncateLongCellWidth,
handleExpandButtonClick,
]);
const wrappedColumnsOptions = useMemo(() => {
return columnsOptions.filter(column => column.wrap);
}, [columnsOptions]);
const table = useReactTable({
data,
columns,
state: {
columnSizing,
columnOrder,
columnPinning: { left: [ROW_ID_COLUMN_ID] },
},
getCoreRowModel: getCoreRowModel(),
columnResizeMode: "onChange",
onColumnOrderChange: setColumnOrder,
onColumnSizingChange: setColumnSizing,
});
const measureRowHeight = useCallback(
(rowIndex: number) => {
if (wrappedColumnsOptions.length === 0) {
return defaultRowHeight;
}
const height = Math.max(
...wrappedColumnsOptions.map(column => {
const value = column.accessorFn(data[rowIndex]);
const formattedValue = column.formatter
? column.formatter(value, rowIndex, column.id)
: value;
if (
value == null ||
typeof formattedValue !== "string" ||
formattedValue === ""
) {
return defaultRowHeight;
}
const tableColumn = table.getColumn(column.id);
const cellDimensions = measureBodyCellDimensions(
formattedValue,
tableColumn?.getSize(),
);
return cellDimensions.height;
}),
defaultRowHeight,
);
return height;
},
[
data,
defaultRowHeight,
measureBodyCellDimensions,
table,
wrappedColumnsOptions,
],
);
const virtualGrid = useVirtualGrid({
gridRef,
table,
defaultRowHeight,
measureRowHeight,
});
const measureColumnWidths = useMeasureColumnWidths(
table,
data,
columnsOptions,
setMeasuredColumnSizing,
truncateLongCellWidth,
);
const { measureGrid } = virtualGrid;
const prevColumnSizing = useRef<ColumnSizingState>();
const prevWrappedColumns = useRef<string[]>();
useEffect(() => {
const didColumnSizingChange =
prevColumnSizing.current != null &&
!_.isEqual(prevColumnSizing.current, columnSizing);
const didColumnWrappingChange =
prevWrappedColumns.current != null &&
!_.isEqual(
wrappedColumnsOptions.map(column => column.id),
prevWrappedColumns.current,
);
if (didColumnSizingChange || didColumnWrappingChange) {
measureGrid();
}
prevColumnSizing.current = columnSizing;
prevWrappedColumns.current = wrappedColumnsOptions.map(column => column.id);
}, [columnSizing, measureGrid, wrappedColumnsOptions]);
const handleColumnResize = useCallback(
(columnName: string, width: number) => {
const newWidth = Math.max(MIN_COLUMN_WIDTH, width);
const newColumnSizing = { ...columnSizing, [columnName]: newWidth };
setColumnSizing(newColumnSizing);
if (newWidth > truncateLongCellWidth) {
handleUpdateColumnExpanded(columnName);
}
onColumnResize?.(newColumnSizing);
},
[
columnSizing,
onColumnResize,
handleUpdateColumnExpanded,
truncateLongCellWidth,
],
);
useColumnResizeObserver(table.getState(), handleColumnResize);
const columnsReordering = useColumnsReordering(
table,
gridRef,
onColumnReorder,
);
return {
table,
refs,
virtualGrid,
measureRoot,
columnsReordering,
measureColumnWidths,
};
};

View File

@@ -0,0 +1,204 @@
import {
type ColumnSizingState,
type Table as ReactTable,
flexRender,
} from "@tanstack/react-table";
import React, { useCallback, useEffect, useRef } from "react";
import type { Root } from "react-dom/client";
import type { ColumnOptions } from "metabase/data-grid/types";
import { pickRowsToMeasure } from "metabase/data-grid/utils/measure";
import { renderRoot } from "metabase/lib/react-compat";
import { isNotNull } from "metabase/lib/types";
import { EmotionCacheProvider } from "metabase/styled-components/components/EmotionCacheProvider";
import { ThemeProvider } from "metabase/ui";
const EXTRA_COLUMN_SPACING = 14;
const getTruncatedColumnSizing = (
columnSizing: ColumnSizingState,
truncateWidth: number,
): ColumnSizingState =>
Object.fromEntries(
Object.entries(columnSizing).map(([key, value]) => [
key,
Math.min(value, truncateWidth),
]),
);
export const useMeasureColumnWidths = <TData, TValue>(
table: ReactTable<TData>,
data: TData[],
columnsOptions: ColumnOptions<TData, TValue>[],
setMeasuredColumnSizing: (columnSizing: ColumnSizingState) => void,
truncateLongCellWidth: number,
) => {
const measureRootRef = useRef<HTMLDivElement>();
const measureRootTree = useRef<Root>();
const measureColumnWidths = useCallback(
(updateCurrent: boolean = true, truncate: boolean = true) => {
const onMeasureHeaderRender = (div: HTMLDivElement) => {
if (div === null) {
return;
}
const contentWidths = Array.from(
div.querySelectorAll("[data-measure-id]"),
)
.map(columnElement => {
const columnId = columnElement.getAttribute("data-measure-id");
if (columnId == null) {
return null;
}
const width =
(columnElement as HTMLElement).offsetWidth + EXTRA_COLUMN_SPACING;
return { columnId, width };
})
.filter(isNotNull);
const columnSizing: ColumnSizingState = contentWidths.reduce<
Record<string, number>
>(
(acc, { columnId, width }) => ({
...acc,
[columnId]: Math.max(width, acc[columnId] || 0),
}),
{},
);
setMeasuredColumnSizing(columnSizing);
if (updateCurrent) {
table.setColumnSizing(
truncate
? getTruncatedColumnSizing(columnSizing, truncateLongCellWidth)
: columnSizing,
);
}
// Schedule unmounting asynchronously instead of doing it during render
setTimeout(() => {
if (measureRootTree.current) {
measureRootTree.current.unmount();
measureRootTree.current = undefined;
}
}, 0);
};
const content = (
<EmotionCacheProvider>
<ThemeProvider>
<div style={{ display: "flex" }} ref={onMeasureHeaderRender}>
{table
.getHeaderGroups()
.flatMap(headerGroup => headerGroup.headers)
.map(header => {
const headerCell = flexRender(
header.column.columnDef.header,
header.getContext(),
);
return (
<div
key={header.column.id}
data-measure-id={header.column.id}
>
{headerCell}
</div>
);
})}
{columnsOptions.map(columnOptions => {
return (
<div
key={columnOptions.id}
data-measure-id={columnOptions.id}
>
{pickRowsToMeasure(data, columnOptions.accessorFn).map(
rowIndex => {
const cell = table
.getRowModel()
.rows[rowIndex].getVisibleCells()
.find(cell => cell.column.id === columnOptions.id);
if (!cell) {
return null;
}
return (
<React.Fragment
key={`${columnOptions.id}-${rowIndex}`}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</React.Fragment>
);
},
)}
</div>
);
})}
</div>
</ThemeProvider>
</EmotionCacheProvider>
);
// Instead of unmounting and creating a new root, reuse the existing root when possible
if (measureRootRef.current) {
if (measureRootTree.current) {
// If a root already exists, just update it
measureRootTree.current.render(content);
} else {
// Only create a new root if one doesn't exist
measureRootTree.current = renderRoot(content, measureRootRef.current);
}
}
},
[
columnsOptions,
data,
setMeasuredColumnSizing,
table,
truncateLongCellWidth,
],
);
useEffect(() => {
if (!measureRootRef.current) {
const measureRoot = document.createElement("div");
measureRoot.style.position = "absolute";
measureRoot.style.top = "-9999px";
measureRoot.style.left = "-9999px";
measureRoot.style.visibility = "hidden";
measureRoot.style.pointerEvents = "none";
measureRoot.style.zIndex = "-999";
document.body.appendChild(measureRoot);
measureRootRef.current = measureRoot;
}
const columnSizing = table.getState().columnSizing;
const shouldUpdateCurrentWidths =
!columnSizing || Object.values(columnSizing).length === 0;
measureColumnWidths(shouldUpdateCurrentWidths);
// Cleanup function
return () => {
if (measureRootTree.current) {
measureRootTree.current.unmount();
measureRootTree.current = undefined;
}
if (measureRootRef.current) {
document.body.removeChild(measureRootRef.current);
measureRootRef.current = undefined;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return measureColumnWidths;
};

View File

@@ -0,0 +1,101 @@
import type { Table as ReactTable } from "@tanstack/react-table";
import {
type VirtualItem,
type Virtualizer,
useVirtualizer,
} from "@tanstack/react-virtual";
import type React from "react";
import { useCallback, useMemo } from "react";
interface VirtualGridOptions<TData> {
gridRef: React.RefObject<HTMLDivElement>;
table: ReactTable<TData>;
measureRowHeight: (rowIndex: number) => number;
defaultRowHeight: number;
}
export interface VirtualGrid {
virtualColumns: VirtualItem[];
virtualRows: VirtualItem[];
virtualPaddingLeft: number | undefined;
virtualPaddingRight: number | undefined;
rowVirtualizer: Virtualizer<HTMLDivElement, Element>;
measureGrid: () => void;
}
export const useVirtualGrid = <TData,>({
gridRef,
table,
measureRowHeight,
defaultRowHeight,
}: VirtualGridOptions<TData>): VirtualGrid => {
const { rows: tableRows } = table.getRowModel();
const visibleColumns = table.getVisibleLeafColumns();
const columnVirtualizer = useVirtualizer({
count: visibleColumns.length,
getScrollElement: () => gridRef.current,
estimateSize: index => {
const column = visibleColumns[index];
const size = visibleColumns[index].getSize();
const actualSize = table.getState().columnSizing[column.id];
return actualSize ?? size;
},
horizontal: true,
overscan: 3,
});
const rowVirtualizer = useVirtualizer({
count: tableRows.length,
getScrollElement: () => gridRef.current,
estimateSize: () => defaultRowHeight,
overscan: 3,
measureElement: element => {
const rowIndexRaw = element?.getAttribute("data-index");
const rowIndex = rowIndexRaw != null ? parseInt(rowIndexRaw, 10) : null;
if (rowIndex == null || !isFinite(rowIndex)) {
return defaultRowHeight;
}
return measureRowHeight(rowIndex);
},
});
const virtualColumns = columnVirtualizer.getVirtualItems();
const virtualRows = rowVirtualizer.getVirtualItems();
let virtualPaddingLeft: number | undefined;
let virtualPaddingRight: number | undefined;
if (columnVirtualizer && virtualColumns?.length) {
virtualPaddingLeft = virtualColumns[0]?.start ?? 0;
virtualPaddingRight =
columnVirtualizer.getTotalSize() -
(virtualColumns[virtualColumns.length - 1]?.end ?? 0);
}
const measureGrid = useCallback(() => {
Array.from(rowVirtualizer.elementsCache.values()).forEach(el =>
rowVirtualizer.measureElement(el),
);
columnVirtualizer.measure();
}, [rowVirtualizer, columnVirtualizer]);
return useMemo(
() => ({
virtualColumns,
virtualRows,
virtualPaddingLeft,
virtualPaddingRight,
rowVirtualizer,
measureGrid,
}),
[
virtualColumns,
virtualRows,
virtualPaddingLeft,
virtualPaddingRight,
rowVirtualizer,
measureGrid,
],
);
};

View File

@@ -0,0 +1,6 @@
export * from "./components/DataGrid";
export * from "./components/BaseCell";
export * from "./components/HeaderCell";
export * from "./components/BodyCell";
export * from "./types";
export * from "./hooks/use-data-grid-instance";

View File

@@ -0,0 +1,173 @@
import type {
CellContext,
ColumnDefTemplate,
ColumnSizingState,
HeaderContext,
RowData,
Table,
} from "@tanstack/react-table";
import type React from "react";
import type { RefObject } from "react";
import type { ColumnsReordering } from "./hooks/use-columns-reordering";
import type { VirtualGrid } from "./hooks/use-virtual-grid";
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData, TValue> {
wrap?: boolean;
enableReordering?: boolean;
headerClickTargetSelector?: string;
}
}
export type HeaderCellBaseProps = {
name?: React.ReactNode;
align?: CellAlign;
sort?: "asc" | "desc";
};
export type BodyCellBaseProps<TValue> = {
value: TValue;
formatter?: CellFormatter<TValue>;
backgroundColor?: string;
align?: CellAlign;
wrap?: boolean;
canExpand?: boolean;
columnId: string;
rowIndex: number;
className?: string;
onExpand?: (id: string, formattedValue: React.ReactNode) => void;
};
/**
* Configuration options for a table column
* @template TRow The type of the row data
* @template TValue The type of the value in this column
*/
export interface ColumnOptions<TRow extends RowData, TValue = unknown> {
/** Unique identifier for the column */
id: string;
/** Display name for the column header */
name: string;
/** Function to extract the cell value from a row */
accessorFn: (row: TRow) => TValue;
/** Custom cell render template */
cell?: ColumnDefTemplate<CellContext<TRow, TValue>>;
/** Custom header render template */
header?: ColumnDefTemplate<HeaderContext<TRow, TValue>>;
/** Visual style of the body cells */
cellVariant?: BodyCellVariant;
/** Function to determine CSS class names for cells */
getCellClassName?: (value: TValue, rowIndex: number) => string;
/** Visual style of the header cell */
headerVariant?: HeaderCellVariant;
/** CSS selector for the clickable area in header */
headerClickTargetSelector?: string;
/** Text alignment within cells */
align?: CellAlign;
/** Whether text should wrap in this column */
wrap?: boolean;
/** Initial sort direction for this column */
sortDirection?: "asc" | "desc";
/** Whether this column can be resized */
enableResizing?: boolean;
/** Function to determine background color for cells */
getBackgroundColor?: (value: TValue, rowIndex: number) => string;
/** Function to format cell values for display */
formatter?: CellFormatter<TValue>;
}
/**
* Configuration for the row ID column
*/
export interface RowIdColumnOptions {
/** Display style of the row ID column */
variant: RowIdVariant;
/** Function to determine background color for the ID cells */
getBackgroundColor?: (rowIndex: number) => string;
}
/**
* Configuration options for the table
*/
export interface DataGridOptions<TData = any, TValue = any> {
/** Array of data to display in the table */
data: TData[];
/** Order of columns by ID */
columnOrder?: string[];
/** Width of each column by ID */
columnSizing?: ColumnSizingState;
/** Default row height in pixels */
defaultRowHeight?: number;
/** Configuration for columns */
columnsOptions: ColumnOptions<TData, TValue>[];
/** Row ID accessor and display options */
rowId?: RowIdColumnOptions;
/** Width in pixels at which to truncate long cell content */
truncateLongCellWidth?: number;
/** Callback when a column is resized */
onColumnResize?: (columnSizing: ColumnSizingState) => void;
/** Callback when columns are reordered */
onColumnReorder?: (columnOrder: string[]) => void;
}
export type CellAlign = "left" | "middle" | "right";
export type BodyCellVariant = "text" | "pill";
export type HeaderCellVariant = "light" | "outline";
export type RowIdVariant = "indexExpand" | "expandButton";
export type CellFormatter<TValue> = (
value: TValue,
rowIndex: number,
columnId: string,
) => React.ReactNode;
export type ExpandedColumnsState = Record<string, boolean>;
export interface DataGridRefs {
gridRef: RefObject<HTMLDivElement>;
}
export interface DataGridInstance<TData> {
table: Table<TData>;
refs: DataGridRefs;
virtualGrid: VirtualGrid;
measureRoot: React.ReactNode;
columnsReordering: ColumnsReordering;
measureColumnWidths: () => void;
onHeaderCellClick?: (
event: React.MouseEvent<HTMLDivElement>,
columnId?: string,
) => void;
onBodyCellClick?: (
event: React.MouseEvent<HTMLDivElement>,
rowIndex: number,
columnId: string,
) => void;
onAddColumnClick?: React.MouseEventHandler<HTMLButtonElement>;
onScroll?: React.UIEventHandler<HTMLDivElement>;
}

View File

@@ -0,0 +1,112 @@
import type {
CellContext,
ColumnDef,
ColumnSizingState,
} from "@tanstack/react-table";
import type React from "react";
import { memo } from "react";
import { BodyCell } from "metabase/data-grid/components/BodyCell";
import { HeaderCell } from "metabase/data-grid/components/HeaderCell";
import { MIN_COLUMN_WIDTH } from "metabase/data-grid/constants";
import type {
ColumnOptions,
ExpandedColumnsState,
} from "metabase/data-grid/types";
const getDefaultCellTemplate = <TRow, TValue>(
{
id,
align,
getBackgroundColor,
formatter,
cellVariant,
wrap,
getCellClassName,
}: ColumnOptions<TRow, TValue>,
isTruncated: boolean,
onExpand: (columnName: string, content: React.ReactNode) => void,
) => {
return function Cell({ getValue, row }: CellContext<TRow, TValue>) {
const value = getValue();
const backgroundColor = getBackgroundColor?.(value, row?.index);
return (
<BodyCell
rowIndex={row.index}
columnId={id}
value={value}
align={align}
canExpand={!wrap && isTruncated}
formatter={formatter}
backgroundColor={backgroundColor}
onExpand={onExpand}
variant={cellVariant}
wrap={wrap}
className={getCellClassName?.(value, row.index)}
/>
);
};
};
const getDefaultHeaderTemplate = <TRow, TValue>({
name,
align,
sortDirection,
headerVariant,
}: ColumnOptions<TRow, TValue>) => {
return function Header() {
return (
<HeaderCell
name={name}
align={align}
sort={sortDirection}
variant={headerVariant}
/>
);
};
};
export const getDataColumn = <TRow, TValue>(
columnOptions: ColumnOptions<TRow, TValue>,
columnSizing: ColumnSizingState,
measuredColumnSizing: ColumnSizingState,
expandedColumns: ExpandedColumnsState,
truncateWidth: number,
onExpand: (columnName: string, content: React.ReactNode) => void,
): ColumnDef<TRow, TValue> => {
const { id, accessorFn, wrap, cell, header, headerClickTargetSelector } =
columnOptions;
const columnWidth = columnSizing[id] ?? 0;
const measuredColumnWidth = measuredColumnSizing[id] ?? 0;
const isTruncated =
!expandedColumns[id] &&
columnWidth < measuredColumnWidth &&
measuredColumnWidth > truncateWidth;
const columnDefinition: ColumnDef<TRow, TValue> = {
accessorFn,
id,
header:
typeof header !== "string"
? memo(header ?? getDefaultHeaderTemplate(columnOptions))
: header,
cell:
typeof cell !== "string"
? memo(
cell ??
getDefaultCellTemplate(columnOptions, isTruncated, onExpand),
)
: cell,
minSize: MIN_COLUMN_WIDTH,
enableResizing: true,
meta: {
wrap,
enableReordering: true,
headerClickTargetSelector,
},
};
return columnDefinition;
};

View File

@@ -0,0 +1,39 @@
import type { ColumnDef } from "@tanstack/react-table";
import { RowIdCell } from "metabase/data-grid/components/RowIdCell";
import { RowIdHeaderCell } from "metabase/data-grid/components/RowIdHeaderCell";
import { ROW_ID_COLUMN_ID } from "metabase/data-grid/constants";
import type {
RowIdColumnOptions,
RowIdVariant,
} from "metabase/data-grid/types";
export const getRowIdColumnSize = (variant: RowIdVariant) =>
variant === "expandButton" ? 36 : 46;
export const getRowIdColumn = <TRow, TValue>({
variant,
getBackgroundColor,
}: RowIdColumnOptions): ColumnDef<TRow, TValue> => {
const shouldShowIndex = ["indexOnly", "indexExpand"].includes(variant);
return {
accessorFn: (_row, index) => index as TValue,
id: ROW_ID_COLUMN_ID,
size: getRowIdColumnSize(variant),
enableSorting: false,
enableResizing: false,
enablePinning: true,
cell: ({ row }) => {
const value = shouldShowIndex ? row.index + 1 : null;
return (
<RowIdCell
value={value}
backgroundColor={getBackgroundColor?.(row.index)}
/>
);
},
header: () => {
return <RowIdHeaderCell name={shouldShowIndex ? "#" : ""} />;
},
};
};

View File

@@ -0,0 +1,17 @@
export const pickRowsToMeasure = <TData, TValue>(
data: TData[],
accessorFn: (row: TData) => TValue,
count = 10,
) => {
const rowIndexes = [];
for (
let rowIndex = 0;
rowIndex < data.length && rowIndexes.length < count;
rowIndex++
) {
if (accessorFn(data[rowIndex]) != null) {
rowIndexes.push(rowIndex);
}
}
return rowIndexes;
};

View File

@@ -0,0 +1,67 @@
import { pickRowsToMeasure } from "./measure";
describe("pickRowsToMeasure", () => {
const createData = (values: (string | null | undefined)[]) =>
values.map(value => ({ value }));
const accessorFn = (row: { value: string | null | undefined }) => row.value;
it("should return an empty array for empty data", () => {
const data: { value: string | null | undefined }[] = [];
const result = pickRowsToMeasure(data, accessorFn);
expect(result).toEqual([]);
});
it("should return an empty array if all rows have null or undefined values", () => {
const data = createData([null, undefined, null]);
const result = pickRowsToMeasure(data, accessorFn);
expect(result).toEqual([]);
});
it("should return indexes of rows with non-null values", () => {
const data = createData(["a", null, "b", undefined, "c"]);
const result = pickRowsToMeasure(data, accessorFn);
expect(result).toEqual([0, 2, 4]);
});
it("should return only up to count indexes", () => {
const data = createData(["a", "b", "c", "d", "e", "f"]);
const result = pickRowsToMeasure(data, accessorFn, 3);
expect(result).toEqual([0, 1, 2]);
});
it("should handle default count of 10", () => {
const values = Array.from({ length: 15 }, (_, i) => `value-${i}`);
const data = createData(values);
const result = pickRowsToMeasure(data, accessorFn);
expect(result).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
it("should limit results to specified count", () => {
const data = createData([
null,
"a",
null,
"b",
"c",
null,
"d",
"e",
"f",
null,
"g",
"h",
"i",
"j",
"k",
]);
const result = pickRowsToMeasure(data, accessorFn, 5);
expect(result).toEqual([1, 3, 4, 6, 7]);
});
});

View File

@@ -0,0 +1,56 @@
import { useMemo } from "react";
type MapLike<K, V> = Map<K, V> | WeakMap<object & K, V>;
function getWithFallback<K, V>(
map: MapLike<K, V>,
key: K,
fallback: () => V,
): V {
if ("has" in map && map.has(key as any)) {
return map.get(key as any)!;
} else {
const value = fallback();
map.set(key as any, value);
return value;
}
}
type CacheValue = unknown;
type CacheMap = Map<unknown, CacheValue | CacheMap>;
const rootCache = new WeakMap<object, CacheMap>();
const createMap = () => new Map() as CacheMap;
export function memoize<T extends (...args: any[]) => any>(fn: T): T {
const memoized = (...args: Parameters<T>): ReturnType<T> => {
const [first, ...rest] = [fn, args.length, ...args];
const lastKey = rest.pop()!;
let currentMap = getWithFallback(rootCache, first, createMap);
for (const key of rest) {
const nextMap = getWithFallback(currentMap, key, createMap);
if (nextMap instanceof Map) {
currentMap = nextMap;
} else {
throw new Error("Invalid cache structure");
}
}
return getWithFallback(currentMap, lastKey, () => fn(...args));
};
return memoized as T;
}
export function useMemoizedCallback<T extends (...args: any[]) => any>(
callback: T,
deps: React.DependencyList = [],
): T {
return useMemo(() => {
return memoize((...args: Parameters<T>): ReturnType<T> => {
return callback(...args);
}) as T;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}

View File

@@ -0,0 +1,82 @@
import { memoize } from "./use-memoized-callback";
describe("memoize", () => {
it("should return same result for same arguments", () => {
const add = jest.fn((a: number, b: number) => a + b);
const memoizedAdd = memoize(add);
expect(memoizedAdd(1, 2)).toBe(3);
expect(add).toHaveBeenCalledTimes(1);
expect(add).toHaveBeenLastCalledWith(1, 2);
expect(memoizedAdd(1, 2)).toBe(3);
expect(add).toHaveBeenCalledTimes(1);
});
it("should handle different argument combinations", () => {
const add = jest.fn((a: number, b: number) => a + b);
const memoizedAdd = memoize(add);
expect(memoizedAdd(1, 2)).toBe(3);
expect(memoizedAdd(2, 3)).toBe(5);
expect(memoizedAdd(1, 2)).toBe(3);
expect(add).toHaveBeenCalledTimes(2);
expect(add).toHaveBeenNthCalledWith(1, 1, 2);
expect(add).toHaveBeenNthCalledWith(2, 2, 3);
});
it("should handle functions with different argument lengths", () => {
const sum = jest.fn((...args: number[]) => args.reduce((a, b) => a + b, 0));
const memoizedSum = memoize(sum);
expect(memoizedSum(1)).toBe(1);
expect(memoizedSum(1, 2)).toBe(3);
expect(memoizedSum(1)).toBe(1);
expect(sum).toHaveBeenCalledTimes(2);
expect(sum).toHaveBeenNthCalledWith(1, 1);
expect(sum).toHaveBeenNthCalledWith(2, 1, 2);
});
it("should handle object arguments", () => {
const processObject = jest.fn((obj: { x: number }) => obj.x * 2);
const memoizedProcess = memoize(processObject);
const obj = { x: 5 };
expect(memoizedProcess(obj)).toBe(10);
expect(memoizedProcess(obj)).toBe(10);
expect(memoizedProcess({ x: 5 })).toBe(10);
expect(processObject).toHaveBeenCalledTimes(2);
expect(processObject).toHaveBeenNthCalledWith(1, obj);
expect(processObject).toHaveBeenNthCalledWith(2, { x: 5 });
});
it("should handle functions as arguments", () => {
const compose = jest.fn((f: (x: number) => number, x: number) => f(x));
const double = (x: number) => x * 2;
const memoizedCompose = memoize(compose);
expect(memoizedCompose(double, 5)).toBe(10);
expect(memoizedCompose(double, 5)).toBe(10);
expect(compose).toHaveBeenCalledTimes(1);
expect(compose).toHaveBeenCalledWith(double, 5);
});
it("should handle null and undefined arguments", () => {
const processNullable = jest.fn((a: null | undefined | number) =>
a === null ? "null" : a === undefined ? "undefined" : "number",
);
const memoizedProcess = memoize(processNullable);
expect(memoizedProcess(null)).toBe("null");
expect(memoizedProcess(undefined)).toBe("undefined");
expect(memoizedProcess(null)).toBe("null");
expect(processNullable).toHaveBeenCalledTimes(2);
expect(processNullable).toHaveBeenNthCalledWith(1, null);
expect(processNullable).toHaveBeenNthCalledWith(2, undefined);
});
});

View File

@@ -0,0 +1,25 @@
import { useCallback, useEffect, useState } from "react";
export const useMousePressed = () => {
const [isMousePressed, setIsMousePressed] = useState(false);
const handleMouseDown = useCallback(() => {
setIsMousePressed(true);
}, []);
const handleMouseUp = useCallback(() => {
setIsMousePressed(false);
}, []);
useEffect(() => {
window.addEventListener("mousedown", handleMouseDown);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousedown", handleMouseDown);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [handleMouseDown, handleMouseUp]);
return isMousePressed;
};

View File

@@ -76,7 +76,7 @@
flex-direction: row;
align-items: center;
min-width: 35px;
margin: 24px 0.75em;
height: 26px;
padding: 3px 0.5rem;
white-space: nowrap;
text-overflow: ellipsis;

View File

@@ -706,6 +706,8 @@ export const getRawSeries = createSelector(
visualization_settings: {
...card.visualization_settings,
"table.pivot": false,
"table.column_formatting": [],
column_settings: {},
},
},
},

View File

@@ -1,112 +1,5 @@
.TableInteractive {
color: #4f575d;
overflow: hidden;
}
.TableInteractiveHeaderCellData .cellData {
font-weight: 900;
border: 1px solid transparent;
padding: 0.25em 0.65em;
border-radius: 6px;
min-width: 35px;
}
.TableInteractiveHeaderCellData .cellData:hover {
border: 1px solid transparent;
}
.TableInteractiveHeaderCellData :global(.Icon-chevrondown),
.TableInteractiveHeaderCellData :global(.Icon-chevronup) {
opacity: 0.5;
}
.TableInteractiveHeader {
box-sizing: border-box;
border-bottom: 1px solid var(--mb-color-border);
}
.TableInteractive .TableInteractiveCellWrapper {
overflow: hidden;
display: flex;
align-items: center;
border-top: 1px solid transparent;
border-left: 1px solid transparent;
border-right: 1px solid transparent;
border-bottom: 1px solid var(--mb-color-border-alpha-30);
}
.TableInteractive .TableInteractiveHeader,
.TableInteractive .TableInteractiveHeader .TableInteractiveCellWrapper,
.TableInteractive .TableInteractiveHeader .TableInteractiveCellWrapper:hover {
background-color: var(--mb-color-bg-white);
background-image: none;
}
.TableInteractive .TableInteractiveHeader,
.TableInteractive .TableInteractiveHeader .TableInteractiveCellWrapper {
background-color: var(--mb-color-bg-white);
}
/* cell overflow ellipsis */
.TableInteractive .cellData {
margin: 0 0.75em;
display: block;
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
font-weight: 700;
}
/* pivot */
.TableInteractive.TableInteractivePivot
.TableInteractiveCellWrapperFirstColumn {
border-right: 1px solid var(--mb-color-border);
}
.TableInteractive
.TableInteractiveHeader
.TableInteractiveCellWrapper:global(.tether-enabled)
.cellData {
background-color: transparent;
color: white !important;
}
.TableInteractiveCellWrapper:hover {
background-color: transparent;
}
.TableID .cellData {
border: 1px solid transparent;
padding: 0.25em 0.65em;
border-radius: 99px;
min-width: 35px;
text-align: center;
}
.TableInteractiveHeaderCellDataSorted :global(.Icon-chevrondown),
.TableInteractiveHeaderCellDataSorted :global(.Icon-chevronup) {
opacity: 1;
transition: opacity 0.3s linear;
}
.TableInteractiveCellWrapperFirstColumn.padLeft {
padding-left: 1.5em;
}
.shortcutsWrapper {
position: absolute;
z-index: 50;
top: 0;
background: var(--mb-color-bg-white);
display: flex;
justify-content: center;
align-items: center;
}
.isOverflowing {
border-left: 1px solid var(--mb-color-border);
}
.fill {
.root {
width: 100%;
height: 100%;
overflow: hidden;
}

View File

@@ -1,27 +0,0 @@
import type { StoryFn } from "@storybook/react";
import { VisualizationWrapper } from "__support__/storybook";
import { Box } from "metabase/ui";
import { registerVisualization } from "metabase/visualizations";
import Visualization from "metabase/visualizations/components/Visualization";
import Table from "metabase/visualizations/visualizations/Table";
import RAW_SERIES from "../TableSimple/stories-data/table-simple-orders-with-people.json";
import TableInteractive from "./TableInteractive";
// @ts-expect-error: incompatible prop types with registerVisualization
registerVisualization(Table);
export default {
title: "viz/TableInteractive",
component: TableInteractive,
};
export const Default: StoryFn = () => (
<VisualizationWrapper>
<Box h={500}>
<Visualization rawSeries={RAW_SERIES} />,
</Box>
</VisualizationWrapper>
);

View File

@@ -1,73 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { css } from "@emotion/react";
// eslint-disable-next-line no-restricted-imports
import styled from "@emotion/styled";
import Draggable from "react-draggable";
import Button from "metabase/core/components/Button";
import { alpha, lighten } from "metabase/lib/colors";
import { Box } from "metabase/ui";
import TableS from "./TableInteractive.module.css";
import { getCellHoverBackground } from "./table-theme-utils";
export const TableInteractiveRoot = styled(Box)`
.${TableS.TableInteractiveHeaderCellData} .${TableS.cellData} {
border: 1px solid ${({ theme }) => alpha(theme.fn.themeColor("brand"), 0.2)};
}
.${TableS.TableInteractiveHeaderCellData} .${TableS.cellData}:hover {
border: 1px solid
${({ theme }) => alpha(theme.fn.themeColor("brand"), 0.56)};
}
.${TableS.TableInteractiveCellWrapper}:hover {
background-color: ${getCellHoverBackground};
}
` as unknown as typeof Box;
interface TableDraggableProps {
enableCustomUserSelectHack?: boolean;
}
// this wrapper adds prop-types warning because of the nature of emotion component
// TableDraggable doesn't allow className, style to be passed, but emotion does it
// TODO: drop this style definition and maybe move styles to the global stylesheet
export const TableDraggable = styled(Draggable)<TableDraggableProps>`
${props =>
props.enableCustomUserSelectHack &&
css`
.react-draggable-transparent-selection *::-moz-selection {
all: inherit;
}
.react-draggable-transparent-selection *::selection {
all: inherit;
}
`}
`;
export const ResizeHandle = styled.div`
&:active {
background-color: var(--mb-color-brand);
}
&:hover {
background-color: var(--mb-color-brand);
}
`;
export const ExpandButton = styled(Button)`
border: 1px solid
${({ theme }) => lighten(theme.fn?.themeColor("brand"), 0.3)};
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
color: var(--mb-color-brand);
margin-right: 0.5rem;
margin-left: auto;
&:hover {
color: var(--mb-color-text-white);
background-color: var(--mb-color-brand);
}
`;

View File

@@ -0,0 +1,582 @@
import type { CellContext, ColumnSizingState } from "@tanstack/react-table";
import cx from "classnames";
import type React from "react";
import {
type Ref,
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
} from "react";
import _ from "underscore";
import ExplicitSize from "metabase/components/ExplicitSize";
import ExternalLink from "metabase/core/components/ExternalLink";
import { DataGrid } from "metabase/data-grid";
import { ROW_ID_COLUMN_ID } from "metabase/data-grid/constants";
import { useDataGridInstance } from "metabase/data-grid/hooks/use-data-grid-instance";
import type {
BodyCellVariant,
ColumnOptions,
RowIdColumnOptions,
} from "metabase/data-grid/types";
import { withMantineTheme } from "metabase/hoc/MantineTheme";
import {
memoize,
useMemoizedCallback,
} from "metabase/hooks/use-memoized-callback";
import { formatValue } from "metabase/lib/formatting";
import { useDispatch, useSelector } from "metabase/lib/redux";
import {
getIsShowingRawTable,
getQueryBuilderMode,
} from "metabase/query_builder/selectors";
import { getIsEmbeddingSdk } from "metabase/selectors/embed";
import type { MantineTheme } from "metabase/ui";
import {
getTableCellClickedObject,
getTableClickedObjectRowData,
getTableHeaderClickedObject,
} from "metabase/visualizations/lib/table";
import { getColumnExtent } from "metabase/visualizations/lib/utils";
import type {
QueryClickActionsMode,
VisualizationProps,
} from "metabase/visualizations/types";
import type { ClickObject, OrderByDirection } from "metabase-lib/types";
import type Question from "metabase-lib/v1/Question";
import { isFK, isID, isPK } from "metabase-lib/v1/types/utils/isa";
import type {
DatasetColumn,
RowValue,
RowValues,
VisualizationSettings,
} from "metabase-types/api";
import S from "./TableInteractive.module.css";
import {
HeaderCellWithColumnInfo,
type HeaderCellWithColumnInfoProps,
} from "./cells/HeaderCellWithColumnInfo";
import { MiniBarCell } from "./cells/MiniBarCell";
import { useObjectDetail } from "./hooks/use-object-detail";
const getBodyCellVariant = (column: DatasetColumn): BodyCellVariant => {
const isPill = isPK(column) || isFK(column);
if (isPill) {
return "pill";
}
return "text";
};
const getColumnIdFromPivotedColumnId = (pivotedColumnId: string) =>
pivotedColumnId.split(":")[0];
interface TableProps extends VisualizationProps {
rowIndexToPkMap?: Record<number, string>;
isPivoted?: boolean;
hasMetadataPopovers?: boolean;
question: Question;
mode: QueryClickActionsMode;
scrollToColumn?: number;
scrollToLastColumn?: boolean;
theme: MantineTheme;
getColumnTitle: (columnIndex: number) => string;
getColumnSortDirection: (columnIndex: number) => OrderByDirection | undefined;
renderTableHeaderWrapper: HeaderCellWithColumnInfoProps["renderTableHeaderWrapper"];
onUpdateVisualizationSettings: (settings: VisualizationSettings) => void;
onZoomRow?: (objectId: number | string) => void;
}
const getColumnOrder = (cols: DatasetColumn[], hasIndexColumn: boolean) => {
const dataColumns = cols.map(col => col.name);
if (!hasIndexColumn) {
return dataColumns;
}
return [ROW_ID_COLUMN_ID, ...dataColumns];
};
const getColumnSizing = (
cols: DatasetColumn[],
widths: number[] = [],
): ColumnSizingState => {
return cols.reduce((acc: ColumnSizingState, column, index) => {
const width = widths[index];
if (width != null) {
acc[column.name] = width;
}
return acc;
}, {});
};
export const TableInteractiveInner = forwardRef(function TableInteractiveInner(
{
className,
data,
series,
height,
settings,
width,
isPivoted = false,
question,
clicked,
getColumnTitle,
hasMetadataPopovers = true,
mode,
theme,
renderTableHeaderWrapper,
visualizationIsClickable,
getColumnSortDirection,
onVisualizationClick,
onUpdateVisualizationSettings,
scrollToColumn,
scrollToLastColumn,
}: TableProps,
ref: Ref<HTMLDivElement>,
) {
const dispatch = useDispatch();
const queryBuilderMode = useSelector(getQueryBuilderMode);
const isEmbeddingSdk = useSelector(getIsEmbeddingSdk);
const isRawTable = useSelector(getIsShowingRawTable);
const { rows, cols } = data;
const prevColNamesRef = useRef<Set<string>>(
new Set(cols.map(col => col.name)),
);
const columnOrder = useMemo(() => {
return getColumnOrder(cols, settings["table.row_index"]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cols, settings["table.row_index"]]);
const columnSizing = useMemo(() => {
return getColumnSizing(cols, settings["table.column_widths"]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cols, settings["table.column_widths"]]);
const onOpenObjectDetail = useObjectDetail(data);
const getIsCellClickable = useMemoizedCallback(
(clicked: ClickObject) => {
return visualizationIsClickable(clicked);
},
[onVisualizationClick, visualizationIsClickable],
);
const getCellClickedObject = useMemoizedCallback(
(datasetColumnIndex: number, rowIndex: number) => {
const clickedRowData = getTableClickedObjectRowData(
series as any,
rowIndex,
datasetColumnIndex,
isPivoted,
data,
);
const clicked = getTableCellClickedObject(
data,
settings,
rowIndex,
datasetColumnIndex,
isPivoted,
clickedRowData,
);
return clicked;
},
[series, isPivoted, data, settings],
);
const columnFormatters = useMemo(() => {
return cols.map(col => {
const columnSettings = settings.column?.(col);
const columnIndex = cols.findIndex(c => c.name === col.name);
return memoize((value, rowIndex) => {
const clicked = getCellClickedObject(columnIndex, rowIndex);
return formatValue(value, {
...columnSettings,
type: "cell",
jsx: true,
rich: true,
clicked,
});
});
});
}, [cols, settings, getCellClickedObject]);
const handleBodyCellClick = useCallback(
(
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
rowIndex: number,
columnId: string,
) => {
if (isPivoted) {
columnId = getColumnIdFromPivotedColumnId(columnId);
}
if (columnId === ROW_ID_COLUMN_ID) {
onOpenObjectDetail(rowIndex);
return;
}
const columnIndex = data.cols.findIndex(col => col.name === columnId);
const formatter = columnFormatters[columnIndex];
const formattedValue = formatter(
data.rows[rowIndex][columnIndex],
rowIndex,
);
const clicked = getCellClickedObject(columnIndex, rowIndex);
const isLink = (formattedValue as any)?.type === ExternalLink;
if (getIsCellClickable(clicked) && !isLink) {
onVisualizationClick?.({
...clicked,
element: event.currentTarget,
});
}
},
[
data,
isPivoted,
columnFormatters,
getIsCellClickable,
getCellClickedObject,
onOpenObjectDetail,
onVisualizationClick,
],
);
const handleHeaderCellClick = useCallback(
(
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
columnId?: string,
) => {
if (!columnId) {
return;
}
if (isPivoted) {
columnId = getColumnIdFromPivotedColumnId(columnId);
}
const columnIndex = data.cols.findIndex(col => col.name === columnId);
if (columnIndex === -1) {
return;
}
const clicked = getTableHeaderClickedObject(data, columnIndex, isPivoted);
onVisualizationClick({ ...clicked, element: event.currentTarget });
},
[data, isPivoted, onVisualizationClick],
);
const handleColumnReordering = useCallback(
(columnsOrder: string[]) => {
const result = settings["table.columns"]?.slice() ?? [];
const enabledIndices = result
.map((col, index) => (col.enabled ? index : -1))
.filter(index => index !== -1);
columnsOrder.forEach((columnName, orderIndex) => {
const sourceIndex = result.findIndex(col => col.name === columnName);
if (sourceIndex !== -1) {
const targetIndex = enabledIndices[orderIndex];
const [column] = result.splice(sourceIndex, 1);
result.splice(targetIndex, 0, column);
if (sourceIndex > targetIndex) {
for (let i = orderIndex + 1; i < enabledIndices.length; i++) {
enabledIndices[i]++;
}
}
}
});
const settingsUpdate = {
"table.columns": result,
};
onUpdateVisualizationSettings(settingsUpdate);
},
[onUpdateVisualizationSettings, settings],
);
const handleAddColumnButtonClick = useMemo(() => {
if (!question || !mode?.clickActions || !onVisualizationClick) {
return undefined;
}
for (const action of mode.clickActions) {
const res = action({
question,
clicked: {
columnShortcuts: true,
extraData: {
isRawTable,
},
},
});
if (res?.length > 0) {
return (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
onVisualizationClick({
columnShortcuts: true,
element: e.currentTarget,
});
};
}
}
}, [isRawTable, mode, onVisualizationClick, question]);
const columnsOptions: ColumnOptions<RowValues, RowValue>[] = useMemo(() => {
return cols.map((col, columnIndex) => {
const columnSettings = settings.column?.(col) ?? {};
const wrap = Boolean(columnSettings["text_wrapping"]);
const isMinibar = columnSettings["show_mini_bar"];
const cellVariant = getBodyCellVariant(col);
const headerVariant = mode != null ? "light" : "outline";
const getBackgroundColor = memoize((value: RowValue, rowIndex: number) =>
settings["table._cell_background_getter"]?.(value, rowIndex, col.name),
);
const formatter = columnFormatters[columnIndex];
const columnName = getColumnTitle(columnIndex);
let align;
let id;
let sortDirection: "asc" | "desc" | undefined;
if (isPivoted) {
align = columnIndex === 0 ? "right" : columnSettings["text_align"];
id = `${col.name}:${columnIndex}`;
} else {
align = columnSettings["text_align"];
id = col.name;
sortDirection = getColumnSortDirection(columnIndex);
}
const options: ColumnOptions<RowValues, RowValue> = {
id,
name: columnName,
accessorFn: (row: RowValues) => row[columnIndex],
cellVariant,
getCellClassName: value =>
cx("test-TableInteractive-cellWrapper", {
"test-Table-ID": value != null && isID(col),
"test-Table-FK": value != null && isFK(col),
"test-TableInteractive-cellWrapper--firstColumn": columnIndex === 0,
"test-TableInteractive-cellWrapper--lastColumn":
columnIndex === cols.length - 1,
"test-TableInteractive-emptyCell": value == null,
}),
header: () => {
return (
<HeaderCellWithColumnInfo
infoPopoversDisabled={!hasMetadataPopovers}
timezone={data.results_timezone}
question={question}
column={col}
name={columnName}
align={align}
sort={sortDirection}
variant={headerVariant}
columnIndex={columnIndex}
theme={theme}
renderTableHeaderWrapper={renderTableHeaderWrapper}
/>
);
},
headerClickTargetSelector: "[data-header-click-target]",
align,
wrap,
sortDirection,
enableResizing: true,
getBackgroundColor,
formatter,
};
if (isMinibar) {
options.cell = ({
getValue,
row,
}: CellContext<RowValues, RowValue>) => {
const value = getValue();
const backgroundColor = getBackgroundColor(value, row?.index);
const columnExtent = getColumnExtent(cols, rows, columnIndex);
return (
<MiniBarCell
rowIndex={row.index}
columnId={id}
align={align}
backgroundColor={backgroundColor}
value={value}
formatter={formatter}
extent={columnExtent}
/>
);
};
}
return options;
});
}, [
theme,
hasMetadataPopovers,
data,
question,
mode,
renderTableHeaderWrapper,
cols,
getColumnSortDirection,
getColumnTitle,
columnFormatters,
isPivoted,
rows,
settings,
]);
const handleColumnResize = useCallback(
(columnSizing: ColumnSizingState) => {
const newWidths = cols.map(col => columnSizing[col.name] ?? 0);
onUpdateVisualizationSettings({
"table.column_widths": newWidths,
});
},
[cols, onUpdateVisualizationSettings],
);
const rowId: RowIdColumnOptions | undefined = useMemo(() => {
const getBackgroundColor = memoize((rowIndex: number) =>
settings["table._cell_background_getter"]?.(null, rowIndex),
);
const hasAggregation = cols.some(column => column.source === "aggregation");
const isNotebookPreview = queryBuilderMode === "notebook";
const isModelEditor = queryBuilderMode === "dataset";
const hasObjectDetail =
!(isPivoted || hasAggregation || isNotebookPreview || isModelEditor) &&
!isEmbeddingSdk;
const shouldShowRowIndex =
settings["table.row_index"] && !isNotebookPreview && !isModelEditor;
if (!hasObjectDetail && !shouldShowRowIndex) {
return undefined;
}
return {
variant: shouldShowRowIndex ? "indexExpand" : "expandButton",
getBackgroundColor,
};
}, [cols, isEmbeddingSdk, isPivoted, queryBuilderMode, settings]);
const tableProps = useDataGridInstance({
data: rows,
rowId,
columnOrder,
columnSizing,
columnsOptions,
onColumnResize: handleColumnResize,
onColumnReorder: handleColumnReordering,
});
const { measureColumnWidths } = tableProps;
useEffect(() => {
const currentColNames = new Set(
cols.map((_col, columnIndex) => getColumnTitle(columnIndex)),
);
const prevColNames = prevColNamesRef.current;
const isSame =
prevColNames.size === currentColNames.size &&
[...prevColNames].every(name => currentColNames.has(name));
if (!isSame) {
prevColNamesRef.current = currentColNames;
measureColumnWidths();
}
}, [cols, measureColumnWidths, prevColNamesRef, getColumnTitle]);
useEffect(() => {
if (scrollToLastColumn && width && height) {
dispatch({
type: "metabase/qb/SET_UI_CONTROLS",
payload: { scrollToLastColumn: false },
});
}
}, [scrollToLastColumn, width, height, dispatch]);
useEffect(() => {
const gridElement = tableProps.refs.gridRef.current;
if (!gridElement) {
return;
}
if (scrollToLastColumn) {
const totalWidth = tableProps.table.getTotalSize();
const visibleWidth = gridElement.offsetWidth;
if (totalWidth > visibleWidth) {
gridElement.scrollLeft = totalWidth - visibleWidth;
}
} else if (scrollToColumn !== undefined && scrollToColumn >= 0) {
let leftPosition = 0;
const visibleColumns = tableProps.table.getVisibleLeafColumns();
for (let i = 0; i < scrollToColumn && i < visibleColumns.length; i++) {
leftPosition += visibleColumns[i].getSize();
}
gridElement.scrollLeft = leftPosition;
}
}, [
scrollToColumn,
scrollToLastColumn,
tableProps.table,
tableProps.refs.gridRef,
width,
height,
]);
const handleScroll = useCallback(() => {
if (clicked === null) {
return;
}
onVisualizationClick(undefined);
}, [clicked, onVisualizationClick]);
if (!width || !height) {
return <div ref={ref} className={className} />;
}
return (
<div
ref={ref}
className={cx(S.root, className)}
style={{
width: "100%",
height: "100%",
overflow: "hidden",
}}
>
<DataGrid
{...tableProps}
onBodyCellClick={handleBodyCellClick}
onAddColumnClick={handleAddColumnButtonClick}
onHeaderCellClick={handleHeaderCellClick}
onScroll={handleScroll}
/>
</div>
);
});
export const TableInteractive = _.compose(
withMantineTheme,
ExplicitSize({
refreshMode: "throttle",
}),
)(TableInteractiveInner);

View File

@@ -0,0 +1,4 @@
.headerPillWrapper {
width: auto;
overflow: hidden;
}

View File

@@ -0,0 +1,79 @@
import type React from "react";
import { memo } from "react";
import { QueryColumnInfoPopover } from "metabase/components/MetadataInfo/ColumnInfoPopover";
import {
HeaderCellPill,
type HeaderCellProps,
HeaderCellWrapper,
} from "metabase/data-grid";
import { useMousePressed } from "metabase/hooks/use-mouse-pressed";
import type { MantineTheme } from "metabase/ui";
import * as Lib from "metabase-lib";
import type Question from "metabase-lib/v1/Question";
import type { DatasetColumn } from "metabase-types/api";
import S from "./HeaderCellWithColumnInfo.module.css";
export interface HeaderCellWithColumnInfoProps extends HeaderCellProps {
infoPopoversDisabled: boolean;
timezone?: string;
question: Question;
column: DatasetColumn;
columnIndex: number;
theme: MantineTheme;
renderTableHeaderWrapper: (
content: React.ReactNode,
column: DatasetColumn,
index: number,
theme: MantineTheme,
) => React.ReactNode;
}
export const HeaderCellWithColumnInfo = memo(
function HeaderCellWithColumnInfoInner({
name,
align,
sort,
variant = "light",
infoPopoversDisabled,
question,
timezone,
column,
columnIndex,
theme,
renderTableHeaderWrapper,
}: HeaderCellWithColumnInfoProps) {
const isMousePressed = useMousePressed();
const query = question?.query();
const stageIndex = -1;
return (
<HeaderCellWrapper variant={variant} align={align}>
<QueryColumnInfoPopover
position="bottom-start"
query={query}
stageIndex={-1}
column={query && Lib.fromLegacyColumn(query, stageIndex, column)}
timezone={timezone}
disabled={infoPopoversDisabled || isMousePressed}
openDelay={500}
showFingerprintInfo
>
<div className={S.headerPillWrapper}>
{renderTableHeaderWrapper != null ? (
renderTableHeaderWrapper(
<HeaderCellPill name={name} sort={sort} align={align} />,
column,
columnIndex,
theme,
)
) : (
<HeaderCellPill name={name} sort={sort} align={align} />
)}
</div>
</QueryColumnInfoPopover>
</HeaderCellWrapper>
);
},
);

View File

@@ -0,0 +1,8 @@
.root {
position: relative;
display: flex;
align-items: center;
justify-content: end;
height: 100%;
padding: 0 8px;
}

View File

@@ -0,0 +1,122 @@
import cx from "classnames";
import CS from "metabase/css/core/index.css";
import {
BaseCell,
type CellAlign,
type CellFormatter,
} from "metabase/data-grid";
import { alpha, color } from "metabase/lib/colors";
import S from "./MiniBarCell.module.css";
const BAR_HEIGHT = 8;
const BAR_WIDTH = 70;
const BORDER_RADIUS = 3;
const LABEL_MIN_WIDTH = 30;
export interface MiniBarCellProps<TValue> {
value: TValue;
extent: [number, number];
formatter: CellFormatter<TValue>;
backgroundColor?: string;
align?: CellAlign;
rowIndex: number;
columnId: string;
}
export const MiniBarCell = <TValue,>({
value,
extent: [min, max],
formatter,
backgroundColor,
align,
rowIndex,
columnId,
}: MiniBarCellProps<TValue>) => {
if (typeof value !== "number") {
return null;
}
const hasNegative = min < 0;
const isNegative = value < 0;
const barPercent =
(Math.abs(value) / Math.max(Math.abs(min), Math.abs(max))) * 100;
const barColor = isNegative ? color("error") : color("brand");
const barStyle = !hasNegative
? {
width: barPercent + "%",
left: 0,
borderRadius: BORDER_RADIUS,
}
: isNegative
? {
width: barPercent / 2 + "%",
right: "50%",
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
borderTopLeftRadius: BORDER_RADIUS,
borderBottomLeftRadius: BORDER_RADIUS,
}
: {
width: barPercent / 2 + "%",
left: "50%",
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderTopRightRadius: BORDER_RADIUS,
borderBottomRightRadius: BORDER_RADIUS,
};
return (
<BaseCell
className={S.root}
backgroundColor={backgroundColor}
align={align}
>
{/* TEXT VALUE */}
<div
className={cx(CS.textEllipsis, CS.textBold, CS.textRight, CS.flexFull)}
style={{ minWidth: LABEL_MIN_WIDTH }}
>
{formatter(value, rowIndex, columnId)}
</div>
{/* OUTER CONTAINER BAR */}
<div
data-testid="mini-bar"
className={CS.ml1}
style={{
position: "relative",
width: BAR_WIDTH,
height: BAR_HEIGHT,
backgroundColor: alpha(barColor, 0.2),
borderRadius: BORDER_RADIUS,
}}
>
{/* INNER PROGRESS BAR */}
<div
style={{
position: "absolute",
top: 0,
bottom: 0,
backgroundColor: barColor,
...barStyle,
}}
/>
{/* CENTER LINE */}
{hasNegative && (
<div
style={{
position: "absolute",
left: "50%",
top: 0,
bottom: 0,
borderLeft: `1px solid ${color("white")}`,
}}
/>
)}
</div>
</BaseCell>
);
};

View File

@@ -0,0 +1,50 @@
import { useCallback, useMemo } from "react";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { zoomInRow } from "metabase/query_builder/actions";
import { getRowIndexToPKMap } from "metabase/query_builder/selectors";
import type { ObjectId } from "metabase/visualizations/components/ObjectDetail/types";
import type { ColumnDescriptor } from "metabase/visualizations/lib/graph/columns";
import { isPK } from "metabase-lib/v1/types/utils/isa";
import type { DatasetData } from "metabase-types/api";
export const useObjectDetail = ({ rows, cols }: DatasetData) => {
const dispatch = useDispatch();
const rowIndexToPkMap: Record<number, ObjectId> =
useSelector(getRowIndexToPKMap);
const primaryKeyColumn: ColumnDescriptor | null = useMemo(() => {
const primaryKeyColumns = cols.filter(isPK);
if (primaryKeyColumns.length !== 1) {
return null;
}
const primaryKeyColumn = primaryKeyColumns[0];
return {
column: primaryKeyColumn,
index: cols.indexOf(primaryKeyColumn),
};
}, [cols]);
const onOpenObjectDetail = useCallback(
(rowIndex: number) => {
let objectId: number | string;
if (primaryKeyColumn) {
const value = rows[rowIndex][primaryKeyColumn.index];
objectId =
typeof value === "number" || typeof value === "string"
? value
: rowIndex;
} else {
objectId = rowIndexToPkMap?.[rowIndex] ?? rowIndex;
}
dispatch(zoomInRow({ objectId }));
},
[dispatch, primaryKeyColumn, rowIndexToPkMap, rows],
);
return onOpenObjectDetail;
};

View File

@@ -0,0 +1 @@
export * from "./TableInteractive";

View File

@@ -1,50 +0,0 @@
import { alpha, isDark, lighten } from "metabase/lib/colors";
import type { MantineTheme } from "metabase/ui";
/**
* Cell data is the inner container within a table cell.
* It is primarily used to display the ID column.
* The styling here does not apply to the outer cell container.
*/
export function getCellDataTheme({
theme,
isIDColumn,
}: {
theme: MantineTheme;
isIDColumn: boolean;
}) {
const cellTheme = theme.other?.table?.cell;
const idTheme = theme.other?.table?.idColumn;
const fontSize = cellTheme?.fontSize;
if (isIDColumn) {
return {
color: idTheme?.textColor,
fontSize,
background:
idTheme?.backgroundColor || alpha(theme.fn.themeColor("brand"), 0.08),
border: `1px solid ${alpha(
idTheme?.backgroundColor || theme.fn.themeColor("brand"),
0.14,
)}`,
};
}
return { color: cellTheme?.textColor, fontSize };
}
export const getCellHoverBackground = ({
theme,
}: {
theme: MantineTheme;
}): string => {
const brand = theme.fn.themeColor("brand");
const background = theme.other?.table?.cell?.backgroundColor;
if (background && isDark(background)) {
return lighten(background, 0.1);
}
return alpha(brand, 0.1);
};

View File

@@ -0,0 +1,7 @@
.TableID .cellData {
border: 1px solid transparent;
padding: 0.25em 0.65em;
border-radius: 99px;
min-width: 35px;
text-align: center;
}

View File

@@ -5,7 +5,7 @@ import styled from "@emotion/styled";
import { alpha } from "metabase/lib/colors";
import type { MantineTheme } from "metabase/ui";
import TableS from "metabase/visualizations/components/TableInteractive/TableInteractive.module.css";
import TableS from "metabase/visualizations/components/TableRoot/TableRoot.module.css";
export const TableRoot = styled.div`
.${TableS.TableID} .${TableS.cellData} {

View File

@@ -6,7 +6,7 @@ import DashboardS from "metabase/css/dashboard.module.css";
import { formatValue } from "metabase/lib/formatting";
import type { OptionsType } from "metabase/lib/formatting/types";
import EmbedFrameS from "metabase/public/components/EmbedFrame/EmbedFrame.module.css";
import TableS from "metabase/visualizations/components/TableInteractive/TableInteractive.module.css";
import TableS from "metabase/visualizations/components/TableRoot/TableRoot.module.css";
import {
getTableCellClickedObject,
getTableClickedObjectRowData,

View File

@@ -23,6 +23,23 @@ const series = (rows, settings = {}) => {
};
describe("Table", () => {
const getDOMRect = (width, height) => ({
width,
height,
top: 0,
left: 0,
bottom: 0,
right: 0,
x: 0,
y: 0,
});
beforeAll(() => {
Element.prototype.getBoundingClientRect = jest.fn(() => {
return getDOMRect(500, 500);
});
});
beforeEach(() => {
jest.useFakeTimers();
});
@@ -52,13 +69,13 @@ describe("Table", () => {
.map(([value]) => screen.getByText(String(value)))
.map(element =>
window
.getComputedStyle(element.parentNode)
.getPropertyValue("background"),
.getComputedStyle(element.closest("[role=gridcell]"))
.getPropertyValue("background-color"),
);
expect(bgColors).toEqual([
"",
"",
"rgba(0, 0, 0, 0)",
"rgba(0, 0, 0, 0)",
"rgba(255, 0, 0, 0.65)",
"rgba(255, 0, 0, 0.65)",
]);

View File

@@ -1,3 +1,4 @@
import { memoize } from "metabase/hooks/use-memoized-callback";
import { NULL_DISPLAY_VALUE } from "metabase/lib/constants";
import { formatValue } from "metabase/lib/formatting";
import type { OptionsType } from "metabase/lib/formatting/types";
@@ -44,7 +45,6 @@ import {
POSITIVE_STACK_TOTAL_DATA_KEY,
} from "../constants/dataset";
import { CHART_STYLE } from "../constants/style";
import { cachedFormatter } from "../utils/formatter";
import { WATERFALL_VALUE_KEY } from "../waterfall/constants";
import { getFormattingOptionsWithoutScaling } from "./util";
@@ -634,7 +634,7 @@ const createSeriesLabelsFormatter = (
formattingOptions: OptionsType,
settings: ComputedVisualizationSettings,
) =>
cachedFormatter((value: RowValue) => {
memoize(value => {
if (typeof value !== "number") {
return "";
}

View File

@@ -1,16 +0,0 @@
import type { RowValue } from "metabase-types/api";
import type { RawValueFormatter } from "../model/types";
export const cachedFormatter = (formatter: RawValueFormatter) => {
const cache = new Map();
return (value: RowValue) => {
if (cache.has(value)) {
return cache.get(value);
}
const result = formatter(value);
cache.set(value, result);
return result;
};
};

Some files were not shown because too many files have changed in this diff Show More