Compare commits
52 Commits
proj-pytho
...
feature-ta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2717d873cc | ||
|
|
13c3d74e62 | ||
|
|
aafdd22d5e | ||
|
|
d5d3a80bf5 | ||
|
|
2c368d1dba | ||
|
|
488dce1f4c | ||
|
|
2d34d288db | ||
|
|
5fd299571a | ||
|
|
9635412745 | ||
|
|
1d6c937463 | ||
|
|
7d5a4216be | ||
|
|
faff498d02 | ||
|
|
e16b2e1780 | ||
|
|
7635aef269 | ||
|
|
8c8c68ef03 | ||
|
|
1d5519854d | ||
|
|
0f162c4ac6 | ||
|
|
d044eee043 | ||
|
|
53fc899587 | ||
|
|
a64d0cf5df | ||
|
|
6a4d87f3b0 | ||
|
|
198e898989 | ||
|
|
587e863f92 | ||
|
|
09de6a3265 | ||
|
|
2406546749 | ||
|
|
579818de35 | ||
|
|
ed7c6227b0 | ||
|
|
5fe9ae35e2 | ||
|
|
b7b2c9df43 | ||
|
|
91f47ccd52 | ||
|
|
4995cbba67 | ||
|
|
44545f9f12 | ||
|
|
3dae6db4bd | ||
|
|
eafee0e4e7 | ||
|
|
d360d06514 | ||
|
|
b1cae109a9 | ||
|
|
17095a5712 | ||
|
|
8f72bd5e82 | ||
|
|
98c03ed29b | ||
|
|
7c2b9019a8 | ||
|
|
01b84f9150 | ||
|
|
afddb6af0b | ||
|
|
986727575b | ||
|
|
78552db5f8 | ||
|
|
486176f301 | ||
|
|
18fc9d1926 | ||
|
|
c6accba56c | ||
|
|
9c195a20e9 | ||
|
|
6ffa6ba6fc | ||
|
|
27ea35502b | ||
|
|
e7b424c2cf | ||
|
|
b17d3ae582 |
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -380,7 +380,7 @@ function uploadFileToCollection(testFile) {
|
||||
cy.wait("@dataset");
|
||||
|
||||
cy.url().should("include", "/model/");
|
||||
cy.findByTestId("TableInteractive-root");
|
||||
H.tableInteractive();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./AddColumnButton";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./BaseCell";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./BodyCell";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
200
frontend/src/metabase/data-grid/components/DataGrid/DataGrid.tsx
Normal file
200
frontend/src/metabase/data-grid/components/DataGrid/DataGrid.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./DataGrid";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export { ExpandButton } from "./ExpandButton";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./HeaderCell";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./RowIdCell";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./RowIdHeaderCell";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./SortableHeader";
|
||||
5
frontend/src/metabase/data-grid/constants.ts
Normal file
5
frontend/src/metabase/data-grid/constants.ts
Normal 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;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
121
frontend/src/metabase/data-grid/hooks/use-columns-reordering.tsx
Normal file
121
frontend/src/metabase/data-grid/hooks/use-columns-reordering.tsx
Normal 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],
|
||||
);
|
||||
};
|
||||
273
frontend/src/metabase/data-grid/hooks/use-data-grid-instance.tsx
Normal file
273
frontend/src/metabase/data-grid/hooks/use-data-grid-instance.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
101
frontend/src/metabase/data-grid/hooks/use-virtual-grid.tsx
Normal file
101
frontend/src/metabase/data-grid/hooks/use-virtual-grid.tsx
Normal 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,
|
||||
],
|
||||
);
|
||||
};
|
||||
6
frontend/src/metabase/data-grid/index.ts
Normal file
6
frontend/src/metabase/data-grid/index.ts
Normal 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";
|
||||
173
frontend/src/metabase/data-grid/types.ts
Normal file
173
frontend/src/metabase/data-grid/types.ts
Normal 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>;
|
||||
}
|
||||
112
frontend/src/metabase/data-grid/utils/columns/data-column.tsx
Normal file
112
frontend/src/metabase/data-grid/utils/columns/data-column.tsx
Normal 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;
|
||||
};
|
||||
@@ -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 ? "#" : ""} />;
|
||||
},
|
||||
};
|
||||
};
|
||||
17
frontend/src/metabase/data-grid/utils/measure.ts
Normal file
17
frontend/src/metabase/data-grid/utils/measure.ts
Normal 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;
|
||||
};
|
||||
67
frontend/src/metabase/data-grid/utils/measure.unit.spec.ts
Normal file
67
frontend/src/metabase/data-grid/utils/measure.unit.spec.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
56
frontend/src/metabase/hooks/use-memoized-callback.ts
Normal file
56
frontend/src/metabase/hooks/use-memoized-callback.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
25
frontend/src/metabase/hooks/use-mouse-pressed.ts
Normal file
25
frontend/src/metabase/hooks/use-mouse-pressed.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -706,6 +706,8 @@ export const getRawSeries = createSelector(
|
||||
visualization_settings: {
|
||||
...card.visualization_settings,
|
||||
"table.pivot": false,
|
||||
"table.column_formatting": [],
|
||||
column_settings: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
@@ -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);
|
||||
@@ -0,0 +1,4 @@
|
||||
.headerPillWrapper {
|
||||
width: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
.root {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
height: 100%;
|
||||
padding: 0 8px;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./TableInteractive";
|
||||
@@ -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);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
.TableID .cellData {
|
||||
border: 1px solid transparent;
|
||||
padding: 0.25em 0.65em;
|
||||
border-radius: 99px;
|
||||
min-width: 35px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -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} {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)",
|
||||
]);
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user