diff --git a/e2e/test/scenarios/admin/datamodel/editor.cy.spec.js b/e2e/test/scenarios/admin/datamodel/editor.cy.spec.js index 64984db66a35de6aea8db4ce5bce1c4d8b6d0ea9..82216574b2076e412a7c8545ccd1e2983fcb0e26 100644 --- a/e2e/test/scenarios/admin/datamodel/editor.cy.spec.js +++ b/e2e/test/scenarios/admin/datamodel/editor.cy.spec.js @@ -59,7 +59,6 @@ describe("scenarios > admin > datamodel > editor", () => { }); }); - // QUESTION - can we check update in the admin instead? it("should allow changing the table description", () => { visitTableMetadata(); setValueAndBlurInput(ORDERS_DESCRIPTION, "New description"); @@ -67,6 +66,12 @@ describe("scenarios > admin > datamodel > editor", () => { cy.findByDisplayValue("New description").should("be.visible"); // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage cy.findByText("Updated Table description").should("be.visible"); + + cy.visit(`/reference/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}`); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Orders").should("be.visible"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("New description").should("be.visible"); }); it("should allow clearing the table description", () => { @@ -75,6 +80,12 @@ describe("scenarios > admin > datamodel > editor", () => { cy.wait("@updateTable"); // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage cy.findByText("Updated Table description").should("be.visible"); + + cy.visit(`/reference/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}`); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Orders").should("be.visible"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("No description yet").should("be.visible"); }); it("should allow changing the table visibility", () => { @@ -137,6 +148,14 @@ describe("scenarios > admin > datamodel > editor", () => { .should("be.visible"); // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage cy.findByText("Updated Total").should("be.visible"); + + cy.visit( + `/reference/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}/fields/${ORDERS.TOTAL}`, + ); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Total").should("be.visible"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("New description").should("be.visible"); }); it("should allow clearing the field description", () => { @@ -147,6 +166,14 @@ describe("scenarios > admin > datamodel > editor", () => { cy.wait("@updateField"); // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage cy.findByText("Updated Total").should("be.visible"); + + cy.visit( + `/reference/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}/fields/${ORDERS.TOTAL}`, + ); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Total").should("be.visible"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("No description yet").should("be.visible"); }); it("should allow changing the field visibility", () => { @@ -319,6 +346,14 @@ describe("scenarios > admin > datamodel > editor", () => { cy.findByDisplayValue("New description").should("be.visible"); // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage cy.findByText("Updated Total").should("be.visible"); + + cy.visit( + `/reference/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}/fields/${ORDERS.TOTAL}`, + ); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Total").should("be.visible"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("New description").should("be.visible"); }); it("should allow changing the field visibility", () => { diff --git a/e2e/test/scenarios/admin/datamodel/metrics.cy.spec.js b/e2e/test/scenarios/admin/datamodel/metrics.cy.spec.js index 53f68be1c0d110c7d1eab54540e06bc644e4cb28..b8a387a11f8f7f1216a916208b70af4dfa3aa9c4 100644 --- a/e2e/test/scenarios/admin/datamodel/metrics.cy.spec.js +++ b/e2e/test/scenarios/admin/datamodel/metrics.cy.spec.js @@ -5,6 +5,8 @@ import { openOrdersTable, visualize, summarize, + filter, + filterField, } from "e2e/support/helpers"; import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { createMetric } from "e2e/support/helpers/e2e-table-metadata-helpers"; @@ -82,6 +84,16 @@ describe("scenarios > admin > datamodel > metrics", () => { ); }); + it("should show how to create metrics", () => { + cy.visit("/reference/metrics"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText( + "Metrics are the official numbers that your team cares about", + ); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Learn how to create metrics"); + }); + it("custom expression aggregation should work in metrics (metabase#22700)", () => { cy.intercept("POST", "/api/dataset").as("dataset"); @@ -136,6 +148,48 @@ describe("scenarios > admin > datamodel > metrics", () => { }); }); + it("should show no questions based on a new metric", () => { + cy.visit("/reference/metrics/1/questions"); + cy.findAllByText("Questions about orders < 100"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Loading..."); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Loading...").should("not.exist"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText( + "Questions about this metric will appear here as they're added", + ); + }); + + it("should see a newly asked question in its questions list", () => { + // Ask a new qustion + cy.visit("/reference/metrics/1/questions"); + cy.get(".full").find(".Button").click(); + + filter(); + filterField("Total", { + placeholder: "min", + value: "50", + }); + + cy.findByTestId("apply-filters").click(); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Save").click(); + cy.findAllByText("Save").last().click(); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Not now").click(); + + // Check the list + cy.visit("/reference/metrics/1/questions"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Our analysis").should("not.exist"); + cy.findAllByText("Questions about orders < 100"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText( + "Orders, orders < 100, Filtered by Total is greater than or equal to 50", + ); + }); + it("should show the metric detail view for a specific id", () => { cy.visit("/admin/datamodel/metric/1"); // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage diff --git a/e2e/test/scenarios/admin/datamodel/segments.cy.spec.js b/e2e/test/scenarios/admin/datamodel/segments.cy.spec.js index 21bee16c1ea71cb207ab1413e9067450c3801ff8..b74cff964cc358ef48f1fc76a4411e6e561afcad 100644 --- a/e2e/test/scenarios/admin/datamodel/segments.cy.spec.js +++ b/e2e/test/scenarios/admin/datamodel/segments.cy.spec.js @@ -1,5 +1,11 @@ // Ported from `segments.e2e.spec.js` -import { restore, popover, modal } from "e2e/support/helpers"; +import { + restore, + popover, + modal, + filter, + filterField, +} from "e2e/support/helpers"; import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { createSegment } from "e2e/support/helpers/e2e-table-metadata-helpers"; @@ -50,6 +56,14 @@ describe("scenarios > admin > datamodel > segments", () => { cy.findByText("Custom Expression"); }); }); + + it("should show no segments", () => { + cy.visit("/reference/segments"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Segments are interesting subsets of tables"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Learn how to create segments"); + }); }); describe("with segment", () => { @@ -69,6 +83,29 @@ describe("scenarios > admin > datamodel > segments", () => { }); }); + it("should show the segment fields list and detail view", () => { + // In the list + cy.visit("/reference/segments"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText(SEGMENT_NAME); + + // Detail view + cy.visit("/reference/segments/1"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Description"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("See this segment"); + + // Segment fields + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Fields in this segment").click(); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("See this segment").should("not.exist"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText(`Fields in ${SEGMENT_NAME}`); + cy.findAllByText("Discount"); + }); + it("should show up in UI list", () => { cy.visit("/admin/datamodel/segments"); // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage @@ -85,6 +122,46 @@ describe("scenarios > admin > datamodel > segments", () => { cy.findByText("Preview"); }); + it("should show no questions based on a new segment", () => { + cy.visit("/reference/segments/1/questions"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText(`Questions about ${SEGMENT_NAME}`); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText( + "Questions about this segment will appear here as they're added", + ); + }); + + it("should see a newly asked question in its questions list", () => { + // Ask question + cy.visit("/reference/segments/1/questions"); + cy.get(".full .Button").click(); + cy.findAllByText("37.65"); + + filter(); + filterField("Product ID", { + value: "14", + }); + cy.findByTestId("apply-filters").click(); + + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Product ID is 14"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Save").click(); + cy.findAllByText("Save").last().click(); + + // Check list + cy.visit("/reference/segments/1/questions"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText( + "Questions about this segment will appear here as they're added", + ).should("not.exist"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText( + `Orders, Filtered by ${SEGMENT_NAME} and Product ID equals 14`, + ); + }); + it("should update that segment", () => { cy.visit("/admin"); // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage diff --git a/e2e/test/scenarios/onboarding/home/browse.cy.spec.js b/e2e/test/scenarios/onboarding/home/browse.cy.spec.js index 1e0aa6b4db17eb701f7df31fca983c4f535ddcf5..7d71b03153e20147636389e92b4ef1eb3eee6f15 100644 --- a/e2e/test/scenarios/onboarding/home/browse.cy.spec.js +++ b/e2e/test/scenarios/onboarding/home/browse.cy.spec.js @@ -14,6 +14,10 @@ describe("scenarios > browse data", () => { // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage cy.findByText(/^Our data$/i); // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Learn about our data").click(); + cy.location("pathname").should("eq", "/reference/databases"); + cy.go("back"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage cy.findByText("Sample Database").click(); // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage cy.findByText("Products").click(); diff --git a/e2e/test/scenarios/onboarding/reference/databases.cy.spec.js b/e2e/test/scenarios/onboarding/reference/databases.cy.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a266900a89e83c13ec4756b972c4f64a7c21437f --- /dev/null +++ b/e2e/test/scenarios/onboarding/reference/databases.cy.spec.js @@ -0,0 +1,111 @@ +import { popover, restore, startNewQuestion } from "e2e/support/helpers"; + +describe("scenarios > reference > databases", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + }); + + it("should see the listing", () => { + cy.visit("/reference/databases"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.contains("Sample Database"); + }); + + xit("should let the user navigate to details", () => { + cy.visit("/reference/databases"); + cy.contains("Sample Database").click(); + cy.contains("Why this database is interesting"); + }); + + it("should let an admin edit details about the database", () => { + cy.visit("/reference/databases/1"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.contains("Edit").click(); + // Q - is there any cleaner way to get a nearby element without having to know the DOM? + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.contains("Description") + .parent() + .parent() + .find("textarea") + .type("A pretty ok store"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.contains("Save").click(); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.contains("A pretty ok store"); + }); + + it("should let an admin start to edit and cancel without saving", () => { + cy.visit("/reference/databases/1"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.contains("Edit").click(); + // Q - is there any cleaner way to get a nearby element without having to know the DOM? + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.contains("Why this") + .parent() + .parent() + .find("textarea") + .type("Turns out it's not"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.contains("Cancel").click(); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.contains("Turns out").should("have.length", 0); + }); + + it("should let an admin edit the database name", () => { + cy.visit("/reference/databases/1"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.contains("Edit").click(); + cy.get(".wrapper input").clear().type("My definitely profitable business"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.contains("Save").click(); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.contains("My definitely profitable business"); + }); + + describe("multiple databases sorting order", () => { + beforeEach(() => { + ["d", "b", "a", "c"].forEach(name => { + cy.addH2SampleDatabase({ name }); + }); + }); + + it.skip("should sort data reference database list (metabase#15598)", () => { + cy.visit("/browse"); + checkReferenceDatabasesOrder(); + + cy.visit("/reference/databases/"); + checkReferenceDatabasesOrder(); + }); + + it("should sort databases in new UI based question data selection popover", () => { + checkQuestionSourceDatabasesOrder(); + }); + + it.skip("should sort databases in new native question data selection popover", () => { + checkQuestionSourceDatabasesOrder("Native query"); + }); + }); +}); + +function checkReferenceDatabasesOrder() { + cy.get("[class*=Card]").as("databaseCard").first().should("have.text", "a"); + cy.get("@databaseCard").last().should("have.text", "Sample Database"); +} + +function checkQuestionSourceDatabasesOrder(question_type) { + // Last item is "Saved Questions" for UI based questions so we have to check for the one before that (-2), and the last one for "Native" (-1) + const lastDatabaseIndex = question_type === "Native query" ? -1 : -2; + const selector = + question_type === "Native query" + ? ".List-item-title" + : ".List-section-title"; + + startNewQuestion(); + popover().within(() => { + cy.get(selector).as("databaseName").first().should("have.text", "a"); + cy.get("@databaseName") + .eq(lastDatabaseIndex) + .should("have.text", "Sample Database"); + }); +} diff --git a/e2e/test/scenarios/onboarding/reference/metrics.cy.spec.js b/e2e/test/scenarios/onboarding/reference/metrics.cy.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..33a664fe33b455e29f545225fcccec965750438e --- /dev/null +++ b/e2e/test/scenarios/onboarding/reference/metrics.cy.spec.js @@ -0,0 +1,101 @@ +import { restore } from "e2e/support/helpers"; +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; + +const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE; + +describe("scenarios > reference > metrics", () => { + const METRIC_NAME = "orders < 100"; + const METRIC_DESCRIPTION = "Count of orders with a total under $100."; + + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + + cy.request("POST", "/api/metric", { + definition: { + aggregation: ["count"], + filter: ["<", ["field", ORDERS.TOTAL, null], 100], + "source-table": ORDERS_ID, + }, + name: METRIC_NAME, + description: METRIC_DESCRIPTION, + table_id: ORDERS_ID, + }); + }); + + it("should see the listing", () => { + cy.visit("/reference/metrics"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText(METRIC_NAME); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText(METRIC_DESCRIPTION); + }); + + it("should let the user navigate to details", () => { + cy.visit("/reference/metrics"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText(METRIC_NAME).click(); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Why this metric is interesting"); + }); + + it("should let an admin edit details about the metric", () => { + cy.visit("/reference/metrics"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText(METRIC_NAME).click(); + + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Edit").click(); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Description") + .parent() + .parent() + .find("textarea") + .clear() + .type("Count of orders under $100"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Save").click(); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Reason for changes") + .parent() + .parent() + .find("textarea") + .type("Renaming the description"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Save changes").click(); + + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Count of orders under $100"); + }); + + it("should let an admin start to edit and cancel without saving", () => { + cy.visit("/reference/metrics"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText(METRIC_NAME).click(); + + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Edit").click(); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Why this metric is interesting") + .parent() + .parent() + .find("textarea") + .type("Because it's very nice"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Cancel").click(); + + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Because it's very nice").should("have.length", 0); + }); + + it("should have different URI while editing the metric", () => { + cy.visit("/reference/metrics"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText(METRIC_NAME).click(); + + cy.url().should("match", /\/reference\/metrics\/\d+$/); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Edit").click(); + cy.url().should("match", /\/reference\/metrics\/\d+\/edit$/); + }); +}); diff --git a/e2e/test/scenarios/onboarding/reference/reproductions/5276-remove-field-type.cy.spec.js b/e2e/test/scenarios/onboarding/reference/reproductions/5276-remove-field-type.cy.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..090e969169ed700afda49c09dc988ac2fa9817f5 --- /dev/null +++ b/e2e/test/scenarios/onboarding/reference/reproductions/5276-remove-field-type.cy.spec.js @@ -0,0 +1,33 @@ +import { popover, restore } from "e2e/support/helpers"; + +describe("issue 5276", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + cy.intercept("PUT", "/api/field/*").as("updateField"); + }); + + it("should allow removing the field type (metabase#5276)", () => { + cy.visit("/reference/databases"); + + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Sample Database").click(); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Tables in Sample Database").click(); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Products").click(); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Fields in this table").click(); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Edit").click(); + + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Score").click(); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + popover().within(() => cy.findByText("No field type").click()); + cy.button("Save").click(); + cy.wait("@updateField"); + // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage + cy.findByText("Score").should("not.exist"); + }); +}); diff --git a/enterprise/frontend/src/metabase-enterprise/auth/components/SsoButton/SsoButton.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/auth/components/SsoButton/SsoButton.unit.spec.tsx index fb816ab68cdb4451e35c1777668e22ff8a07fc93..120b3f18c282d3a9f3da227a9d6ac9c99b055a94 100644 --- a/enterprise/frontend/src/metabase-enterprise/auth/components/SsoButton/SsoButton.unit.spec.tsx +++ b/enterprise/frontend/src/metabase-enterprise/auth/components/SsoButton/SsoButton.unit.spec.tsx @@ -21,7 +21,7 @@ const setup = () => { renderWithProviders(<SsoButton />, { storeInitialState: state }); }; -describe("SsoButton", () => { +describe("SSOButton", () => { it("should login immediately when embedded", async () => { jest.spyOn(domUtils, "redirect").mockImplementation(() => undefined); diff --git a/frontend/src/metabase/browse/components/BrowseHeader.jsx b/frontend/src/metabase/browse/components/BrowseHeader.jsx index 3a89d92b5a2a200172862f2885691561b27d68b7..fa626a75ee6fcc0da299e9bf04e1ae8e1e4f91ec 100644 --- a/frontend/src/metabase/browse/components/BrowseHeader.jsx +++ b/frontend/src/metabase/browse/components/BrowseHeader.jsx @@ -1,5 +1,9 @@ /* eslint-disable react/prop-types */ +import { t } from "ttag"; + import BrowserCrumbs from "metabase/components/BrowserCrumbs"; +import { Icon } from "metabase/core/components/Icon"; +import Link from "metabase/core/components/Link"; import { ANALYTICS_CONTEXT } from "metabase/browse/constants"; import { BrowseHeaderContent, BrowseHeaderRoot } from "./BrowseHeader.styled"; @@ -9,6 +13,20 @@ export default function BrowseHeader({ crumbs }) { <BrowseHeaderRoot> <BrowseHeaderContent> <BrowserCrumbs crumbs={crumbs} analyticsContext={ANALYTICS_CONTEXT} /> + <div className="flex flex-align-right"> + <Link + className="flex flex-align-right" + to="reference" + data-metabase-event="NavBar;Reference" + > + <div className="flex align-center text-medium text-brand-hover"> + <Icon className="flex align-center" size={14} name="reference" /> + <span className="ml1 flex align-center text-bold"> + {t`Learn about our data`} + </span> + </div> + </Link> + </div> </BrowseHeaderContent> </BrowseHeaderRoot> ); diff --git a/frontend/src/metabase/browse/components/TableBrowser/TableBrowser.jsx b/frontend/src/metabase/browse/components/TableBrowser/TableBrowser.jsx index 3462916f83f582ab8f001af5c66180f2f8dfe374..4d8c033f3182e41b5d499f5fa85fbcbb95e9df61 100644 --- a/frontend/src/metabase/browse/components/TableBrowser/TableBrowser.jsx +++ b/frontend/src/metabase/browse/components/TableBrowser/TableBrowser.jsx @@ -135,6 +135,16 @@ const TableBrowserItemButtons = ({ tableId, dbId, xraysEnabled }) => { /> </TableActionLink> )} + <TableActionLink + to={`/reference/databases/${dbId}/tables/${tableId}`} + data-metabase-event={`${ANALYTICS_CONTEXT};Table Item;Reference Click`} + > + <Icon + name="reference" + tooltip={t`Learn about this table`} + color={color("text-medium")} + /> + </TableActionLink> </Fragment> ); }; diff --git a/frontend/src/metabase/components/List/List.css b/frontend/src/metabase/components/List/List.css new file mode 100644 index 0000000000000000000000000000000000000000..76c31959a1874b7285bdbc059668f3fcded85f9e --- /dev/null +++ b/frontend/src/metabase/components/List/List.css @@ -0,0 +1,172 @@ +:root { + --title-color: var(--color-text-dark); + --subtitle-color: var(--color-text-medium); + --muted-color: var(--color-text-light); +} + +:local(.list) { + composes: ml-auto mr-auto from "style"; +} + +:local(.list-wrapper) { + composes: ml-auto mr-auto from "style"; +} + +:local(.list) a { + text-decoration: none; +} + +:local(.header) { + composes: flex flex-row from "style"; + composes: mt4 mb2 from "style"; + color: var(--title-color); + font-size: 24px; + min-height: 48px; +} + +:local(.headerBody) { + composes: flex flex-full border-bottom from "style"; + align-items: center; + height: 100%; + border-color: var(--color-brand); +} + +:local(.headerLink) { + composes: text-brand ml2 flex-no-shrink from "style"; + font-size: 14px; +} + +:local(.headerButton) { + composes: flex ml1 align-center from "style"; + font-size: 14px; +} + +:local(.empty) { + composes: full flex justify-center from "style"; + padding-top: 75px; +} + +:local(.item) { + composes: flex align-center from "style"; + composes: relative from "style"; +} + +:local(.itemBody) { + composes: flex-full from "style"; + max-width: 100%; +} + +:local(.itemTitle) { + composes: text-bold from "style"; + max-width: 100%; + overflow: hidden; +} + +:local(.itemName) { + composes: text-brand-hover mr1 from "style"; + max-width: 100%; + overflow: hidden; +} + +:local(.itemSubtitle) { + color: var(--subtitle-color); + max-width: 600px; + font-size: 14px; +} + +:local(.itemSubtitleLight) { + composes: text-light from "style"; + font-size: 14px; +} + +:local(.itemSubtitleBold) { + color: var(--title-color); +} + +:local(.icons) { + composes: flex flex-row align-center from "style"; +} +:local(.leftIcons) { + composes: flex-no-shrink flex align-self-start mr2 from "style"; + composes: icons; +} +:local(.rightIcons) { + composes: icons; +} +:local(.itemIcons) { + composes: leftIcons; + padding-top: 4px; +} + +:local(.extraIcons) { + composes: icons; + composes: absolute top full-height from "style"; + right: -40px; +} + +/* hack fix for IE 11 which was hiding the archive icon */ +@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) { + :local(.extraIcons) { + composes: icons; + } +} + +:local(.icon) { + composes: relative from "style"; + color: var(--muted-color); +} + +:local(.item) :local(.icon) { + visibility: hidden; +} +:local(.item):hover :local(.icon) { + visibility: visible; +} +:local(.icon):hover { + color: var(--color-brand); +} + +/* ITEM CHECKBOX */ +:local(.itemCheckbox) { + composes: icon; + display: none; + visibility: visible !important; + margin-left: 10px; +} +:local(.item):hover :local(.itemCheckbox), +:local(.item.selected) :local(.itemCheckbox) { + display: inline; +} +:local(.item.selected) :local(.itemCheckbox) { + color: var(--color-brand); +} + +/* ITEM ICON */ +:local(.itemIcon) { + composes: icon; + visibility: visible !important; + composes: relative from "style"; +} +:local(.item):hover :local(.itemIcon), +:local(.item.selected) :local(.itemIcon) { + display: none; +} + +/* CHART ICON */ +:local(.chartIcon) { + composes: icon; + visibility: visible !important; + composes: relative from "style"; +} + +/* ACTION ICONS */ +:local(.tagIcon), +:local(.favoriteIcon), +:local(.archiveIcon) { + composes: icon; + composes: mx1 from "style"; +} + +:local(.trigger) { + line-height: 0; +} diff --git a/frontend/src/metabase/components/List/List.jsx b/frontend/src/metabase/components/List/List.jsx new file mode 100644 index 0000000000000000000000000000000000000000..5d89949aaf7ff9229cc9f63103ee2265995bfa7c --- /dev/null +++ b/frontend/src/metabase/components/List/List.jsx @@ -0,0 +1,13 @@ +/* eslint "react/prop-types": "warn" */ +import { memo } from "react"; +import PropTypes from "prop-types"; + +import S from "./List.css"; + +const List = ({ children }) => <ul className={S.list}>{children}</ul>; + +List.propTypes = { + children: PropTypes.any.isRequired, +}; + +export default memo(List); diff --git a/frontend/src/metabase/components/List/index.jsx b/frontend/src/metabase/components/List/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..8e34bd39702766ae79f67c8e688c61074e185c3d --- /dev/null +++ b/frontend/src/metabase/components/List/index.jsx @@ -0,0 +1 @@ +export { default } from "./List"; diff --git a/frontend/src/metabase/components/ListItem/ListItem.jsx b/frontend/src/metabase/components/ListItem/ListItem.jsx new file mode 100644 index 0000000000000000000000000000000000000000..4e0bc15f81120f8b716be95c7614d1e0ea9f42d6 --- /dev/null +++ b/frontend/src/metabase/components/ListItem/ListItem.jsx @@ -0,0 +1,49 @@ +/* eslint "react/prop-types": "warn" */ +import { memo } from "react"; +import PropTypes from "prop-types"; +import { Link } from "react-router"; +import cx from "classnames"; +import Ellipsified from "metabase/core/components/Ellipsified"; +import Card from "metabase/components/Card"; +import S from "metabase/components/List/List.css"; +import { Icon } from "metabase/core/components/Icon"; + +const ListItem = ({ name, description, placeholder, url, icon }) => ( + <li className="relative"> + <Link to={url} className="text-brand-hover"> + <Card hoverable className="mb2 p3 bg-white rounded bordered"> + <div className={cx(S.item)}> + <div className={S.itemIcons}> + {icon && <Icon className={S.chartIcon} name={icon} size={16} />} + </div> + <div className={S.itemBody}> + <div className={S.itemTitle}> + <Ellipsified + className={S.itemName} + tooltip={name} + tooltipMaxWidth="100%" + > + <h3>{name}</h3> + </Ellipsified> + </div> + {(description || placeholder) && ( + <div className={cx(S.itemSubtitle)}> + {description || placeholder} + </div> + )} + </div> + </div> + </Card> + </Link> + </li> +); + +ListItem.propTypes = { + name: PropTypes.string.isRequired, + url: PropTypes.string, + description: PropTypes.string, + placeholder: PropTypes.string, + icon: PropTypes.string, +}; + +export default memo(ListItem); diff --git a/frontend/src/metabase/components/ListItem/ListItem.unit.spec.js b/frontend/src/metabase/components/ListItem/ListItem.unit.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..50939dd9dd745ab8478804df9f2944ddb2099aca --- /dev/null +++ b/frontend/src/metabase/components/ListItem/ListItem.unit.spec.js @@ -0,0 +1,56 @@ +import { Route } from "react-router"; +import { screen, getIcon, renderWithProviders } from "__support__/ui"; +import ListItem from "./ListItem"; + +const ITEM_NAME = "Table Foo"; +const ITEM_DESCRIPTION = "Nice table description."; + +function setup({ name, ...opts }) { + return renderWithProviders( + <Route path="/" component={() => <ListItem name={name} {...opts} />} />, + { withRouter: true }, + ); +} + +describe("ListItem", () => { + it("should render", () => { + setup({ + name: ITEM_NAME, + description: ITEM_DESCRIPTION, + icon: "table", + url: "/foo", + }); + + expect(screen.getByText(ITEM_NAME)).toBeInTheDocument(); + expect(screen.getByText(ITEM_DESCRIPTION)).toBeInTheDocument(); + expect(getIcon("table")).toBeInTheDocument(); + expect(screen.getByRole("link")).toHaveProperty( + "href", + "http://localhost/foo", + ); + }); + + it("should render with just the name", () => { + setup({ name: ITEM_NAME }); + expect(screen.getByText(ITEM_NAME)).toBeInTheDocument(); + }); + + it("should display the placeholder if there's no description", () => { + setup({ name: ITEM_NAME, placeholder: "Placeholder text" }); + + expect(screen.getByText(ITEM_NAME)).toBeInTheDocument(); + expect(screen.getByText("Placeholder text")).toBeInTheDocument(); + }); + + it("should display the description and omit the placeholder if both are present", () => { + setup({ + name: ITEM_NAME, + description: ITEM_DESCRIPTION, + placeholder: "Placeholder text", + }); + + expect(screen.getByText(ITEM_NAME)).toBeInTheDocument(); + expect(screen.getByText(ITEM_DESCRIPTION)).toBeInTheDocument(); + expect(screen.queryByText("Placeholder text")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/metabase/components/ListItem/index.jsx b/frontend/src/metabase/components/ListItem/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..3fb6c1bab362d14ef8abb0f5dcf765d0b1bd1d10 --- /dev/null +++ b/frontend/src/metabase/components/ListItem/index.jsx @@ -0,0 +1 @@ +export { default } from "./ListItem"; diff --git a/frontend/src/metabase/components/QueryButton/QueryButton.css b/frontend/src/metabase/components/QueryButton/QueryButton.css new file mode 100644 index 0000000000000000000000000000000000000000..fdb2d0b5a9485b64b18b848231d45cd25bececb2 --- /dev/null +++ b/frontend/src/metabase/components/QueryButton/QueryButton.css @@ -0,0 +1,8 @@ +:local(.queryButton) { + composes: flex align-center no-decoration text-brand py1 from "style"; +} + +:local(.queryButtonText) { + composes: flex-full ml2 from "style"; + max-width: 100%; +} diff --git a/frontend/src/metabase/components/QueryButton/QueryButton.jsx b/frontend/src/metabase/components/QueryButton/QueryButton.jsx new file mode 100644 index 0000000000000000000000000000000000000000..1a050d7cb8ec4d8321a3608e5e24a28bb21fe24f --- /dev/null +++ b/frontend/src/metabase/components/QueryButton/QueryButton.jsx @@ -0,0 +1,30 @@ +import { memo } from "react"; +import PropTypes from "prop-types"; +import { Link } from "react-router"; +import cx from "classnames"; + +import { Icon } from "metabase/core/components/Icon"; +import S from "./QueryButton.css"; + +const QueryButton = ({ className, text, icon, iconClass, onClick, link }) => ( + <div className={className}> + <Link + className={cx(S.queryButton, "bg-light-hover px1 rounded")} + onClick={onClick} + to={link} + > + <Icon name={icon} /> + <span className={S.queryButtonText}>{text}</span> + </Link> + </div> +); +QueryButton.propTypes = { + className: PropTypes.string, + icon: PropTypes.any.isRequired, + text: PropTypes.string.isRequired, + iconClass: PropTypes.string, + onClick: PropTypes.func, + link: PropTypes.string, +}; + +export default memo(QueryButton); diff --git a/frontend/src/metabase/components/QueryButton/index.jsx b/frontend/src/metabase/components/QueryButton/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..9604a98ed97bad4b63af2ebd3e53a1d986b91675 --- /dev/null +++ b/frontend/src/metabase/components/QueryButton/index.jsx @@ -0,0 +1 @@ +export { default } from "./QueryButton"; diff --git a/frontend/src/metabase/entities/databases.js b/frontend/src/metabase/entities/databases.js index fe9ff5b8f728cc6ae476da5ac919f657a1bc15f2..a3a7836daaf6cf1ce0de01961e837ed9cdb89784 100644 --- a/frontend/src/metabase/entities/databases.js +++ b/frontend/src/metabase/entities/databases.js @@ -1,10 +1,13 @@ import _ from "underscore"; +import { normalize } from "normalizr"; import { createSelector } from "@reduxjs/toolkit"; import { createEntity } from "metabase/lib/entities"; import * as Urls from "metabase/lib/urls"; import { color } from "metabase/lib/colors"; import { + fetchData, + createThunkAction, compose, withAction, withCachedDataAndRequestState, @@ -41,6 +44,25 @@ const Databases = createEntity({ // ACTION CREATORS objectActions: { + fetchDatabaseMetadata: createThunkAction( + FETCH_DATABASE_METADATA, + ({ id }, { reload = false, params } = {}) => + (dispatch, getState) => + fetchData({ + dispatch, + getState, + requestStatePath: ["metadata", "databases", id], + existingStatePath: ["metadata", "databases", id], + getData: async () => { + const databaseMetadata = await MetabaseApi.db_metadata({ + dbId: id, + ...params, + }); + return normalize(databaseMetadata, DatabaseSchema); + }, + reload, + }), + ), fetchIdFields: compose( withAction(FETCH_DATABASE_IDFIELDS), withCachedDataAndRequestState( diff --git a/frontend/src/metabase/reducers-main.js b/frontend/src/metabase/reducers-main.js index f0f6384b9e714c571a02cda6f2f15ee55a1fbb49..94e6730da8006d4a09cdc839f5d4652ae7014fb2 100644 --- a/frontend/src/metabase/reducers-main.js +++ b/frontend/src/metabase/reducers-main.js @@ -19,6 +19,9 @@ import * as parameters from "metabase/parameters/reducers"; /* query builder */ import * as qb from "metabase/query_builder/reducers"; +/* data reference */ +import reference from "metabase/reference/reference"; + /* revisions */ import revisions from "metabase/redux/revisions"; @@ -43,6 +46,7 @@ export default { metabot: combineReducers(metabot), pulse: combineReducers(pulse), qb: combineReducers(qb), + reference, revisions, setup, admin, diff --git a/frontend/src/metabase/redux/metadata.js b/frontend/src/metabase/redux/metadata.js index 1479f69d9d29c30b81a3058df4bf95602e7a39c0..f517abcaedc0943cb848dff286d8f393be81fd71 100644 --- a/frontend/src/metabase/redux/metadata.js +++ b/frontend/src/metabase/redux/metadata.js @@ -1,3 +1,4 @@ +import { getIn } from "icepick"; import _ from "underscore"; import { createThunkAction, fetchData } from "metabase/lib/redux"; @@ -8,11 +9,79 @@ import { MetabaseApi, RevisionsApi } from "metabase/services"; import Schemas from "metabase/entities/schemas"; import Tables from "metabase/entities/tables"; import Fields from "metabase/entities/fields"; +import Segments from "metabase/entities/segments"; +import Metrics from "metabase/entities/metrics"; +import Databases from "metabase/entities/databases"; // NOTE: All of these actions are deprecated. Use metadata entities directly. const deprecated = message => console.warn("DEPRECATED: " + message); +export const FETCH_METRICS = Metrics.actions.fetchList.toString(); +export const fetchMetrics = (reload = false) => { + deprecated("metabase/redux/metadata fetchMetrics"); + return Metrics.actions.fetchList(null, { reload }); +}; + +export const updateMetric = metric => { + deprecated("metabase/redux/metadata updateMetric"); + return Metrics.actions.update(metric); +}; + +export const FETCH_SEGMENTS = Segments.actions.fetchList.toString(); +export const fetchSegments = (reload = false) => { + deprecated("metabase/redux/metadata fetchSegments"); + return Segments.actions.fetchList(null, { reload }); +}; + +export const updateSegment = segment => { + deprecated("metabase/redux/metadata updateSegment"); + return Segments.actions.update(segment); +}; + +export const FETCH_REAL_DATABASES = Databases.actions.fetchList.toString(); +export const fetchRealDatabases = (reload = false) => { + deprecated("metabase/redux/metadata fetchRealDatabases"); + return Databases.actions.fetchList({ include: "tables" }, { reload }); +}; + +export const FETCH_DATABASE_METADATA = + Databases.actions.fetchDatabaseMetadata.toString(); +export const fetchDatabaseMetadata = (dbId, reload = false) => { + deprecated("metabase/redux/metadata fetchDatabaseMetadata"); + return Databases.actions.fetchDatabaseMetadata({ id: dbId }, { reload }); +}; + +export const updateDatabase = database => { + deprecated("metabase/redux/metadata updateDatabase"); + const slimDatabase = _.omit(database, "tables", "tables_lookup"); + return Databases.actions.update(slimDatabase); +}; + +export const updateTable = table => { + deprecated("metabase/redux/metadata updateTable"); + const slimTable = _.omit( + table, + "fields", + "fields_lookup", + "aggregation_operators", + "metrics", + "segments", + ); + return Tables.actions.update(slimTable); +}; + +export const fetchTables = (reload = false) => { + deprecated("metabase/redux/metadata fetchTables"); + return Tables.actions.fetchList(null, { reload }); +}; + +export { FETCH_TABLE_METADATA } from "metabase/entities/tables"; +export const fetchTableMetadata = (id, reload = false) => { + deprecated("metabase/redux/metadata fetchTableMetadata"); + return Tables.actions.fetchMetadataAndForeignTables({ id }, { reload }); +}; + export const METADATA_FETCH_FIELD = "metabase/metadata/FETCH_FIELD"; export const fetchField = createThunkAction( METADATA_FETCH_FIELD, @@ -57,6 +126,27 @@ export const addFields = fieldMaps => { return Fields.actions.addFields(fieldMaps); }; +export const UPDATE_FIELD = Fields.actions.update.toString(); +export const updateField = field => { + deprecated("metabase/redux/metadata updateField"); + const slimField = _.omit(field, "filter_operators_lookup"); + return Fields.actions.update(slimField); +}; + +export const DELETE_FIELD_DIMENSION = + Fields.actions.deleteFieldDimension.toString(); +export const deleteFieldDimension = fieldId => { + deprecated("metabase/redux/metadata deleteFieldDimension"); + return Fields.actions.deleteFieldDimension({ id: fieldId }); +}; + +export const UPDATE_FIELD_DIMENSION = + Fields.actions.updateFieldDimension.toString(); +export const updateFieldDimension = (fieldId, dimension) => { + deprecated("metabase/redux/metadata updateFieldDimension"); + return Fields.actions.updateFieldDimension({ id: fieldId }, dimension); +}; + export const FETCH_REVISIONS = "metabase/metadata/FETCH_REVISIONS"; export const fetchRevisions = createThunkAction( FETCH_REVISIONS, @@ -84,6 +174,83 @@ export const fetchRevisions = createThunkAction( }, ); +// for fetches with data dependencies in /reference +export const FETCH_METRIC_TABLE = "metabase/metadata/FETCH_METRIC_TABLE"; +export const fetchMetricTable = createThunkAction( + FETCH_METRIC_TABLE, + (metricId, reload = false) => { + return async (dispatch, getState) => { + await dispatch(fetchMetrics()); // FIXME: fetchMetric? + const metric = getIn(getState(), ["entities", "metrics", metricId]); + const tableId = metric.table_id; + await dispatch(fetchTableMetadata(tableId)); + }; + }, +); + +export const FETCH_METRIC_REVISIONS = + "metabase/metadata/FETCH_METRIC_REVISIONS"; +export const fetchMetricRevisions = createThunkAction( + FETCH_METRIC_REVISIONS, + (metricId, reload = false) => { + return async (dispatch, getState) => { + await Promise.all([ + dispatch(fetchRevisions("metric", metricId)), + dispatch(fetchMetrics()), + ]); + const metric = getIn(getState(), ["entities", "metrics", metricId]); + const tableId = metric.table_id; + await dispatch(fetchTableMetadata(tableId)); + }; + }, +); + +export const FETCH_SEGMENT_FIELDS = "metabase/metadata/FETCH_SEGMENT_FIELDS"; +export const fetchSegmentFields = createThunkAction( + FETCH_SEGMENT_FIELDS, + (segmentId, reload = false) => { + return async (dispatch, getState) => { + await dispatch(fetchSegments()); // FIXME: fetchSegment? + const segment = getIn(getState(), ["entities", "segments", segmentId]); + const tableId = segment.table_id; + await dispatch(fetchTableMetadata(tableId)); + const table = getIn(getState(), ["entities", "tables", tableId]); + const databaseId = table.db_id; + await dispatch(fetchDatabaseMetadata(databaseId)); + }; + }, +); + +export const FETCH_SEGMENT_TABLE = "metabase/metadata/FETCH_SEGMENT_TABLE"; +export const fetchSegmentTable = createThunkAction( + FETCH_SEGMENT_TABLE, + (segmentId, reload = false) => { + return async (dispatch, getState) => { + await dispatch(fetchSegments()); // FIXME: fetchSegment? + const segment = getIn(getState(), ["entities", "segments", segmentId]); + const tableId = segment.table_id; + await dispatch(fetchTableMetadata(tableId)); + }; + }, +); + +export const FETCH_SEGMENT_REVISIONS = + "metabase/metadata/FETCH_SEGMENT_REVISIONS"; +export const fetchSegmentRevisions = createThunkAction( + FETCH_SEGMENT_REVISIONS, + (segmentId, reload = false) => { + return async (dispatch, getState) => { + await Promise.all([ + dispatch(fetchRevisions("segment", segmentId)), + dispatch(fetchSegments()), + ]); + const segment = getIn(getState(), ["entities", "segments", segmentId]); + const tableId = segment.table_id; + await dispatch(fetchTableMetadata(tableId)); + }; + }, +); + export const addRemappings = (fieldId, remappings) => { deprecated("metabase/redux/metadata addRemappings"); return Fields.actions.addRemappings({ id: fieldId }, remappings); @@ -124,6 +291,23 @@ export const fetchRemapping = createThunkAction( }, ); +const FETCH_REAL_DATABASES_WITH_METADATA = + "metabase/metadata/FETCH_REAL_DATABASES_WITH_METADATA"; +export const fetchRealDatabasesWithMetadata = createThunkAction( + FETCH_REAL_DATABASES_WITH_METADATA, + (reload = false) => { + return async (dispatch, getState) => { + await dispatch(fetchRealDatabases()); + const databases = getIn(getState(), ["entities", "databases"]); + await Promise.all( + Object.values(databases).map(database => + dispatch(fetchDatabaseMetadata(database.id)), + ), + ); + }; + }, +); + export const loadMetadataForQuery = (query, extraDependencies) => loadMetadataForQueries([query], extraDependencies); diff --git a/frontend/src/metabase/reference/Reference.css b/frontend/src/metabase/reference/Reference.css new file mode 100644 index 0000000000000000000000000000000000000000..a263098939cf056e7f8077722845efc7da79a39f --- /dev/null +++ b/frontend/src/metabase/reference/Reference.css @@ -0,0 +1,200 @@ +:root { + --title-color: var(--color-text-medium); + --subtitle-color: var(--color-text-medium); + --icon-width: 60px; +} + +:local(.guideEmpty) { + composes: flex full justify-center from "style"; + padding-top: 75px; +} + +:local(.guideEmptyBody) { + composes: text-centered from "style"; + max-width: 400px; +} + +:local(.guideEmptyMessage) { + composes: text-dark text-paragraph text-centered mt3 from "style"; +} + +:local(.columnHeader) { + composes: flex flex-full from "style"; + padding-top: 20px; + padding-bottom: 20px; +} + +:local(.revisionsWrapper) { + padding-top: 20px; + padding-left: var(--icon-width); +} + +:local(.schemaSeparator) { + composes: text-light mt2 from "style"; + margin-left: var(--icon-width); + font-size: 18px; +} + +:local(.tableActualName) { + font-family: "Lucida Console", Monaco, monospace; + font-size: 13px; + line-height: 1.4em; + letter-spacing: 1px; + white-space: pre-wrap; + color: var(--color-text-medium); + background-color: var(--color-bg-light); + border: 1px solid var(--color-text-light); + border-radius: 4px; + padding: 0.2em 0.4em; +} + +:local(.guideLeftPadded) { + composes: flex full justify-center from "style"; +} + +:local(.guideLeftPadded)::before { + /*FIXME: not sure how to share this with other components + because we can't use composes here apparently. any workarounds?*/ + content: ""; + display: block; + flex: 0.3; + max-width: 250px; + margin-right: 50px; +} + +:local(.guideLeftPaddedBody) { + flex: 0.7; + max-width: 550px; +} + +:local(.guideWrapper) { + margin-bottom: 50px; +} + +:local(.guideTitle) { + composes: guideLeftPadded; + font-size: 24px; + margin-top: 50px; +} + +:local(.guideTitleBody) { + composes: full text-dark text-bold from "style"; + composes: guideLeftPaddedBody; +} + +:local(.guideSeeAll) { + composes: guideLeftPadded; + font-size: 18px; +} + +:local(.guideSeeAllBody) { + composes: flex full text-dark text-bold mt4 from "style"; + composes: guideLeftPaddedBody; +} + +:local(.guideSeeAllLink) { + composes: flex-full block no-decoration py1 border-top from "style"; +} + +:local(.guideContact) { + composes: mt4 from "style"; + composes: guideLeftPadded; + margin-bottom: 100px; +} + +:local(.guideContactBody) { + composes: full from "style"; + composes: guideLeftPaddedBody; + font-size: 16px; +} + +:local(.guideEditHeader) { + composes: full text-body my4 from "style"; + max-width: 550px; + color: var(--color-text-dark); +} + +:local(.guideEditHeaderTitle) { + composes: text-bold mb2 from "style"; + font-size: 24px; +} + +:local(.guideEditCards) { + composes: mt2 mb4 from "style"; +} + +:local(.guideEditCard) { + composes: input p4 from "style"; +} + +:local(.guideEditLabel) { + composes: block text-bold mb2 from "style"; + font-size: 16px; + color: var(--title-color); +} + +:local(.guideEditHeaderDescription) { + font-size: 16px; +} + +:local(.guideEditTitle) { + composes: block text-body text-bold from "style"; + color: var(--title-color); + font-size: 16px; + margin-top: 50px; +} + +:local(.guideEditSubtitle) { + composes: text-body from "style"; + color: var(--color-text-light); + font-size: 16px; + max-width: 700px; +} + +:local(.guideEditAddButton) { + composes: flex full my2 pl4 from "style"; + padding-right: 3.5rem; +} + +:local(.guideEditAddButton)::before { + content: ""; + display: block; + flex: 250; + max-width: 250px; + margin-right: 50px; +} + +:local(.guideEditAddButtonBody) { + flex: 550; + max-width: 550px; +} + +:local(.guideEditTextarea) { + composes: text-dark input p2 from "style"; + resize: none; + font-size: 16px; + width: 100%; + max-width: 850px; + min-height: 100px; +} + +:local(.guideEditContact) { + composes: flex from "style"; +} + +:local(.guideEditContactName) { + flex: 250; + max-width: 250px; + margin-right: 50px; +} + +:local(.guideEditContactEmail) { + flex: 550; + max-width: 550px; +} + +:local(.guideEditInput) { + composes: full text-dark input p2 from "style"; + font-size: 16px; + display: block; +} diff --git a/frontend/src/metabase/reference/components/Detail.css b/frontend/src/metabase/reference/components/Detail.css new file mode 100644 index 0000000000000000000000000000000000000000..b15eac4ae763c912b482b4394b7cec7f470d04cb --- /dev/null +++ b/frontend/src/metabase/reference/components/Detail.css @@ -0,0 +1,42 @@ +:root { + --title-color: var(--color-text-medium); + --muted-color: var(--color-text-light); + --blue-color: var(--color-brand); +} + +:local(.detail) { + composes: flex align-center from "style"; + composes: relative from "style"; +} + +:local(.detailBody) { + composes: pb4 from "style"; + max-width: 900px; +} + +:local(.detailTitle) { + composes: text-bold inline-block from "style"; + color: var(--title-color); +} + +:local(.detailSubtitle) { + composes: text-dark text-paragraph from "style"; + white-space: pre-wrap; + font-size: 16px; + line-height: 24px; + padding-top: 6px; +} + +:local(.detailSubtitleLight) { + composes: text-light from "style"; + padding-top: 6px; +} + +:local(.detailTextarea) { + composes: text-dark input p2 from "style"; + resize: none; + font-size: 16px; + width: 100%; + min-height: 100px; + border-color: var(--color-text-light); +} diff --git a/frontend/src/metabase/reference/components/Detail.jsx b/frontend/src/metabase/reference/components/Detail.jsx new file mode 100644 index 0000000000000000000000000000000000000000..114592434066afe64321aced467d187044dd7591 --- /dev/null +++ b/frontend/src/metabase/reference/components/Detail.jsx @@ -0,0 +1,67 @@ +/* eslint "react/prop-types": "warn" */ +import { memo } from "react"; +import PropTypes from "prop-types"; +import { Link } from "react-router"; +import { t } from "ttag"; +import cx from "classnames"; +import S from "./Detail.css"; + +const Detail = ({ + name, + description, + placeholder, + subtitleClass, + url, + icon, + isEditing, + field, +}) => ( + <div className={cx(S.detail)}> + <div className={isEditing ? cx(S.detailBody, "flex-full") : S.detailBody}> + <div className={S.detailTitle}> + {url ? ( + <Link to={url} className={S.detailName}> + {name} + </Link> + ) : ( + <span className={S.detailName}>{name}</span> + )} + </div> + <div + className={cx(description ? S.detailSubtitle : S.detailSubtitleLight)} + > + {isEditing ? ( + <textarea + className={S.detailTextarea} + name={field.name} + placeholder={placeholder} + onChange={field.onChange} + //FIXME: use initialValues from redux forms instead of default value + // to allow for reinitializing on cancel (see GettingStartedGuide.jsx) + defaultValue={description} + /> + ) : ( + <span className={subtitleClass}> + {description || placeholder || t`No description yet`} + </span> + )} + {isEditing && field.error && field.touched && ( + <span className="text-error">{field.error}</span> + )} + </div> + </div> + </div> +); + +Detail.propTypes = { + name: PropTypes.string.isRequired, + url: PropTypes.string, + description: PropTypes.string, + placeholder: PropTypes.string, + subtitleClass: PropTypes.string, + icon: PropTypes.string, + isEditing: PropTypes.bool, + field: PropTypes.object, +}; + +export default memo(Detail); diff --git a/frontend/src/metabase/reference/components/EditHeader.css b/frontend/src/metabase/reference/components/EditHeader.css new file mode 100644 index 0000000000000000000000000000000000000000..b0d19554dc7288bf97662ea1c993227492975f87 --- /dev/null +++ b/frontend/src/metabase/reference/components/EditHeader.css @@ -0,0 +1,31 @@ +:root { + --edit-header-color: var(--color-brand); +} + +:local(.editHeader) { + composes: text-white flex align-center from "style"; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 43px; + background-color: color-mod(var(--color-bg-white) alpha(-85%)); +} + +:local(.editHeaderButtons) { + composes: flex-align-right from "style"; +} + +:local(.editHeaderButton) { + border: none; + color: var(--edit-header-color); +} + +:local(.saveButton) { + composes: editHeaderButton; +} + +:local(.cancelButton) { + composes: editHeaderButton; + opacity: 0.5; +} diff --git a/frontend/src/metabase/reference/components/EditHeader.jsx b/frontend/src/metabase/reference/components/EditHeader.jsx new file mode 100644 index 0000000000000000000000000000000000000000..fade39a5093e38f5108194277a3fb9b48c19e6e7 --- /dev/null +++ b/frontend/src/metabase/reference/components/EditHeader.jsx @@ -0,0 +1,82 @@ +import { memo } from "react"; +import PropTypes from "prop-types"; +import cx from "classnames"; +import { t } from "ttag"; +import RevisionMessageModal from "metabase/reference/components/RevisionMessageModal"; +import S from "./EditHeader.css"; + +const EditHeader = ({ + hasRevisionHistory, + endEditing, + reinitializeForm = () => undefined, + submitting, + onSubmit, + revisionMessageFormField, +}) => ( + <div className={cx("EditHeader wrapper py1 px3", S.editHeader)}> + <div>{t`You are editing this page`}</div> + <div className={S.editHeaderButtons}> + <button + type="button" + className={cx( + "Button", + "Button--white", + "Button--small", + S.cancelButton, + )} + onClick={() => { + endEditing(); + reinitializeForm(); + }} + > + {t`Cancel`} + </button> + + {hasRevisionHistory ? ( + <RevisionMessageModal + action={() => onSubmit()} + field={revisionMessageFormField} + submitting={submitting} + > + <button + className={cx( + "Button", + "Button--primary", + "Button--white", + "Button--small", + S.saveButton, + )} + type="button" + disabled={submitting} + > + {t`Save`} + </button> + </RevisionMessageModal> + ) : ( + <button + className={cx( + "Button", + "Button--primary", + "Button--white", + "Button--small", + S.saveButton, + )} + type="submit" + disabled={submitting} + > + {t`Save`} + </button> + )} + </div> + </div> +); +EditHeader.propTypes = { + hasRevisionHistory: PropTypes.bool, + endEditing: PropTypes.func.isRequired, + reinitializeForm: PropTypes.func, + submitting: PropTypes.bool.isRequired, + onSubmit: PropTypes.func, + revisionMessageFormField: PropTypes.object, +}; + +export default memo(EditHeader); diff --git a/frontend/src/metabase/reference/components/EditableReferenceHeader.jsx b/frontend/src/metabase/reference/components/EditableReferenceHeader.jsx new file mode 100644 index 0000000000000000000000000000000000000000..295c0e6918111550aaadfa1c6c09a8fb2bef47fc --- /dev/null +++ b/frontend/src/metabase/reference/components/EditableReferenceHeader.jsx @@ -0,0 +1,110 @@ +import { memo } from "react"; +import PropTypes from "prop-types"; +import { Link } from "react-router"; +import cx from "classnames"; +import { t } from "ttag"; +import L from "metabase/components/List/List.css"; + +import { Icon } from "metabase/core/components/Icon"; +import InputBlurChange from "metabase/components/InputBlurChange"; +import Ellipsified from "metabase/core/components/Ellipsified"; +import Button from "metabase/core/components/Button"; +import S from "./ReferenceHeader.css"; + +const EditableReferenceHeader = ({ + entity = {}, + table, + type, + headerIcon, + headerLink, + name, + user, + isEditing, + hasSingleSchema, + hasDisplayName, + startEditing, + displayNameFormField, + nameFormField, +}) => ( + <div className="wrapper"> + <div className={cx("relative", L.header)}> + <div className="flex align-center mr1"> + {headerIcon && ( + <Icon className="text-light" name={headerIcon} size={21} /> + )} + </div> + {type === "table" && !hasSingleSchema && !isEditing && ( + <div className={S.headerSchema}>{entity.schema}</div> + )} + <div + className={S.headerBody} + style={ + isEditing && name === "Details" ? { alignItems: "flex-start" } : {} + } + > + {isEditing && name === "Details" ? ( + <InputBlurChange + className={S.headerTextInput} + type="text" + name={ + hasDisplayName ? displayNameFormField.name : nameFormField.name + } + placeholder={entity.name} + onChange={ + hasDisplayName + ? displayNameFormField.onChange + : nameFormField.onChange + } + defaultValue={hasDisplayName ? entity.display_name : entity.name} + /> + ) : ( + [ + <Ellipsified + key="1" + className={!headerLink && "flex-full"} + tooltipMaxWidth="100%" + > + {name === "Details" + ? hasDisplayName + ? entity.display_name || entity.name + : entity.name + : name} + </Ellipsified>, + headerLink && ( + <Button + primary + className="flex flex-align-right mr2" + style={{ fontSize: 14 }} + data-metabase-event={`Data Reference;Entity -> QB click;${type}`} + > + <Link to={headerLink}>{t`See this ${type}`}</Link> + </Button> + ), + ] + )} + {user && user.is_superuser && !isEditing && ( + <Button icon="pencil" style={{ fontSize: 14 }} onClick={startEditing}> + {t`Edit`} + </Button> + )} + </div> + </div> + </div> +); +EditableReferenceHeader.propTypes = { + entity: PropTypes.object, + table: PropTypes.object, + type: PropTypes.string, + headerIcon: PropTypes.string, + headerLink: PropTypes.string, + name: PropTypes.string, + user: PropTypes.object, + isEditing: PropTypes.bool, + hasSingleSchema: PropTypes.bool, + hasDisplayName: PropTypes.bool, + startEditing: PropTypes.func, + displayNameFormField: PropTypes.object, + nameFormField: PropTypes.object, +}; + +export default memo(EditableReferenceHeader); diff --git a/frontend/src/metabase/reference/components/Field.css b/frontend/src/metabase/reference/components/Field.css new file mode 100644 index 0000000000000000000000000000000000000000..c5087edd07223b42cbd946380b4f2e08b88fc31f --- /dev/null +++ b/frontend/src/metabase/reference/components/Field.css @@ -0,0 +1,56 @@ +:root { + --title-color: var(--color-text-medium); +} + +:local(.field) { + composes: flex align-center from "style"; +} + +:local(.fieldNameTitle) { + composes: flex-half pr2 from "style"; +} + +:local(.fieldName) { + composes: fieldNameTitle; +} + +:local(.fieldNameTextInput) { + composes: input p1 from "style"; + color: var(--title-color); + width: 100%; + font-size: 14px; +} + +:local(.fieldSelect) { + composes: input p1 block from "style"; +} + +:local(.fieldType) { + composes: flex-1-quarter text-medium pr2 from "style"; + overflow: hidden; + white-space: nowrap; +} + +:local(.fieldDataType) { + composes: flex-1-quarter text-medium from "style"; +} + +:local(.fieldSecondary) { + composes: field; + font-size: 13px; +} + +:local(.fieldActualName) { + composes: fieldNameTitle; + composes: text-monospace text-light from "style"; + font-size: 12px; + letter-spacing: 1px; +} + +:local(.fieldForeignKey) { + composes: fieldType; +} + +:local(.fieldOther) { + composes: fieldDataType; +} diff --git a/frontend/src/metabase/reference/components/Field.jsx b/frontend/src/metabase/reference/components/Field.jsx new file mode 100644 index 0000000000000000000000000000000000000000..6a752c62b8fd44a6629780ebfa120973b288605d --- /dev/null +++ b/frontend/src/metabase/reference/components/Field.jsx @@ -0,0 +1,128 @@ +/* eslint "react/prop-types": "warn" */ +import { memo } from "react"; +import PropTypes from "prop-types"; +import { Link } from "react-router"; +import { t } from "ttag"; +import { getIn } from "icepick"; +import cx from "classnames"; +import * as MetabaseCore from "metabase/lib/core"; + +import S from "metabase/components/List/List.css"; +import Select from "metabase/core/components/Select"; +import { Icon } from "metabase/core/components/Icon"; +import { isTypeFK } from "metabase-lib/types/utils/isa"; +import F from "./Field.css"; + +const Field = ({ field, foreignKeys, url, icon, isEditing, formField }) => ( + <div className={cx(S.item, "pt1", "border-top")}> + <div className={S.itemBody} style={{ maxWidth: "100%", borderTop: "none" }}> + <div className={F.field}> + <div className={cx(S.itemTitle, F.fieldName)}> + {isEditing ? ( + <input + className={F.fieldNameTextInput} + type="text" + placeholder={field.name} + {...formField.display_name} + defaultValue={field.display_name} + /> + ) : ( + <div> + <Link to={url}> + <span className="text-brand">{field.display_name}</span> + <span className={cx(F.fieldActualName, "ml2")}> + {field.name} + </span> + </Link> + </div> + )} + </div> + <div className={F.fieldType}> + {isEditing ? ( + <Select + name={formField.semantic_type.name} + placeholder={t`Select a field type`} + value={ + formField.semantic_type.value !== undefined + ? formField.semantic_type.value + : field.semantic_type + } + onChange={formField.semantic_type.onChange} + options={MetabaseCore.field_semantic_types.concat({ + id: null, + name: t`No field type`, + section: t`Other`, + })} + optionValueFn={o => o.id} + optionSectionFn={o => o.section} + /> + ) : ( + <div className="flex"> + <div className={S.leftIcons}> + {icon && <Icon className={S.chartIcon} name={icon} size={20} />} + </div> + <span + className={ + getIn(MetabaseCore.field_semantic_types_map, [ + field.semantic_type, + "name", + ]) + ? "text-medium" + : "text-light" + } + > + {getIn(MetabaseCore.field_semantic_types_map, [ + field.semantic_type, + "name", + ]) || t`No field type`} + </span> + </div> + )} + </div> + <div className={F.fieldDataType}>{field.base_type}</div> + </div> + <div className={cx(S.itemSubtitle, F.fieldSecondary, { mt1: true })}> + <div className={F.fieldForeignKey}> + {isEditing + ? (isTypeFK(formField.semantic_type.value) || + (isTypeFK(field.semantic_type) && + formField.semantic_type.value === undefined)) && ( + <Select + name={formField.fk_target_field_id.name} + placeholder={t`Select a target`} + value={ + formField.fk_target_field_id.value || + field.fk_target_field_id + } + onChange={formField.fk_target_field_id.onChange} + options={Object.values(foreignKeys)} + optionValueFn={o => o.id} + /> + ) + : isTypeFK(field.semantic_type) && ( + <span> + {getIn(foreignKeys, [field.fk_target_field_id, "name"])} + </span> + )} + </div> + <div className={F.fieldOther} /> + </div> + {field.description && ( + <div className={cx(S.itemSubtitle, "mb2", { mt1: isEditing })}> + {field.description} + </div> + )} + </div> + </div> +); +Field.propTypes = { + field: PropTypes.object.isRequired, + foreignKeys: PropTypes.object.isRequired, + url: PropTypes.string.isRequired, + placeholder: PropTypes.string, + icon: PropTypes.string, + isEditing: PropTypes.bool, + formField: PropTypes.object, +}; + +export default memo(Field); diff --git a/frontend/src/metabase/reference/components/FieldToGroupBy.css b/frontend/src/metabase/reference/components/FieldToGroupBy.css new file mode 100644 index 0000000000000000000000000000000000000000..81c761f56ada006c881607032c361ce89fcf8bb3 --- /dev/null +++ b/frontend/src/metabase/reference/components/FieldToGroupBy.css @@ -0,0 +1,5 @@ +:local(.fieldToGroupByText) { + composes: flex-full from "style"; + font-size: 14px; + color: var(--color-text-medium); +} diff --git a/frontend/src/metabase/reference/components/FieldToGroupBy.jsx b/frontend/src/metabase/reference/components/FieldToGroupBy.jsx new file mode 100644 index 0000000000000000000000000000000000000000..aff73679ed42f2deb972455074fd4c461f88a678 --- /dev/null +++ b/frontend/src/metabase/reference/components/FieldToGroupBy.jsx @@ -0,0 +1,44 @@ +/* eslint-disable react/prop-types */ +import { memo } from "react"; +import PropTypes from "prop-types"; +import { t } from "ttag"; +import cx from "classnames"; +import Q from "metabase/components/QueryButton/QueryButton.css"; + +import { Icon } from "metabase/core/components/Icon"; +import S from "./FieldToGroupBy.css"; + +const FieldToGroupBy = ({ + className, + metric, + field, + icon, + iconClass, + onClick, + secondaryOnClick, +}) => ( + <div className={className}> + <a className={Q.queryButton} onClick={onClick}> + <div className={S.fieldToGroupByText}> + <div className="text-brand text-bold">{field.display_name}</div> + </div> + <Icon + className={cx(iconClass, "pr1")} + tooltip={field.description ? field.description : t`Look up this field`} + size={16} + name="reference" + onClick={secondaryOnClick} + /> + </a> + </div> +); +FieldToGroupBy.propTypes = { + className: PropTypes.string, + metric: PropTypes.object.isRequired, + field: PropTypes.object.isRequired, + iconClass: PropTypes.string, + onClick: PropTypes.func, + secondaryOnClick: PropTypes.func, +}; + +export default memo(FieldToGroupBy); diff --git a/frontend/src/metabase/reference/components/FieldTypeDetail.jsx b/frontend/src/metabase/reference/components/FieldTypeDetail.jsx new file mode 100644 index 0000000000000000000000000000000000000000..da1b91d50da8105bb95106e22c735b14589e32e0 --- /dev/null +++ b/frontend/src/metabase/reference/components/FieldTypeDetail.jsx @@ -0,0 +1,89 @@ +import { memo } from "react"; +import PropTypes from "prop-types"; +import cx from "classnames"; +import { getIn } from "icepick"; +import { t } from "ttag"; +import * as MetabaseCore from "metabase/lib/core"; + +import Select from "metabase/core/components/Select"; + +import D from "metabase/reference/components/Detail.css"; +import { isTypeFK, isNumericBaseType } from "metabase-lib/types/utils/isa"; + +const FieldTypeDetail = ({ + field, + foreignKeys, + fieldTypeFormField, + foreignKeyFormField, + isEditing, +}) => ( + <div className={cx(D.detail)}> + <div className={D.detailBody}> + <div className={D.detailTitle}> + <span className={D.detailName}>{t`Field type`}</span> + </div> + <div className={cx(D.detailSubtitle, { mt1: true })}> + <span> + {isEditing ? ( + <Select + placeholder={t`Select a field type`} + value={fieldTypeFormField.value || field.semantic_type} + options={MetabaseCore.field_semantic_types + .concat({ + id: null, + name: t`No field type`, + section: t`Other`, + }) + .filter(type => + !isNumericBaseType(field) + ? !(type.id && type.id.startsWith("timestamp_")) + : true, + )} + optionValueFn={o => o.id} + onChange={({ target: { value } }) => + fieldTypeFormField.onChange(value) + } + /> + ) : ( + <span> + {getIn(MetabaseCore.field_semantic_types_map, [ + field.semantic_type, + "name", + ]) || t`No field type`} + </span> + )} + </span> + <span className="ml4"> + {isEditing + ? (isTypeFK(fieldTypeFormField.value) || + (isTypeFK(field.semantic_type) && + fieldTypeFormField.value === undefined)) && ( + <Select + placeholder={t`Select a foreign key`} + value={foreignKeyFormField.value || field.fk_target_field_id} + options={Object.values(foreignKeys)} + onChange={({ target: { value } }) => + foreignKeyFormField.onChange(value) + } + optionValueFn={o => o.id} + /> + ) + : isTypeFK(field.semantic_type) && ( + <span> + {getIn(foreignKeys, [field.fk_target_field_id, "name"])} + </span> + )} + </span> + </div> + </div> + </div> +); +FieldTypeDetail.propTypes = { + field: PropTypes.object.isRequired, + foreignKeys: PropTypes.object.isRequired, + fieldTypeFormField: PropTypes.object.isRequired, + foreignKeyFormField: PropTypes.object.isRequired, + isEditing: PropTypes.bool.isRequired, +}; + +export default memo(FieldTypeDetail); diff --git a/frontend/src/metabase/reference/components/FieldsToGroupBy.jsx b/frontend/src/metabase/reference/components/FieldsToGroupBy.jsx new file mode 100644 index 0000000000000000000000000000000000000000..d092531c9895b49c01c1dbceac55624f8421ba83 --- /dev/null +++ b/frontend/src/metabase/reference/components/FieldsToGroupBy.jsx @@ -0,0 +1,69 @@ +/* eslint-disable react/prop-types */ +import { Component } from "react"; +import { connect } from "react-redux"; + +import D from "metabase/reference/components/Detail.css"; +import L from "metabase/components/List/List.css"; + +import FieldToGroupBy from "metabase/reference/components/FieldToGroupBy"; + +import { fetchTableMetadata } from "metabase/redux/metadata"; +import { getMetadata } from "metabase/selectors/metadata"; +import { getQuestionUrl } from "../utils"; +import S from "./UsefulQuestions.css"; + +const mapDispatchToProps = { + fetchTableMetadata, +}; + +const mapStateToProps = (state, props) => ({ + metadata: getMetadata(state, props), +}); + +class FieldsToGroupBy extends Component { + render() { + const { fields, databaseId, metric, title, onChangeLocation, metadata } = + this.props; + + return ( + <div> + <div className={D.detailBody}> + <div className={D.detailTitle}> + <span className={D.detailName}>{title}</span> + </div> + <div className={S.usefulQuestions}> + {fields && + Object.values(fields).map((field, index, fields) => ( + <FieldToGroupBy + key={field.id} + className="px1 mb1 rounded bg-light-hover" + iconClass={L.icon} + field={field} + metric={metric} + onClick={() => + onChangeLocation( + getQuestionUrl({ + dbId: databaseId, + tableId: field.table_id, + fieldId: field.id, + metricId: metric.id, + metadata, + }), + ) + } + secondaryOnClick={event => { + event.stopPropagation(); + onChangeLocation( + `/reference/databases/${databaseId}/tables/${field.table_id}/fields/${field.id}`, + ); + }} + /> + ))} + </div> + </div> + </div> + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(FieldsToGroupBy); diff --git a/frontend/src/metabase/reference/components/Formula.css b/frontend/src/metabase/reference/components/Formula.css new file mode 100644 index 0000000000000000000000000000000000000000..0978919ff9bb32859dde86ab26b7f6c4399e295a --- /dev/null +++ b/frontend/src/metabase/reference/components/Formula.css @@ -0,0 +1,38 @@ +:local(.formula) { + composes: bordered rounded from "style"; + background-color: var(--color-bg-light); + cursor: pointer; +} + +:local(.formulaHeader) { + composes: flex align-center text-brand py1 px2 from "style"; +} + +:local(.formulaTitle) { + composes: ml2 from "style"; + font-size: 16px; +} + +:local(.formulaDefinitionInner) { + composes: p2 from "style"; +} + +.formulaDefinition { + overflow: hidden; +} + +.formulaDefinition-enter { + max-height: 0px; +} +.formulaDefinition-enter.formulaDefinition-enter-active { + /* using 100% max-height breaks the transition */ + max-height: 150px; + transition: max-height 300ms ease-out; +} +.formulaDefinition-exit { + max-height: 150px; +} +.formulaDefinition-exit.formulaDefinition-exit-active { + max-height: 0px; + transition: max-height 300ms ease-out; +} diff --git a/frontend/src/metabase/reference/components/Formula.jsx b/frontend/src/metabase/reference/components/Formula.jsx new file mode 100644 index 0000000000000000000000000000000000000000..a764c11b829b184da4cbb5687b2b3caa9d41189d --- /dev/null +++ b/frontend/src/metabase/reference/components/Formula.jsx @@ -0,0 +1,56 @@ +/* eslint-disable react/prop-types */ +import { Component } from "react"; +import cx from "classnames"; +import { connect } from "react-redux"; +import { t } from "ttag"; +import { TransitionGroup, CSSTransition } from "react-transition-group"; + +import { Icon } from "metabase/core/components/Icon"; + +import QueryDefinition from "metabase/query_builder/components/QueryDefinition"; +import { fetchTableMetadata } from "metabase/redux/metadata"; +import S from "./Formula.css"; + +const mapDispatchToProps = { + fetchTableMetadata, +}; + +class Formula extends Component { + render() { + const { type, entity, isExpanded, expandFormula, collapseFormula } = + this.props; + + return ( + <div + className={cx(S.formula)} + onClick={isExpanded ? collapseFormula : expandFormula} + > + <div className={S.formulaHeader}> + <Icon name="beaker" size={24} /> + <span className={S.formulaTitle}>{t`View the ${type} formula`}</span> + </div> + <TransitionGroup> + {isExpanded && ( + <CSSTransition + key="formulaDefinition" + classNames="formulaDefinition" + timeout={{ + enter: 300, + exit: 300, + }} + > + <div className="formulaDefinition"> + <QueryDefinition + className={S.formulaDefinitionInner} + object={entity} + /> + </div> + </CSSTransition> + )} + </TransitionGroup> + </div> + ); + } +} + +export default connect(null, mapDispatchToProps)(Formula); diff --git a/frontend/src/metabase/reference/components/ReferenceHeader.css b/frontend/src/metabase/reference/components/ReferenceHeader.css new file mode 100644 index 0000000000000000000000000000000000000000..3f8e18b66ce4456bc13fe582d16b2a5003bdcba3 --- /dev/null +++ b/frontend/src/metabase/reference/components/ReferenceHeader.css @@ -0,0 +1,42 @@ +:root { + --title-color: var(--color-text-medium); +} + +:local(.headerBody) { + composes: flex flex-full text-dark text-bold from "style"; + overflow: hidden; + align-items: center; + border-color: var(--color-border); +} + +:local(.headerTextInput) { + composes: p1 from "style"; + font-size: 18px; + color: var(--title-color); + max-width: 550px; +} + +:local(.subheader) { + composes: mt1 mb2 from "style"; +} + +:local(.subheaderBody) { + composes: text-medium text-bold from "style"; + font-size: 14px; +} + +:local(.subheaderLink) { + color: var(--color-brand); + text-decoration: none; +} + +:local(.subheaderLink):hover { + color: var(--color-brand); + transition: color 0.3s linear; +} + +:local(.headerSchema) { + composes: text-light absolute from "style"; + top: -10px; + font-size: 12px; +} diff --git a/frontend/src/metabase/reference/components/ReferenceHeader.jsx b/frontend/src/metabase/reference/components/ReferenceHeader.jsx new file mode 100644 index 0000000000000000000000000000000000000000..472f3cc5434b990b0be43e5b7d64678fed1a83b4 --- /dev/null +++ b/frontend/src/metabase/reference/components/ReferenceHeader.jsx @@ -0,0 +1,63 @@ +import { memo } from "react"; +import PropTypes from "prop-types"; +import { Link } from "react-router"; +import cx from "classnames"; + +import { t } from "ttag"; +import L from "metabase/components/List/List.css"; + +import { Icon } from "metabase/core/components/Icon"; +import Ellipsified from "metabase/core/components/Ellipsified"; +import S from "./ReferenceHeader.css"; + +const ReferenceHeader = ({ + name, + type, + headerIcon, + headerBody, + headerLink, +}) => ( + <div className="wrapper"> + <div className={cx("relative", L.header)}> + {headerIcon && ( + <div className="flex align-center mr2"> + <Icon className="text-light" name={headerIcon} size={21} /> + </div> + )} + <div className={S.headerBody}> + <Ellipsified + key="1" + className={!headerLink && "flex-full"} + tooltipMaxWidth="100%" + > + {name} + </Ellipsified> + + {headerLink && ( + <div key="2" className={cx("flex-full", S.headerButton)}> + <Link + to={headerLink} + className={cx("Button", "Button--borderless", "ml3")} + data-metabase-event={`Data Reference;Entity -> QB click;${type}`} + > + <div className="flex align-center relative"> + <span className="mr1 flex-no-shrink">{t`See this ${type}`}</span> + <Icon name="chevronright" size={16} /> + </div> + </Link> + </div> + )} + </div> + </div> + </div> +); + +ReferenceHeader.propTypes = { + name: PropTypes.string.isRequired, + type: PropTypes.string, + headerIcon: PropTypes.string, + headerBody: PropTypes.string, + headerLink: PropTypes.string, +}; + +export default memo(ReferenceHeader); diff --git a/frontend/src/metabase/reference/components/RevisionMessageModal.css b/frontend/src/metabase/reference/components/RevisionMessageModal.css new file mode 100644 index 0000000000000000000000000000000000000000..7c97265c891cbb0efc7fc948cac43b530e84196f --- /dev/null +++ b/frontend/src/metabase/reference/components/RevisionMessageModal.css @@ -0,0 +1,13 @@ +:local(.modalBody) { + composes: flex justify-center align-center from "style"; + padding-left: 32px; + padding-right: 32px; + padding-bottom: 32px; +} + +:local(.modalTextArea) { + composes: flex-full text-dark input p2 from "style"; + resize: none; + font-size: 16px; + min-height: 100px; +} diff --git a/frontend/src/metabase/reference/components/RevisionMessageModal.jsx b/frontend/src/metabase/reference/components/RevisionMessageModal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..f494d302d783d75afd2b2b708f9494f9e7ad5e01 --- /dev/null +++ b/frontend/src/metabase/reference/components/RevisionMessageModal.jsx @@ -0,0 +1,64 @@ +/* eslint "react/prop-types": "warn" */ +import { createRef, Component } from "react"; +import PropTypes from "prop-types"; +import { t } from "ttag"; +import ModalWithTrigger from "metabase/components/ModalWithTrigger"; +import ModalContent from "metabase/components/ModalContent"; + +import S from "./RevisionMessageModal.css"; + +export default class RevisionMessageModal extends Component { + static propTypes = { + action: PropTypes.func.isRequired, + field: PropTypes.object.isRequired, + submitting: PropTypes.bool, + children: PropTypes.any, + }; + + constructor(props) { + super(props); + + this.modal = createRef(); + } + + render() { + const { action, children, field, submitting } = this.props; + + const onClose = () => { + this.modal.current.close(); + }; + + const onAction = () => { + onClose(); + action(); + }; + + return ( + <ModalWithTrigger ref={this.modal} triggerElement={children}> + <ModalContent title={t`Reason for changes`} onClose={onClose}> + <div className={S.modalBody}> + <textarea + className={S.modalTextArea} + placeholder={t`Leave a note to explain what changes you made and why they were required`} + {...field} + /> + </div> + + <div className="Form-actions"> + <button + type="button" + className="Button Button--primary" + onClick={onAction} + disabled={submitting || field.error} + >{t`Save changes`}</button> + <button + type="button" + className="Button ml1" + onClick={onClose} + >{t`Cancel`}</button> + </div> + </ModalContent> + </ModalWithTrigger> + ); + } +} diff --git a/frontend/src/metabase/reference/components/UsefulQuestions.css b/frontend/src/metabase/reference/components/UsefulQuestions.css new file mode 100644 index 0000000000000000000000000000000000000000..f6ef86c10677b52f4224dfd24d39e592bfcd1e96 --- /dev/null +++ b/frontend/src/metabase/reference/components/UsefulQuestions.css @@ -0,0 +1,4 @@ +:local(.usefulQuestions) { + composes: text-brand mt1 from "style"; + font-size: 16px; +} diff --git a/frontend/src/metabase/reference/components/UsefulQuestions.jsx b/frontend/src/metabase/reference/components/UsefulQuestions.jsx new file mode 100644 index 0000000000000000000000000000000000000000..6c2eac8a6c0f0f10c1a0c8e795af08a7f917f8dd --- /dev/null +++ b/frontend/src/metabase/reference/components/UsefulQuestions.jsx @@ -0,0 +1,27 @@ +import { memo } from "react"; +import PropTypes from "prop-types"; +import { t } from "ttag"; +import D from "metabase/reference/components/Detail.css"; + +import QueryButton from "metabase/components/QueryButton"; +import S from "./UsefulQuestions.css"; + +const UsefulQuestions = ({ questions }) => ( + <div className={D.detail}> + <div className={D.detailBody}> + <div className={D.detailTitle}> + <span className={D.detailName}>{t`Potentially useful questions`}</span> + </div> + <div className={S.usefulQuestions}> + {questions.map((question, index, questions) => ( + <QueryButton key={index} {...question} /> + ))} + </div> + </div> + </div> +); +UsefulQuestions.propTypes = { + questions: PropTypes.array.isRequired, +}; + +export default memo(UsefulQuestions); diff --git a/frontend/src/metabase/reference/databases/DatabaseDetail.jsx b/frontend/src/metabase/reference/databases/DatabaseDetail.jsx new file mode 100644 index 0000000000000000000000000000000000000000..3f75ffe897cf8be69beef4b8d5902e2bdbb46341 --- /dev/null +++ b/frontend/src/metabase/reference/databases/DatabaseDetail.jsx @@ -0,0 +1,175 @@ +/* eslint "react/prop-types": "warn" */ +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import { useFormik } from "formik"; +import { push } from "react-router-redux"; +import { t } from "ttag"; +import List from "metabase/components/List"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; + +import EditHeader from "metabase/reference/components/EditHeader"; +import EditableReferenceHeader from "metabase/reference/components/EditableReferenceHeader"; +import Detail from "metabase/reference/components/Detail"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; +import { + getDatabase, + getTable, + getFields, + getError, + getLoading, + getUser, + getIsEditing, + getIsFormulaExpanded, + getForeignKeys, +} from "../selectors"; + +const mapStateToProps = (state, props) => { + const entity = getDatabase(state, props) || {}; + const fields = getFields(state, props); + + return { + entity, + table: getTable(state, props), + metadataFields: fields, + loading: getLoading(state, props), + // naming this 'error' will conflict with redux form + loadingError: getError(state, props), + user: getUser(state, props), + foreignKeys: getForeignKeys(state, props), + isEditing: getIsEditing(state, props), + isFormulaExpanded: getIsFormulaExpanded(state, props), + }; +}; + +const mapDispatchToProps = { + ...metadataActions, + ...actions, + onSubmit: actions.rUpdateDatabaseDetail, + onChangeLocation: push, +}; + +const propTypes = { + style: PropTypes.object.isRequired, + entity: PropTypes.object.isRequired, + table: PropTypes.object, + user: PropTypes.object.isRequired, + isEditing: PropTypes.bool, + startEditing: PropTypes.func.isRequired, + endEditing: PropTypes.func.isRequired, + startLoading: PropTypes.func.isRequired, + endLoading: PropTypes.func.isRequired, + setError: PropTypes.func.isRequired, + updateField: PropTypes.func.isRequired, + loading: PropTypes.bool, + loadingError: PropTypes.object, + onSubmit: PropTypes.func.isRequired, +}; + +const DatabaseDetail = props => { + const { + style, + entity, + table, + loadingError, + loading, + user, + isEditing, + startEditing, + endEditing, + onSubmit, + } = props; + + const { + isSubmitting, + getFieldProps, + getFieldMeta, + handleSubmit, + handleReset, + } = useFormik({ + initialValues: {}, + onSubmit: fields => onSubmit(fields, { ...props, resetForm: handleReset }), + }); + + const getFormField = name => ({ + ...getFieldProps(name), + ...getFieldMeta(name), + }); + + return ( + <form style={style} className="full" onSubmit={handleSubmit}> + {isEditing && ( + <EditHeader + hasRevisionHistory={false} + onSubmit={handleSubmit} + endEditing={endEditing} + reinitializeForm={handleReset} + submitting={isSubmitting} + revisionMessageFormField={getFormField("revision_message")} + /> + )} + <EditableReferenceHeader + entity={entity} + table={table} + type="database" + name="Details" + headerIcon="database" + user={user} + isEditing={isEditing} + hasSingleSchema={false} + hasDisplayName={false} + startEditing={startEditing} + displayNameFormField={getFormField("display_name")} + nameFormField={getFormField("name")} + /> + <LoadingAndErrorWrapper + loading={!loadingError && loading} + error={loadingError} + > + {() => ( + <div className="wrapper"> + <div className="pl4 pr3 pt4 mb4 mb1 bg-white rounded bordered"> + <List> + <li className="relative"> + <Detail + id="description" + name={t`Description`} + description={entity.description} + placeholder={t`No description yet`} + isEditing={isEditing} + field={getFormField("description")} + /> + </li> + <li className="relative"> + <Detail + id="points_of_interest" + name={t`Why this database is interesting`} + description={entity.points_of_interest} + placeholder={t`Nothing interesting yet`} + isEditing={isEditing} + field={getFormField("points_of_interest")} + /> + </li> + <li className="relative"> + <Detail + id="caveats" + name={t`Things to be aware of about this database`} + description={entity.caveats} + placeholder={t`Nothing to be aware of yet`} + isEditing={isEditing} + field={getFormField("caveats")} + /> + </li> + </List> + </div> + </div> + )} + </LoadingAndErrorWrapper> + </form> + ); +}; + +DatabaseDetail.propTypes = propTypes; + +export default connect(mapStateToProps, mapDispatchToProps)(DatabaseDetail); diff --git a/frontend/src/metabase/reference/databases/DatabaseDetailContainer.jsx b/frontend/src/metabase/reference/databases/DatabaseDetailContainer.jsx new file mode 100644 index 0000000000000000000000000000000000000000..1b7ed0a751ca6d9a1856994203ff10660db63200 --- /dev/null +++ b/frontend/src/metabase/reference/databases/DatabaseDetailContainer.jsx @@ -0,0 +1,72 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; + +import SidebarLayout from "metabase/components/SidebarLayout"; +import DatabaseDetail from "metabase/reference/databases/DatabaseDetail"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; + +import { getDatabase, getDatabaseId, getIsEditing } from "../selectors"; +import DatabaseSidebar from "./DatabaseSidebar"; + +const mapStateToProps = (state, props) => ({ + database: getDatabase(state, props), + databaseId: getDatabaseId(state, props), + isEditing: getIsEditing(state, props), +}); + +const mapDispatchToProps = { + ...metadataActions, + ...actions, +}; + +class DatabaseDetailContainer extends Component { + static propTypes = { + params: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + database: PropTypes.object.isRequired, + databaseId: PropTypes.number.isRequired, + isEditing: PropTypes.bool, + }; + + async fetchContainerData() { + await actions.wrappedFetchDatabaseMetadata( + this.props, + this.props.databaseId, + ); + } + + UNSAFE_componentWillMount() { + this.fetchContainerData(); + } + + UNSAFE_componentWillReceiveProps(newProps) { + if (this.props.location.pathname === newProps.location.pathname) { + return; + } + + actions.clearState(newProps); + } + + render() { + const { database, isEditing } = this.props; + + return ( + <SidebarLayout + className="flex-full relative" + style={isEditing ? { paddingTop: "43px" } : {}} + sidebar={<DatabaseSidebar database={database} />} + > + <DatabaseDetail {...this.props} /> + </SidebarLayout> + ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(DatabaseDetailContainer); diff --git a/frontend/src/metabase/reference/databases/DatabaseList.jsx b/frontend/src/metabase/reference/databases/DatabaseList.jsx new file mode 100644 index 0000000000000000000000000000000000000000..7709ea9f22fe5a2348a48a5d40342cdc4fd15972 --- /dev/null +++ b/frontend/src/metabase/reference/databases/DatabaseList.jsx @@ -0,0 +1,85 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import { t } from "ttag"; + +import S from "metabase/components/List/List.css"; + +import List from "metabase/components/List"; +import ListItem from "metabase/components/ListItem"; + +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; + +import * as metadataActions from "metabase/redux/metadata"; +import NoDatabasesEmptyState from "metabase/reference/databases/NoDatabasesEmptyState"; +import ReferenceHeader from "../components/ReferenceHeader"; + +import { getDatabases, getError, getLoading } from "../selectors"; + +const mapStateToProps = (state, props) => ({ + entities: getDatabases(state, props), + loading: getLoading(state, props), + loadingError: getError(state, props), +}); + +const mapDispatchToProps = { + ...metadataActions, +}; + +class DatabaseList extends Component { + static propTypes = { + style: PropTypes.object.isRequired, + entities: PropTypes.object.isRequired, + loading: PropTypes.bool, + loadingError: PropTypes.object, + }; + + render() { + const { entities, style, loadingError, loading } = this.props; + + const databases = Object.values(entities) + .filter(database => { + const exists = Boolean(database?.id && database?.name); + return exists && !database.is_saved_questions; + }) + .sort((a, b) => { + const compared = a.name.localeCompare(b.name); + return compared !== 0 ? compared : a.engine.localeCompare(b.engine); + }); + + return ( + <div style={style} className="full"> + <ReferenceHeader name={t`Our data`} /> + <LoadingAndErrorWrapper + loading={!loadingError && loading} + error={loadingError} + > + {() => + Object.keys(entities).length > 0 ? ( + <div className="wrapper"> + <List> + {databases.map(database => ( + <ListItem + key={database.id} + name={database.name} + description={database.description} + url={`/reference/databases/${database.id}`} + icon="database" + /> + ))} + </List> + </div> + ) : ( + <div className={S.empty}> + <NoDatabasesEmptyState /> + </div> + ) + } + </LoadingAndErrorWrapper> + </div> + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(DatabaseList); diff --git a/frontend/src/metabase/reference/databases/DatabaseListContainer.jsx b/frontend/src/metabase/reference/databases/DatabaseListContainer.jsx new file mode 100644 index 0000000000000000000000000000000000000000..4290919c6d15d7cae3c0b998bc1f965bbcdd6ad2 --- /dev/null +++ b/frontend/src/metabase/reference/databases/DatabaseListContainer.jsx @@ -0,0 +1,67 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; + +import BaseSidebar from "metabase/reference/guide/BaseSidebar"; +import SidebarLayout from "metabase/components/SidebarLayout"; +import DatabaseList from "metabase/reference/databases/DatabaseList"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; + +import { getDatabaseId, getIsEditing } from "../selectors"; + +const mapStateToProps = (state, props) => ({ + databaseId: getDatabaseId(state, props), + isEditing: getIsEditing(state, props), +}); + +const mapDispatchToProps = { + ...metadataActions, + ...actions, +}; + +class DatabaseListContainer extends Component { + static propTypes = { + params: PropTypes.object.isRequired, + databaseId: PropTypes.number.isRequired, + location: PropTypes.object.isRequired, + isEditing: PropTypes.bool, + }; + + async fetchContainerData() { + await actions.wrappedFetchDatabases(this.props); + } + + UNSAFE_componentWillMount() { + this.fetchContainerData(); + } + + UNSAFE_componentWillReceiveProps(newProps) { + if (this.props.location.pathname === newProps.location.pathname) { + return; + } + + actions.clearState(newProps); + } + + render() { + const { isEditing } = this.props; + + return ( + <SidebarLayout + className="flex-full relative" + style={isEditing ? { paddingTop: "43px" } : {}} + sidebar={<BaseSidebar />} + > + <DatabaseList {...this.props} /> + </SidebarLayout> + ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(DatabaseListContainer); diff --git a/frontend/src/metabase/reference/databases/DatabaseSidebar.jsx b/frontend/src/metabase/reference/databases/DatabaseSidebar.jsx new file mode 100644 index 0000000000000000000000000000000000000000..201ea508bce7077bf0af8f5cf41b8be37357c464 --- /dev/null +++ b/frontend/src/metabase/reference/databases/DatabaseSidebar.jsx @@ -0,0 +1,44 @@ +/* eslint "react/prop-types": "warn" */ +import { memo } from "react"; +import PropTypes from "prop-types"; +import { t } from "ttag"; +import cx from "classnames"; +import S from "metabase/components/Sidebar.css"; +import Breadcrumbs from "metabase/components/Breadcrumbs"; +import SidebarItem from "metabase/components/SidebarItem"; + +const DatabaseSidebar = ({ database, style, className }) => ( + <div className={cx(S.sidebar, className)} style={style}> + <ul> + <div className={S.breadcrumbs}> + <Breadcrumbs + className="py4 ml3" + crumbs={[[t`Databases`, "/reference/databases"], [database.name]]} + inSidebar={true} + placeholder={t`Data Reference`} + /> + </div> + <ol className="mx3"> + <SidebarItem + key={`/reference/databases/${database.id}`} + href={`/reference/databases/${database.id}`} + icon="document" + name={t`Details`} + /> + <SidebarItem + key={`/reference/databases/${database.id}/tables`} + href={`/reference/databases/${database.id}/tables`} + icon="table2" + name={t`Tables in ${database.name}`} + /> + </ol> + </ul> + </div> +); +DatabaseSidebar.propTypes = { + database: PropTypes.object, + className: PropTypes.string, + style: PropTypes.object, +}; + +export default memo(DatabaseSidebar); diff --git a/frontend/src/metabase/reference/databases/FieldDetail.jsx b/frontend/src/metabase/reference/databases/FieldDetail.jsx new file mode 100644 index 0000000000000000000000000000000000000000..984214d072f54f309b0f353004f579ae95fb7ba1 --- /dev/null +++ b/frontend/src/metabase/reference/databases/FieldDetail.jsx @@ -0,0 +1,267 @@ +/* eslint "react/prop-types": "warn" */ +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import { useFormik } from "formik"; +import { push } from "react-router-redux"; +import { t } from "ttag"; +import S from "metabase/reference/Reference.css"; + +import List from "metabase/components/List"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; + +import EditHeader from "metabase/reference/components/EditHeader"; +import EditableReferenceHeader from "metabase/reference/components/EditableReferenceHeader"; +import Detail from "metabase/reference/components/Detail"; +import FieldTypeDetail from "metabase/reference/components/FieldTypeDetail"; +import UsefulQuestions from "metabase/reference/components/UsefulQuestions"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; +import { getQuestionUrl } from "../utils"; + +import { + getDatabase, + getError, + getField, + getForeignKeys, + getIsEditing, + getIsFormulaExpanded, + getLoading, + getTable, + getUser, +} from "../selectors"; + +const interestingQuestions = (database, table, field, metadata) => { + return [ + { + text: t`Number of ${table.display_name} grouped by ${field.display_name}`, + icon: "bar", + link: getQuestionUrl({ + dbId: database.id, + tableId: table.id, + fieldId: field.id, + getCount: true, + visualization: "bar", + metadata, + }), + }, + { + text: t`Number of ${table.display_name} grouped by ${field.display_name}`, + icon: "pie", + link: getQuestionUrl({ + dbId: database.id, + tableId: table.id, + fieldId: field.id, + getCount: true, + visualization: "pie", + metadata, + }), + }, + { + text: t`All distinct values of ${field.display_name}`, + icon: "table2", + link: getQuestionUrl({ + dbId: database.id, + tableId: table.id, + fieldId: field.id, + metadata, + }), + }, + ]; +}; + +const mapStateToProps = (state, props) => { + const entity = getField(state, props) || {}; + + return { + entity, + field: entity, + table: getTable(state, props), + database: getDatabase(state, props), + loading: getLoading(state, props), + // naming this 'error' will conflict with redux form + loadingError: getError(state, props), + user: getUser(state, props), + foreignKeys: getForeignKeys(state, props), + isEditing: getIsEditing(state, props), + isFormulaExpanded: getIsFormulaExpanded(state, props), + }; +}; + +const mapDispatchToProps = { + ...metadataActions, + ...actions, + onSubmit: actions.rUpdateFieldDetail, + onChangeLocation: push, +}; + +const propTypes = { + style: PropTypes.object.isRequired, + entity: PropTypes.object.isRequired, + field: PropTypes.object.isRequired, + table: PropTypes.object, + user: PropTypes.object.isRequired, + database: PropTypes.object.isRequired, + foreignKeys: PropTypes.object, + isEditing: PropTypes.bool, + startEditing: PropTypes.func.isRequired, + endEditing: PropTypes.func.isRequired, + startLoading: PropTypes.func.isRequired, + endLoading: PropTypes.func.isRequired, + setError: PropTypes.func.isRequired, + updateField: PropTypes.func.isRequired, + loading: PropTypes.bool, + loadingError: PropTypes.object, + metadata: PropTypes.object, + onSubmit: PropTypes.func.isRequired, +}; + +const FieldDetail = props => { + const { + style, + entity, + table, + loadingError, + loading, + user, + foreignKeys, + isEditing, + startEditing, + endEditing, + metadata, + onSubmit, + } = props; + + const { + isSubmitting, + getFieldProps, + getFieldMeta, + handleSubmit, + handleReset, + } = useFormik({ + initialValues: {}, + onSubmit: fields => onSubmit(fields, { ...props, resetForm: handleReset }), + }); + + const getFormField = name => ({ + ...getFieldProps(name), + ...getFieldMeta(name), + }); + + return ( + <form style={style} className="full" onSubmit={handleSubmit}> + {isEditing && ( + <EditHeader + hasRevisionHistory={false} + onSubmit={handleSubmit} + endEditing={endEditing} + reinitializeForm={handleReset} + submitting={isSubmitting} + revisionMessageFormField={getFormField("revision_message")} + /> + )} + <EditableReferenceHeader + entity={entity} + table={table} + type="field" + headerIcon="field" + name="Details" + user={user} + isEditing={isEditing} + hasSingleSchema={false} + hasDisplayName={true} + startEditing={startEditing} + displayNameFormField={getFormField("display_name")} + nameFormField={getFormField("name")} + /> + <LoadingAndErrorWrapper + loading={!loadingError && loading} + error={loadingError} + > + {() => ( + <div className="wrapper"> + <div className="pl4 pr3 pt4 mb4 mb1 bg-white rounded bordered"> + <List> + <li className="relative"> + <Detail + id="description" + name={t`Description`} + description={entity.description} + placeholder={t`No description yet`} + isEditing={isEditing} + field={getFormField("description")} + /> + </li> + {!isEditing && ( + <li className="relative"> + <Detail + id="name" + name={t`Actual name in database`} + description={entity.name} + subtitleClass={S.tableActualName} + /> + </li> + )} + <li className="relative"> + <Detail + id="points_of_interest" + name={t`Why this field is interesting`} + description={entity.points_of_interest} + placeholder={t`Nothing interesting yet`} + isEditing={isEditing} + field={getFormField("points_of_interest")} + /> + </li> + <li className="relative"> + <Detail + id="caveats" + name={t`Things to be aware of about this field`} + description={entity.caveats} + placeholder={t`Nothing to be aware of yet`} + isEditing={isEditing} + field={getFormField("caveats")} + /> + </li> + + {!isEditing && ( + <li className="relative"> + <Detail + id="base_type" + name={t`Data type`} + description={entity.base_type} + /> + </li> + )} + <li className="relative"> + <FieldTypeDetail + field={entity} + foreignKeys={foreignKeys} + fieldTypeFormField={getFormField("semantic_type")} + foreignKeyFormField={getFormField("fk_target_field_id")} + isEditing={isEditing} + /> + </li> + {!isEditing && ( + <li className="relative"> + <UsefulQuestions + questions={interestingQuestions( + props.database, + props.table, + props.field, + metadata, + )} + /> + </li> + )} + </List> + </div> + </div> + )} + </LoadingAndErrorWrapper> + </form> + ); +}; + +FieldDetail.propTypes = propTypes; + +export default connect(mapStateToProps, mapDispatchToProps)(FieldDetail); diff --git a/frontend/src/metabase/reference/databases/FieldDetailContainer.jsx b/frontend/src/metabase/reference/databases/FieldDetailContainer.jsx new file mode 100644 index 0000000000000000000000000000000000000000..00ab2e200954b5adf5df294d272d75d8754eafe5 --- /dev/null +++ b/frontend/src/metabase/reference/databases/FieldDetailContainer.jsx @@ -0,0 +1,87 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; + +import SidebarLayout from "metabase/components/SidebarLayout"; +import FieldDetail from "metabase/reference/databases/FieldDetail"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; +import { getMetadata } from "metabase/selectors/metadata"; + +import { + getDatabase, + getTable, + getField, + getDatabaseId, + getIsEditing, +} from "../selectors"; +import FieldSidebar from "./FieldSidebar"; + +const mapStateToProps = (state, props) => ({ + database: getDatabase(state, props), + table: getTable(state, props), + field: getField(state, props), + databaseId: getDatabaseId(state, props), + isEditing: getIsEditing(state, props), + metadata: getMetadata(state, props), +}); + +const mapDispatchToProps = { + ...metadataActions, + ...actions, +}; + +class FieldDetailContainer extends Component { + static propTypes = { + params: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + database: PropTypes.object.isRequired, + databaseId: PropTypes.number.isRequired, + table: PropTypes.object.isRequired, + field: PropTypes.object.isRequired, + isEditing: PropTypes.bool, + metadata: PropTypes.object, + }; + + async fetchContainerData() { + await actions.wrappedFetchDatabaseMetadata( + this.props, + this.props.databaseId, + ); + } + + UNSAFE_componentWillMount() { + this.fetchContainerData(); + } + + UNSAFE_componentWillReceiveProps(newProps) { + if (this.props.location.pathname === newProps.location.pathname) { + return; + } + + actions.clearState(newProps); + } + + render() { + const { database, table, field, isEditing } = this.props; + + return ( + <SidebarLayout + className="flex-full relative" + style={isEditing ? { paddingTop: "43px" } : {}} + sidebar={ + <FieldSidebar database={database} table={table} field={field} /> + } + > + <FieldDetail {...this.props} /> + </SidebarLayout> + ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(FieldDetailContainer); diff --git a/frontend/src/metabase/reference/databases/FieldList.jsx b/frontend/src/metabase/reference/databases/FieldList.jsx new file mode 100644 index 0000000000000000000000000000000000000000..454cb33746d9239e256c4cecd8bc86f8d8ae0bdb --- /dev/null +++ b/frontend/src/metabase/reference/databases/FieldList.jsx @@ -0,0 +1,194 @@ +/* eslint "react/prop-types": "warn" */ +/* eslint-disable react/no-unknown-property */ +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import { useFormik } from "formik"; +import { t } from "ttag"; +import cx from "classnames"; +import S from "metabase/components/List/List.css"; +import R from "metabase/reference/Reference.css"; +import F from "metabase/reference/components/Field.css"; + +import Field from "metabase/reference/components/Field"; +import List from "metabase/components/List"; +import EmptyState from "metabase/components/EmptyState"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; + +import EditHeader from "metabase/reference/components/EditHeader"; +import EditableReferenceHeader from "metabase/reference/components/EditableReferenceHeader"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; +import { getIconForField } from "metabase-lib/metadata/utils/fields"; +import { + getError, + getFieldsByTable, + getForeignKeys, + getIsEditing, + getLoading, + getTable, + getUser, +} from "../selectors"; + +const emptyStateData = { + message: t`Fields in this table will appear here as they're added`, + icon: "fields", +}; + +const mapStateToProps = (state, props) => { + const data = getFieldsByTable(state, props); + return { + table: getTable(state, props), + entities: data, + foreignKeys: getForeignKeys(state, props), + loading: getLoading(state, props), + loadingError: getError(state, props), + user: getUser(state, props), + isEditing: getIsEditing(state, props), + }; +}; + +const mapDispatchToProps = { + ...metadataActions, + ...actions, + onSubmit: actions.rUpdateFields, +}; + +const propTypes = { + style: PropTypes.object.isRequired, + entities: PropTypes.object.isRequired, + foreignKeys: PropTypes.object.isRequired, + isEditing: PropTypes.bool, + startEditing: PropTypes.func.isRequired, + endEditing: PropTypes.func.isRequired, + startLoading: PropTypes.func.isRequired, + endLoading: PropTypes.func.isRequired, + setError: PropTypes.func.isRequired, + updateField: PropTypes.func.isRequired, + user: PropTypes.object.isRequired, + table: PropTypes.object.isRequired, + loading: PropTypes.bool, + loadingError: PropTypes.object, + onSubmit: PropTypes.func.isRequired, + "data-testid": PropTypes.string, +}; + +const FieldList = props => { + const { + style, + entities, + foreignKeys, + table, + loadingError, + loading, + user, + isEditing, + startEditing, + endEditing, + onSubmit, + } = props; + + const { + isSubmitting, + getFieldProps, + getFieldMeta, + handleSubmit, + handleReset, + } = useFormik({ + initialValues: {}, + onSubmit: fields => + onSubmit(entities, fields, { ...props, resetForm: handleReset }), + }); + + const getFormField = name => ({ + ...getFieldProps(name), + ...getFieldMeta(name), + }); + + const getNestedFormField = id => ({ + display_name: getFormField(`${id}.display_name`), + semantic_type: getFormField(`${id}.semantic_type`), + fk_target_field_id: getFormField(`${id}.fk_target_field_id`), + }); + + return ( + <form + style={style} + className="full" + onSubmit={handleSubmit} + testID={props["data-testid"]} + > + {isEditing && ( + <EditHeader + hasRevisionHistory={false} + reinitializeForm={handleReset} + endEditing={endEditing} + submitting={isSubmitting} + /> + )} + <EditableReferenceHeader + headerIcon="table2" + name={t`Fields in ${table.display_name}`} + user={user} + isEditing={isEditing} + startEditing={startEditing} + /> + <LoadingAndErrorWrapper + loading={!loadingError && loading} + error={loadingError} + > + {() => + Object.keys(entities).length > 0 ? ( + <div className="wrapper"> + <div className="pl4 pb2 mb4 bg-white rounded bordered"> + <div className={S.item}> + <div className={R.columnHeader}> + <div className={cx(S.itemTitle, F.fieldNameTitle)}> + {t`Field name`} + </div> + <div className={cx(S.itemTitle, F.fieldType)}> + {t`Field type`} + </div> + <div className={cx(S.itemTitle, F.fieldDataType)}> + {t`Data type`} + </div> + </div> + </div> + <List> + {Object.values(entities) + // respect the column sort order + .sort((a, b) => a.position - b.position) + .map( + entity => + entity && + entity.id && + entity.name && ( + <li key={entity.id}> + <Field + field={entity} + foreignKeys={foreignKeys} + url={`/reference/databases/${table.db_id}/tables/${table.id}/fields/${entity.id}`} + icon={getIconForField(entity)} + isEditing={isEditing} + formField={getNestedFormField(entity.id)} + /> + </li> + ), + )} + </List> + </div> + </div> + ) : ( + <div className={S.empty}> + <EmptyState {...emptyStateData} /> + </div> + ) + } + </LoadingAndErrorWrapper> + </form> + ); +}; + +FieldList.propTypes = propTypes; + +export default connect(mapStateToProps, mapDispatchToProps)(FieldList); diff --git a/frontend/src/metabase/reference/databases/FieldListContainer.jsx b/frontend/src/metabase/reference/databases/FieldListContainer.jsx new file mode 100644 index 0000000000000000000000000000000000000000..3f207cd58fbf137ee6c7fd56c4ab0d9d9c94ab67 --- /dev/null +++ b/frontend/src/metabase/reference/databases/FieldListContainer.jsx @@ -0,0 +1,76 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; + +import SidebarLayout from "metabase/components/SidebarLayout"; +import FieldList from "metabase/reference/databases/FieldList"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; + +import { + getDatabase, + getTable, + getDatabaseId, + getIsEditing, +} from "../selectors"; +import TableSidebar from "./TableSidebar"; + +const mapStateToProps = (state, props) => ({ + database: getDatabase(state, props), + table: getTable(state, props), + databaseId: getDatabaseId(state, props), + isEditing: getIsEditing(state, props), +}); + +const mapDispatchToProps = { + ...metadataActions, + ...actions, +}; + +class FieldListContainer extends Component { + static propTypes = { + params: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + database: PropTypes.object.isRequired, + databaseId: PropTypes.number.isRequired, + table: PropTypes.object.isRequired, + isEditing: PropTypes.bool, + }; + + async fetchContainerData() { + await actions.wrappedFetchDatabaseMetadata( + this.props, + this.props.databaseId, + ); + } + + UNSAFE_componentWillMount() { + this.fetchContainerData(); + } + + UNSAFE_componentWillReceiveProps(newProps) { + if (this.props.location.pathname === newProps.location.pathname) { + return; + } + + actions.clearState(newProps); + } + + render() { + const { database, table, isEditing } = this.props; + + return ( + <SidebarLayout + className="flex-full relative" + style={isEditing ? { paddingTop: "43px" } : {}} + sidebar={<TableSidebar database={database} table={table} />} + > + <FieldList {...this.props} /> + </SidebarLayout> + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(FieldListContainer); diff --git a/frontend/src/metabase/reference/databases/FieldSidebar.jsx b/frontend/src/metabase/reference/databases/FieldSidebar.jsx new file mode 100644 index 0000000000000000000000000000000000000000..e3c3679331911826dba799c90b5f07b0393e31df --- /dev/null +++ b/frontend/src/metabase/reference/databases/FieldSidebar.jsx @@ -0,0 +1,61 @@ +/* eslint "react/prop-types": "warn" */ +import { memo } from "react"; +import PropTypes from "prop-types"; +import { t } from "ttag"; +import cx from "classnames"; + +import MetabaseSettings from "metabase/lib/settings"; + +import Breadcrumbs from "metabase/components/Breadcrumbs"; +import SidebarItem from "metabase/components/SidebarItem"; + +import S from "metabase/components/Sidebar.css"; + +const FieldSidebar = ({ database, table, field, style, className }) => ( + <div className={cx(S.sidebar, className)} style={style}> + <ul> + <div className={S.breadcrumbs}> + <Breadcrumbs + className="py4 ml3" + crumbs={[ + [database.name, `/reference/databases/${database.id}`], + [ + table.name, + `/reference/databases/${database.id}/tables/${table.id}`, + ], + [field.name], + ]} + inSidebar={true} + placeholder={t`Data Reference`} + /> + </div> + <ol className="mx3"> + <SidebarItem + key={`/reference/databases/${database.id}/tables/${table.id}/fields/${field.id}`} + href={`/reference/databases/${database.id}/tables/${table.id}/fields/${field.id}`} + icon="document" + name={t`Details`} + /> + + {MetabaseSettings.get("enable-xrays") && ( + <SidebarItem + key={`/auto/dashboard/field/${field.id}`} + href={`/auto/dashboard/field/${field.id}`} + icon="bolt" + name={t`X-ray this field`} + /> + )} + </ol> + </ul> + </div> +); + +FieldSidebar.propTypes = { + database: PropTypes.object, + table: PropTypes.object, + field: PropTypes.object, + className: PropTypes.string, + style: PropTypes.object, +}; + +export default memo(FieldSidebar); diff --git a/frontend/src/metabase/reference/databases/NoDatabasesEmptyState.jsx b/frontend/src/metabase/reference/databases/NoDatabasesEmptyState.jsx new file mode 100644 index 0000000000000000000000000000000000000000..2f8c6d69f8e0d0aea614c09266cbfd1159cc1fab --- /dev/null +++ b/frontend/src/metabase/reference/databases/NoDatabasesEmptyState.jsx @@ -0,0 +1,17 @@ +import { t } from "ttag"; + +import AdminAwareEmptyState from "metabase/components/AdminAwareEmptyState"; + +const NoDatabasesEmptyState = user => ( + <AdminAwareEmptyState + title={t`Metabase is no fun without any data`} + adminMessage={t`Your databases will appear here once you connect one`} + message={t`Databases will appear here once your admins have added some`} + image="app/assets/img/databases-list" + adminAction={t`Connect a database`} + adminLink="/admin/databases/create" + user={user} + /> +); + +export default NoDatabasesEmptyState; diff --git a/frontend/src/metabase/reference/databases/TableDetail.jsx b/frontend/src/metabase/reference/databases/TableDetail.jsx new file mode 100644 index 0000000000000000000000000000000000000000..41273cacf1c8e67738684573fe8c1df4ba421ac3 --- /dev/null +++ b/frontend/src/metabase/reference/databases/TableDetail.jsx @@ -0,0 +1,228 @@ +/* eslint "react/prop-types": "warn" */ +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import { useFormik } from "formik"; +import { push } from "react-router-redux"; +import { t } from "ttag"; +import S from "metabase/reference/Reference.css"; + +import List from "metabase/components/List"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; + +import EditHeader from "metabase/reference/components/EditHeader"; +import EditableReferenceHeader from "metabase/reference/components/EditableReferenceHeader"; +import Detail from "metabase/reference/components/Detail"; +import UsefulQuestions from "metabase/reference/components/UsefulQuestions"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; +import { getQuestionUrl } from "../utils"; + +import { + getTable, + getFields, + getError, + getLoading, + getUser, + getIsEditing, + getHasSingleSchema, + getIsFormulaExpanded, + getForeignKeys, +} from "../selectors"; + +const interestingQuestions = table => { + return [ + { + text: t`Count of ${table.display_name}`, + icon: "number", + link: getQuestionUrl({ + dbId: table.db_id, + tableId: table.id, + getCount: true, + }), + }, + { + text: t`See raw data for ${table.display_name}`, + icon: "table2", + link: getQuestionUrl({ + dbId: table.db_id, + tableId: table.id, + }), + }, + ]; +}; +const mapStateToProps = (state, props) => { + const entity = getTable(state, props) || {}; + const fields = getFields(state, props); + + return { + entity, + table: getTable(state, props), + metadataFields: fields, + loading: getLoading(state, props), + // naming this 'error' will conflict with redux form + loadingError: getError(state, props), + user: getUser(state, props), + foreignKeys: getForeignKeys(state, props), + isEditing: getIsEditing(state, props), + hasSingleSchema: getHasSingleSchema(state, props), + isFormulaExpanded: getIsFormulaExpanded(state, props), + }; +}; + +const mapDispatchToProps = { + ...metadataActions, + ...actions, + onSubmit: actions.rUpdateTableDetail, + onChangeLocation: push, +}; + +const propTypes = { + style: PropTypes.object.isRequired, + entity: PropTypes.object.isRequired, + table: PropTypes.object, + user: PropTypes.object.isRequired, + isEditing: PropTypes.bool, + startEditing: PropTypes.func.isRequired, + endEditing: PropTypes.func.isRequired, + startLoading: PropTypes.func.isRequired, + endLoading: PropTypes.func.isRequired, + setError: PropTypes.func.isRequired, + updateField: PropTypes.func.isRequired, + handleSubmit: PropTypes.func.isRequired, + resetForm: PropTypes.func.isRequired, + fields: PropTypes.object.isRequired, + hasSingleSchema: PropTypes.bool, + loading: PropTypes.bool, + loadingError: PropTypes.object, + onSubmit: PropTypes.func.isRequired, +}; + +const TableDetail = props => { + const { + style, + entity, + table, + loadingError, + loading, + user, + isEditing, + startEditing, + endEditing, + hasSingleSchema, + onSubmit, + } = props; + + const { + isSubmitting, + getFieldProps, + getFieldMeta, + handleSubmit, + handleReset, + } = useFormik({ + initialValues: {}, + onSubmit: fields => onSubmit(fields, { ...props, resetForm: handleReset }), + }); + + const getFormField = name => ({ + ...getFieldProps(name), + ...getFieldMeta(name), + }); + + return ( + <form style={style} className="full" onSubmit={handleSubmit}> + {isEditing && ( + <EditHeader + hasRevisionHistory={false} + onSubmit={handleSubmit} + endEditing={endEditing} + reinitializeForm={handleReset} + submitting={isSubmitting} + revisionMessageFormField={getFormField("revision_message")} + /> + )} + <EditableReferenceHeader + entity={entity} + table={table} + type="table" + headerIcon="table2" + headerLink={getQuestionUrl({ + dbId: entity.db_id, + tableId: entity.id, + })} + name={t`Details`} + user={user} + isEditing={isEditing} + hasSingleSchema={hasSingleSchema} + hasDisplayName={true} + startEditing={startEditing} + displayNameFormField={getFormField("display_name")} + nameFormField={getFormField("name")} + /> + <LoadingAndErrorWrapper + loading={!loadingError && loading} + error={loadingError} + > + {() => ( + <div className="wrapper"> + <div className="pl4 pr3 pt4 mb4 mb1 bg-white rounded bordered"> + <List> + <li className="relative"> + <Detail + id="description" + name={t`Description`} + description={entity.description} + placeholder={t`No description yet`} + isEditing={isEditing} + field={getFormField("description")} + /> + </li> + {!isEditing && ( + <li className="relative"> + <Detail + id="name" + name={t`Actual name in database`} + description={entity.name} + subtitleClass={S.tableActualName} + /> + </li> + )} + <li className="relative"> + <Detail + id="points_of_interest" + name={t`Why this table is interesting`} + description={entity.points_of_interest} + placeholder={t`Nothing interesting yet`} + isEditing={isEditing} + field={getFormField("points_of_interest")} + /> + </li> + <li className="relative"> + <Detail + id="caveats" + name={t`Things to be aware of about this table`} + description={entity.caveats} + placeholder={t`Nothing to be aware of yet`} + isEditing={isEditing} + field={getFormField("caveats")} + /> + </li> + {!isEditing && ( + <li className="relative"> + <UsefulQuestions + questions={interestingQuestions(props.table)} + /> + </li> + )} + </List> + </div> + </div> + )} + </LoadingAndErrorWrapper> + </form> + ); +}; + +TableDetail.propTypes = propTypes; + +export default connect(mapStateToProps, mapDispatchToProps)(TableDetail); diff --git a/frontend/src/metabase/reference/databases/TableDetailContainer.jsx b/frontend/src/metabase/reference/databases/TableDetailContainer.jsx new file mode 100644 index 0000000000000000000000000000000000000000..1b0ff16dc0c9079ee0c247051a8a6afe23fa7982 --- /dev/null +++ b/frontend/src/metabase/reference/databases/TableDetailContainer.jsx @@ -0,0 +1,79 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; + +import SidebarLayout from "metabase/components/SidebarLayout"; +import TableDetail from "metabase/reference/databases/TableDetail"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; + +import { + getDatabase, + getTable, + getDatabaseId, + getIsEditing, +} from "../selectors"; +import TableSidebar from "./TableSidebar"; + +const mapStateToProps = (state, props) => ({ + database: getDatabase(state, props), + table: getTable(state, props), + databaseId: getDatabaseId(state, props), + isEditing: getIsEditing(state, props), +}); + +const mapDispatchToProps = { + ...metadataActions, + ...actions, +}; + +class TableDetailContainer extends Component { + static propTypes = { + params: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + database: PropTypes.object.isRequired, + databaseId: PropTypes.number.isRequired, + table: PropTypes.object.isRequired, + isEditing: PropTypes.bool, + }; + + async fetchContainerData() { + await actions.wrappedFetchDatabaseMetadata( + this.props, + this.props.databaseId, + ); + } + + UNSAFE_componentWillMount() { + this.fetchContainerData(); + } + + UNSAFE_componentWillReceiveProps(newProps) { + if (this.props.location.pathname === newProps.location.pathname) { + return; + } + + actions.clearState(newProps); + } + + render() { + const { database, table, isEditing } = this.props; + + return ( + <SidebarLayout + className="flex-full relative" + style={isEditing ? { paddingTop: "43px" } : {}} + sidebar={<TableSidebar database={database} table={table} />} + > + <TableDetail {...this.props} /> + </SidebarLayout> + ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(TableDetailContainer); diff --git a/frontend/src/metabase/reference/databases/TableList.jsx b/frontend/src/metabase/reference/databases/TableList.jsx new file mode 100644 index 0000000000000000000000000000000000000000..5f2d57b440f4671891ca12e08a2c338e418297d3 --- /dev/null +++ b/frontend/src/metabase/reference/databases/TableList.jsx @@ -0,0 +1,146 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import { t } from "ttag"; +import _ from "underscore"; + +import S from "metabase/components/List/List.css"; +import R from "metabase/reference/Reference.css"; + +import List from "metabase/components/List"; +import ListItem from "metabase/components/ListItem"; +import EmptyState from "metabase/components/EmptyState"; + +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; + +import * as metadataActions from "metabase/redux/metadata"; +import ReferenceHeader from "../components/ReferenceHeader"; + +import { + getDatabase, + getTablesByDatabase, + getHasSingleSchema, + getError, + getLoading, +} from "../selectors"; + +const emptyStateData = { + message: t`Tables in this database will appear here as they're added`, + icon: "table2", +}; + +const mapStateToProps = (state, props) => ({ + database: getDatabase(state, props), + entities: getTablesByDatabase(state, props), + hasSingleSchema: getHasSingleSchema(state, props), + loading: getLoading(state, props), + loadingError: getError(state, props), +}); + +const mapDispatchToProps = { + ...metadataActions, +}; + +const createListItem = table => ( + <ListItem + key={table.id} + name={table.display_name || table.name} + description={table.description} + url={`/reference/databases/${table.db_id}/tables/${table.id}`} + icon="table2" + /> +); + +const createSchemaSeparator = table => ( + <li className={R.schemaSeparator}>{table.schema_name}</li> +); + +export const separateTablesBySchema = ( + tables, + createSchemaSeparator, + createListItem, +) => { + const sortedTables = _.chain(tables) + .sortBy(t => t.name) + .sortBy(t => t.schema_name) + .value(); + + return sortedTables.map((table, index, sortedTables) => { + if (!table || !table.id || !table.name) { + return; + } + // add schema header for first element and if schema is different from previous + const previousTableId = Object.keys(sortedTables)[index - 1]; + return index === 0 || + sortedTables[previousTableId].schema_name !== table.schema_name + ? [createSchemaSeparator(table), createListItem(table)] + : createListItem(table); + }); +}; + +class TableList extends Component { + static propTypes = { + style: PropTypes.object.isRequired, + entities: PropTypes.object.isRequired, + database: PropTypes.object.isRequired, + hasSingleSchema: PropTypes.bool, + loading: PropTypes.bool, + loadingError: PropTypes.object, + }; + + render() { + const { + entities, + style, + database, + hasSingleSchema, + loadingError, + loading, + } = this.props; + + const tables = Object.values(entities); + + return ( + <div style={style} className="full" data-testid="table-list"> + <ReferenceHeader + name={t`Tables in ${database.name}`} + type="tables" + headerIcon="database" + /> + <LoadingAndErrorWrapper + loading={!loadingError && loading} + error={loadingError} + > + {() => + tables.length > 0 ? ( + <div className="wrapper wrapper--trim"> + <List> + {!hasSingleSchema + ? separateTablesBySchema( + tables, + createSchemaSeparator, + createListItem, + ) + : _.sortBy(tables, "name").map( + table => + table && + table.id && + table.name && + createListItem(table), + )} + </List> + </div> + ) : ( + <div className={S.empty}> + <EmptyState {...emptyStateData} /> + </div> + ) + } + </LoadingAndErrorWrapper> + </div> + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(TableList); diff --git a/frontend/src/metabase/reference/databases/TableListContainer.jsx b/frontend/src/metabase/reference/databases/TableListContainer.jsx new file mode 100644 index 0000000000000000000000000000000000000000..2ff183481919d53308b33f79f2df0474006b26ee --- /dev/null +++ b/frontend/src/metabase/reference/databases/TableListContainer.jsx @@ -0,0 +1,69 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; + +import SidebarLayout from "metabase/components/SidebarLayout"; +import TableList from "metabase/reference/databases/TableList"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; + +import { getDatabase, getDatabaseId, getIsEditing } from "../selectors"; +import DatabaseSidebar from "./DatabaseSidebar"; + +const mapStateToProps = (state, props) => ({ + database: getDatabase(state, props), + databaseId: getDatabaseId(state, props), + isEditing: getIsEditing(state, props), +}); + +const mapDispatchToProps = { + ...metadataActions, + ...actions, +}; + +class TableListContainer extends Component { + static propTypes = { + params: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + database: PropTypes.object.isRequired, + databaseId: PropTypes.number.isRequired, + isEditing: PropTypes.bool, + }; + + async fetchContainerData() { + await actions.wrappedFetchDatabaseMetadata( + this.props, + this.props.databaseId, + ); + } + + UNSAFE_componentWillMount() { + this.fetchContainerData(); + } + + UNSAFE_componentWillReceiveProps(newProps) { + if (this.props.location.pathname === newProps.location.pathname) { + return; + } + + actions.clearState(newProps); + } + + render() { + const { database, isEditing } = this.props; + + return ( + <SidebarLayout + className="flex-full relative" + style={isEditing ? { paddingTop: "43px" } : {}} + sidebar={<DatabaseSidebar database={database} />} + > + <TableList {...this.props} /> + </SidebarLayout> + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(TableListContainer); diff --git a/frontend/src/metabase/reference/databases/TableQuestions.jsx b/frontend/src/metabase/reference/databases/TableQuestions.jsx new file mode 100644 index 0000000000000000000000000000000000000000..1af9b97c85ca16093a9024910e1b1ef6a9980436 --- /dev/null +++ b/frontend/src/metabase/reference/databases/TableQuestions.jsx @@ -0,0 +1,110 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import moment from "moment-timezone"; +import { t } from "ttag"; +import visualizations from "metabase/visualizations"; +import * as Urls from "metabase/lib/urls"; + +import S from "metabase/components/List/List.css"; + +import List from "metabase/components/List"; +import ListItem from "metabase/components/ListItem"; +import AdminAwareEmptyState from "metabase/components/AdminAwareEmptyState"; + +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; + +import * as metadataActions from "metabase/redux/metadata"; +import ReferenceHeader from "../components/ReferenceHeader"; + +import { getQuestionUrl } from "../utils"; + +import { + getTableQuestions, + getError, + getLoading, + getTable, +} from "../selectors"; + +const emptyStateData = table => { + return { + message: t`Questions about this table will appear here as they're added`, + icon: "folder", + action: t`Ask a question`, + link: getQuestionUrl({ + dbId: table.db_id, + tableId: table.id, + }), + }; +}; + +const mapStateToProps = (state, props) => ({ + table: getTable(state, props), + entities: getTableQuestions(state, props), + loading: getLoading(state, props), + loadingError: getError(state, props), +}); + +const mapDispatchToProps = { + ...metadataActions, +}; + +class TableQuestions extends Component { + static propTypes = { + table: PropTypes.object.isRequired, + style: PropTypes.object.isRequired, + entities: PropTypes.object.isRequired, + loading: PropTypes.bool, + loadingError: PropTypes.object, + }; + + render() { + const { entities, style, loadingError, loading } = this.props; + + return ( + <div style={style} className="full"> + <ReferenceHeader + name={t`Questions about ${this.props.table.display_name}`} + type="questions" + headerIcon="table2" + /> + <LoadingAndErrorWrapper + loading={!loadingError && loading} + error={loadingError} + > + {() => + Object.keys(entities).length > 0 ? ( + <div className="wrapper wrapper--trim"> + <List> + {Object.values(entities).map( + entity => + entity && + entity.id && + entity.name && ( + <ListItem + key={entity.id} + name={entity.display_name || entity.name} + description={t`Created ${moment( + entity.created_at, + ).fromNow()} by ${entity.creator.common_name}`} + url={Urls.question(entity)} + icon={visualizations.get(entity.display).iconName} + /> + ), + )} + </List> + </div> + ) : ( + <div className={S.empty}> + <AdminAwareEmptyState {...emptyStateData(this.props.table)} /> + </div> + ) + } + </LoadingAndErrorWrapper> + </div> + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(TableQuestions); diff --git a/frontend/src/metabase/reference/databases/TableQuestionsContainer.jsx b/frontend/src/metabase/reference/databases/TableQuestionsContainer.jsx new file mode 100644 index 0000000000000000000000000000000000000000..9d529f0a71106b23bab7916e5680664f4443f1b9 --- /dev/null +++ b/frontend/src/metabase/reference/databases/TableQuestionsContainer.jsx @@ -0,0 +1,82 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; + +import SidebarLayout from "metabase/components/SidebarLayout"; + +import TableQuestions from "metabase/reference/databases/TableQuestions"; +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; + +import Questions from "metabase/entities/questions"; +import { + getDatabase, + getTable, + getDatabaseId, + getIsEditing, +} from "../selectors"; + +import TableSidebar from "./TableSidebar"; + +const mapStateToProps = (state, props) => ({ + database: getDatabase(state, props), + table: getTable(state, props), + databaseId: getDatabaseId(state, props), + isEditing: getIsEditing(state, props), +}); + +const mapDispatchToProps = { + fetchQuestions: Questions.actions.fetchList, + ...metadataActions, + ...actions, +}; + +class TableQuestionsContainer extends Component { + static propTypes = { + params: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + database: PropTypes.object.isRequired, + databaseId: PropTypes.number.isRequired, + table: PropTypes.object.isRequired, + isEditing: PropTypes.bool, + }; + + async fetchContainerData() { + await actions.wrappedFetchDatabaseMetadataAndQuestion( + this.props, + this.props.databaseId, + ); + } + + UNSAFE_componentWillMount() { + this.fetchContainerData(); + } + + UNSAFE_componentWillReceiveProps(newProps) { + if (this.props.location.pathname === newProps.location.pathname) { + return; + } + + actions.clearState(newProps); + } + + render() { + const { database, table, isEditing } = this.props; + + return ( + <SidebarLayout + className="flex-full relative" + style={isEditing ? { paddingTop: "43px" } : {}} + sidebar={<TableSidebar database={database} table={table} />} + > + <TableQuestions {...this.props} /> + </SidebarLayout> + ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(TableQuestionsContainer); diff --git a/frontend/src/metabase/reference/databases/TableSidebar.jsx b/frontend/src/metabase/reference/databases/TableSidebar.jsx new file mode 100644 index 0000000000000000000000000000000000000000..616bb4a0a86dd598eea5aea1858d2eebc000a857 --- /dev/null +++ b/frontend/src/metabase/reference/databases/TableSidebar.jsx @@ -0,0 +1,66 @@ +/* eslint "react/prop-types": "warn" */ +import { memo } from "react"; +import PropTypes from "prop-types"; +import { t } from "ttag"; +import cx from "classnames"; + +import MetabaseSettings from "metabase/lib/settings"; + +import Breadcrumbs from "metabase/components/Breadcrumbs"; +import SidebarItem from "metabase/components/SidebarItem"; + +import S from "metabase/components/Sidebar.css"; + +const TableSidebar = ({ database, table, style, className }) => ( + <div className={cx(S.sidebar, className)} style={style}> + <div className={S.breadcrumbs}> + <Breadcrumbs + className="py4 ml3" + crumbs={[ + [t`Databases`, "/reference/databases"], + [database.name, `/reference/databases/${database.id}`], + [table.name], + ]} + inSidebar={true} + placeholder={t`Data Reference`} + /> + </div> + <ol className="mx3"> + <SidebarItem + key={`/reference/databases/${database.id}/tables/${table.id}`} + href={`/reference/databases/${database.id}/tables/${table.id}`} + icon="document" + name={t`Details`} + /> + <SidebarItem + key={`/reference/databases/${database.id}/tables/${table.id}/fields`} + href={`/reference/databases/${database.id}/tables/${table.id}/fields`} + icon="field" + name={t`Fields in this table`} + /> + <SidebarItem + key={`/reference/databases/${database.id}/tables/${table.id}/questions`} + href={`/reference/databases/${database.id}/tables/${table.id}/questions`} + icon="folder" + name={t`Questions about this table`} + /> + {MetabaseSettings.get("enable-xrays") && ( + <SidebarItem + key={`/auto/dashboard/table/${table.id}`} + href={`/auto/dashboard/table/${table.id}`} + icon="bolt" + name={t`X-ray this table`} + /> + )} + </ol> + </div> +); + +TableSidebar.propTypes = { + database: PropTypes.object, + table: PropTypes.object, + className: PropTypes.string, + style: PropTypes.object, +}; + +export default memo(TableSidebar); diff --git a/frontend/src/metabase/reference/guide/BaseSidebar.jsx b/frontend/src/metabase/reference/guide/BaseSidebar.jsx new file mode 100644 index 0000000000000000000000000000000000000000..324fbd10cf8ed8531f2e33ecfe6d61072fdef04e --- /dev/null +++ b/frontend/src/metabase/reference/guide/BaseSidebar.jsx @@ -0,0 +1,48 @@ +/* eslint "react/prop-types": "warn" */ +import { memo } from "react"; +import PropTypes from "prop-types"; +import { t } from "ttag"; +import cx from "classnames"; +import S from "metabase/components/Sidebar.css"; +import Breadcrumbs from "metabase/components/Breadcrumbs"; +import SidebarItem from "metabase/components/SidebarItem"; + +const BaseSidebar = ({ style, className }) => ( + <div className={cx(S.sidebar, className)} style={style}> + <div className={S.breadcrumbs}> + <Breadcrumbs + className="py4 ml3" + crumbs={[[t`Data Reference`]]} + inSidebar={true} + placeholder={t`Data Reference`} + /> + </div> + <ol className="mx3"> + <SidebarItem + key="/reference/metrics" + href="/reference/metrics" + icon="ruler" + name={t`Metrics`} + /> + <SidebarItem + key="/reference/segments" + href="/reference/segments" + icon="segment" + name={t`Segments`} + /> + <SidebarItem + key="/reference/databases" + href="/reference/databases" + icon="database" + name={t`Our data`} + /> + </ol> + </div> +); + +BaseSidebar.propTypes = { + className: PropTypes.string, + style: PropTypes.object, +}; + +export default memo(BaseSidebar); diff --git a/frontend/src/metabase/reference/metrics/MetricDetail.jsx b/frontend/src/metabase/reference/metrics/MetricDetail.jsx new file mode 100644 index 0000000000000000000000000000000000000000..83b47ef8f41c96f199794358551947cc70fbfa80 --- /dev/null +++ b/frontend/src/metabase/reference/metrics/MetricDetail.jsx @@ -0,0 +1,239 @@ +/* eslint "react/prop-types": "warn" */ +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import { useFormik } from "formik"; +import { push } from "react-router-redux"; +import { t } from "ttag"; +import _ from "underscore"; + +import List from "metabase/components/List"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; + +import EditHeader from "metabase/reference/components/EditHeader"; +import EditableReferenceHeader from "metabase/reference/components/EditableReferenceHeader"; +import Detail from "metabase/reference/components/Detail"; +import FieldsToGroupBy from "metabase/reference/components/FieldsToGroupBy"; +import Formula from "metabase/reference/components/Formula"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; +import { getQuestionUrl } from "../utils"; + +import { + getMetric, + getTable, + getFields, + getError, + getLoading, + getUser, + getIsFormulaExpanded, + getForeignKeys, +} from "../selectors"; + +const mapStateToProps = (state, props) => { + const entity = getMetric(state, props) || {}; + const fields = getFields(state, props); + + return { + entity, + table: getTable(state, props), + metadataFields: fields, + loading: getLoading(state, props), + // naming this 'error' will conflict with redux form + loadingError: getError(state, props), + user: getUser(state, props), + foreignKeys: getForeignKeys(state, props), + isFormulaExpanded: getIsFormulaExpanded(state, props), + }; +}; + +const mapDispatchToProps = { + ...metadataActions, + + // Metric page doesn't use Redux isEditing state and related callbacks + // The state and callbacks are received via props + ..._.omit(actions, "startEditing", "endEditing"), + + onSubmit: actions.rUpdateMetricDetail, + onChangeLocation: push, +}; + +const validate = values => + !values.revision_message + ? { revision_message: t`Please enter a revision message` } + : {}; + +const propTypes = { + style: PropTypes.object.isRequired, + entity: PropTypes.object.isRequired, + table: PropTypes.object, + metadataFields: PropTypes.object, + user: PropTypes.object.isRequired, + isEditing: PropTypes.bool, + startEditing: PropTypes.func.isRequired, + endEditing: PropTypes.func.isRequired, + startLoading: PropTypes.func.isRequired, + endLoading: PropTypes.func.isRequired, + expandFormula: PropTypes.func.isRequired, + collapseFormula: PropTypes.func.isRequired, + setError: PropTypes.func.isRequired, + updateField: PropTypes.func.isRequired, + isFormulaExpanded: PropTypes.bool, + loading: PropTypes.bool, + loadingError: PropTypes.object, + onSubmit: PropTypes.func.isRequired, + onChangeLocation: PropTypes.func.isRequired, +}; + +const MetricDetail = props => { + const { + style, + entity, + table, + metadataFields, + loadingError, + loading, + user, + isEditing, + startEditing, + endEditing, + expandFormula, + collapseFormula, + isFormulaExpanded, + onSubmit, + onChangeLocation, + } = props; + + const { + isSubmitting, + getFieldProps, + getFieldMeta, + handleSubmit, + handleReset, + } = useFormik({ + validate, + initialValues: {}, + initialErrors: validate({}), + onSubmit: fields => + onSubmit(entity, fields, { ...props, resetForm: handleReset }), + }); + + const getFormField = name => ({ + ...getFieldProps(name), + ...getFieldMeta(name), + }); + + return ( + <form style={style} className="full" onSubmit={handleSubmit}> + {isEditing && ( + <EditHeader + hasRevisionHistory={true} + onSubmit={handleSubmit} + endEditing={endEditing} + reinitializeForm={handleReset} + submitting={isSubmitting} + revisionMessageFormField={getFormField("revision_message")} + /> + )} + <EditableReferenceHeader + entity={entity} + table={table} + type="metric" + headerIcon="ruler" + headerLink={getQuestionUrl({ + dbId: table && table.db_id, + tableId: entity.table_id, + metricId: entity.id, + })} + name={t`Details`} + user={user} + isEditing={isEditing} + hasSingleSchema={false} + hasDisplayName={false} + startEditing={startEditing} + displayNameFormField={getFormField("display_name")} + nameFormField={getFormField("name")} + /> + <LoadingAndErrorWrapper + loading={!loadingError && loading} + error={loadingError} + > + {() => ( + <div className="wrapper"> + <div className="pl4 pr3 pt4 mb4 mb1 bg-white rounded bordered"> + <List> + <li className="relative"> + <Detail + field={getFormField("description")} + name={t`Description`} + description={entity.description} + placeholder={t`No description yet`} + isEditing={isEditing} + /> + </li> + <li className="relative"> + <Detail + field={getFormField("points_of_interest")} + name={t`Why this metric is interesting`} + description={entity.points_of_interest} + placeholder={t`Nothing interesting yet`} + isEditing={isEditing} + /> + </li> + <li className="relative"> + <Detail + field={getFormField("caveats")} + name={t`Things to be aware of about this metric`} + description={entity.caveats} + placeholder={t`Nothing to be aware of yet`} + isEditing={isEditing} + /> + </li> + <li className="relative"> + <Detail + field={getFormField("how_is_this_calculated")} + name={t`How this metric is calculated`} + description={entity.how_is_this_calculated} + placeholder={t`Nothing on how it's calculated yet`} + isEditing={isEditing} + /> + </li> + {table && !isEditing && ( + <li className="relative"> + <Formula + type="metric" + entity={entity} + isExpanded={isFormulaExpanded} + expandFormula={expandFormula} + collapseFormula={collapseFormula} + /> + </li> + )} + {!isEditing && ( + <li className="relative mt4"> + <FieldsToGroupBy + fields={table.fields + .map(fieldId => metadataFields[fieldId]) + .reduce( + (map, field) => ({ ...map, [field.id]: field }), + {}, + )} + databaseId={table && table.db_id} + metric={entity} + title={t`Fields you can group this metric by`} + onChangeLocation={onChangeLocation} + /> + </li> + )} + </List> + </div> + </div> + )} + </LoadingAndErrorWrapper> + </form> + ); +}; + +MetricDetail.propTypes = propTypes; + +export default connect(mapStateToProps, mapDispatchToProps)(MetricDetail); diff --git a/frontend/src/metabase/reference/metrics/MetricDetailContainer.jsx b/frontend/src/metabase/reference/metrics/MetricDetailContainer.jsx new file mode 100644 index 0000000000000000000000000000000000000000..92b5bf82aeabc936bec6420a97abe292a5fdac8f --- /dev/null +++ b/frontend/src/metabase/reference/metrics/MetricDetailContainer.jsx @@ -0,0 +1,100 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; + +import * as MetabaseAnalytics from "metabase/lib/analytics"; + +import SidebarLayout from "metabase/components/SidebarLayout"; +import MetricDetail from "metabase/reference/metrics/MetricDetail"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; + +import { getUser, getMetric, getMetricId, getDatabaseId } from "../selectors"; +import MetricSidebar from "./MetricSidebar"; + +const mapStateToProps = (state, props) => ({ + user: getUser(state, props), + metric: getMetric(state, props), + metricId: getMetricId(state, props), + databaseId: getDatabaseId(state, props), +}); + +const mapDispatchToProps = { + ...metadataActions, + ...actions, +}; + +class MetricDetailContainer extends Component { + static propTypes = { + router: PropTypes.shape({ + replace: PropTypes.func.isRequired, + }).isRequired, + params: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + user: PropTypes.object.isRequired, + metric: PropTypes.object.isRequired, + metricId: PropTypes.number.isRequired, + databaseId: PropTypes.number.isRequired, + }; + + constructor(props) { + super(props); + this.startEditing = this.startEditing.bind(this); + this.endEditing = this.endEditing.bind(this); + } + + async fetchContainerData() { + await actions.wrappedFetchMetricDetail(this.props, this.props.metricId); + } + + startEditing() { + const { metric, router } = this.props; + router.replace(`/reference/metrics/${metric.id}/edit`); + MetabaseAnalytics.trackStructEvent("Data Reference", "Started Editing"); + } + + endEditing() { + const { metric, router } = this.props; + router.replace(`/reference/metrics/${metric.id}`); + // No need to track end of editing here, as it's done by actions.clearState below + } + + UNSAFE_componentWillMount() { + this.fetchContainerData(); + } + + UNSAFE_componentWillReceiveProps(newProps) { + if (this.props.location.pathname === newProps.location.pathname) { + return; + } + + actions.clearState(newProps); + } + + render() { + const { location, user, metric } = this.props; + const isEditing = location.pathname.endsWith("/edit"); + + return ( + <SidebarLayout + className="flex-full relative" + style={isEditing ? { paddingTop: "43px" } : {}} + sidebar={<MetricSidebar metric={metric} user={user} />} + > + <MetricDetail + {...this.props} + isEditing={isEditing} + startEditing={this.startEditing} + endEditing={this.endEditing} + /> + </SidebarLayout> + ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(MetricDetailContainer); diff --git a/frontend/src/metabase/reference/metrics/MetricList.jsx b/frontend/src/metabase/reference/metrics/MetricList.jsx new file mode 100644 index 0000000000000000000000000000000000000000..6097af58dd4983936e4822760c71019579eef0fa --- /dev/null +++ b/frontend/src/metabase/reference/metrics/MetricList.jsx @@ -0,0 +1,93 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import { t } from "ttag"; + +import S from "metabase/components/List/List.css"; + +import List from "metabase/components/List"; +import ListItem from "metabase/components/ListItem"; +import AdminAwareEmptyState from "metabase/components/AdminAwareEmptyState"; + +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; + +import MetabaseSettings from "metabase/lib/settings"; +import * as metadataActions from "metabase/redux/metadata"; +import ReferenceHeader from "../components/ReferenceHeader"; + +import { getMetrics, getError, getLoading } from "../selectors"; + +const emptyStateData = { + title: t`Metrics are the official numbers that your team cares about`, + adminMessage: t`Defining common metrics for your team makes it even easier to ask questions`, + message: t`Metrics will appear here once your admins have created some`, + image: "app/assets/img/metrics-list", + adminAction: t`Learn how to create metrics`, + adminLink: MetabaseSettings.docsUrl( + "data-modeling/segments-and-metrics", + "creating-a-metric", + ), +}; + +const mapStateToProps = (state, props) => ({ + entities: getMetrics(state, props), + loading: getLoading(state, props), + loadingError: getError(state, props), +}); + +const mapDispatchToProps = { + ...metadataActions, +}; + +class MetricList extends Component { + static propTypes = { + style: PropTypes.object.isRequired, + entities: PropTypes.object.isRequired, + loading: PropTypes.bool, + loadingError: PropTypes.object, + }; + + render() { + const { entities, style, loadingError, loading } = this.props; + + return ( + <div style={style} className="full"> + <ReferenceHeader name={t`Metrics`} /> + <LoadingAndErrorWrapper + loading={!loadingError && loading} + error={loadingError} + > + {() => + Object.keys(entities).length > 0 ? ( + <div className="wrapper wrapper--trim"> + <List> + {Object.values(entities).map( + entity => + entity && + entity.id && + entity.name && ( + <ListItem + key={entity.id} + name={entity.display_name || entity.name} + description={entity.description} + url={`/reference/metrics/${entity.id}`} + icon="ruler" + /> + ), + )} + </List> + </div> + ) : ( + <div className={S.empty}> + <AdminAwareEmptyState {...emptyStateData} /> + </div> + ) + } + </LoadingAndErrorWrapper> + </div> + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(MetricList); diff --git a/frontend/src/metabase/reference/metrics/MetricListContainer.jsx b/frontend/src/metabase/reference/metrics/MetricListContainer.jsx new file mode 100644 index 0000000000000000000000000000000000000000..0dadc5950800d8ff0599018cadfe5e0dc7c35bdc --- /dev/null +++ b/frontend/src/metabase/reference/metrics/MetricListContainer.jsx @@ -0,0 +1,67 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; + +import BaseSidebar from "metabase/reference/guide/BaseSidebar"; +import SidebarLayout from "metabase/components/SidebarLayout"; +import MetricList from "metabase/reference/metrics/MetricList"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; + +import { getDatabaseId, getIsEditing } from "../selectors"; + +const mapStateToProps = (state, props) => ({ + databaseId: getDatabaseId(state, props), + isEditing: getIsEditing(state, props), +}); + +const mapDispatchToProps = { + ...metadataActions, + ...actions, +}; + +class MetricListContainer extends Component { + static propTypes = { + params: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + databaseId: PropTypes.number.isRequired, + isEditing: PropTypes.bool, + }; + + async fetchContainerData() { + await actions.wrappedFetchMetrics(this.props); + } + + UNSAFE_componentWillMount() { + this.fetchContainerData(); + } + + UNSAFE_componentWillReceiveProps(newProps) { + if (this.props.location.pathname === newProps.location.pathname) { + return; + } + + actions.clearState(newProps); + } + + render() { + const { isEditing } = this.props; + + return ( + <SidebarLayout + className="flex-full relative" + style={isEditing ? { paddingTop: "43px" } : {}} + sidebar={<BaseSidebar />} + > + <MetricList {...this.props} /> + </SidebarLayout> + ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(MetricListContainer); diff --git a/frontend/src/metabase/reference/metrics/MetricQuestions.jsx b/frontend/src/metabase/reference/metrics/MetricQuestions.jsx new file mode 100644 index 0000000000000000000000000000000000000000..0a764a89c4306c6a9f9fd78d5818999d631d5fe5 --- /dev/null +++ b/frontend/src/metabase/reference/metrics/MetricQuestions.jsx @@ -0,0 +1,116 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import moment from "moment-timezone"; +import { t } from "ttag"; +import visualizations from "metabase/visualizations"; +import * as Urls from "metabase/lib/urls"; + +import S from "metabase/components/List/List.css"; + +import List from "metabase/components/List"; +import ListItem from "metabase/components/ListItem"; +import AdminAwareEmptyState from "metabase/components/AdminAwareEmptyState"; + +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; + +import * as metadataActions from "metabase/redux/metadata"; +import ReferenceHeader from "../components/ReferenceHeader"; + +import { getQuestionUrl } from "../utils"; + +import { + getMetricQuestions, + getError, + getLoading, + getTable, + getMetric, +} from "../selectors"; + +const emptyStateData = (table, metric) => { + return { + message: t`Questions about this metric will appear here as they're added`, + icon: "all", + action: t`Ask a question`, + link: getQuestionUrl({ + dbId: table && table.db_id, + tableId: metric.table_id, + metricId: metric.id, + }), + }; +}; + +const mapStateToProps = (state, props) => ({ + metric: getMetric(state, props), + table: getTable(state, props), + entities: getMetricQuestions(state, props), + loading: getLoading(state, props), + loadingError: getError(state, props), +}); + +const mapDispatchToProps = { + ...metadataActions, +}; + +class MetricQuestions extends Component { + static propTypes = { + style: PropTypes.object.isRequired, + entities: PropTypes.object.isRequired, + loading: PropTypes.bool, + loadingError: PropTypes.object, + metric: PropTypes.object, + table: PropTypes.object, + }; + + render() { + const { entities, style, loadingError, loading } = this.props; + + return ( + <div style={style} className="full"> + <ReferenceHeader + name={t`Questions about ${this.props.metric.name}`} + type="questions" + headerIcon="ruler" + /> + <LoadingAndErrorWrapper + loading={!loadingError && loading} + error={loadingError} + > + {() => + Object.keys(entities).length > 0 ? ( + <div className="wrapper wrapper--trim"> + <List> + {Object.values(entities).map( + entity => + entity && + entity.id && + entity.name && ( + <ListItem + key={entity.id} + name={entity.display_name || entity.name} + description={t`Created ${moment( + entity.created_at, + ).fromNow()} by ${entity.creator.common_name}`} + url={Urls.question(entity)} + icon={visualizations.get(entity.display).iconName} + /> + ), + )} + </List> + </div> + ) : ( + <div className={S.empty}> + <AdminAwareEmptyState + {...emptyStateData(this.props.table, this.props.metric)} + /> + </div> + ) + } + </LoadingAndErrorWrapper> + </div> + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(MetricQuestions); diff --git a/frontend/src/metabase/reference/metrics/MetricQuestionsContainer.jsx b/frontend/src/metabase/reference/metrics/MetricQuestionsContainer.jsx new file mode 100644 index 0000000000000000000000000000000000000000..63750c61eb3532518534e5ea127b2822309a7fa4 --- /dev/null +++ b/frontend/src/metabase/reference/metrics/MetricQuestionsContainer.jsx @@ -0,0 +1,82 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; + +import SidebarLayout from "metabase/components/SidebarLayout"; +import MetricQuestions from "metabase/reference/metrics/MetricQuestions"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; + +import Questions from "metabase/entities/questions"; +import { + getUser, + getMetric, + getMetricId, + getDatabaseId, + getIsEditing, +} from "../selectors"; + +import MetricSidebar from "./MetricSidebar"; + +const mapStateToProps = (state, props) => ({ + user: getUser(state, props), + metric: getMetric(state, props), + metricId: getMetricId(state, props), + databaseId: getDatabaseId(state, props), + isEditing: getIsEditing(state, props), +}); + +const mapDispatchToProps = { + fetchQuestions: Questions.actions.fetchList, + ...metadataActions, + ...actions, +}; + +class MetricQuestionsContainer extends Component { + static propTypes = { + params: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + user: PropTypes.object.isRequired, + metric: PropTypes.object.isRequired, + metricId: PropTypes.number.isRequired, + databaseId: PropTypes.number.isRequired, + isEditing: PropTypes.bool, + }; + + async fetchContainerData() { + await actions.wrappedFetchMetricQuestions(this.props, this.props.metricId); + } + + UNSAFE_componentWillMount() { + this.fetchContainerData(); + } + + UNSAFE_componentWillReceiveProps(newProps) { + if (this.props.location.pathname === newProps.location.pathname) { + return; + } + + actions.clearState(newProps); + } + + render() { + const { user, metric, isEditing } = this.props; + + return ( + <SidebarLayout + className="flex-full relative" + style={isEditing ? { paddingTop: "43px" } : {}} + sidebar={<MetricSidebar metric={metric} user={user} />} + > + <MetricQuestions {...this.props} /> + </SidebarLayout> + ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(MetricQuestionsContainer); diff --git a/frontend/src/metabase/reference/metrics/MetricRevisions.jsx b/frontend/src/metabase/reference/metrics/MetricRevisions.jsx new file mode 100644 index 0000000000000000000000000000000000000000..81d40960a1a5440d678544d805a6207974cbd577 --- /dev/null +++ b/frontend/src/metabase/reference/metrics/MetricRevisions.jsx @@ -0,0 +1,129 @@ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import { t } from "ttag"; +import { getIn } from "icepick"; + +import S from "metabase/components/List/List.css"; +import R from "metabase/reference/Reference.css"; + +import * as metadataActions from "metabase/redux/metadata"; +import { assignUserColors } from "metabase/lib/formatting"; + +import Revision from "metabase/admin/datamodel/components/revisions/Revision"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; +import EmptyState from "metabase/components/EmptyState"; +import { + getMetricRevisions, + getMetric, + getSegment, + getTables, + getUser, + getLoading, + getError, +} from "../selectors"; +import ReferenceHeader from "../components/ReferenceHeader"; + +const emptyStateData = { + message: t`There are no revisions for this metric`, +}; + +const mapStateToProps = (state, props) => { + return { + revisions: getMetricRevisions(state, props), + metric: getMetric(state, props), + segment: getSegment(state, props), + tables: getTables(state, props), + user: getUser(state, props), + loading: getLoading(state, props), + loadingError: getError(state, props), + }; +}; + +const mapDispatchToProps = { + ...metadataActions, +}; + +class MetricRevisions extends Component { + static propTypes = { + style: PropTypes.object.isRequired, + revisions: PropTypes.object.isRequired, + metric: PropTypes.object.isRequired, + segment: PropTypes.object.isRequired, + tables: PropTypes.object.isRequired, + user: PropTypes.object.isRequired, + loading: PropTypes.bool, + loadingError: PropTypes.object, + }; + + render() { + const { + style, + revisions, + metric, + segment, + tables, + user, + loading, + loadingError, + } = this.props; + + const entity = metric.id ? metric : segment; + + const userColorAssignments = + user && Object.keys(revisions).length > 0 + ? assignUserColors( + Object.values(revisions).map(revision => + getIn(revision, ["user", "id"]), + ), + user.id, + ) + : {}; + + return ( + <div style={style} className="full"> + <ReferenceHeader + name={t`Revision history for ${this.props.metric.name}`} + headerIcon="ruler" + /> + <LoadingAndErrorWrapper + loading={!loadingError && loading} + error={loadingError} + > + {() => + Object.keys(revisions).length > 0 && tables[entity.table_id] ? ( + <div className="wrapper wrapper--trim"> + <div className={R.revisionsWrapper}> + {Object.values(revisions) + .map(revision => + revision && revision.diff ? ( + <Revision + key={revision.id} + revision={revision || {}} + tableMetadata={tables[entity.table_id] || {}} + objectName={entity.name} + currentUser={user || {}} + userColor={ + userColorAssignments[ + getIn(revision, ["user", "id"]) + ] + } + /> + ) : null, + ) + .reverse()} + </div> + </div> + ) : ( + <div className={S.empty}> + <EmptyState {...emptyStateData} /> + </div> + ) + } + </LoadingAndErrorWrapper> + </div> + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(MetricRevisions); diff --git a/frontend/src/metabase/reference/metrics/MetricRevisionsContainer.jsx b/frontend/src/metabase/reference/metrics/MetricRevisionsContainer.jsx new file mode 100644 index 0000000000000000000000000000000000000000..4445530b4356c96729d305098d0b3b0ec78171c0 --- /dev/null +++ b/frontend/src/metabase/reference/metrics/MetricRevisionsContainer.jsx @@ -0,0 +1,79 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; + +import SidebarLayout from "metabase/components/SidebarLayout"; +import MetricRevisions from "metabase/reference/metrics/MetricRevisions"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; + +import { + getUser, + getMetric, + getMetricId, + getDatabaseId, + getIsEditing, +} from "../selectors"; +import MetricSidebar from "./MetricSidebar"; + +const mapStateToProps = (state, props) => ({ + user: getUser(state, props), + metric: getMetric(state, props), + metricId: getMetricId(state, props), + databaseId: getDatabaseId(state, props), + isEditing: getIsEditing(state, props), +}); + +const mapDispatchToProps = { + ...metadataActions, + ...actions, +}; + +class MetricRevisionsContainer extends Component { + static propTypes = { + params: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + user: PropTypes.object.isRequired, + metric: PropTypes.object.isRequired, + metricId: PropTypes.number.isRequired, + databaseId: PropTypes.number.isRequired, + isEditing: PropTypes.bool, + }; + + async fetchContainerData() { + await actions.wrappedFetchMetricRevisions(this.props, this.props.metricId); + } + + UNSAFE_componentWillMount() { + this.fetchContainerData(); + } + + UNSAFE_componentWillReceiveProps(newProps) { + if (this.props.location.pathname === newProps.location.pathname) { + return; + } + + actions.clearState(newProps); + } + + render() { + const { user, metric, isEditing } = this.props; + + return ( + <SidebarLayout + className="flex-full relative" + style={isEditing ? { paddingTop: "43px" } : {}} + sidebar={<MetricSidebar metric={metric} user={user} />} + > + <MetricRevisions {...this.props} /> + </SidebarLayout> + ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(MetricRevisionsContainer); diff --git a/frontend/src/metabase/reference/metrics/MetricSidebar.jsx b/frontend/src/metabase/reference/metrics/MetricSidebar.jsx new file mode 100644 index 0000000000000000000000000000000000000000..abc87e2fe15923b9d55250c7e651dc5029430e05 --- /dev/null +++ b/frontend/src/metabase/reference/metrics/MetricSidebar.jsx @@ -0,0 +1,66 @@ +/* eslint "react/prop-types": "warn" */ +import { memo } from "react"; +import PropTypes from "prop-types"; +import { t } from "ttag"; +import cx from "classnames"; + +import MetabaseSettings from "metabase/lib/settings"; + +import Breadcrumbs from "metabase/components/Breadcrumbs"; +import SidebarItem from "metabase/components/SidebarItem"; + +import S from "metabase/components/Sidebar.css"; + +const MetricSidebar = ({ metric, user, style, className }) => ( + <div className={cx(S.sidebar, className)} style={style}> + <ul> + <div className={S.breadcrumbs}> + <Breadcrumbs + className="py4 ml3" + crumbs={[[t`Metrics`, "/reference/metrics"], [metric.name]]} + inSidebar={true} + placeholder={t`Data Reference`} + /> + </div> + <ol className="mx3"> + <SidebarItem + key={`/reference/metrics/${metric.id}`} + href={`/reference/metrics/${metric.id}`} + icon="document" + name={t`Details`} + /> + <SidebarItem + key={`/reference/metrics/${metric.id}/questions`} + href={`/reference/metrics/${metric.id}/questions`} + icon="folder" + name={t`Questions about ${metric.name}`} + /> + {MetabaseSettings.get("enable-xrays") && ( + <SidebarItem + key={`/auto/dashboard/metric/${metric.id}`} + href={`/auto/dashboard/metric/${metric.id}`} + icon="bolt" + name={t`X-ray this metric`} + /> + )} + {user && user.is_superuser && ( + <SidebarItem + key={`/reference/metrics/${metric.id}/revisions`} + href={`/reference/metrics/${metric.id}/revisions`} + icon="history" + name={t`Revision history for ${metric.name}`} + /> + )} + </ol> + </ul> + </div> +); + +MetricSidebar.propTypes = { + metric: PropTypes.object, + user: PropTypes.object, + className: PropTypes.string, + style: PropTypes.object, +}; + +export default memo(MetricSidebar); diff --git a/frontend/src/metabase/reference/reference.js b/frontend/src/metabase/reference/reference.js new file mode 100644 index 0000000000000000000000000000000000000000..8988fdc22e797447ec708582348c71dd48fea892 --- /dev/null +++ b/frontend/src/metabase/reference/reference.js @@ -0,0 +1,278 @@ +import { assoc } from "icepick"; + +import { handleActions, createAction } from "metabase/lib/redux"; + +import * as MetabaseAnalytics from "metabase/lib/analytics"; + +import { filterUntouchedFields, isEmptyObject } from "./utils.js"; + +export const SET_ERROR = "metabase/reference/SET_ERROR"; +export const CLEAR_ERROR = "metabase/reference/CLEAR_ERROR"; +export const START_LOADING = "metabase/reference/START_LOADING"; +export const END_LOADING = "metabase/reference/END_LOADING"; +export const START_EDITING = "metabase/reference/START_EDITING"; +export const END_EDITING = "metabase/reference/END_EDITING"; +export const EXPAND_FORMULA = "metabase/reference/EXPAND_FORMULA"; +export const COLLAPSE_FORMULA = "metabase/reference/COLLAPSE_FORMULA"; +export const SHOW_DASHBOARD_MODAL = "metabase/reference/SHOW_DASHBOARD_MODAL"; +export const HIDE_DASHBOARD_MODAL = "metabase/reference/HIDE_DASHBOARD_MODAL"; + +export const setError = createAction(SET_ERROR); + +export const clearError = createAction(CLEAR_ERROR); + +export const startLoading = createAction(START_LOADING); + +export const endLoading = createAction(END_LOADING); + +export const startEditing = createAction(START_EDITING, () => { + MetabaseAnalytics.trackStructEvent("Data Reference", "Started Editing"); +}); + +export const endEditing = createAction(END_EDITING, () => { + MetabaseAnalytics.trackStructEvent("Data Reference", "Ended Editing"); +}); + +export const expandFormula = createAction(EXPAND_FORMULA); + +export const collapseFormula = createAction(COLLAPSE_FORMULA); + +//TODO: consider making an app-wide modal state reducer and related actions +export const showDashboardModal = createAction(SHOW_DASHBOARD_MODAL); + +export const hideDashboardModal = createAction(HIDE_DASHBOARD_MODAL); + +// Helper functions. This is meant to be a transitional state to get things out of tryFetchData() and friends + +const fetchDataWrapper = (props, fn) => { + return async argument => { + props.clearError(); + props.startLoading(); + try { + await fn(argument); + } catch (error) { + console.error(error); + props.setError(error); + } + + props.endLoading(); + }; +}; + +export const wrappedFetchDatabaseMetadata = (props, databaseID) => { + fetchDataWrapper(props, props.fetchDatabaseMetadata)(databaseID); +}; + +export const wrappedFetchDatabaseMetadataAndQuestion = async ( + props, + databaseID, +) => { + fetchDataWrapper(props, async dbID => { + await Promise.all([ + props.fetchDatabaseMetadata(dbID), + props.fetchQuestions(), + ]); + })(databaseID); +}; +export const wrappedFetchMetricDetail = async (props, metricID) => { + fetchDataWrapper(props, async mID => { + await Promise.all([props.fetchMetricTable(mID), props.fetchMetrics()]); + })(metricID); +}; +export const wrappedFetchMetricQuestions = async (props, metricID) => { + fetchDataWrapper(props, async mID => { + await Promise.all([ + props.fetchMetricTable(mID), + props.fetchMetrics(), + props.fetchQuestions(), + ]); + })(metricID); +}; +export const wrappedFetchMetricRevisions = async (props, metricID) => { + fetchDataWrapper(props, async mID => { + await Promise.all([props.fetchMetricRevisions(mID), props.fetchMetrics()]); + })(metricID); +}; + +export const wrappedFetchDatabases = props => { + fetchDataWrapper(props, props.fetchRealDatabases)({}); +}; +export const wrappedFetchMetrics = props => { + fetchDataWrapper(props, props.fetchMetrics)({}); +}; + +export const wrappedFetchSegments = props => { + fetchDataWrapper(props, props.fetchSegments)({}); +}; + +export const wrappedFetchSegmentDetail = (props, segmentID) => { + fetchDataWrapper(props, props.fetchSegmentTable)(segmentID); +}; + +export const wrappedFetchSegmentQuestions = async (props, segmentID) => { + fetchDataWrapper(props, async sID => { + await props.fetchSegments(sID); + await Promise.all([props.fetchSegmentTable(sID), props.fetchQuestions()]); + })(segmentID); +}; +export const wrappedFetchSegmentRevisions = async (props, segmentID) => { + fetchDataWrapper(props, async sID => { + await props.fetchSegments(sID); + await Promise.all([ + props.fetchSegmentRevisions(sID), + props.fetchSegmentTable(sID), + ]); + })(segmentID); +}; +export const wrappedFetchSegmentFields = async (props, segmentID) => { + fetchDataWrapper(props, async sID => { + await props.fetchSegments(sID); + await Promise.all([ + props.fetchSegmentFields(sID), + props.fetchSegmentTable(sID), + ]); + })(segmentID); +}; + +// This is called when a component gets a new set of props. +// I *think* this is un-necessary in all cases as we're using multiple +// components where the old code re-used the same component +export const clearState = props => { + props.endEditing(); + props.endLoading(); + props.clearError(); + props.collapseFormula(); +}; + +// This is called on the success or failure of a form triggered update +const resetForm = props => { + props.resetForm(); + props.endLoading(); + props.endEditing(); +}; + +// Update actions +// these use the "fetchDataWrapper" for now. It should probably be renamed. +// Using props to fire off actions, which imo should be refactored to +// dispatch directly, since there is no actual dependence with the props +// of that component + +const updateDataWrapper = (props, fn) => { + return async fields => { + props.clearError(); + props.startLoading(); + try { + const editedFields = filterUntouchedFields(fields, props.entity); + if (!isEmptyObject(editedFields)) { + const newEntity = { ...props.entity, ...editedFields }; + await fn(newEntity); + } + } catch (error) { + console.error(error); + props.setError(error); + } + resetForm(props); + }; +}; + +export const rUpdateSegmentDetail = (formFields, props) => { + return () => updateDataWrapper(props, props.updateSegment)(formFields); +}; +export const rUpdateSegmentFieldDetail = (formFields, props) => { + return () => updateDataWrapper(props, props.updateField)(formFields); +}; +export const rUpdateDatabaseDetail = (formFields, props) => { + return () => updateDataWrapper(props, props.updateDatabase)(formFields); +}; +export const rUpdateTableDetail = (formFields, props) => { + return () => updateDataWrapper(props, props.updateTable)(formFields); +}; +export const rUpdateFieldDetail = (formFields, props) => { + return () => updateDataWrapper(props, props.updateField)(formFields); +}; + +export const rUpdateMetricDetail = (metric, formFields, props) => { + return async () => { + props.startLoading(); + try { + const editedFields = filterUntouchedFields(formFields, metric); + if (!isEmptyObject(editedFields)) { + const newMetric = { ...metric, ...editedFields }; + await props.updateMetric(newMetric); + } + } catch (error) { + props.setError(error); + console.error(error); + } + + resetForm(props); + }; +}; + +export const rUpdateFields = (fields, formFields, props) => { + return async () => { + props.startLoading(); + try { + const updatedFields = Object.keys(formFields) + .map(fieldId => ({ + field: fields[fieldId], + formField: filterUntouchedFields( + formFields[fieldId], + fields[fieldId], + ), + })) + .filter(({ field, formField }) => !isEmptyObject(formField)) + .map(({ field, formField }) => ({ ...field, ...formField })); + + await Promise.all(updatedFields.map(props.updateField)); + } catch (error) { + props.setError(error); + console.error(error); + } + + resetForm(props); + }; +}; + +const initialState = { + error: null, + isLoading: false, + isEditing: false, + isFormulaExpanded: false, + isDashboardModalOpen: false, +}; +export default handleActions( + { + [SET_ERROR]: { + throw: (state, { payload }) => assoc(state, "error", payload), + }, + [CLEAR_ERROR]: { + next: state => assoc(state, "error", null), + }, + [START_LOADING]: { + next: state => assoc(state, "isLoading", true), + }, + [END_LOADING]: { + next: state => assoc(state, "isLoading", false), + }, + [START_EDITING]: { + next: state => assoc(state, "isEditing", true), + }, + [END_EDITING]: { + next: state => assoc(state, "isEditing", false), + }, + [EXPAND_FORMULA]: { + next: state => assoc(state, "isFormulaExpanded", true), + }, + [COLLAPSE_FORMULA]: { + next: state => assoc(state, "isFormulaExpanded", false), + }, + [SHOW_DASHBOARD_MODAL]: { + next: state => assoc(state, "isDashboardModalOpen", true), + }, + [HIDE_DASHBOARD_MODAL]: { + next: state => assoc(state, "isDashboardModalOpen", false), + }, + }, + initialState, +); diff --git a/frontend/src/metabase/reference/segments/SegmentDetail.jsx b/frontend/src/metabase/reference/segments/SegmentDetail.jsx new file mode 100644 index 0000000000000000000000000000000000000000..50566a86a62db0dc4d193ec4578ec7c39067e685 --- /dev/null +++ b/frontend/src/metabase/reference/segments/SegmentDetail.jsx @@ -0,0 +1,263 @@ +/* eslint "react/prop-types": "warn" */ +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import { useFormik } from "formik"; +import { t } from "ttag"; +import List from "metabase/components/List"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; + +import EditHeader from "metabase/reference/components/EditHeader"; +import EditableReferenceHeader from "metabase/reference/components/EditableReferenceHeader"; +import Detail from "metabase/reference/components/Detail"; +import UsefulQuestions from "metabase/reference/components/UsefulQuestions"; +import Formula from "metabase/reference/components/Formula"; +import Link from "metabase/core/components/Link"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; +import { getQuestionUrl } from "../utils"; + +import { + getSegment, + getTable, + getFields, + getError, + getLoading, + getUser, + getIsEditing, + getIsFormulaExpanded, +} from "../selectors"; + +import S from "../components/Detail.css"; + +const interestingQuestions = (table, segment) => { + return [ + { + text: t`Number of ${segment.name}`, + icon: "number", + link: getQuestionUrl({ + dbId: table && table.db_id, + tableId: table.id, + segmentId: segment.id, + getCount: true, + }), + }, + { + text: t`See all ${segment.name}`, + icon: "table2", + link: getQuestionUrl({ + dbId: table && table.db_id, + tableId: table.id, + segmentId: segment.id, + }), + }, + ]; +}; + +const mapStateToProps = (state, props) => { + const entity = getSegment(state, props) || {}; + const fields = getFields(state, props); + + return { + entity, + table: getTable(state, props), + metadataFields: fields, + loading: getLoading(state, props), + // naming this 'error' will conflict with redux form + loadingError: getError(state, props), + user: getUser(state, props), + isEditing: getIsEditing(state, props), + isFormulaExpanded: getIsFormulaExpanded(state, props), + }; +}; + +const mapDispatchToProps = { + ...metadataActions, + ...actions, + onSubmit: actions.rUpdateSegmentDetail, +}; + +const validate = values => + !values.revision_message + ? { revision_message: t`Please enter a revision message` } + : {}; + +const propTypes = { + style: PropTypes.object.isRequired, + entity: PropTypes.object.isRequired, + table: PropTypes.object, + user: PropTypes.object.isRequired, + isEditing: PropTypes.bool, + startEditing: PropTypes.func.isRequired, + endEditing: PropTypes.func.isRequired, + startLoading: PropTypes.func.isRequired, + endLoading: PropTypes.func.isRequired, + expandFormula: PropTypes.func.isRequired, + collapseFormula: PropTypes.func.isRequired, + setError: PropTypes.func.isRequired, + updateField: PropTypes.func.isRequired, + isFormulaExpanded: PropTypes.bool, + loading: PropTypes.bool, + loadingError: PropTypes.object, + onSubmit: PropTypes.func.isRequired, +}; + +const SegmentDetail = props => { + const { + style, + entity, + table, + loadingError, + loading, + user, + isEditing, + startEditing, + endEditing, + expandFormula, + collapseFormula, + isFormulaExpanded, + onSubmit, + } = props; + + const { + isSubmitting, + getFieldProps, + getFieldMeta, + handleSubmit, + handleReset, + } = useFormik({ + validate, + initialValues: {}, + initialErrors: validate({}), + onSubmit: fields => onSubmit(fields, { ...props, resetForm: handleReset }), + }); + + const getFormField = name => ({ + ...getFieldProps(name), + ...getFieldMeta(name), + }); + + return ( + <form style={style} className="full" onSubmit={handleSubmit}> + {isEditing && ( + <EditHeader + hasRevisionHistory={true} + onSubmit={handleSubmit} + endEditing={endEditing} + reinitializeForm={handleReset} + submitting={isSubmitting} + revisionMessageFormField={getFormField("revision_message")} + /> + )} + <EditableReferenceHeader + entity={entity} + table={table} + type="segment" + headerIcon="segment" + headerLink={getQuestionUrl({ + dbId: table && table.db_id, + tableId: entity.table_id, + segmentId: entity.id, + })} + name={t`Details`} + user={user} + isEditing={isEditing} + hasSingleSchema={false} + hasDisplayName={false} + startEditing={startEditing} + displayNameFormField={getFormField("display_name")} + nameFormField={getFormField("name")} + /> + <LoadingAndErrorWrapper + loading={!loadingError && loading} + error={loadingError} + > + {() => ( + <div className="wrapper"> + <div className="pl4 pr3 pt4 mb4 mb1 bg-white rounded bordered"> + <List> + <li> + <div className={S.detail}> + <div className={S.detailBody}> + <div> + <div className={S.detailTitle}> + {t`Table this is based on`} + </div> + {table && ( + <div> + <Link + className="text-brand text-bold text-paragraph" + to={`/reference/databases/${table.db_id}/tables/${table.id}`} + > + <span className="pt1">{table.display_name}</span> + </Link> + </div> + )} + </div> + </div> + </div> + </li> + <li className="relative"> + <Detail + id="description" + name={t`Description`} + description={entity.description} + placeholder={t`No description yet`} + isEditing={isEditing} + field={getFormField("description")} + /> + </li> + <li className="relative"> + <Detail + id="points_of_interest" + name={t`Why this Segment is interesting`} + description={entity.points_of_interest} + placeholder={t`Nothing interesting yet`} + isEditing={isEditing} + field={getFormField("points_of_interest")} + /> + </li> + <li className="relative"> + <Detail + id="caveats" + name={t`Things to be aware of about this Segment`} + description={entity.caveats} + placeholder={t`Nothing to be aware of yet`} + isEditing={isEditing} + field={getFormField("caveats")} + /> + </li> + {!isEditing && ( + <li className="relative"> + <UsefulQuestions + questions={interestingQuestions( + props.table, + props.entity, + )} + /> + </li> + )} + {table && !isEditing && ( + <li className="relative mb4"> + <Formula + type="segment" + entity={entity} + table={table} + isExpanded={isFormulaExpanded} + expandFormula={expandFormula} + collapseFormula={collapseFormula} + /> + </li> + )} + </List> + </div> + </div> + )} + </LoadingAndErrorWrapper> + </form> + ); +}; + +SegmentDetail.propTypes = propTypes; + +export default connect(mapStateToProps, mapDispatchToProps)(SegmentDetail); diff --git a/frontend/src/metabase/reference/segments/SegmentDetailContainer.jsx b/frontend/src/metabase/reference/segments/SegmentDetailContainer.jsx new file mode 100644 index 0000000000000000000000000000000000000000..8e0a2959417f3d75b7351c2ab2535694ac83954e --- /dev/null +++ b/frontend/src/metabase/reference/segments/SegmentDetailContainer.jsx @@ -0,0 +1,79 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; + +import SidebarLayout from "metabase/components/SidebarLayout"; +import SegmentDetail from "metabase/reference/segments/SegmentDetail"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; + +import { + getUser, + getSegment, + getSegmentId, + getDatabaseId, + getIsEditing, +} from "../selectors"; +import SegmentSidebar from "./SegmentSidebar"; + +const mapStateToProps = (state, props) => ({ + user: getUser(state, props), + segment: getSegment(state, props), + segmentId: getSegmentId(state, props), + databaseId: getDatabaseId(state, props), + isEditing: getIsEditing(state, props), +}); + +const mapDispatchToProps = { + ...metadataActions, + ...actions, +}; + +class SegmentDetailContainer extends Component { + static propTypes = { + params: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + databaseId: PropTypes.number.isRequired, + user: PropTypes.object.isRequired, + segment: PropTypes.object.isRequired, + segmentId: PropTypes.number.isRequired, + isEditing: PropTypes.bool, + }; + + async fetchContainerData() { + await actions.wrappedFetchSegmentDetail(this.props, this.props.segmentId); + } + + UNSAFE_componentWillMount() { + this.fetchContainerData(); + } + + UNSAFE_componentWillReceiveProps(newProps) { + if (this.props.location.pathname === newProps.location.pathname) { + return; + } + + actions.clearState(newProps); + } + + render() { + const { user, segment, isEditing } = this.props; + + return ( + <SidebarLayout + className="flex-full relative" + style={isEditing ? { paddingTop: "43px" } : {}} + sidebar={<SegmentSidebar segment={segment} user={user} />} + > + <SegmentDetail {...this.props} /> + </SidebarLayout> + ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(SegmentDetailContainer); diff --git a/frontend/src/metabase/reference/segments/SegmentFieldDetail.jsx b/frontend/src/metabase/reference/segments/SegmentFieldDetail.jsx new file mode 100644 index 0000000000000000000000000000000000000000..a774c42bfb4bd8343e172fafab77d05fe831ba9f --- /dev/null +++ b/frontend/src/metabase/reference/segments/SegmentFieldDetail.jsx @@ -0,0 +1,243 @@ +/* eslint "react/prop-types": "warn" */ +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import { useFormik } from "formik"; +import { t } from "ttag"; +import S from "metabase/reference/Reference.css"; + +import List from "metabase/components/List"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; + +import EditHeader from "metabase/reference/components/EditHeader"; +import EditableReferenceHeader from "metabase/reference/components/EditableReferenceHeader"; +import Detail from "metabase/reference/components/Detail"; +import FieldTypeDetail from "metabase/reference/components/FieldTypeDetail"; +import UsefulQuestions from "metabase/reference/components/UsefulQuestions"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; +import { getQuestionUrl } from "../utils"; + +import { + getFieldBySegment, + getTable, + getError, + getLoading, + getUser, + getIsEditing, + getForeignKeys, + getIsFormulaExpanded, +} from "../selectors"; + +const interestingQuestions = (table, field) => { + return [ + { + text: t`Number of ${table && table.display_name} grouped by ${ + field.display_name + }`, + icon: "number", + link: getQuestionUrl({ + dbId: table && table.db_id, + tableId: table.id, + fieldId: field.id, + getCount: true, + }), + }, + { + text: t`All distinct values of ${field.display_name}`, + icon: "table2", + link: getQuestionUrl({ + dbId: table && table.db_id, + tableId: table.id, + fieldId: field.id, + }), + }, + ]; +}; + +const mapStateToProps = (state, props) => { + const entity = getFieldBySegment(state, props) || {}; + + return { + entity, + table: getTable(state, props), + loading: getLoading(state, props), + // naming this 'error' will conflict with redux form + loadingError: getError(state, props), + user: getUser(state, props), + foreignKeys: getForeignKeys(state, props), + isEditing: getIsEditing(state, props), + isFormulaExpanded: getIsFormulaExpanded(state, props), + }; +}; + +const mapDispatchToProps = { + ...metadataActions, + ...actions, + onSubmit: actions.rUpdateSegmentFieldDetail, +}; + +const propTypes = { + style: PropTypes.object.isRequired, + entity: PropTypes.object.isRequired, + table: PropTypes.object, + user: PropTypes.object.isRequired, + foreignKeys: PropTypes.object, + isEditing: PropTypes.bool, + startEditing: PropTypes.func.isRequired, + endEditing: PropTypes.func.isRequired, + startLoading: PropTypes.func.isRequired, + endLoading: PropTypes.func.isRequired, + setError: PropTypes.func.isRequired, + updateField: PropTypes.func.isRequired, + loading: PropTypes.bool, + loadingError: PropTypes.object, + onSubmit: PropTypes.func.isRequired, +}; + +const SegmentFieldDetail = props => { + const { + style, + entity, + table, + loadingError, + loading, + user, + foreignKeys, + isEditing, + startEditing, + endEditing, + onSubmit, + } = props; + + const { + isSubmitting, + getFieldProps, + getFieldMeta, + handleSubmit, + handleReset, + } = useFormik({ + initialValues: {}, + onSubmit: fields => onSubmit(fields, { ...props, resetForm: handleReset }), + }); + + const getFormField = name => ({ + ...getFieldProps(name), + ...getFieldMeta(name), + }); + + return ( + <form style={style} className="full" onSubmit={handleSubmit}> + {isEditing && ( + <EditHeader + hasRevisionHistory={false} + onSubmit={handleSubmit} + endEditing={endEditing} + reinitializeForm={handleReset()} + submitting={isSubmitting} + revisionMessageFormField={getFormField("revision_message")} + /> + )} + <EditableReferenceHeader + entity={entity} + table={table} + headerIcon="field" + name={t`Details`} + type="field" + user={user} + isEditing={isEditing} + hasSingleSchema={false} + hasDisplayName={true} + startEditing={startEditing} + displayNameFormField={getFormField("display_name")} + nameFormField={getFormField("name")} + /> + <LoadingAndErrorWrapper + loading={!loadingError && loading} + error={loadingError} + > + {() => ( + <div className="wrapper"> + <div className="pl3 py2 mb4 bg-white bordered"> + <List> + <li className="relative"> + <Detail + id="description" + name={t`Description`} + description={entity.description} + placeholder={t`No description yet`} + isEditing={isEditing} + field={getFormField("description")} + /> + </li> + {!isEditing && ( + <li className="relative"> + <Detail + id="name" + name={t`Actual name in database`} + description={entity.name} + subtitleClass={S.tableActualName} + /> + </li> + )} + <li className="relative"> + <Detail + id="points_of_interest" + name={t`Why this field is interesting`} + description={entity.points_of_interest} + placeholder={t`Nothing interesting yet`} + isEditing={isEditing} + field={getFormField("points_of_interest")} + /> + </li> + <li className="relative"> + <Detail + id="caveats" + name={t`Things to be aware of about this field`} + description={entity.caveats} + placeholder={t`Nothing to be aware of yet`} + isEditing={isEditing} + field={getFormField("caveats")} + /> + </li> + + {!isEditing && ( + <li className="relative"> + <Detail + id="base_type" + name={t`Data type`} + description={entity.base_type} + /> + </li> + )} + <li className="relative"> + <FieldTypeDetail + field={entity} + foreignKeys={foreignKeys} + fieldTypeFormField={getFormField("semantic_type")} + foreignKeyFormField={getFormField("fk_target_field_id")} + isEditing={isEditing} + /> + </li> + {!isEditing && ( + <li className="relative"> + <UsefulQuestions + questions={interestingQuestions( + props.table, + props.entity, + )} + /> + </li> + )} + </List> + </div> + </div> + )} + </LoadingAndErrorWrapper> + </form> + ); +}; + +SegmentFieldDetail.propTypes = propTypes; + +export default connect(mapStateToProps, mapDispatchToProps)(SegmentFieldDetail); diff --git a/frontend/src/metabase/reference/segments/SegmentFieldDetailContainer.jsx b/frontend/src/metabase/reference/segments/SegmentFieldDetailContainer.jsx new file mode 100644 index 0000000000000000000000000000000000000000..e530f438d21b67e35d58e465478c2253009c163d --- /dev/null +++ b/frontend/src/metabase/reference/segments/SegmentFieldDetailContainer.jsx @@ -0,0 +1,79 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; + +import SidebarLayout from "metabase/components/SidebarLayout"; +import SegmentFieldDetail from "metabase/reference/segments/SegmentFieldDetail"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; + +import { + getSegment, + getSegmentId, + getField, + getDatabaseId, + getIsEditing, +} from "../selectors"; +import SegmentFieldSidebar from "./SegmentFieldSidebar"; + +const mapStateToProps = (state, props) => ({ + segment: getSegment(state, props), + segmentId: getSegmentId(state, props), + field: getField(state, props), + databaseId: getDatabaseId(state, props), + isEditing: getIsEditing(state, props), +}); + +const mapDispatchToProps = { + ...metadataActions, + ...actions, +}; + +class SegmentFieldDetailContainer extends Component { + static propTypes = { + params: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + databaseId: PropTypes.number.isRequired, + segment: PropTypes.object.isRequired, + segmentId: PropTypes.number.isRequired, + field: PropTypes.object.isRequired, + isEditing: PropTypes.bool, + }; + + async fetchContainerData() { + await actions.wrappedFetchSegmentFields(this.props, this.props.segmentId); + } + + UNSAFE_componentWillMount() { + this.fetchContainerData(); + } + + UNSAFE_componentWillReceiveProps(newProps) { + if (this.props.location.pathname === newProps.location.pathname) { + return; + } + + actions.clearState(newProps); + } + + render() { + const { segment, field, isEditing } = this.props; + + return ( + <SidebarLayout + className="flex-full relative" + style={isEditing ? { paddingTop: "43px" } : {}} + sidebar={<SegmentFieldSidebar segment={segment} field={field} />} + > + <SegmentFieldDetail {...this.props} /> + </SidebarLayout> + ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(SegmentFieldDetailContainer); diff --git a/frontend/src/metabase/reference/segments/SegmentFieldList.jsx b/frontend/src/metabase/reference/segments/SegmentFieldList.jsx new file mode 100644 index 0000000000000000000000000000000000000000..5c8578a2fdcf3b19c140ae7867c319ebc41fa1ba --- /dev/null +++ b/frontend/src/metabase/reference/segments/SegmentFieldList.jsx @@ -0,0 +1,185 @@ +/* eslint "react/prop-types": "warn" */ +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import { useFormik } from "formik"; +import { t } from "ttag"; +import cx from "classnames"; +import S from "metabase/components/List/List.css"; +import R from "metabase/reference/Reference.css"; +import F from "metabase/reference/components/Field.css"; + +import Field from "metabase/reference/components/Field"; +import List from "metabase/components/List"; +import EmptyState from "metabase/components/EmptyState"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; + +import EditHeader from "metabase/reference/components/EditHeader"; +import EditableReferenceHeader from "metabase/reference/components/EditableReferenceHeader"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; +import { getIconForField } from "metabase-lib/metadata/utils/fields"; +import { + getError, + getFieldsBySegment, + getForeignKeys, + getIsEditing, + getLoading, + getSegment, + getUser, +} from "../selectors"; + +const emptyStateData = { + message: t`Fields in this table will appear here as they're added`, + icon: "fields", +}; + +const mapStateToProps = (state, props) => { + const data = getFieldsBySegment(state, props); + return { + segment: getSegment(state, props), + entities: data, + foreignKeys: getForeignKeys(state, props), + loading: getLoading(state, props), + loadingError: getError(state, props), + user: getUser(state, props), + isEditing: getIsEditing(state, props), + }; +}; + +const mapDispatchToProps = { + ...metadataActions, + ...actions, + onSubmit: actions.rUpdateFields, +}; + +const propTypes = { + segment: PropTypes.object.isRequired, + style: PropTypes.object.isRequired, + entities: PropTypes.object.isRequired, + foreignKeys: PropTypes.object.isRequired, + isEditing: PropTypes.bool, + startEditing: PropTypes.func.isRequired, + endEditing: PropTypes.func.isRequired, + startLoading: PropTypes.func.isRequired, + endLoading: PropTypes.func.isRequired, + setError: PropTypes.func.isRequired, + updateField: PropTypes.func.isRequired, + user: PropTypes.object.isRequired, + loading: PropTypes.bool, + loadingError: PropTypes.object, + onSubmit: PropTypes.func, +}; + +const SegmentFieldList = props => { + const { + segment, + style, + entities, + foreignKeys, + loadingError, + loading, + user, + isEditing, + startEditing, + endEditing, + onSubmit, + } = props; + + const { + isSubmitting, + getFieldProps, + getFieldMeta, + handleSubmit, + handleReset, + } = useFormik({ + initialValues: {}, + onSubmit: fields => + onSubmit(entities, fields, { ...props, resetForm: handleReset }), + }); + + const getFormField = name => ({ + ...getFieldProps(name), + ...getFieldMeta(name), + }); + + const getNestedFormField = id => ({ + display_name: getFormField(`${id}.display_name`), + semantic_type: getFormField(`${id}.semantic_type`), + fk_target_field_id: getFormField(`${id}.fk_target_field_id`), + }); + + return ( + <form style={style} className="full" onSubmit={handleSubmit}> + {isEditing && ( + <EditHeader + hasRevisionHistory={false} + reinitializeForm={handleReset} + endEditing={endEditing} + submitting={isSubmitting} + /> + )} + <EditableReferenceHeader + type="segment" + headerIcon="segment" + name={t`Fields in ${segment.name}`} + user={user} + isEditing={isEditing} + startEditing={startEditing} + /> + <LoadingAndErrorWrapper + loading={!loadingError && loading} + error={loadingError} + > + {() => + Object.keys(entities).length > 0 ? ( + <div className="wrapper"> + <div className="pl4 pb2 mb4 bg-white rounded bordered"> + <div className={S.item}> + <div className={R.columnHeader}> + <div className={cx(S.itemTitle, F.fieldNameTitle)}> + {t`Field name`} + </div> + <div className={cx(S.itemTitle, F.fieldType)}> + {t`Field type`} + </div> + <div className={cx(S.itemTitle, F.fieldDataType)}> + {t`Data type`} + </div> + </div> + </div> + <List> + {Object.values(entities).map( + entity => + entity && + entity.id && + entity.name && ( + <li className="relative" key={entity.id}> + <Field + field={entity} + foreignKeys={foreignKeys} + url={`/reference/segments/${segment.id}/fields/${entity.id}`} + icon={getIconForField(entity)} + isEditing={isEditing} + formField={getNestedFormField(entity.id)} + /> + </li> + ), + )} + </List> + </div> + </div> + ) : ( + <div className={S.empty}> + <EmptyState {...emptyStateData} /> + </div> + ) + } + </LoadingAndErrorWrapper> + </form> + ); +}; + +SegmentFieldList.propTypes = propTypes; + +export default connect(mapStateToProps, mapDispatchToProps)(SegmentFieldList); diff --git a/frontend/src/metabase/reference/segments/SegmentFieldListContainer.jsx b/frontend/src/metabase/reference/segments/SegmentFieldListContainer.jsx new file mode 100644 index 0000000000000000000000000000000000000000..8ebda9b275458a1ea615b8bae913220fe2ffeb2f --- /dev/null +++ b/frontend/src/metabase/reference/segments/SegmentFieldListContainer.jsx @@ -0,0 +1,79 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; + +import SidebarLayout from "metabase/components/SidebarLayout"; +import SegmentFieldList from "metabase/reference/segments/SegmentFieldList"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; + +import { + getUser, + getSegment, + getSegmentId, + getDatabaseId, + getIsEditing, +} from "../selectors"; +import SegmentSidebar from "./SegmentSidebar"; + +const mapStateToProps = (state, props) => ({ + user: getUser(state, props), + segment: getSegment(state, props), + segmentId: getSegmentId(state, props), + databaseId: getDatabaseId(state, props), + isEditing: getIsEditing(state, props), +}); + +const mapDispatchToProps = { + ...metadataActions, + ...actions, +}; + +class SegmentFieldListContainer extends Component { + static propTypes = { + params: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + databaseId: PropTypes.number.isRequired, + user: PropTypes.object.isRequired, + segment: PropTypes.object.isRequired, + segmentId: PropTypes.number.isRequired, + isEditing: PropTypes.bool, + }; + + async fetchContainerData() { + await actions.wrappedFetchSegmentFields(this.props, this.props.segmentId); + } + + UNSAFE_componentWillMount() { + this.fetchContainerData(); + } + + UNSAFE_componentWillReceiveProps(newProps) { + if (this.props.location.pathname === newProps.location.pathname) { + return; + } + + actions.clearState(newProps); + } + + render() { + const { user, segment, isEditing } = this.props; + + return ( + <SidebarLayout + className="flex-full relative" + style={isEditing ? { paddingTop: "43px" } : {}} + sidebar={<SegmentSidebar segment={segment} user={user} />} + > + <SegmentFieldList {...this.props} /> + </SidebarLayout> + ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(SegmentFieldListContainer); diff --git a/frontend/src/metabase/reference/segments/SegmentFieldSidebar.jsx b/frontend/src/metabase/reference/segments/SegmentFieldSidebar.jsx new file mode 100644 index 0000000000000000000000000000000000000000..65b30b6e485621fb66c96b7de264a54fb4422d71 --- /dev/null +++ b/frontend/src/metabase/reference/segments/SegmentFieldSidebar.jsx @@ -0,0 +1,42 @@ +/* eslint "react/prop-types": "warn" */ +import { memo } from "react"; +import PropTypes from "prop-types"; +import { t } from "ttag"; +import cx from "classnames"; +import S from "metabase/components/Sidebar.css"; +import Breadcrumbs from "metabase/components/Breadcrumbs"; +import SidebarItem from "metabase/components/SidebarItem"; + +const SegmentFieldSidebar = ({ segment, field, style, className }) => ( + <div className={cx(S.sidebar, className)} style={style}> + <ul className="mx3"> + <div className={S.breadcrumbs}> + <Breadcrumbs + className="py4" + crumbs={[ + [t`Segments`, "/reference/segments"], + [segment.name, `/reference/segments/${segment.id}`], + [field.name], + ]} + inSidebar={true} + placeholder={t`Data Reference`} + /> + </div> + <SidebarItem + key={`/reference/segments/${segment.id}/fields/${field.id}`} + href={`/reference/segments/${segment.id}/fields/${field.id}`} + icon="document" + name={t`Details`} + /> + </ul> + </div> +); + +SegmentFieldSidebar.propTypes = { + segment: PropTypes.object, + field: PropTypes.object, + className: PropTypes.string, + style: PropTypes.object, +}; + +export default memo(SegmentFieldSidebar); diff --git a/frontend/src/metabase/reference/segments/SegmentList.jsx b/frontend/src/metabase/reference/segments/SegmentList.jsx new file mode 100644 index 0000000000000000000000000000000000000000..6ab37213572346795df0c948e300eb497aea42df --- /dev/null +++ b/frontend/src/metabase/reference/segments/SegmentList.jsx @@ -0,0 +1,93 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import { t } from "ttag"; +import MetabaseSettings from "metabase/lib/settings"; + +import S from "metabase/components/List/List.css"; + +import List from "metabase/components/List"; +import ListItem from "metabase/components/ListItem"; +import AdminAwareEmptyState from "metabase/components/AdminAwareEmptyState"; + +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; + +import * as metadataActions from "metabase/redux/metadata"; +import ReferenceHeader from "../components/ReferenceHeader"; + +import { getSegments, getError, getLoading } from "../selectors"; + +const emptyStateData = { + title: t`Segments are interesting subsets of tables`, + adminMessage: t`Defining common segments for your team makes it even easier to ask questions`, + message: t`Segments will appear here once your admins have created some`, + image: "app/assets/img/segments-list", + adminAction: t`Learn how to create segments`, + adminLink: MetabaseSettings.docsUrl( + "data-modeling/segments-and-metrics", + "creating-a-segment", + ), +}; + +const mapStateToProps = (state, props) => ({ + entities: getSegments(state, props), + loading: getLoading(state, props), + loadingError: getError(state, props), +}); + +const mapDispatchToProps = { + ...metadataActions, +}; + +class SegmentList extends Component { + static propTypes = { + style: PropTypes.object.isRequired, + entities: PropTypes.object.isRequired, + loading: PropTypes.bool, + loadingError: PropTypes.object, + }; + + render() { + const { entities, style, loadingError, loading } = this.props; + + return ( + <div style={style} className="full"> + <ReferenceHeader name={t`Segments`} /> + <LoadingAndErrorWrapper + loading={!loadingError && loading} + error={loadingError} + > + {() => + Object.keys(entities).length > 0 ? ( + <div className="wrapper wrapper--trim"> + <List> + {Object.values(entities).map( + entity => + entity && + entity.id && + entity.name && ( + <ListItem + key={entity.id} + name={entity.display_name || entity.name} + description={entity.description} + url={`/reference/segments/${entity.id}`} + icon="segment" + /> + ), + )} + </List> + </div> + ) : ( + <div className={S.empty}> + <AdminAwareEmptyState {...emptyStateData} /> + </div> + ) + } + </LoadingAndErrorWrapper> + </div> + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(SegmentList); diff --git a/frontend/src/metabase/reference/segments/SegmentListContainer.jsx b/frontend/src/metabase/reference/segments/SegmentListContainer.jsx new file mode 100644 index 0000000000000000000000000000000000000000..47fb171140aece70c9617c6e59ceaa485e88791a --- /dev/null +++ b/frontend/src/metabase/reference/segments/SegmentListContainer.jsx @@ -0,0 +1,67 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; + +import BaseSidebar from "metabase/reference/guide/BaseSidebar"; +import SidebarLayout from "metabase/components/SidebarLayout"; +import SegmentList from "metabase/reference/segments/SegmentList"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; + +import { getDatabaseId, getIsEditing } from "../selectors"; + +const mapStateToProps = (state, props) => ({ + databaseId: getDatabaseId(state, props), + isEditing: getIsEditing(state, props), +}); + +const mapDispatchToProps = { + ...metadataActions, + ...actions, +}; + +class SegmentListContainer extends Component { + static propTypes = { + params: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + databaseId: PropTypes.number.isRequired, + isEditing: PropTypes.bool, + }; + + async fetchContainerData() { + await actions.wrappedFetchSegments(this.props); + } + + UNSAFE_componentWillMount() { + this.fetchContainerData(); + } + + UNSAFE_componentWillReceiveProps(newProps) { + if (this.props.location.pathname === newProps.location.pathname) { + return; + } + + actions.clearState(newProps); + } + + render() { + const { isEditing } = this.props; + + return ( + <SidebarLayout + className="flex-full relative" + style={isEditing ? { paddingTop: "43px" } : {}} + sidebar={<BaseSidebar />} + > + <SegmentList {...this.props} /> + </SidebarLayout> + ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(SegmentListContainer); diff --git a/frontend/src/metabase/reference/segments/SegmentQuestions.jsx b/frontend/src/metabase/reference/segments/SegmentQuestions.jsx new file mode 100644 index 0000000000000000000000000000000000000000..22cec2a2b59ae5949a1aed282feeff74d79cfc5e --- /dev/null +++ b/frontend/src/metabase/reference/segments/SegmentQuestions.jsx @@ -0,0 +1,115 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import moment from "moment-timezone"; +import { t } from "ttag"; +import visualizations from "metabase/visualizations"; +import * as Urls from "metabase/lib/urls"; + +import S from "metabase/components/List/List.css"; + +import List from "metabase/components/List"; +import ListItem from "metabase/components/ListItem"; +import AdminAwareEmptyState from "metabase/components/AdminAwareEmptyState"; + +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; + +import * as metadataActions from "metabase/redux/metadata"; +import ReferenceHeader from "../components/ReferenceHeader"; + +import { getQuestionUrl } from "../utils"; + +import { + getSegmentQuestions, + getError, + getLoading, + getTableBySegment, + getSegment, +} from "../selectors"; + +const emptyStateData = (table, segment) => { + return { + message: t`Questions about this segment will appear here as they're added`, + icon: "folder", + action: t`Ask a question`, + link: getQuestionUrl({ + dbId: table && table.db_id, + tableId: segment.table_id, + segmentId: segment.id, + }), + }; +}; +const mapStateToProps = (state, props) => ({ + segment: getSegment(state, props), + table: getTableBySegment(state, props), + entities: getSegmentQuestions(state, props), + loading: getLoading(state, props), + loadingError: getError(state, props), +}); + +const mapDispatchToProps = { + ...metadataActions, +}; + +class SegmentQuestions extends Component { + static propTypes = { + table: PropTypes.object.isRequired, + segment: PropTypes.object.isRequired, + style: PropTypes.object.isRequired, + entities: PropTypes.object.isRequired, + loading: PropTypes.bool, + loadingError: PropTypes.object, + }; + + render() { + const { entities, style, loadingError, loading } = this.props; + + return ( + <div style={style} className="full"> + <ReferenceHeader + name={t`Questions about ${this.props.segment.name}`} + type="questions" + headerIcon="segment" + /> + <LoadingAndErrorWrapper + loading={!loadingError && loading} + error={loadingError} + > + {() => + Object.keys(entities).length > 0 ? ( + <div className="wrapper wrapper--trim"> + <List> + {Object.values(entities).map( + entity => + entity && + entity.id && + entity.name && ( + <ListItem + key={entity.id} + name={entity.display_name || entity.name} + description={t`Created ${moment( + entity.created_at, + ).fromNow()} by ${entity.creator.common_name}`} + url={Urls.question(entity)} + icon={visualizations.get(entity.display).iconName} + /> + ), + )} + </List> + </div> + ) : ( + <div className={S.empty}> + <AdminAwareEmptyState + {...emptyStateData(this.props.table, this.props.segment)} + /> + </div> + ) + } + </LoadingAndErrorWrapper> + </div> + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(SegmentQuestions); diff --git a/frontend/src/metabase/reference/segments/SegmentQuestionsContainer.jsx b/frontend/src/metabase/reference/segments/SegmentQuestionsContainer.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c15b912fafe814766c80a12e54d3fc60a55eeac0 --- /dev/null +++ b/frontend/src/metabase/reference/segments/SegmentQuestionsContainer.jsx @@ -0,0 +1,85 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; + +import SidebarLayout from "metabase/components/SidebarLayout"; +import SegmentQuestions from "metabase/reference/segments/SegmentQuestions"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; + +import Questions from "metabase/entities/questions"; +import { + getUser, + getSegment, + getSegmentId, + getDatabaseId, + getIsEditing, +} from "../selectors"; + +import SegmentSidebar from "./SegmentSidebar"; + +const mapStateToProps = (state, props) => ({ + user: getUser(state, props), + segment: getSegment(state, props), + segmentId: getSegmentId(state, props), + databaseId: getDatabaseId(state, props), + isEditing: getIsEditing(state, props), +}); + +const mapDispatchToProps = { + fetchQuestions: Questions.actions.fetchList, + ...metadataActions, + ...actions, +}; + +class SegmentQuestionsContainer extends Component { + static propTypes = { + params: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + databaseId: PropTypes.number.isRequired, + user: PropTypes.object.isRequired, + segment: PropTypes.object.isRequired, + segmentId: PropTypes.number.isRequired, + isEditing: PropTypes.bool, + }; + + async fetchContainerData() { + await actions.wrappedFetchSegmentQuestions( + this.props, + this.props.segmentId, + ); + } + + UNSAFE_componentWillMount() { + this.fetchContainerData(); + } + + UNSAFE_componentWillReceiveProps(newProps) { + if (this.props.location.pathname === newProps.location.pathname) { + return; + } + + actions.clearState(newProps); + } + + render() { + const { user, segment, isEditing } = this.props; + + return ( + <SidebarLayout + className="flex-full relative" + style={isEditing ? { paddingTop: "43px" } : {}} + sidebar={<SegmentSidebar segment={segment} user={user} />} + > + <SegmentQuestions {...this.props} /> + </SidebarLayout> + ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(SegmentQuestionsContainer); diff --git a/frontend/src/metabase/reference/segments/SegmentRevisions.jsx b/frontend/src/metabase/reference/segments/SegmentRevisions.jsx new file mode 100644 index 0000000000000000000000000000000000000000..6a17f78d6d638fb0c7b1bf587a74461380041393 --- /dev/null +++ b/frontend/src/metabase/reference/segments/SegmentRevisions.jsx @@ -0,0 +1,130 @@ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; +import { t } from "ttag"; +import { getIn } from "icepick"; + +import S from "metabase/components/List/List.css"; + +import * as metadataActions from "metabase/redux/metadata"; +import { assignUserColors } from "metabase/lib/formatting"; + +import Revision from "metabase/admin/datamodel/components/revisions/Revision"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; +import EmptyState from "metabase/components/EmptyState"; +import { + getSegmentRevisions, + getMetric, + getSegment, + getTables, + getUser, + getLoading, + getError, +} from "../selectors"; +import ReferenceHeader from "../components/ReferenceHeader"; + +const emptyStateData = { + message: t`There are no revisions for this segment`, +}; + +const mapStateToProps = (state, props) => { + return { + revisions: getSegmentRevisions(state, props), + metric: getMetric(state, props), + segment: getSegment(state, props), + tables: getTables(state, props), + user: getUser(state, props), + loading: getLoading(state, props), + loadingError: getError(state, props), + }; +}; + +const mapDispatchToProps = { + ...metadataActions, +}; + +class SegmentRevisions extends Component { + static propTypes = { + style: PropTypes.object.isRequired, + revisions: PropTypes.object.isRequired, + metric: PropTypes.object.isRequired, + segment: PropTypes.object.isRequired, + tables: PropTypes.object.isRequired, + user: PropTypes.object.isRequired, + loading: PropTypes.bool, + loadingError: PropTypes.object, + }; + + render() { + const { + style, + revisions, + metric, + segment, + tables, + user, + loading, + loadingError, + } = this.props; + + const entity = metric.id ? metric : segment; + + const userColorAssignments = + user && Object.keys(revisions).length > 0 + ? assignUserColors( + Object.values(revisions).map(revision => + getIn(revision, ["user", "id"]), + ), + user.id, + ) + : {}; + + return ( + <div style={style} className="full"> + <ReferenceHeader + name={t`Revision history for ${this.props.segment.name}`} + headerIcon="segment" + /> + <LoadingAndErrorWrapper + loading={!loadingError && loading} + error={loadingError} + > + {() => + Object.keys(revisions).length > 0 && tables[entity.table_id] ? ( + <div className="wrapper"> + <div className="px3 py3 mb4 bg-white bordered"> + <div> + {Object.values(revisions) + .map(revision => + revision && revision.diff ? ( + <Revision + key={revision.id} + revision={revision || {}} + tableMetadata={tables[entity.table_id] || {}} + objectName={entity.name} + currentUser={user || {}} + userColor={ + userColorAssignments[ + getIn(revision, ["user", "id"]) + ] + } + /> + ) : null, + ) + .reverse()} + </div> + </div> + </div> + ) : ( + <div className={S.empty}> + <EmptyState {...emptyStateData} /> + </div> + ) + } + </LoadingAndErrorWrapper> + </div> + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(SegmentRevisions); diff --git a/frontend/src/metabase/reference/segments/SegmentRevisionsContainer.jsx b/frontend/src/metabase/reference/segments/SegmentRevisionsContainer.jsx new file mode 100644 index 0000000000000000000000000000000000000000..330bb3aa2c0075cc7d8f93050d186ae71e46b3e2 --- /dev/null +++ b/frontend/src/metabase/reference/segments/SegmentRevisionsContainer.jsx @@ -0,0 +1,82 @@ +/* eslint "react/prop-types": "warn" */ +import { Component } from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; + +import SidebarLayout from "metabase/components/SidebarLayout"; +import SegmentRevisions from "metabase/reference/segments/SegmentRevisions"; + +import * as metadataActions from "metabase/redux/metadata"; +import * as actions from "metabase/reference/reference"; + +import { + getUser, + getSegment, + getSegmentId, + getDatabaseId, + getIsEditing, +} from "../selectors"; +import SegmentSidebar from "./SegmentSidebar"; + +const mapStateToProps = (state, props) => ({ + user: getUser(state, props), + segment: getSegment(state, props), + segmentId: getSegmentId(state, props), + databaseId: getDatabaseId(state, props), + isEditing: getIsEditing(state, props), +}); + +const mapDispatchToProps = { + ...metadataActions, + ...actions, +}; + +class SegmentRevisionsContainer extends Component { + static propTypes = { + params: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + databaseId: PropTypes.number.isRequired, + user: PropTypes.object.isRequired, + segment: PropTypes.object.isRequired, + segmentId: PropTypes.number.isRequired, + isEditing: PropTypes.bool, + }; + + async fetchContainerData() { + await actions.wrappedFetchSegmentRevisions( + this.props, + this.props.segmentId, + ); + } + + UNSAFE_componentWillMount() { + this.fetchContainerData(); + } + + UNSAFE_componentWillReceiveProps(newProps) { + if (this.props.location.pathname === newProps.location.pathname) { + return; + } + + actions.clearState(newProps); + } + + render() { + const { user, segment, isEditing } = this.props; + + return ( + <SidebarLayout + className="flex-full relative" + style={isEditing ? { paddingTop: "43px" } : {}} + sidebar={<SegmentSidebar segment={segment} user={user} />} + > + <SegmentRevisions {...this.props} /> + </SidebarLayout> + ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(SegmentRevisionsContainer); diff --git a/frontend/src/metabase/reference/segments/SegmentSidebar.jsx b/frontend/src/metabase/reference/segments/SegmentSidebar.jsx new file mode 100644 index 0000000000000000000000000000000000000000..ca1cdc4042ee2c9fd607a40b2a30c874c6fd2251 --- /dev/null +++ b/frontend/src/metabase/reference/segments/SegmentSidebar.jsx @@ -0,0 +1,72 @@ +/* eslint "react/prop-types": "warn" */ +import { memo } from "react"; +import PropTypes from "prop-types"; +import { t } from "ttag"; +import cx from "classnames"; + +import MetabaseSettings from "metabase/lib/settings"; + +import Breadcrumbs from "metabase/components/Breadcrumbs"; +import SidebarItem from "metabase/components/SidebarItem"; + +import S from "metabase/components/Sidebar.css"; + +const SegmentSidebar = ({ segment, user, style, className }) => ( + <div className={cx(S.sidebar, className)} style={style}> + <ul> + <div className={S.breadcrumbs}> + <Breadcrumbs + className="py4 ml3" + crumbs={[[t`Segments`, "/reference/segments"], [segment.name]]} + inSidebar={true} + placeholder={t`Data Reference`} + /> + </div> + <ol className="mx3"> + <SidebarItem + key={`/reference/segments/${segment.id}`} + href={`/reference/segments/${segment.id}`} + icon="document" + name={t`Details`} + /> + <SidebarItem + key={`/reference/segments/${segment.id}/fields`} + href={`/reference/segments/${segment.id}/fields`} + icon="field" + name={t`Fields in this segment`} + /> + <SidebarItem + key={`/reference/segments/${segment.id}/questions`} + href={`/reference/segments/${segment.id}/questions`} + icon="folder" + name={t`Questions about this segment`} + /> + {MetabaseSettings.get("enable-xrays") && ( + <SidebarItem + key={`/auto/dashboard/segment/${segment.id}`} + href={`/auto/dashboard/segment/${segment.id}`} + icon="bolt" + name={t`X-ray this segment`} + /> + )} + {user && user.is_superuser && ( + <SidebarItem + key={`/reference/segments/${segment.id}/revisions`} + href={`/reference/segments/${segment.id}/revisions`} + icon="history" + name={t`Revision history`} + /> + )} + </ol> + </ul> + </div> +); + +SegmentSidebar.propTypes = { + segment: PropTypes.object, + user: PropTypes.object, + className: PropTypes.string, + style: PropTypes.object, +}; + +export default memo(SegmentSidebar); diff --git a/frontend/src/metabase/reference/selectors.js b/frontend/src/metabase/reference/selectors.js new file mode 100644 index 0000000000000000000000000000000000000000..4cea36d1454e5c1c778a334035fc293ba6b5ec3a --- /dev/null +++ b/frontend/src/metabase/reference/selectors.js @@ -0,0 +1,201 @@ +import { createSelector } from "@reduxjs/toolkit"; +import { assoc, getIn } from "icepick"; + +import Dashboards from "metabase/entities/dashboards"; + +import { resourceListToMap } from "metabase/lib/redux"; +import { + getShallowDatabases as getDatabases, + getShallowTables as getTables, + getShallowFields as getFields, + getShallowMetrics as getMetrics, + getShallowSegments as getSegments, +} from "metabase/selectors/metadata"; + +import Question from "metabase-lib/Question"; + +import { idsToObjectMap, databaseToForeignKeys } from "./utils"; + +// import { getDatabases, getTables, getFields, getMetrics, getSegments } from "metabase/selectors/metadata"; + +export { + getShallowDatabases as getDatabases, + getShallowTables as getTables, + getShallowFields as getFields, + getShallowMetrics as getMetrics, + getShallowSegments as getSegments, +} from "metabase/selectors/metadata"; + +export const getUser = (state, props) => state.currentUser; + +export const getMetricId = (state, props) => + Number.parseInt(props.params.metricId); +export const getMetric = createSelector( + [getMetricId, getMetrics], + (metricId, metrics) => metrics[metricId] || { id: metricId }, +); + +export const getSegmentId = (state, props) => + Number.parseInt(props.params.segmentId); +export const getSegment = createSelector( + [getSegmentId, getSegments], + (segmentId, segments) => segments[segmentId] || { id: segmentId }, +); + +export const getDatabaseId = (state, props) => + Number.parseInt(props.params.databaseId); + +export const getDatabase = createSelector( + [getDatabaseId, getDatabases], + (databaseId, databases) => databases[databaseId] || { id: databaseId }, +); + +export const getTableId = (state, props) => + Number.parseInt(props.params.tableId); +// export const getTableId = (state, props) => Number.parseInt(props.params.tableId); +export const getTablesByDatabase = createSelector( + [getTables, getDatabase], + (tables, database) => + tables && database && database.tables + ? idsToObjectMap(database.tables, tables) + : {}, +); +export const getTableBySegment = createSelector( + [getSegment, getTables], + (segment, tables) => + segment && segment.table_id ? tables[segment.table_id] : {}, +); +const getTableByMetric = createSelector( + [getMetric, getTables], + (metric, tables) => + metric && metric.table_id ? tables[metric.table_id] : {}, +); +export const getTable = createSelector( + [ + getTableId, + getTables, + getMetricId, + getTableByMetric, + getSegmentId, + getTableBySegment, + ], + (tableId, tables, metricId, tableByMetric, segmentId, tableBySegment) => + tableId + ? tables[tableId] || { id: tableId } + : metricId + ? tableByMetric + : segmentId + ? tableBySegment + : {}, +); + +export const getFieldId = (state, props) => + Number.parseInt(props.params.fieldId); +export const getFieldsByTable = createSelector( + [getTable, getFields], + (table, fields) => + table && table.fields ? idsToObjectMap(table.fields, fields) : {}, +); +export const getFieldsBySegment = createSelector( + [getTableBySegment, getFields], + (table, fields) => + table && table.fields ? idsToObjectMap(table.fields, fields) : {}, +); +export const getField = createSelector( + [getFieldId, getFields], + (fieldId, fields) => fields[fieldId] || { id: fieldId }, +); +export const getFieldBySegment = createSelector( + [getFieldId, getFieldsBySegment], + (fieldId, fields) => fields[fieldId] || { id: fieldId }, +); + +const getQuestions = (state, props) => + getIn(state, ["entities", "questions"]) || {}; + +export const getMetricQuestions = createSelector( + [getMetricId, getQuestions], + (metricId, questions) => + Object.values(questions) + .filter(question => new Question(question).usesMetric(metricId)) + .reduce((map, question) => assoc(map, question.id, question), {}), +); + +const getRevisions = (state, props) => state.revisions; + +export const getMetricRevisions = createSelector( + [getMetricId, getRevisions], + (metricId, revisions) => getIn(revisions, ["metric", metricId]) || {}, +); + +export const getSegmentRevisions = createSelector( + [getSegmentId, getRevisions], + (segmentId, revisions) => getIn(revisions, ["segment", segmentId]) || {}, +); + +export const getSegmentQuestions = createSelector( + [getSegmentId, getQuestions], + (segmentId, questions) => + Object.values(questions) + .filter(question => new Question(question).usesSegment(segmentId)) + .reduce((map, question) => assoc(map, question.id, question), {}), +); + +export const getTableQuestions = createSelector( + [getTable, getQuestions], + (table, questions) => + Object.values(questions).filter(question => question.table_id === table.id), +); + +const getDatabaseBySegment = createSelector( + [getSegment, getTables, getDatabases], + (segment, tables, databases) => + (segment && + segment.table_id && + tables[segment.table_id] && + databases[tables[segment.table_id].db_id]) || + {}, +); + +const getForeignKeysBySegment = createSelector( + [getDatabaseBySegment], + databaseToForeignKeys, +); + +const getForeignKeysByDatabase = createSelector( + [getDatabase], + databaseToForeignKeys, +); + +export const getForeignKeys = createSelector( + [getSegmentId, getForeignKeysBySegment, getForeignKeysByDatabase], + (segmentId, foreignKeysBySegment, foreignKeysByDatabase) => + segmentId ? foreignKeysBySegment : foreignKeysByDatabase, +); + +export const getLoading = (state, props) => state.reference.isLoading; + +export const getError = (state, props) => state.reference.error; + +export const getHasSingleSchema = createSelector( + [getTablesByDatabase], + tables => + tables && Object.keys(tables).length > 0 + ? Object.values(tables).every( + (table, index, tables) => table.schema_name === tables[0].schema, + ) + : true, +); + +export const getIsEditing = (state, props) => state.reference.isEditing; + +export const getIsFormulaExpanded = (state, props) => + state.reference.isFormulaExpanded; + +export const getDashboards = (state, props) => { + const list = Dashboards.selectors.getList(state); + return list && resourceListToMap(list); +}; + +export const getIsDashboardModalOpen = (state, props) => + state.reference.isDashboardModalOpen; diff --git a/frontend/src/metabase/reference/utils.js b/frontend/src/metabase/reference/utils.js new file mode 100644 index 0000000000000000000000000000000000000000..d627d8141784722086ff71df1bcf61d34b0f6664 --- /dev/null +++ b/frontend/src/metabase/reference/utils.js @@ -0,0 +1,104 @@ +import { assoc, assocIn, chain } from "icepick"; + +import { titleize, humanize } from "metabase/lib/formatting"; +import { startNewCard } from "metabase/lib/card"; +import * as Urls from "metabase/lib/urls"; +import { isTypePK } from "metabase-lib/types/utils/isa"; + +export const idsToObjectMap = (ids, objects) => + ids + .map(id => objects[id]) + .reduce((map, object) => ({ ...map, [object.id]: object }), {}); +// recursive freezing done by assoc here is too expensive +// hangs browser for large databases +// .reduce((map, object) => assoc(map, object.id, object), {}); + +export const filterUntouchedFields = (fields, entity = {}) => + Object.keys(fields) + .filter(key => fields[key] !== undefined && entity[key] !== fields[key]) + .reduce((map, key) => ({ ...map, [key]: fields[key] }), {}); + +export const isEmptyObject = object => Object.keys(object).length === 0; + +export const databaseToForeignKeys = database => + database && database.tables_lookup + ? Object.values(database.tables_lookup) + // ignore tables without primary key + .filter( + table => + table && table.fields.find(field => isTypePK(field.semantic_type)), + ) + .map(table => ({ + table: table, + field: + table && table.fields.find(field => isTypePK(field.semantic_type)), + })) + .map(({ table, field }) => ({ + id: field.id, + name: + table.schema_name && table.schema_name !== "public" + ? `${titleize(humanize(table.schema_name))}.${ + table.display_name + } → ${field.display_name}` + : `${table.display_name} → ${field.display_name}`, + description: field.description, + })) + .reduce((map, foreignKey) => assoc(map, foreignKey.id, foreignKey), {}) + : {}; + +// TODO Atte Keinänen 7/3/17: Construct question with Question of metabase-lib instead of this using function +export const getQuestion = ({ + dbId, + tableId, + fieldId, + metricId, + segmentId, + getCount, + visualization, + metadata, +}) => { + const newQuestion = startNewCard("query", dbId, tableId); + + // consider taking a look at Ramda as a possible underscore alternative? + // http://ramdajs.com/0.21.0/index.html + const question = chain(newQuestion) + .updateIn(["dataset_query", "query", "aggregation"], aggregation => + getCount ? [["count"]] : aggregation, + ) + .updateIn(["display"], display => visualization || display) + .updateIn(["dataset_query", "query", "breakout"], oldBreakout => { + if (fieldId && metadata && metadata.field(fieldId)) { + return [metadata.field(fieldId).getDefaultBreakout()]; + } + if (fieldId) { + return [["field", fieldId, null]]; + } + return oldBreakout; + }) + .value(); + + if (metricId) { + return assocIn( + question, + ["dataset_query", "query", "aggregation"], + [["metric", metricId]], + ); + } + + if (segmentId) { + return assocIn( + question, + ["dataset_query", "query", "filter"], + ["segment", segmentId], + ); + } + + return question; +}; + +export const getQuestionUrl = getQuestionArgs => + Urls.question(null, { hash: getQuestion(getQuestionArgs) }); + +// little utility function to determine if we 'has' things, useful +// for handling entity empty states +export const has = entity => entity && entity.length > 0; diff --git a/frontend/src/metabase/reference/utils.unit.spec.js b/frontend/src/metabase/reference/utils.unit.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..061773532e35168f649bf6d031c8e90723018003 --- /dev/null +++ b/frontend/src/metabase/reference/utils.unit.spec.js @@ -0,0 +1,275 @@ +import { databaseToForeignKeys, getQuestion } from "metabase/reference/utils"; + +import { separateTablesBySchema } from "metabase/reference/databases/TableList"; +import { TYPE } from "metabase-lib/types/constants"; + +describe("Reference utils.js", () => { + describe("databaseToForeignKeys()", () => { + it("should build foreignKey viewmodels from database", () => { + const database = { + tables_lookup: { + 1: { + id: 1, + display_name: "foo", + schema_name: "PUBLIC", + fields: [ + { + id: 1, + semantic_type: TYPE.PK, + display_name: "bar", + description: "foobar", + }, + ], + }, + 2: { + id: 2, + display_name: "bar", + schema_name: "public", + fields: [ + { + id: 2, + semantic_type: TYPE.PK, + display_name: "foo", + description: "barfoo", + }, + ], + }, + 3: { + id: 3, + display_name: "boo", + schema_name: "TEST", + fields: [ + { + id: 3, + display_name: "boo", + description: "booboo", + }, + ], + }, + }, + }; + + const foreignKeys = databaseToForeignKeys(database); + + expect(foreignKeys).toEqual({ + 1: { id: 1, name: "Public.foo → bar", description: "foobar" }, + 2: { id: 2, name: "bar → foo", description: "barfoo" }, + }); + }); + }); + + describe("tablesToSchemaSeparatedTables()", () => { + it("should add schema separator to appropriate locations and sort tables by name", () => { + const tables = { + 1: { id: 1, name: "Toucan", schema_name: "foo" }, + 2: { id: 2, name: "Elephant", schema_name: "bar" }, + 3: { id: 3, name: "Giraffe", schema_name: "boo" }, + 4: { id: 4, name: "Wombat", schema_name: "bar" }, + 5: { id: 5, name: "Anaconda", schema_name: "foo" }, + 6: { id: 6, name: "Buffalo", schema_name: "bar" }, + }; + + const createSchemaSeparator = table => table.schema_name; + const createListItem = table => table; + + const schemaSeparatedTables = separateTablesBySchema( + tables, + createSchemaSeparator, + createListItem, + ); + + expect(schemaSeparatedTables).toEqual([ + ["bar", { id: 6, name: "Buffalo", schema_name: "bar" }], + { id: 2, name: "Elephant", schema_name: "bar" }, + { id: 4, name: "Wombat", schema_name: "bar" }, + ["boo", { id: 3, name: "Giraffe", schema_name: "boo" }], + ["foo", { id: 5, name: "Anaconda", schema_name: "foo" }], + { id: 1, name: "Toucan", schema_name: "foo" }, + ]); + }); + }); + + describe("getQuestion()", () => { + const getNewQuestion = ({ + database = 1, + table = 2, + display = "table", + aggregation, + breakout, + filter, + }) => { + const card = { + name: null, + display: display, + visualization_settings: {}, + dataset_query: { + database: database, + type: "query", + query: { + "source-table": table, + }, + }, + }; + if (aggregation != null) { + card.dataset_query.query.aggregation = aggregation; + } + if (breakout != null) { + card.dataset_query.query.breakout = breakout; + } + if (filter != null) { + card.dataset_query.query.filter = filter; + } + return card; + }; + + it("should generate correct question for table raw data", () => { + const question = getQuestion({ + dbId: 3, + tableId: 4, + }); + + expect(question).toEqual( + getNewQuestion({ + database: 3, + table: 4, + }), + ); + }); + + it("should generate correct question for table counts", () => { + const question = getQuestion({ + dbId: 3, + tableId: 4, + getCount: true, + }); + + expect(question).toEqual( + getNewQuestion({ + database: 3, + table: 4, + aggregation: [["count"]], + }), + ); + }); + + it("should generate correct question for field raw data", () => { + const question = getQuestion({ + dbId: 3, + tableId: 4, + fieldId: 5, + }); + + expect(question).toEqual( + getNewQuestion({ + database: 3, + table: 4, + breakout: [["field", 5, null]], + }), + ); + }); + + it("should generate correct question for field group by bar chart", () => { + const question = getQuestion({ + dbId: 3, + tableId: 4, + fieldId: 5, + getCount: true, + visualization: "bar", + }); + + expect(question).toEqual( + getNewQuestion({ + database: 3, + table: 4, + display: "bar", + breakout: [["field", 5, null]], + aggregation: [["count"]], + }), + ); + }); + + it("should generate correct question for field group by pie chart", () => { + const question = getQuestion({ + dbId: 3, + tableId: 4, + fieldId: 5, + getCount: true, + visualization: "pie", + }); + + expect(question).toEqual( + getNewQuestion({ + database: 3, + table: 4, + display: "pie", + breakout: [["field", 5, null]], + aggregation: [["count"]], + }), + ); + }); + + it("should generate correct question for metric raw data", () => { + const question = getQuestion({ + dbId: 1, + tableId: 2, + metricId: 3, + }); + + expect(question).toEqual( + getNewQuestion({ + aggregation: [["metric", 3]], + }), + ); + }); + + it("should generate correct question for metric group by fields", () => { + const question = getQuestion({ + dbId: 1, + tableId: 2, + fieldId: 4, + metricId: 3, + }); + + expect(question).toEqual( + getNewQuestion({ + aggregation: [["metric", 3]], + breakout: [["field", 4, null]], + }), + ); + }); + + it("should generate correct question for segment raw data", () => { + const question = getQuestion({ + dbId: 2, + tableId: 3, + segmentId: 4, + }); + + expect(question).toEqual( + getNewQuestion({ + database: 2, + table: 3, + filter: ["segment", 4], + }), + ); + }); + + it("should generate correct question for segment counts", () => { + const question = getQuestion({ + dbId: 2, + tableId: 3, + segmentId: 4, + getCount: true, + }); + + expect(question).toEqual( + getNewQuestion({ + database: 2, + table: 3, + aggregation: [["count"]], + filter: ["segment", 4], + }), + ); + }); + }); +}); diff --git a/frontend/src/metabase/routes.jsx b/frontend/src/metabase/routes.jsx index 41ea58f624b29412fe58511d446dc3279119d3e2..dad1385932ffa02cea6771b506badb8fcd3de7bc 100644 --- a/frontend/src/metabase/routes.jsx +++ b/frontend/src/metabase/routes.jsx @@ -44,6 +44,29 @@ import { UnsubscribePage } from "metabase/containers/Unsubscribe"; import { Unauthorized } from "metabase/containers/ErrorPages"; import NotFoundFallbackPage from "metabase/containers/NotFoundFallbackPage"; +// Reference Metrics +import MetricListContainer from "metabase/reference/metrics/MetricListContainer"; +import MetricDetailContainer from "metabase/reference/metrics/MetricDetailContainer"; +import MetricQuestionsContainer from "metabase/reference/metrics/MetricQuestionsContainer"; +import MetricRevisionsContainer from "metabase/reference/metrics/MetricRevisionsContainer"; + +// Reference Segments +import SegmentListContainer from "metabase/reference/segments/SegmentListContainer"; +import SegmentDetailContainer from "metabase/reference/segments/SegmentDetailContainer"; +import SegmentQuestionsContainer from "metabase/reference/segments/SegmentQuestionsContainer"; +import SegmentRevisionsContainer from "metabase/reference/segments/SegmentRevisionsContainer"; +import SegmentFieldListContainer from "metabase/reference/segments/SegmentFieldListContainer"; +import SegmentFieldDetailContainer from "metabase/reference/segments/SegmentFieldDetailContainer"; + +// Reference Databases +import DatabaseListContainer from "metabase/reference/databases/DatabaseListContainer"; +import DatabaseDetailContainer from "metabase/reference/databases/DatabaseDetailContainer"; +import TableListContainer from "metabase/reference/databases/TableListContainer"; +import TableDetailContainer from "metabase/reference/databases/TableDetailContainer"; +import TableQuestionsContainer from "metabase/reference/databases/TableQuestionsContainer"; +import FieldListContainer from "metabase/reference/databases/FieldListContainer"; +import FieldDetailContainer from "metabase/reference/databases/FieldDetailContainer"; + import getAccountRoutes from "metabase/account/routes"; import getAdminRoutes from "metabase/admin/routes"; import getCollectionTimelineRoutes from "metabase/timelines/collections/routes"; @@ -197,6 +220,71 @@ export const getRoutes = store => ( <Route path="/auto/dashboard/*" component={AutomaticDashboardApp} /> + {/* REFERENCE */} + <Route path="/reference" title={t`Data Reference`}> + <IndexRedirect to="/reference/databases" /> + <Route path="metrics" component={MetricListContainer} /> + <Route path="metrics/:metricId" component={MetricDetailContainer} /> + <Route + path="metrics/:metricId/edit" + component={MetricDetailContainer} + /> + <Route + path="metrics/:metricId/questions" + component={MetricQuestionsContainer} + /> + <Route + path="metrics/:metricId/revisions" + component={MetricRevisionsContainer} + /> + <Route path="segments" component={SegmentListContainer} /> + <Route + path="segments/:segmentId" + component={SegmentDetailContainer} + /> + <Route + path="segments/:segmentId/fields" + component={SegmentFieldListContainer} + /> + <Route + path="segments/:segmentId/fields/:fieldId" + component={SegmentFieldDetailContainer} + /> + <Route + path="segments/:segmentId/questions" + component={SegmentQuestionsContainer} + /> + <Route + path="segments/:segmentId/revisions" + component={SegmentRevisionsContainer} + /> + <Route path="databases" component={DatabaseListContainer} /> + <Route + path="databases/:databaseId" + component={DatabaseDetailContainer} + /> + <Route + path="databases/:databaseId/tables" + component={TableListContainer} + /> + <Route + path="databases/:databaseId/tables/:tableId" + component={TableDetailContainer} + /> + <Route + path="databases/:databaseId/tables/:tableId/fields" + component={FieldListContainer} + /> + <Route + path="databases/:databaseId/tables/:tableId/fields/:fieldId" + component={FieldDetailContainer} + /> + <Route + path="databases/:databaseId/tables/:tableId/questions" + component={TableQuestionsContainer} + /> + </Route> + {/* PULSE */} <Route path="/pulse" title={t`Pulses`}> {/* NOTE: legacy route, not linked to in app */} diff --git a/frontend/src/metabase/selectors/metadata.ts b/frontend/src/metabase/selectors/metadata.ts index eea37392bf26258499908369fadc0ebf871095ac..2d8d47ef4cb12eb16f2203b6424d3478cc24d42d 100644 --- a/frontend/src/metabase/selectors/metadata.ts +++ b/frontend/src/metabase/selectors/metadata.ts @@ -89,6 +89,12 @@ const getNormalizedMetrics = (state: State) => state.entities.metrics; const getNormalizedSegments = (state: State) => state.entities.segments; const getNormalizedQuestions = (state: State) => state.entities.questions; +export const getShallowDatabases = getNormalizedDatabases; +export const getShallowTables = getNormalizedTables; +export const getShallowFields = getNormalizedFields; +export const getShallowMetrics = getNormalizedMetrics; +export const getShallowSegments = getNormalizedSegments; + export const getMetadata: ( state: State, props?: MetadataSelectorOpts, diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js index ef52ca115d83cc96e1c6b4840e0c70717a1734c0..030e2dcb4a62b15bbe941cf93c9634f0e74fc5d6 100644 --- a/frontend/src/metabase/services.js +++ b/frontend/src/metabase/services.js @@ -320,6 +320,7 @@ export const MetabaseApi = { db_get: GET("/api/database/:dbId"), db_update: PUT("/api/database/:id"), db_delete: DELETE("/api/database/:dbId"), + db_metadata: GET("/api/database/:dbId/metadata"), db_schemas: GET("/api/database/:dbId/schemas"), db_syncable_schemas: GET("/api/database/:dbId/syncable_schemas"), db_schema_tables: GET("/api/database/:dbId/schema/:schemaName"),