diff --git a/e2e/test/scenarios/admin/databases/default-sample-database.cy.spec.js b/e2e/test/scenarios/admin/databases/default-sample-database.cy.spec.js index dc5b6797356b047d24cd5e9f1f7eee75dbe5fe97..65a2a904313a1728796bdf582f7a9b30f9f543a7 100644 --- a/e2e/test/scenarios/admin/databases/default-sample-database.cy.spec.js +++ b/e2e/test/scenarios/admin/databases/default-sample-database.cy.spec.js @@ -297,6 +297,7 @@ describe("scenarios > admin > databases > sample database", () => { cy.findByText("Browse data").click(); }); + cy.findByRole("tab", { name: "Databases" }).click(); cy.findByTestId("database-browser").within(() => { cy.findByText("Sample Database").should("exist"); }); diff --git a/e2e/test/scenarios/admin/datamodel/hide_tables.cy.spec.js b/e2e/test/scenarios/admin/datamodel/hide_tables.cy.spec.js index 90316b02134595df695aced5f0232de851680a79..de108eae514651ca68baa6dd777915133fc4c77b 100644 --- a/e2e/test/scenarios/admin/datamodel/hide_tables.cy.spec.js +++ b/e2e/test/scenarios/admin/datamodel/hide_tables.cy.spec.js @@ -16,7 +16,7 @@ describe("scenarios > admin > datamodel > hidden tables (metabase#9759)", () => it("hidden table should not show up in various places in UI", () => { // Visit the main page, we shouldn't be able to see the table - cy.visit(`/browse/${SAMPLE_DB_ID}`); + cy.visit(`/browse/databases/${SAMPLE_DB_ID}`); // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage cy.contains("Products"); // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage @@ -24,7 +24,7 @@ describe("scenarios > admin > datamodel > hidden tables (metabase#9759)", () => // It shouldn't show up for a normal user either cy.signInAsNormalUser(); - cy.visit(`/browse/${SAMPLE_DB_ID}`); + cy.visit(`/browse/databases/${SAMPLE_DB_ID}`); // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage cy.contains("Products"); // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage diff --git a/e2e/test/scenarios/embedding/embedding-full-app.cy.spec.js b/e2e/test/scenarios/embedding/embedding-full-app.cy.spec.js index e227285553bf60b9f9a78feeba9dd97d2a849083..9e089cc4b67760f1b20967f2f71627ac0b61895c 100644 --- a/e2e/test/scenarios/embedding/embedding-full-app.cy.spec.js +++ b/e2e/test/scenarios/embedding/embedding-full-app.cy.spec.js @@ -158,8 +158,8 @@ describeEE("scenarios > embedding > full app", () => { url: "/browse", qs: { side_nav: false, logo: false }, }); - cy.findByRole("heading", { name: /Our data/ }).should("be.visible"); - cy.findByRole("treeitem", { name: /Our data/ }).should("not.exist"); + cy.findByRole("heading", { name: /Browse data/ }).should("be.visible"); + cy.findByRole("treeitem", { name: /Browse data/ }).should("not.exist"); cy.findByRole("treeitem", { name: "Our analytics" }).should("not.exist"); appBar().should("not.exist"); }); diff --git a/e2e/test/scenarios/onboarding/auth/signin.cy.spec.js b/e2e/test/scenarios/onboarding/auth/signin.cy.spec.js index b8b9152a847a2b4e64ff764d6aea30f82ddf7a59..22bbcc1cd6ed228e85d1807002cccbe91cfe5d78 100644 --- a/e2e/test/scenarios/onboarding/auth/signin.cy.spec.js +++ b/e2e/test/scenarios/onboarding/auth/signin.cy.spec.js @@ -76,6 +76,7 @@ describe("scenarios > auth > signin", () => { cy.signInAsAdmin(); cy.visit("/"); browse().click(); + cy.findByRole("tab", { name: "Databases" }).click(); cy.findByRole("heading", { name: "Sample Database" }).click(); cy.findByRole("heading", { name: "Orders" }).click(); cy.wait("@dataset"); diff --git a/e2e/test/scenarios/onboarding/home/browse.cy.spec.js b/e2e/test/scenarios/onboarding/home/browse.cy.spec.js index 7d71b03153e20147636389e92b4ef1eb3eee6f15..bf3648a05e008540bc9ac7fedf4a667db3d3ca31 100644 --- a/e2e/test/scenarios/onboarding/home/browse.cy.spec.js +++ b/e2e/test/scenarios/onboarding/home/browse.cy.spec.js @@ -6,22 +6,38 @@ describe("scenarios > browse data", () => { cy.signInAsAdmin(); }); - it("basic UI flow should work", () => { + it("can browse to a model", () => { cy.visit("/"); - // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage - cy.findByText(/Browse data/).click(); - cy.location("pathname").should("eq", "/browse"); - // 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.findByRole("listitem", { name: "Browse data" }).click(); + cy.location("pathname").should("eq", "/browse/models"); + cy.findByTestId("data-browser").findByText("Browse data"); + cy.findByRole("heading", { name: "Orders Model" }).click(); + cy.findByRole("button", { name: "Filter" }); + }); + it("can view summary of model's last edit", () => { + cy.visit("/"); + cy.findByRole("listitem", { name: "Browse data" }).click(); + cy.findByRole("note", /Bobby Tables.*7h./).realHover(); + cy.findByRole("tooltip", { name: /Last edited by Bobby Tables/ }); + }); + it("can browse to a database", () => { + cy.visit("/"); + cy.findByRole("listitem", { name: "Browse data" }).click(); + cy.findByRole("tab", { name: "Databases" }).click(); + cy.findByRole("heading", { name: "Sample Database" }).click(); + cy.findByRole("heading", { name: "Products" }).click(); + cy.findByRole("button", { name: "Summarize" }); + cy.findByRole("link", { name: /Sample Database/ }).click(); + }); + it("can visit 'Learn about our data' page", () => { + cy.visit("/"); + cy.findByRole("listitem", { name: "Browse data" }).click(); + cy.findByRole("link", { name: /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(); - // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage - cy.findByText("Rustic Paper Wallet"); + cy.findByRole("tab", { name: "Databases" }).click(); + cy.findByRole("heading", { name: "Sample Database" }).click(); + cy.findByRole("heading", { name: "Products" }).click(); + cy.findByRole("gridcell", { name: "Rustic Paper Wallet" }); }); }); diff --git a/e2e/test/scenarios/onboarding/setup/setup.cy.spec.js b/e2e/test/scenarios/onboarding/setup/setup.cy.spec.js index 2065089b62f10f01b0c52cfaaea48486b4bfe946..3e525c73124c7196dca5ba3b8d1db520dc350d9f 100644 --- a/e2e/test/scenarios/onboarding/setup/setup.cy.spec.js +++ b/e2e/test/scenarios/onboarding/setup/setup.cy.spec.js @@ -291,6 +291,7 @@ describe("scenarios > setup", () => { }); cy.visit("/browse"); + cy.findByRole("tab", { name: "Databases" }).click(); cy.findByTestId("database-browser").findByText(dbName); }); }); diff --git a/e2e/test/scenarios/onboarding/urls.cy.spec.js b/e2e/test/scenarios/onboarding/urls.cy.spec.js index c6b361a9a13aa638090b413f1dc74363883a6ecb..e120354a759e1b625e486e7e81174d2c8dab10cf 100644 --- a/e2e/test/scenarios/onboarding/urls.cy.spec.js +++ b/e2e/test/scenarios/onboarding/urls.cy.spec.js @@ -24,20 +24,21 @@ describe("URLs", () => { }); describe("browse databases", () => { - it(`should slugify database name when opening it from /browse"`, () => { - cy.visit("/browse"); + it(`should slugify database name when opening it from /browse/databases"`, () => { + cy.visit("/browse/databases"); + cy.findByRole("tab", { name: "Databases" }).click(); cy.findByTextEnsureVisible("Sample Database").click(); // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage cy.findByText("Sample Database"); cy.location("pathname").should( "eq", - `/browse/${SAMPLE_DB_ID}-sample-database`, + `/browse/databases/${SAMPLE_DB_ID}-sample-database`, ); }); [ - `/browse/${SAVED_QUESTIONS_VIRTUAL_DB_ID}`, - `/browse/${SAVED_QUESTIONS_VIRTUAL_DB_ID}-saved-questions`, + `/browse/databases/${SAVED_QUESTIONS_VIRTUAL_DB_ID}`, + `/browse/databases/${SAVED_QUESTIONS_VIRTUAL_DB_ID}-saved-questions`, ].forEach(url => { it("should open 'Saved Questions' database correctly", () => { cy.visit(url); diff --git a/e2e/test/scenarios/permissions/impersonated.cy.spec.js b/e2e/test/scenarios/permissions/impersonated.cy.spec.js index 55164b3c6babe90583cdc678315182e984baf499..190ac11ab7984930acccffc8c29a33e358162080 100644 --- a/e2e/test/scenarios/permissions/impersonated.cy.spec.js +++ b/e2e/test/scenarios/permissions/impersonated.cy.spec.js @@ -329,7 +329,7 @@ describeEE("impersonated permission", () => { }); it("have limited access", () => { - cy.visit(`/browse/${PG_DB_ID}`); + cy.visit(`/browse/databases/${PG_DB_ID}`); // No access through the visual query builder cy.get("main").within(() => { @@ -340,7 +340,7 @@ describeEE("impersonated permission", () => { }); // Has access to allowed tables - cy.visit(`/browse/${PG_DB_ID}`); + cy.visit(`/browse/databases/${PG_DB_ID}`); cy.get("main").findByText("Orders").click(); cy.findAllByTestId("header-cell").contains("Subtotal"); diff --git a/e2e/test/scenarios/question/new.cy.spec.js b/e2e/test/scenarios/question/new.cy.spec.js index b1a6b7e0f81d5d9aa0281e04c84a2f2cddb09ea2..fbe6efc6b1b2957851f6b07ff5dd49d11ad00913 100644 --- a/e2e/test/scenarios/question/new.cy.spec.js +++ b/e2e/test/scenarios/question/new.cy.spec.js @@ -74,15 +74,9 @@ describe("scenarios > question > new", () => { // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage cy.contains("Our analytics"); - // cy.findAllByRole("link", { name: "Our analytics" }) - // .should("have.attr", "href") - // .and("eq", "/collection/root"); // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage cy.contains("Sample Database"); - // cy.findAllByRole("link", { name: "Sample Database" }) - // .should("have.attr", "href") - // .and("eq", `/browse/${SAMPLE_DB_ID}-sample-database`); // Discarding the search query should take us back to the original selector // that starts with the list of databases and saved questions diff --git a/e2e/test/scenarios/question/settings.cy.spec.js b/e2e/test/scenarios/question/settings.cy.spec.js index ff1ba407c502ea1d0d3d06b100c21d1b48fc2bd5..d8a15e23de0301d97798d52e4cfee1625b7c3ff4 100644 --- a/e2e/test/scenarios/question/settings.cy.spec.js +++ b/e2e/test/scenarios/question/settings.cy.spec.js @@ -469,6 +469,7 @@ describe("scenarios > question > settings", () => { // create a new question to see if the "add to a dashboard" modal is still there openNavigationSidebar(); browse().click(); + cy.findByRole("tab", { name: "Databases" }).click(); // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage cy.contains("Sample Database").click(); // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage diff --git a/frontend/src/metabase-types/api/search.ts b/frontend/src/metabase-types/api/search.ts index 7e5f9cdad887acd91effaace857e8f9935b455b1..98737145b7b136b90bc2441da79279df911d743a 100644 --- a/frontend/src/metabase-types/api/search.ts +++ b/frontend/src/metabase-types/api/search.ts @@ -48,6 +48,11 @@ export interface SearchResults { total: number; } +export type CollectionEssentials = Pick< + Collection, + "id" | "name" | "authority_level" +>; + export interface SearchResult { id: number; name: string; @@ -55,7 +60,7 @@ export interface SearchResult { description: string | null; archived: boolean | null; collection_position: number | null; - collection: Pick<Collection, "id" | "name" | "authority_level">; + collection: CollectionEssentials; table_id: TableId; bookmark: boolean | null; database_id: DatabaseId; diff --git a/frontend/src/metabase/browse/components/BrowseApp.jsx b/frontend/src/metabase/browse/components/BrowseApp.jsx deleted file mode 100644 index aff202d7f8a4e9dee11e35546cca8791891027fc..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/browse/components/BrowseApp.jsx +++ /dev/null @@ -1,6 +0,0 @@ -/* eslint-disable react/prop-types */ -import { BrowseAppRoot } from "./BrowseApp.styled"; - -export default function BrowseApp({ children }) { - return <BrowseAppRoot data-testid="browse-data">{children}</BrowseAppRoot>; -} diff --git a/frontend/src/metabase/browse/components/BrowseApp.styled.tsx b/frontend/src/metabase/browse/components/BrowseApp.styled.tsx index 89e95762902480627b5473c1b5ed2fcbd99b9f67..6c4dee19fbeaad93deabd5ccdb37f4cd67b4f106 100644 --- a/frontend/src/metabase/browse/components/BrowseApp.styled.tsx +++ b/frontend/src/metabase/browse/components/BrowseApp.styled.tsx @@ -1,17 +1,78 @@ import styled from "@emotion/styled"; -import { - breakpointMinSmall, - breakpointMinMedium, -} from "metabase/styled-components/theme"; +import { Tabs } from "metabase/ui"; +import { color } from "metabase/lib/colors"; +import EmptyState from "metabase/components/EmptyState"; export const BrowseAppRoot = styled.div` - margin: 0 0.5rem; + flex: 1; + height: 100%; +`; - ${breakpointMinSmall} { - margin: 0 1rem; - } +export const BrowseTabs = styled(Tabs)` + display: flex; + flex-flow: column nowrap; + flex: 1; +`; + +export const BrowseTabsList = styled(Tabs.List)` + padding: 0 1rem; + background-color: ${color("white")}; + border-bottom-width: 1px; +`; - ${breakpointMinMedium} { - margin: 0 4rem; +export const BrowseTab = styled(Tabs.Tab)` + top: 1px; + margin-bottom: 1px; + border-bottom-width: 3px !important; + padding: 10px; + &:hover { + color: ${color("brand")}; + background-color: inherit; + border-color: transparent; } `; + +export const BrowseTabsPanel = styled(Tabs.Panel)` + display: flex; + flex-flow: column nowrap; + flex: 1; + height: 100%; + padding: 0 1rem; +`; + +export const BrowseContainer = styled.div` + display: flex; + flex: 1; + flex-flow: column nowrap; + height: 100%; +`; + +export const BrowseDataHeader = styled.header` + display: flex; + padding: 1rem; + padding-bottom: 0.375rem; + color: ${color("dark")}; + background-color: ${color("white")}; +`; + +export const BrowseSectionContainer = styled.div` + max-width: 1014px; + margin: 0 auto; + flex: 1; + display: flex; + width: 100%; +`; + +export const BrowseTabsContainer = styled(BrowseSectionContainer)` + flex-flow: column nowrap; + justify-content: flex-start; +`; + +export const CenteredEmptyState = styled(EmptyState)` + display: flex; + flex: 1; + flex-flow: column nowrap; + align-items: center; + justify-content: center; + height: 100%; +`; diff --git a/frontend/src/metabase/browse/components/BrowseApp.tsx b/frontend/src/metabase/browse/components/BrowseApp.tsx new file mode 100644 index 0000000000000000000000000000000000000000..def282c7f4cbe11b9cf7ae744e43b4235b62763d --- /dev/null +++ b/frontend/src/metabase/browse/components/BrowseApp.tsx @@ -0,0 +1,113 @@ +import { t } from "ttag"; +import { push } from "react-router-redux"; +import { Icon, Text } from "metabase/ui"; +import { + useDatabaseListQuery, + useSearchListQuery, +} from "metabase/common/hooks"; +import type { SearchResult } from "metabase-types/api"; +import { useDispatch } from "metabase/lib/redux"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; +import Link from "metabase/core/components/Link"; +import { BrowseDatabases } from "./BrowseDatabases"; +import { BrowseModels } from "./BrowseModels"; +import { + BrowseAppRoot, + BrowseContainer, + BrowseDataHeader, + BrowseSectionContainer, + BrowseTab, + BrowseTabs, + BrowseTabsContainer, + BrowseTabsList, + BrowseTabsPanel, +} from "./BrowseApp.styled"; +import { BrowseHeaderIconContainer } from "./BrowseHeader.styled"; + +export type BrowseTabId = "models" | "databases"; + +const isValidBrowseTab = (value: unknown): value is BrowseTabId => + value === "models" || value === "databases"; + +export const BrowseApp = ({ + tab = "models", + children, +}: { + tab?: string; + children?: React.ReactNode; +}) => { + const dispatch = useDispatch(); + const modelsResult = useSearchListQuery<SearchResult>({ + query: { + models: ["dataset"], + filter_items_in_personal_collection: "exclude", + }, + }); + const databasesResult = useDatabaseListQuery(); + + if (!isValidBrowseTab(tab)) { + return <LoadingAndErrorWrapper error />; + } + + return ( + <BrowseAppRoot data-testid="browse-data"> + <BrowseContainer data-testid="data-browser"> + <BrowseDataHeader> + <BrowseSectionContainer> + <h2>{t`Browse data`}</h2> + <div + className="flex flex-align-right" + style={{ flexBasis: "40.0%" }} + > + <Link className="flex flex-align-right" to="reference"> + <BrowseHeaderIconContainer> + <Icon + className="flex align-center" + size={14} + name="reference" + /> + <Text + size="md" + lh="1" + className="ml1 flex align-center text-bold" + > + {t`Learn about our data`} + </Text> + </BrowseHeaderIconContainer> + </Link> + </div> + </BrowseSectionContainer> + </BrowseDataHeader> + <BrowseTabs + value={tab} + onTabChange={value => { + if (isValidBrowseTab(value)) { + dispatch(push(`/browse/${value}`)); + } + }} + > + <BrowseTabsList> + <BrowseSectionContainer> + <BrowseTab key={"models"} value={"models"}> + {t`Models`} + </BrowseTab> + <BrowseTab key={"databases"} value={"databases"}> + {t`Databases`} + </BrowseTab> + </BrowseSectionContainer> + </BrowseTabsList> + <BrowseTabsPanel key={tab} value={tab}> + <BrowseTabsContainer> + {children || + (tab === "models" ? ( + <BrowseModels modelsResult={modelsResult} /> + ) : ( + <BrowseDatabases databasesResult={databasesResult} /> + ))} + </BrowseTabsContainer> + </BrowseTabsPanel> + </BrowseTabs> + </BrowseContainer> + </BrowseAppRoot> + ); +}; diff --git a/frontend/src/metabase/browse/containers/DatabaseBrowser.styled.tsx b/frontend/src/metabase/browse/components/BrowseDatabases.styled.tsx similarity index 75% rename from frontend/src/metabase/browse/containers/DatabaseBrowser.styled.tsx rename to frontend/src/metabase/browse/components/BrowseDatabases.styled.tsx index 5e119e1af1f234843e4fa563bc5976e7a9166254..95cfcdf6fc6684fff2ecd0ad609a14622cf97f1e 100644 --- a/frontend/src/metabase/browse/containers/DatabaseBrowser.styled.tsx +++ b/frontend/src/metabase/browse/components/BrowseDatabases.styled.tsx @@ -5,10 +5,15 @@ import { breakpointMinSmall, } from "metabase/styled-components/theme"; import Card from "metabase/components/Card"; -import { GridItem } from "metabase/components/Grid"; +import { GridItem, Grid } from "metabase/components/Grid"; + +export const DatabaseGrid = styled(Grid)` + width: 100%; +`; export const DatabaseCard = styled(Card)` padding: 1.5rem; + box-shadow: 0px 1px 4px 0px rgba(0, 0, 0, 0.06) !important; &:hover { color: ${color("brand")}; diff --git a/frontend/src/metabase/browse/components/BrowseDatabases.tsx b/frontend/src/metabase/browse/components/BrowseDatabases.tsx new file mode 100644 index 0000000000000000000000000000000000000000..79b9861bb639334d70ca66f0206ffcd39f7c04eb --- /dev/null +++ b/frontend/src/metabase/browse/components/BrowseDatabases.tsx @@ -0,0 +1,62 @@ +import _ from "underscore"; +import { t } from "ttag"; + +import * as Urls from "metabase/lib/urls"; +import { color } from "metabase/lib/colors"; + +import { Icon, Box } from "metabase/ui"; +import Link from "metabase/core/components/Link"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; + +import type { useDatabaseListQuery } from "metabase/common/hooks"; + +import NoResults from "assets/img/no_results.svg"; +import { + DatabaseCard, + DatabaseGrid, + DatabaseGridItem, +} from "./BrowseDatabases.styled"; +import { CenteredEmptyState } from "./BrowseApp.styled"; + +export const BrowseDatabases = ({ + databasesResult, +}: { + databasesResult: ReturnType<typeof useDatabaseListQuery>; +}) => { + const { data: databases = [], error, isLoading } = databasesResult; + if (error) { + return <LoadingAndErrorWrapper error />; + } + if (isLoading) { + return <LoadingAndErrorWrapper loading />; + } + + return databases.length ? ( + <DatabaseGrid data-testid="database-browser"> + {databases.map(database => ( + <DatabaseGridItem key={database.id}> + <Link to={Urls.browseDatabase(database)}> + <DatabaseCard> + <Icon + name="database" + color={color("accent2")} + className="mb3" + size={32} + /> + <h3 className="text-wrap">{database.name}</h3> + </DatabaseCard> + </Link> + </DatabaseGridItem> + ))} + </DatabaseGrid> + ) : ( + <CenteredEmptyState + title={<Box mb=".5rem">{t`No databases here yet`}</Box>} + illustrationElement={ + <Box mb=".5rem"> + <img src={NoResults} /> + </Box> + } + /> + ); +}; diff --git a/frontend/src/metabase/browse/components/BrowseDatabases.unit.spec.tsx b/frontend/src/metabase/browse/components/BrowseDatabases.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0dba32b0efa82e57cb04a9e71b3b7cb8c1f4a2ec --- /dev/null +++ b/frontend/src/metabase/browse/components/BrowseDatabases.unit.spec.tsx @@ -0,0 +1,36 @@ +import { createMockDatabase } from "metabase-types/api/mocks"; +import { renderWithProviders, screen } from "__support__/ui"; +import type Database from "metabase-lib/metadata/Database"; +import { BrowseDatabases } from "./BrowseDatabases"; + +const renderBrowseDatabases = (modelCount: number) => { + const databases = mockDatabases.slice(0, modelCount); + return renderWithProviders( + <BrowseDatabases + databasesResult={{ data: databases, isLoading: false, error: false }} + />, + ); +}; + +const mockDatabases = [...Array(100)].map( + (_, index) => + createMockDatabase({ id: index, name: `Database ${index}` }) as Database, +); + +describe("BrowseDatabases", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + it("displays databases", async () => { + renderBrowseDatabases(10); + for (let i = 0; i < 10; i++) { + expect(await screen.findByText(`Database ${i}`)).toBeInTheDocument(); + } + }); + it("displays a 'no databases' message in the Databases tab when no databases exist", async () => { + renderBrowseDatabases(0); + expect( + await screen.findByText("No databases here yet"), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/metabase/browse/components/BrowseHeader.styled.tsx b/frontend/src/metabase/browse/components/BrowseHeader.styled.tsx index ada2adc0a917f796b0caeee583351a13737b23aa..f1ae9bb0e7ab4a8943271e3157bc7e586a671ebb 100644 --- a/frontend/src/metabase/browse/components/BrowseHeader.styled.tsx +++ b/frontend/src/metabase/browse/components/BrowseHeader.styled.tsx @@ -1,18 +1,13 @@ import styled from "@emotion/styled"; import { color } from "metabase/lib/colors"; -export const BrowseHeaderRoot = styled.div` - margin-top: 2rem; - margin-bottom: 1rem; -`; - export const BrowseHeaderContent = styled.div` display: flex; align-items: center; - margin-top: 0.5rem; + padding: 1rem 0.5rem 0.5rem 0.5rem; `; -export const BrowserHeaderIconContainer = styled.div` +export const BrowseHeaderIconContainer = styled.div` display: flex; align-items: center; color: ${color("text-medium")}; diff --git a/frontend/src/metabase/browse/components/BrowseHeader.tsx b/frontend/src/metabase/browse/components/BrowseHeader.tsx deleted file mode 100644 index ae00793b30c4428aa2172a8e8ad43583ba9aa956..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/browse/components/BrowseHeader.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { t } from "ttag"; -import BrowserCrumbs from "metabase/components/BrowserCrumbs"; - -import Link from "metabase/core/components/Link"; -import { Icon } from "metabase/ui"; -import { - BrowseHeaderContent, - BrowseHeaderRoot, - BrowserHeaderIconContainer, -} from "./BrowseHeader.styled"; - -type Crumb = { to?: string; title?: string }; - -export const BrowseHeader = ({ crumbs = [] }: { crumbs: Crumb[] }) => { - return ( - <BrowseHeaderRoot> - <BrowseHeaderContent> - <BrowserCrumbs crumbs={crumbs} /> - <div className="flex flex-align-right"> - <Link className="flex flex-align-right" to="reference"> - <BrowserHeaderIconContainer> - <Icon className="flex align-center" size={14} name="reference" /> - <span className="ml1 flex align-center text-bold"> - {t`Learn about our data`} - </span> - </BrowserHeaderIconContainer> - </Link> - </div> - </BrowseHeaderContent> - </BrowseHeaderRoot> - ); -}; diff --git a/frontend/src/metabase/browse/components/BrowseModels.styled.tsx b/frontend/src/metabase/browse/components/BrowseModels.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b30702ee7fa1206f7742c38120df196d089ef786 --- /dev/null +++ b/frontend/src/metabase/browse/components/BrowseModels.styled.tsx @@ -0,0 +1,84 @@ +import styled from "@emotion/styled"; +import { color } from "metabase/lib/colors"; +import { + breakpointMinMedium, + breakpointMinSmall, +} from "metabase/styled-components/theme"; +import Card from "metabase/components/Card"; +import { Ellipsified } from "metabase/core/components/Ellipsified"; +import Link from "metabase/core/components/Link"; +import { Group } from "metabase/ui"; + +export const ModelCard = styled(Card)` + padding: 1.5rem; + padding-bottom: 1rem; + + height: 9rem; + display: flex; + flex-flow: column nowrap; + justify-content: flex-start; + align-items: flex-start; + + border: 1px solid ${color("border")}; + box-shadow: 0 1px 0.25rem 0 rgba(0, 0, 0, 0.06); + &:hover { + box-shadow: 0 1px 0.25rem 0 rgba(0, 0, 0, 0.14); + h4 { + color: ${color("brand")}; + } + } + transition: box-shadow 0.15s; + h4 { + transition: color 0.15s; + } +`; + +export const MultilineEllipsified = styled(Ellipsified)` + white-space: pre-line; + overflow: hidden; + text-overflow: ellipsis; + + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + + // Without the following rule, the useIsTruncated hook, + // which Ellipsified calls, might think that this element + // is truncated when it is not + padding-bottom: 1px; +`; + +export const GridContainer = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: 1rem; + width: 100%; + + ${breakpointMinSmall} { + padding-bottom: 1rem; + } + ${breakpointMinMedium} { + padding-bottom: 3rem; + } +`; + +export const CollectionHeaderContainer = styled.div` + grid-column: 1 / -1; + align-items: center; + padding-top: 0.5rem; + margin-right: 1rem; + &:not(:first-of-type) { + border-top: 1px solid #f0f0f0; + } +`; + +export const CollectionHeaderLink = styled(Link)` + &:hover * { + color: ${color("brand")}; + } +`; + +export const CollectionHeaderGroup = styled(Group)` + position: relative; + top: 0.5rem; +`; diff --git a/frontend/src/metabase/browse/components/BrowseModels.tsx b/frontend/src/metabase/browse/components/BrowseModels.tsx new file mode 100644 index 0000000000000000000000000000000000000000..563cf5664dbf4fabd9d5f8bc0983eddffa517bdd --- /dev/null +++ b/frontend/src/metabase/browse/components/BrowseModels.tsx @@ -0,0 +1,191 @@ +import _ from "underscore"; +import cx from "classnames"; +import { c, t } from "ttag"; + +import type { + Card, + CollectionEssentials, + SearchResult, +} from "metabase-types/api"; +import * as Urls from "metabase/lib/urls"; + +import Link from "metabase/core/components/Link"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; + +import type { useSearchListQuery } from "metabase/common/hooks"; + +import { Box, Group, Icon, Text, Title } from "metabase/ui"; +import NoResults from "assets/img/no_results.svg"; +import { useSelector } from "metabase/lib/redux"; +import { getLocale } from "metabase/setup/selectors"; +import { isInstanceAnalyticsCollection } from "metabase/collections/utils"; +import { getCollectionName, groupModels } from "../utils"; +import { CenteredEmptyState } from "./BrowseApp.styled"; +import { + CollectionHeaderContainer, + CollectionHeaderGroup, + CollectionHeaderLink, + GridContainer, + ModelCard, + MultilineEllipsified, +} from "./BrowseModels.styled"; +import { LastEdited } from "./LastEdited"; + +export const BrowseModels = ({ + modelsResult, +}: { + modelsResult: ReturnType<typeof useSearchListQuery<SearchResult>>; +}) => { + const { data: models = [], error, isLoading } = modelsResult; + const locale = useSelector(getLocale); + const localeCode: string | undefined = locale?.code; + const modelsFiltered = models.filter( + model => !isInstanceAnalyticsCollection(model.collection), + ); + const groupsOfModels = groupModels(modelsFiltered, localeCode); + + if (error || isLoading) { + return ( + <LoadingAndErrorWrapper + error={error} + loading={isLoading} + style={{ display: "flex", flex: 1 }} + /> + ); + } + + if (modelsFiltered.length) { + return ( + <GridContainer role="grid"> + {groupsOfModels.map(groupOfModels => ( + <ModelGroup + models={groupOfModels} + key={`modelgroup-${groupOfModels[0].collection.id}`} + localeCode={localeCode} + /> + ))} + </GridContainer> + ); + } + + return ( + <CenteredEmptyState + title={<Box mb=".5rem">{t`No models here yet`}</Box>} + message={ + <Box maw="24rem">{t`Models help curate data to make it easier to find answers to questions all in one place.`}</Box> + } + illustrationElement={ + <Box mb=".5rem"> + <img src={NoResults} /> + </Box> + } + /> + ); +}; + +const ModelGroup = ({ + models, + localeCode, +}: { + models: SearchResult[]; + localeCode: string | undefined; +}) => { + const sortedModels = models.sort((a, b) => { + if (!a.name && b.name) { + return 1; + } + if (a.name && !b.name) { + return -1; + } + if (!a.name && !b.name) { + return 0; + } + const nameA = a.name.toLowerCase(); + const nameB = b.name.toLowerCase(); + return nameA.localeCompare(nameB, localeCode); + }); + const collection = models[0].collection; + + /** This id is used by aria-labelledby */ + const collectionHtmlId = `collection-${collection.id}`; + + // TODO: Check padding above the collection header + return ( + <> + <CollectionHeader + collection={collection} + key={collectionHtmlId} + id={collectionHtmlId} + /> + {sortedModels.map(model => ( + <ModelCell + model={model} + collectionHtmlId={collectionHtmlId} + key={`model-${model.id}`} + /> + ))} + </> + ); +}; + +interface ModelCellProps { + model: SearchResult; + collectionHtmlId: string; +} + +const ModelCell = ({ model, collectionHtmlId }: ModelCellProps) => { + const headingId = `heading-for-model-${model.id}`; + + const lastEditorFullName = + model.last_editor_common_name ?? model.creator_common_name; + const timestamp = model.last_edited_at ?? model.created_at ?? ""; + + const noDescription = c( + "Indicates that a model has no description associated with it", + ).t`No description.`; + return ( + <Link + aria-labelledby={`${collectionHtmlId} ${headingId}`} + key={model.id} + to={Urls.model(model as unknown as Partial<Card>)} + > + <ModelCard> + <Title order={4} className="text-wrap" lh="1rem" mb=".5rem"> + <MultilineEllipsified tooltipMaxWidth="20rem" id={headingId}> + {model.name} + </MultilineEllipsified> + </Title> + <Text h="2rem" size="xs" mb="auto"> + <MultilineEllipsified + tooltipMaxWidth="20rem" + className={cx({ "text-light": !model.description })} + > + {model.description || noDescription}{" "} + </MultilineEllipsified> + </Text> + <LastEdited editorFullName={lastEditorFullName} timestamp={timestamp} /> + </ModelCard> + </Link> + ); +}; + +const CollectionHeader = ({ + collection, + id, +}: { + collection: CollectionEssentials; + id: string; +}) => { + return ( + <CollectionHeaderContainer id={id} role="heading"> + <CollectionHeaderGroup grow noWrap> + <CollectionHeaderLink to={Urls.collection(collection)}> + <Group spacing=".25rem"> + <Icon name="folder" color="text-dark" size={16} /> + <Text weight="bold">{getCollectionName(collection)}</Text> + </Group> + </CollectionHeaderLink> + </CollectionHeaderGroup> + </CollectionHeaderContainer> + ); +}; diff --git a/frontend/src/metabase/browse/components/BrowseModels.unit.spec.tsx b/frontend/src/metabase/browse/components/BrowseModels.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2f83d21fb5e6ebd19c1781539b4ce9131aabd841 --- /dev/null +++ b/frontend/src/metabase/browse/components/BrowseModels.unit.spec.tsx @@ -0,0 +1,294 @@ +import { renderWithProviders, screen, within } from "__support__/ui"; +import type { SearchResult } from "metabase-types/api"; +import { createMockSetupState } from "metabase-types/store/mocks"; +import { + createMockCollection, + createMockSearchResult, +} from "metabase-types/api/mocks"; +import { defaultRootCollection } from "metabase/admin/permissions/pages/CollectionPermissionsPage/tests/setup"; +import { groupModels } from "../utils"; +import { BrowseModels } from "./BrowseModels"; + +const renderBrowseModels = (modelCount: number) => { + const models = mockModels.slice(0, modelCount); + return renderWithProviders( + <BrowseModels + modelsResult={{ data: models, isLoading: false, error: false }} + />, + { + storeInitialState: { + setup: createMockSetupState({ + locale: { name: "English", code: "en" }, + }), + }, + }, + ); +}; + +const collectionAlpha = createMockCollection({ id: 0, name: "Alpha" }); +const collectionBeta = createMockCollection({ id: 1, name: "Beta" }); +const collectionCharlie = createMockCollection({ id: 2, name: "Charlie" }); +const collectionDelta = createMockCollection({ id: 3, name: "Delta" }); +const collectionZulu = createMockCollection({ id: 4, name: "Zulu" }); +const collectionAngstrom = createMockCollection({ id: 5, name: "Ångström" }); +const collectionOzgur = createMockCollection({ id: 6, name: "Özgür" }); + +const mockModels: SearchResult[] = [ + { + id: 0, + name: "Model 0", + collection: collectionAlpha, + last_editor_common_name: "Bobby", + last_edited_at: "2024-12-15T11:59:59.000Z", + }, + { + id: 1, + name: "Model 1", + collection: collectionAlpha, + last_editor_common_name: "Bobby", + last_edited_at: "2024-12-15T11:59:30.000Z", + }, + { + id: 2, + name: "Model 2", + collection: collectionAlpha, + last_editor_common_name: "Bobby", + last_edited_at: "2024-12-15T11:59:00.000Z", + }, + { + id: 3, + name: "Model 3", + collection: collectionBeta, + last_editor_common_name: "Bobby", + last_edited_at: "2024-12-15T11:50:00.000Z", + }, + { + id: 4, + name: "Model 4", + collection: collectionBeta, + last_editor_common_name: "Bobby", + last_edited_at: "2024-12-15T11:00:00.000Z", + }, + { + id: 5, + name: "Model 5", + collection: collectionBeta, + last_editor_common_name: "Bobby", + last_edited_at: "2024-12-14T22:00:00.000Z", + }, + { + id: 6, + name: "Model 6", + collection: collectionCharlie, + last_editor_common_name: "Bobby", + last_edited_at: "2024-12-14T12:00:00.000Z", + }, + { + id: 7, + name: "Model 7", + collection: collectionCharlie, + last_editor_common_name: "Bobby", + last_edited_at: "2024-12-10T12:00:00.000Z", + }, + { + id: 8, + name: "Model 8", + collection: collectionCharlie, + last_editor_common_name: "Bobby", + last_edited_at: "2024-11-15T12:00:00.000Z", + }, + { + id: 9, + name: "Model 9", + collection: collectionDelta, + last_editor_common_name: "Bobby", + last_edited_at: "2024-02-15T12:00:00.000Z", + }, + { + id: 10, + name: "Model 10", + collection: collectionDelta, + last_editor_common_name: "Bobby", + last_edited_at: "2023-12-15T12:00:00.000Z", + }, + { + id: 11, + name: "Model 11", + collection: collectionDelta, + last_editor_common_name: "Bobby", + last_edited_at: "2020-01-01T00:00:00.000Z", + }, + { + id: 12, + name: "Model 12", + collection: collectionZulu, + last_editor_common_name: "Bobby", + last_edited_at: "2000-01-01T00:00:00.000Z", + }, + { + id: 13, + name: "Model 13", + collection: collectionZulu, + last_editor_common_name: "Bobby", + last_edited_at: "2000-01-01T00:00:00.000Z", + }, + { + id: 14, + name: "Model 14", + collection: collectionZulu, + last_editor_common_name: "Bobby", + last_edited_at: "2000-01-01T00:00:00.000Z", + }, + { + id: 15, + name: "Model 15", + collection: collectionAngstrom, + last_editor_common_name: "Bobby", + last_edited_at: "2000-01-01T00:00:00.000Z", + }, + { + id: 16, + name: "Model 16", + collection: collectionAngstrom, + last_editor_common_name: "Bobby", + last_edited_at: "2000-01-01T00:00:00.000Z", + }, + { + id: 17, + name: "Model 17", + collection: collectionAngstrom, + last_editor_common_name: "Bobby", + last_edited_at: "2000-01-01T00:00:00.000Z", + }, + { + id: 18, + name: "Model 18", + collection: collectionOzgur, + last_editor_common_name: "Bobby", + last_edited_at: "2000-01-01T00:00:00.000Z", + }, + { + id: 19, + name: "Model 19", + collection: collectionOzgur, + last_editor_common_name: "Bobby", + last_edited_at: "2000-01-01T00:00:00.000Z", + }, + { + id: 20, + name: "Model 20", + collection: collectionOzgur, + last_editor_common_name: "Bobby", + last_edited_at: "2000-01-01T00:00:00.000Z", + }, + { + id: 21, + name: "Model 20", + collection: defaultRootCollection, + last_editor_common_name: "Bobby", + last_edited_at: "2000-01-01T00:00:00.000Z", + }, + { + id: 22, + name: "Model 21", + collection: defaultRootCollection, + last_editor_common_name: "Bobby", + last_edited_at: "2000-01-01T00:00:00.000Z", + }, +].map(model => createMockSearchResult(model)); + +describe("BrowseModels", () => { + it("displays models", async () => { + renderBrowseModels(10); + for (let i = 0; i < 10; i++) { + expect(await screen.findByText(`Model ${i}`)).toBeInTheDocument(); + } + }); + it("displays a 'no models' message in the Models tab when no models exist", async () => { + renderBrowseModels(0); + expect(await screen.findByText("No models here yet")).toBeInTheDocument(); + }); + it("displays models, organized by parent collection", async () => { + renderBrowseModels(10); + // Three <a> tags representing models have aria-labelledby="collection-1 model-$id", + // and "collection-1" is the id of an element containing text 'Collection 1', + // so the following line finds those <a> tags. + const modelsInCollection1 = await screen.findAllByLabelText("Alpha"); + expect(modelsInCollection1).toHaveLength(3); + const modelsInCollection2 = await screen.findAllByLabelText("Beta"); + expect(modelsInCollection2).toHaveLength(3); + }); + it("displays the Our Analytics collection if it has a model", async () => { + renderBrowseModels(23); + const modelsInOurAnalytics = await screen.findAllByLabelText( + "Our analytics", + ); + expect(modelsInOurAnalytics).toHaveLength(2); + }); + it("displays last edited information about models", async () => { + jest.useFakeTimers().setSystemTime(new Date("2024-12-15T12:00:00.000Z")); + + renderBrowseModels(12); + const howLongAgo = /\d+(min|h|d|mo|yr)/; + const findWhenModelWasEdited = async (modelName: string) => + ( + await within(await screen.findByLabelText(modelName)).findByText( + howLongAgo, + ) + )?.textContent?.match(howLongAgo)?.[0]; + + expect(await findWhenModelWasEdited("Model 0")).toBe("1min"); + expect(await findWhenModelWasEdited("Model 1")).toBe("1min"); + expect(await findWhenModelWasEdited("Model 2")).toBe("1min"); + expect(await findWhenModelWasEdited("Model 3")).toBe("10min"); + expect(await findWhenModelWasEdited("Model 4")).toBe("1h"); + expect(await findWhenModelWasEdited("Model 5")).toBe("14h"); + expect(await findWhenModelWasEdited("Model 6")).toBe("1d"); + expect(await findWhenModelWasEdited("Model 7")).toBe("5d"); + expect(await findWhenModelWasEdited("Model 8")).toBe("1mo"); + expect(await findWhenModelWasEdited("Model 9")).toBe("10mo"); + expect(await findWhenModelWasEdited("Model 10")).toBe("1yr"); + expect(await findWhenModelWasEdited("Model 11")).toBe("5yr"); + + jest.useRealTimers(); + }); + it("has a function that groups models by collection, sorting the collections alphabetically when English is the locale", () => { + const groupedModels = groupModels(mockModels, "en-US"); + expect(groupedModels[0][0].collection.name).toEqual("Alpha"); + expect(groupedModels[0]).toHaveLength(3); + expect(groupedModels[1][0].collection.name).toEqual("Ångström"); + expect(groupedModels[1]).toHaveLength(3); + expect(groupedModels[2][0].collection.name).toEqual("Beta"); + expect(groupedModels[2]).toHaveLength(3); + expect(groupedModels[3][0].collection.name).toEqual("Charlie"); + expect(groupedModels[3]).toHaveLength(3); + expect(groupedModels[4][0].collection.name).toEqual("Delta"); + expect(groupedModels[4]).toHaveLength(3); + expect(groupedModels[5][0].collection.name).toEqual("Our analytics"); + expect(groupedModels[5]).toHaveLength(2); + expect(groupedModels[6][0].collection.name).toEqual("Özgür"); + expect(groupedModels[6]).toHaveLength(3); + expect(groupedModels[7][0].collection.name).toEqual("Zulu"); + expect(groupedModels[7]).toHaveLength(3); + }); + + it("has a function that groups models by collection, sorting the collections alphabetically when Swedish is the locale", () => { + const groupedModels = groupModels(mockModels, "sv-SV"); + expect(groupedModels[0][0].collection.name).toEqual("Alpha"); + expect(groupedModels[0]).toHaveLength(3); + expect(groupedModels[1][0].collection.name).toEqual("Beta"); + expect(groupedModels[1]).toHaveLength(3); + expect(groupedModels[2][0].collection.name).toEqual("Charlie"); + expect(groupedModels[2]).toHaveLength(3); + expect(groupedModels[3][0].collection.name).toEqual("Delta"); + expect(groupedModels[3]).toHaveLength(3); + expect(groupedModels[4][0].collection.name).toEqual("Our analytics"); + expect(groupedModels[4]).toHaveLength(2); + expect(groupedModels[5][0].collection.name).toEqual("Zulu"); + expect(groupedModels[5]).toHaveLength(3); + expect(groupedModels[6][0].collection.name).toEqual("Ångström"); + expect(groupedModels[6]).toHaveLength(3); + expect(groupedModels[7][0].collection.name).toEqual("Özgür"); + expect(groupedModels[7]).toHaveLength(3); + }); +}); diff --git a/frontend/src/metabase/browse/components/LastEdited.tsx b/frontend/src/metabase/browse/components/LastEdited.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3ba973bcadcad53a4d041334f52be831f6a3c1b3 --- /dev/null +++ b/frontend/src/metabase/browse/components/LastEdited.tsx @@ -0,0 +1,80 @@ +import _ from "underscore"; +import { c, t } from "ttag"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import updateLocale from "dayjs/plugin/updateLocale"; + +import { Text, Tooltip } from "metabase/ui"; +import { formatDateTimeWithUnit } from "metabase/lib/formatting"; + +dayjs.extend(updateLocale); +dayjs.extend(relativeTime); + +const timeFormattingRules: Record<string, unknown> = { + m: t`${1}min`, + mm: t`${"%d"}min`, + h: t`${1}h`, + hh: t`${"%d"}h`, + d: t`${1}d`, + dd: t`${"%d"}d`, + M: t`${1}mo`, + MM: t`${"%d"}mo`, + y: t`${1}yr`, + yy: t`${"%d"}yr`, + // Display any number of seconds as "1min" + s: () => t`${1}min`, + ss: () => t`${1}min`, + // Don't use "ago" + past: "%s", + // For the edge case where a model's last-edit date is somehow in the future + future: t`${"%s"} from now`, +}; + +const getTimePassedSince = (timestamp: string) => { + const date = dayjs(timestamp); + if (timestamp && date.isValid()) { + const locale = dayjs.locale(); + const cachedRules = dayjs.Ls[locale].relativeTime; + dayjs.updateLocale(locale, { relativeTime: timeFormattingRules }); + const timePassed = date.fromNow(); + dayjs.updateLocale(locale, { relativeTime: cachedRules }); + return timePassed; + } else { + return t`(invalid date)`; + } +}; + +export const LastEdited = ({ + editorFullName, + timestamp, +}: { + editorFullName: string | null; + timestamp: string; +}) => { + const timePassed = getTimePassedSince(timestamp); + const timeLabel = timestamp ? timePassed : ""; + const formattedDate = formatDateTimeWithUnit(timestamp, "day", {}); + const time = ( + <time key="time" dateTime={timestamp}> + {formattedDate} + </time> + ); + + const tooltipLabel = c( + "{0} is the full name (or if this is unavailable, the email address) of the last person who edited a model. {1} is a date", + ).jt`Last edited by ${editorFullName}${(<br key="br" />)}${time}`; + + return ( + <Tooltip label={tooltipLabel} withArrow disabled={!timeLabel}> + <Text role="note" size="small"> + {editorFullName} + {editorFullName && timePassed && ( + <Text span px=".33rem" color="text-light"> + • + </Text> + )} + {timePassed} + </Text> + </Tooltip> + ); +}; diff --git a/frontend/src/metabase/browse/containers/SchemaBrowser.jsx b/frontend/src/metabase/browse/components/SchemaBrowser.jsx similarity index 73% rename from frontend/src/metabase/browse/containers/SchemaBrowser.jsx rename to frontend/src/metabase/browse/components/SchemaBrowser.jsx index acc682bcfb9fd147eed7d3e519ce3619fdb660c5..b19a3426bc96ce0b86a2e24d8939dd4594679bdc 100644 --- a/frontend/src/metabase/browse/containers/SchemaBrowser.jsx +++ b/frontend/src/metabase/browse/components/SchemaBrowser.jsx @@ -12,15 +12,20 @@ import TableBrowser from "metabase/browse/containers/TableBrowser"; import * as Urls from "metabase/lib/urls"; import { color } from "metabase/lib/colors"; -import { BrowseHeader } from "metabase/browse/components/BrowseHeader"; -import { SchemaGridItem, SchemaLink } from "./SchemaBrowser.styled"; +import BrowserCrumbs from "metabase/components/BrowserCrumbs"; +import { + SchemaBrowserContainer, + SchemaGridItem, + SchemaLink, +} from "./SchemaBrowser.styled"; +import { BrowseHeaderContent } from "./BrowseHeader.styled"; function SchemaBrowser(props) { const { schemas, params } = props; const { slug } = params; const dbId = Urls.extractEntityId(slug); return ( - <div> + <SchemaBrowserContainer> {schemas.length === 1 ? ( <TableBrowser {...props} @@ -30,13 +35,15 @@ function SchemaBrowser(props) { showSchemaInHeader={false} /> ) : ( - <div> - <BrowseHeader - crumbs={[ - { title: t`Our data`, to: "browse" }, - { title: <Database.Name id={dbId} /> }, - ]} - /> + <> + <BrowseHeaderContent> + <BrowserCrumbs + crumbs={[ + { title: t`Databases`, to: "/browse/databases" }, + { title: <Database.Name id={dbId} /> }, + ]} + /> + </BrowseHeaderContent> {schemas.length === 0 ? ( <h2 className="full text-centered text-medium">{t`This database doesn't have any tables.`}</h2> ) : ( @@ -44,7 +51,7 @@ function SchemaBrowser(props) { {schemas.map(schema => ( <SchemaGridItem key={schema.id}> <SchemaLink - to={`/browse/${dbId}/schema/${encodeURIComponent( + to={`/browse/databases/${dbId}/schema/${encodeURIComponent( schema.name, )}`} > @@ -61,9 +68,9 @@ function SchemaBrowser(props) { ))} </Grid> )} - </div> + </> )} - </div> + </SchemaBrowserContainer> ); } diff --git a/frontend/src/metabase/browse/containers/SchemaBrowser.styled.tsx b/frontend/src/metabase/browse/components/SchemaBrowser.styled.tsx similarity index 89% rename from frontend/src/metabase/browse/containers/SchemaBrowser.styled.tsx rename to frontend/src/metabase/browse/components/SchemaBrowser.styled.tsx index 91571057da6d488b755fae68c7dbd5ba1daa76ec..022f0091beba1d3a89541df5fbd830155c4a84b6 100644 --- a/frontend/src/metabase/browse/containers/SchemaBrowser.styled.tsx +++ b/frontend/src/metabase/browse/components/SchemaBrowser.styled.tsx @@ -27,3 +27,7 @@ export const SchemaLink = styled(Link)` color: ${color("accent2")}; } `; + +export const SchemaBrowserContainer = styled.div` + width: 100%; +`; diff --git a/frontend/src/metabase/browse/components/TableBrowser/TableBrowser.jsx b/frontend/src/metabase/browse/components/TableBrowser/TableBrowser.jsx index 9a90d0ae07abe5fe2027c13c1f917aa2f75938b5..7c5a0b7703129f9e0cff0154afd99aa5e3af46e7 100644 --- a/frontend/src/metabase/browse/components/TableBrowser/TableBrowser.jsx +++ b/frontend/src/metabase/browse/components/TableBrowser/TableBrowser.jsx @@ -7,16 +7,17 @@ import { isSyncInProgress } from "metabase/lib/syncing"; import Database from "metabase/entities/databases"; import EntityItem from "metabase/components/EntityItem"; import { Icon } from "metabase/ui"; -import { Grid } from "metabase/components/Grid"; +import BrowserCrumbs from "metabase/components/BrowserCrumbs"; import { isVirtualCardId, SAVED_QUESTIONS_VIRTUAL_DB_ID, } from "metabase-lib/metadata/utils/saved-questions"; -import { BrowseHeader } from "../BrowseHeader"; +import { BrowseHeaderContent } from "../BrowseHeader.styled"; import { TableActionLink, TableCard, + TableGrid, TableGridItem, TableLink, } from "./TableBrowser.styled"; @@ -43,15 +44,17 @@ const TableBrowser = ({ showSchemaInHeader = true, }) => { return ( - <div> - <BrowseHeader - crumbs={[ - { title: t`Our data`, to: "/browse" }, - getDatabaseCrumbs(dbId), - showSchemaInHeader && { title: schemaName }, - ]} - /> - <Grid> + <> + <BrowseHeaderContent> + <BrowserCrumbs + crumbs={[ + { title: t`Databases`, to: "/browse/databases" }, + getDatabaseCrumbs(dbId), + showSchemaInHeader && { title: schemaName }, + ]} + /> + </BrowseHeaderContent> + <TableGrid> {tables.map(table => ( <TableGridItem key={table.id}> <TableCard hoverable={!isSyncInProgress(table)}> @@ -70,8 +73,8 @@ const TableBrowser = ({ </TableCard> </TableGridItem> ))} - </Grid> - </div> + </TableGrid> + </> ); }; diff --git a/frontend/src/metabase/browse/components/TableBrowser/TableBrowser.styled.tsx b/frontend/src/metabase/browse/components/TableBrowser/TableBrowser.styled.tsx index 5116e7ab30e7ca5801131438fa487fa304484f07..261d632392a819b7ac062f578af41c94ba1362ab 100644 --- a/frontend/src/metabase/browse/components/TableBrowser/TableBrowser.styled.tsx +++ b/frontend/src/metabase/browse/components/TableBrowser/TableBrowser.styled.tsx @@ -6,7 +6,12 @@ import { } from "metabase/styled-components/theme"; import Card from "metabase/components/Card"; import Link from "metabase/core/components/Link"; -import { GridItem } from "metabase/components/Grid"; +import { Grid, GridItem } from "metabase/components/Grid"; + +export const TableGrid = styled(Grid)` + width: 100%; + flex: 1; +`; export const TableGridItem = styled(GridItem)` width: 100%; diff --git a/frontend/src/metabase/browse/containers/DatabaseBrowser.jsx b/frontend/src/metabase/browse/containers/DatabaseBrowser.jsx deleted file mode 100644 index 420a6c1d45b3977c254bf25ba0b9345d02ff3a2d..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/browse/containers/DatabaseBrowser.jsx +++ /dev/null @@ -1,43 +0,0 @@ -/* eslint-disable react/prop-types */ -import { t } from "ttag"; - -import Database from "metabase/entities/databases"; - -import { color } from "metabase/lib/colors"; -import * as Urls from "metabase/lib/urls"; - -import { Grid } from "metabase/components/Grid"; -import { Icon } from "metabase/ui"; -import Link from "metabase/core/components/Link"; - -import { BrowseHeader } from "metabase/browse/components/BrowseHeader"; - -import { DatabaseCard, DatabaseGridItem } from "./DatabaseBrowser.styled"; - -function DatabaseBrowser({ databases }) { - return ( - <div data-testid="database-browser"> - <BrowseHeader crumbs={[{ title: t`Our data` }]} /> - - <Grid> - {databases.map(database => ( - <DatabaseGridItem key={database.id}> - <Link to={Urls.browseDatabase(database)} display="block"> - <DatabaseCard> - <Icon - name="database" - color={color("accent2")} - className="mb3" - size={32} - /> - <h3 className="text-wrap">{database.name}</h3> - </DatabaseCard> - </Link> - </DatabaseGridItem> - ))} - </Grid> - </div> - ); -} - -export default Database.loadList()(DatabaseBrowser); diff --git a/frontend/src/metabase/browse/utils.ts b/frontend/src/metabase/browse/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..5489acf2d83adca52638d04ee65a24f6ae40ad03 --- /dev/null +++ b/frontend/src/metabase/browse/utils.ts @@ -0,0 +1,41 @@ +import _ from "underscore"; +import { t } from "ttag"; +import { + canonicalCollectionId, + coerceCollectionId, + isRootCollection, +} from "metabase/collections/utils"; +import type { CollectionEssentials, SearchResult } from "metabase-types/api"; + +export const getCollectionName = (collection: CollectionEssentials) => { + if (isRootCollection(collection)) { + return t`Our analytics`; + } + return collection?.name || t`Untitled collection`; +}; + +/** The root collection's id might be null or 'root' in different contexts. + * Use 'root' instead of null, for the sake of sorting */ +export const getCollectionIdForSorting = (collection: CollectionEssentials) => { + return coerceCollectionId(canonicalCollectionId(collection.id)); +}; + +/** Group models by collection */ +export const groupModels = ( + models: SearchResult[], + locale: string | undefined, +) => { + const groupedModels = _.groupBy(models, model => + getCollectionIdForSorting(model.collection), + ); + const groupsOfModels: SearchResult[][] = Object.values(groupedModels); + const sortFunction = (a: SearchResult[], b: SearchResult[]) => { + const collection1 = a[0].collection; + const collection2 = b[0].collection; + const name1 = getCollectionName(collection1); + const name2 = getCollectionName(collection2); + return name1.localeCompare(name2, locale); + }; + groupsOfModels.sort(sortFunction); + return groupsOfModels; +}; diff --git a/frontend/src/metabase/core/components/Ellipsified/Ellipsified.tsx b/frontend/src/metabase/core/components/Ellipsified/Ellipsified.tsx index 17aceb10f22ace237d5e4d022523645fe0944ac5..dfda1f2e7adf183b2110836f3f09099c305f34fd 100644 --- a/frontend/src/metabase/core/components/Ellipsified/Ellipsified.tsx +++ b/frontend/src/metabase/core/components/Ellipsified/Ellipsified.tsx @@ -17,6 +17,7 @@ interface EllipsifiedProps { lines?: number; placement?: Placement; "data-testid"?: string; + id?: string; } export const Ellipsified = ({ @@ -30,6 +31,7 @@ export const Ellipsified = ({ lines, placement = "top", "data-testid": dataTestId, + id, }: EllipsifiedProps) => { const canSkipTooltipRendering = !showTooltip && !alwaysShowTooltip; const { isTruncated, ref } = useIsTruncated<HTMLDivElement>({ @@ -49,6 +51,7 @@ export const Ellipsified = ({ lines={lines} style={style} data-testid={dataTestId} + id={id} > {children} </EllipsifiedRoot> diff --git a/frontend/src/metabase/lib/urls/browse.ts b/frontend/src/metabase/lib/urls/browse.ts index 664e4d35dbf95dbd86ecce404a32f3a37de3195d..b5b6b4654b3a29e8f916339ea1a335749cbb48c2 100644 --- a/frontend/src/metabase/lib/urls/browse.ts +++ b/frontend/src/metabase/lib/urls/browse.ts @@ -12,7 +12,7 @@ export function browseDatabase(database: Database) { ? "Saved Questions" : database.name; - return appendSlug(`/browse/${database.id}`, slugg(name)); + return appendSlug(`/browse/databases/${database.id}`, slugg(name)); } export function browseSchema(table: { @@ -21,12 +21,12 @@ export function browseSchema(table: { db?: Pick<Database, "id">; }) { const databaseId = table.db?.id || table.db_id; - return `/browse/${databaseId}/schema/${encodeURIComponent( + return `/browse/databases/${databaseId}/schema/${encodeURIComponent( table.schema_name ?? "", )}`; } export function browseTable(table: Table) { const databaseId = table.db?.id || table.db_id; - return `/browse/${databaseId}/schema/${table.schema_name}`; + return `/browse/databases/${databaseId}/schema/${table.schema_name}`; } diff --git a/frontend/src/metabase/lib/urls/collections.ts b/frontend/src/metabase/lib/urls/collections.ts index afbd56dcb713e4bac921ba04dc9e7d2e89b504a0..8bcc80bfbd77b2f2eeaf2ba010dcaa6a27f1bda7 100644 --- a/frontend/src/metabase/lib/urls/collections.ts +++ b/frontend/src/metabase/lib/urls/collections.ts @@ -32,7 +32,7 @@ function slugifyPersonalCollection(collection: Collection) { return slug; } -export function collection(collection?: Collection) { +export function collection(collection?: Pick<Collection, "id" | "name">) { const isSystemCollection = !collection || collection.id === null || typeof collection.id === "string"; diff --git a/frontend/src/metabase/nav/containers/MainNavbar/MainNavbar.unit.spec.tsx b/frontend/src/metabase/nav/containers/MainNavbar/MainNavbar.unit.spec.tsx index 6cf6da2fb7154bca15e09fdb788849e714ce8c63..68196dc86a1f44ff134f35fad3b45e415c368b37 100644 --- a/frontend/src/metabase/nav/containers/MainNavbar/MainNavbar.unit.spec.tsx +++ b/frontend/src/metabase/nav/containers/MainNavbar/MainNavbar.unit.spec.tsx @@ -214,7 +214,7 @@ describe("nav > containers > MainNavbar", () => { }); it("should be highlighted if child route selected", async () => { - await setup({ pathname: "/browse/1" }); + await setup({ pathname: "/browse/databases/1" }); const link = screen.getByRole("listitem", { name: /Browse data/i }); expect(link).toHaveAttribute("aria-selected", "true"); }); diff --git a/frontend/src/metabase/redux/app.ts b/frontend/src/metabase/redux/app.ts index 2ce9881a586de2f0ba0046c5e852d1ea3d0dc78a..a5f6724dc549492a7f5a0f282c1cbf42ce55d30c 100644 --- a/frontend/src/metabase/redux/app.ts +++ b/frontend/src/metabase/redux/app.ts @@ -58,7 +58,9 @@ const errorPage = handleActions( null, ); -const PATH_WITH_COLLAPSED_NAVBAR = /\/(model|question|dashboard|metabot).*/; +// regexr.com/7r89i +// A word boundary is added to /model so it doesn't match /browse/models +const PATH_WITH_COLLAPSED_NAVBAR = /\/(model\b|question|dashboard|metabot).*/; export function isNavbarOpenForPathname(pathname: string, prevState: boolean) { return ( diff --git a/frontend/src/metabase/routes.jsx b/frontend/src/metabase/routes.jsx index 18d3caff4aa22341236d52a27dae957672f7f822..2ddae3dcbe6ac5397a729601553c06332a87e3e0 100644 --- a/frontend/src/metabase/routes.jsx +++ b/frontend/src/metabase/routes.jsx @@ -23,9 +23,8 @@ import { DashboardAppConnected } from "metabase/dashboard/containers/DashboardAp import { AutomaticDashboardAppConnected } from "metabase/dashboard/containers/AutomaticDashboardApp"; /* Browse data */ -import BrowseApp from "metabase/browse/components/BrowseApp"; -import DatabaseBrowser from "metabase/browse/containers/DatabaseBrowser"; -import SchemaBrowser from "metabase/browse/containers/SchemaBrowser"; +import { BrowseApp } from "metabase/browse/components/BrowseApp"; +import SchemaBrowser from "metabase/browse/components/SchemaBrowser"; import TableBrowser from "metabase/browse/containers/TableBrowser"; import QueryBuilder from "metabase/query_builder/containers/QueryBuilder"; @@ -219,10 +218,36 @@ export const getRoutes = store => { <Route path="metabot" component={QueryBuilder} /> </Route> - <Route path="browse" component={BrowseApp}> - <IndexRoute component={DatabaseBrowser} /> - <Route path=":slug" component={SchemaBrowser} /> - <Route path=":dbId/schema/:schemaName" component={TableBrowser} /> + <Route path="browse"> + <IndexRedirect to="/browse/models" /> + <Route path="models" component={() => <BrowseApp tab="models" />} /> + <Route + path="databases" + component={() => <BrowseApp tab="databases" />} + /> + <Route + path="databases/:slug" + component={({ params }) => ( + <BrowseApp tab="databases"> + <SchemaBrowser params={params} /> + </BrowseApp> + )} + /> + <Route + path="databases/:dbId/schema/:schemaName" + component={({ params }) => ( + <BrowseApp tab="databases"> + <TableBrowser params={params} /> + </BrowseApp> + )} + /> + + {/* These two Redirects support legacy paths in v48 and earlier */} + <Redirect from=":dbId-:slug" to="databases/:dbId-:slug" /> + <Redirect + from=":dbId/schema/:schemaName" + to="databases/:dbId/schema/:schemaName" + /> </Route> {/* INDIVIDUAL DASHBOARDS */} diff --git a/frontend/src/metabase/search/components/InfoText/InfoText.unit.spec.tsx b/frontend/src/metabase/search/components/InfoText/InfoText.unit.spec.tsx index 341133f7859bb74bb9c4f720d546818f336f8b47..5d851a0b2811f5c842811556b58b879baefdbba5 100644 --- a/frontend/src/metabase/search/components/InfoText/InfoText.unit.spec.tsx +++ b/frontend/src/metabase/search/components/InfoText/InfoText.unit.spec.tsx @@ -198,7 +198,7 @@ describe("InfoText", () => { expect(databaseLink).toBeInTheDocument(); expect(databaseLink).toHaveAttribute( "href", - `/browse/${MOCK_DATABASE.id}-database-name`, + `/browse/databases/${MOCK_DATABASE.id}-database-name`, ); expect(screen.getByTestId("revision-history-button")).toHaveTextContent( diff --git a/frontend/test/metabase/lib/urls.unit.spec.js b/frontend/test/metabase/lib/urls.unit.spec.js index 408c3c5e74229418851ce05f84c882a7b5c30f0b..43b2f8983e4c0c5b341ed942d391daec5e1dc726 100644 --- a/frontend/test/metabase/lib/urls.unit.spec.js +++ b/frontend/test/metabase/lib/urls.unit.spec.js @@ -364,8 +364,8 @@ describe("urls", () => { { path: "dashboard/1", expected: false }, { path: "/dashboard/1", expected: false }, { path: "/dashboard/12-orders", expected: false }, - { path: "/browse/1", expected: false }, - { path: "/browse/12-shop", expected: false }, + { path: "/browse/databases/1", expected: false }, + { path: "/browse/databases/12-shop", expected: false }, { path: "/question/1-orders", expected: false }, ]; @@ -458,7 +458,7 @@ describe("urls", () => { it(`should handle ${caseName} correctly for database browse URLs`, () => { expect(browseDatabase(entity)).toBe( - expectedUrl("/browse/1", expectedString), + expectedUrl("/browse/databases/1", expectedString), ); });