Skip to content
Snippets Groups Projects
Unverified Commit 9166c58e authored by Raphael Krut-Landau's avatar Raphael Krut-Landau Committed by GitHub
Browse files

Basic version of Models in Browse Data (#37707)

* Add Models and Databases tabs
* Models are organized by collection
* Exclude items in personal collections
* Simplify BrowseHeader
parent 6657c3cb
No related branches found
No related tags found
No related merge requests found
Showing
with 426 additions and 88 deletions
......@@ -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");
});
......
......@@ -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
......
......@@ -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");
});
......
......@@ -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");
......
......@@ -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" });
});
});
......@@ -291,6 +291,7 @@ describe("scenarios > setup", () => {
});
cy.visit("/browse");
cy.findByRole("tab", { name: "Databases" }).click();
cy.findByTestId("database-browser").findByText(dbName);
});
});
......
......@@ -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);
......
......@@ -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");
......
......@@ -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
......
......@@ -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
......
......@@ -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;
......
/* eslint-disable react/prop-types */
import { BrowseAppRoot } from "./BrowseApp.styled";
export default function BrowseApp({ children }) {
return <BrowseAppRoot data-testid="browse-data">{children}</BrowseAppRoot>;
}
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%;
`;
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>
);
};
......@@ -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")};
......
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>
}
/>
);
};
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();
});
});
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")};
......
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>
);
};
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;
`;
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment