diff --git a/e2e/test/scenarios/metrics/browse.cy.spec.ts b/e2e/test/scenarios/metrics/browse.cy.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..c314987de52164d9f000507e2b9fa12a87738323 --- /dev/null +++ b/e2e/test/scenarios/metrics/browse.cy.spec.ts @@ -0,0 +1,233 @@ +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; +import { + FIRST_COLLECTION_ID, + ORDERS_MODEL_ID, +} from "e2e/support/cypress_sample_instance_data"; +import { + type StructuredQuestionDetails, + assertIsEllipsified, + createQuestion, + main, + navigationSidebar, + restore, +} from "e2e/support/helpers"; + +const { ORDERS_ID, ORDERS, PRODUCTS_ID } = SAMPLE_DATABASE; + +type StructuredQuestionDetailsWithName = StructuredQuestionDetails & { + name: string; +}; + +const ORDERS_SCALAR_METRIC: StructuredQuestionDetailsWithName = { + name: "Count of orders", + type: "metric", + description: "A metric", + query: { + "source-table": ORDERS_ID, + aggregation: [["count"]], + }, + display: "scalar", +}; + +const ORDERS_SCALAR_MODEL_METRIC: StructuredQuestionDetailsWithName = { + name: "Orders model metric", + type: "metric", + description: "A metric", + query: { + "source-table": `card__${ORDERS_MODEL_ID}`, + aggregation: [["count"]], + }, + display: "scalar", + collection_id: FIRST_COLLECTION_ID as number, +}; + +const ORDERS_TIMESERIES_METRIC: StructuredQuestionDetailsWithName = { + name: "Count of orders over time", + type: "metric", + description: "A metric", + query: { + "source-table": ORDERS_ID, + aggregation: [["count"]], + breakout: [ + [ + "field", + ORDERS.CREATED_AT, + { "base-type": "type/DateTime", "temporal-unit": "month" }, + ], + ], + }, + display: "line", +}; + +const PRODUCTS_SCALAR_METRIC: StructuredQuestionDetailsWithName = { + name: "Count of products", + type: "metric", + description: "A metric", + query: { + "source-table": PRODUCTS_ID, + aggregation: [["count"]], + }, + display: "scalar", +}; + +const ALL_METRICS = [ + ORDERS_SCALAR_METRIC, + ORDERS_SCALAR_MODEL_METRIC, + ORDERS_TIMESERIES_METRIC, + PRODUCTS_SCALAR_METRIC, +]; + +function createMetrics( + metrics: StructuredQuestionDetailsWithName[] = ALL_METRICS, +) { + metrics.forEach(metric => createQuestion(metric)); +} + +function metricsTable() { + return cy.findByLabelText("Table of metrics").should("be.visible"); +} + +function findMetric(name: string) { + return metricsTable().findByText(name).should("be.visible"); +} + +function getMetricsTableItem(index: number) { + return metricsTable().findAllByTestId("metric-name").eq(index); +} + +const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +describe("scenarios > browse > metrics", () => { + beforeEach(() => { + restore(); + cy.signInAsNormalUser(); + cy.intercept("POST", "/api/dataset").as("dataset"); + }); + + describe("no metrics", () => { + it("should hide the browse metrics link in the sidebar", () => { + cy.visit("/"); + navigationSidebar().findByText("Metrics").should("not.exist"); + }); + + it("should show the empty metrics page", () => { + cy.visit("/browse/metrics"); + main() + .findByText( + "Metrics help you summarize and analyze your data effortlessly.", + ) + .should("be.visible"); + }); + }); + + describe("multiple metrics", () => { + it("can browse metrics", () => { + createMetrics(ALL_METRICS); + cy.visit("/browse/metrics"); + navigationSidebar().findByText("Metrics").should("be.visible"); + + ALL_METRICS.forEach(metric => { + findMetric(metric.name).should("be.visible"); + }); + }); + + it("should navigate to the metric when clicking a metric title", () => { + createMetrics([ORDERS_SCALAR_METRIC]); + cy.visit("/browse/metrics"); + findMetric(ORDERS_SCALAR_METRIC.name).should("be.visible").click(); + cy.location("pathname").should("match", /^\/metric\/\d+-.*$/); + }); + + it("should navigate to that collection when clicking a collection title", () => { + createMetrics([ORDERS_SCALAR_METRIC]); + cy.visit("/browse/metrics"); + findMetric(ORDERS_SCALAR_METRIC.name).should("be.visible"); + + metricsTable().findByText("Our analytics").should("be.visible").click(); + + cy.location("pathname").should("eq", "/collection/root"); + }); + + it("should open the collections in a new tab when alt-clicking a metric", () => { + cy.on("window:before:load", win => { + // prevent Cypress opening in a new window/tab and spy on this method + cy.stub(win, "open").as("open"); + }); + + createMetrics([ORDERS_SCALAR_METRIC]); + cy.visit("/browse/metrics"); + + const macOSX = Cypress.platform === "darwin"; + findMetric(ORDERS_SCALAR_METRIC.name).should("be.visible").click({ + metaKey: macOSX, + ctrlKey: !macOSX, + }); + + cy.get("@open").should("have.been.calledOnce"); + cy.get("@open").should( + "have.been.calledWithMatch", + /^\/question\/\d+-.*$/, + "_blank", + ); + + // the page did not navigate on this page + cy.location("pathname").should("eq", "/browse/metrics"); + }); + + it("should render truncated markdown in the table", () => { + const description = + "This is a _very_ **long description** that should be truncated"; + + createMetrics([ + { + ...ORDERS_SCALAR_METRIC, + description, + }, + ]); + + cy.visit("/browse/metrics"); + + metricsTable() + .findByText(/This is a/) + .should("be.visible") + .then(el => assertIsEllipsified(el[0])); + + metricsTable() + .findByText(/This is a/) + .realHover(); + + cy.findAllByText(/should be truncated/).should("have.length", 2); + }); + + it("should be possible to sort the metrics", () => { + createMetrics( + ALL_METRICS.map((metric, index) => ({ + ...metric, + name: `Metric ${alphabet[index]}`, + description: `Description ${alphabet[25 - index]}`, + })), + ); + + cy.visit("/browse/metrics"); + + getMetricsTableItem(0).should("contain", "Metric A"); + getMetricsTableItem(1).should("contain", "Metric B"); + getMetricsTableItem(2).should("contain", "Metric C"); + getMetricsTableItem(3).should("contain", "Metric D"); + + metricsTable().findByText("Description").click(); + + getMetricsTableItem(0).should("contain", "Metric D"); + getMetricsTableItem(1).should("contain", "Metric C"); + getMetricsTableItem(2).should("contain", "Metric B"); + getMetricsTableItem(3).should("contain", "Metric A"); + + metricsTable().findByText("Collection").click(); + + getMetricsTableItem(0).should("contain", "Metric B"); + getMetricsTableItem(1).should("contain", "Metric A"); + getMetricsTableItem(2).should("contain", "Metric C"); + getMetricsTableItem(3).should("contain", "Metric D"); + }); + }); +}); diff --git a/e2e/test/scenarios/onboarding/home/browse.cy.spec.ts b/e2e/test/scenarios/onboarding/home/browse.cy.spec.ts index fbc476ed4acf15a474fbb604d33150fff84054d0..3f0be43433c3505991b190710454d78fe9c9f371 100644 --- a/e2e/test/scenarios/onboarding/home/browse.cy.spec.ts +++ b/e2e/test/scenarios/onboarding/home/browse.cy.spec.ts @@ -36,6 +36,26 @@ describeWithSnowplow("scenarios > browse", () => { }); }); + it("can browse to a model in a new tab by meta-clicking", () => { + cy.on("window:before:load", win => { + // prevent Cypress opening in a new window/tab and spy on this method + cy.stub(win, "open").as("open"); + }); + cy.visit("/browse/models"); + const macOSX = Cypress.platform === "darwin"; + cy.findByRole("heading", { name: "Orders Model" }).click({ + metaKey: macOSX, + ctrlKey: !macOSX, + }); + + cy.get("@open").should("have.been.calledOnce"); + cy.get("@open").should( + "have.been.calledOnceWithExactly", + `/question/${ORDERS_MODEL_ID}-orders-model`, + "_blank", + ); + }); + it("can browse to a table in a database", () => { cy.visit("/"); browseDatabases().click(); diff --git a/frontend/src/metabase/browse/components/BrowseMetrics.tsx b/frontend/src/metabase/browse/components/BrowseMetrics.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9a3a4157bf6a7f36f481e48fd6ab126be2daa4a1 --- /dev/null +++ b/frontend/src/metabase/browse/components/BrowseMetrics.tsx @@ -0,0 +1,90 @@ +import { t } from "ttag"; + +import NoResults from "assets/img/metrics_bot.svg"; +import { useFetchMetrics } from "metabase/common/hooks/use-fetch-metrics"; +import EmptyState from "metabase/components/EmptyState"; +import { DelayedLoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper/DelayedLoadingAndErrorWrapper"; +import { Box, Flex, Group, Icon, Stack, Text, Title } from "metabase/ui"; + +import type { MetricResult } from "../types"; + +import { + BrowseContainer, + BrowseHeader, + BrowseMain, + BrowseSection, +} from "./BrowseContainer.styled"; +import { MetricsTable } from "./MetricsTable"; + +export function BrowseMetrics() { + const metricsResult = useFetchMetrics({ + filter_items_in_personal_collection: "exclude", + model_ancestors: false, + }); + const metrics = metricsResult.data?.data as MetricResult[] | undefined; + + const isEmpty = !metricsResult.isLoading && !metrics?.length; + + return ( + <BrowseContainer> + <BrowseHeader role="heading" data-testid="browse-metrics-header"> + <BrowseSection> + <Flex + w="100%" + h="2.25rem" + direction="row" + justify="space-between" + align="center" + > + <Title order={1} color="text-dark"> + <Group spacing="sm"> + <Icon + size={24} + color="var(--mb-color-icon-primary)" + name="metric" + /> + {t`Metrics`} + </Group> + </Title> + </Flex> + </BrowseSection> + </BrowseHeader> + <BrowseMain> + <BrowseSection> + <Stack mb="lg" spacing="md" w="100%"> + {isEmpty ? ( + <MetricsEmptyState /> + ) : ( + <DelayedLoadingAndErrorWrapper + error={metricsResult.error} + loading={metricsResult.isLoading} + style={{ flex: 1 }} + loader={<MetricsTable skeleton />} + > + <MetricsTable metrics={metrics} /> + </DelayedLoadingAndErrorWrapper> + )} + </Stack> + </BrowseSection> + </BrowseMain> + </BrowseContainer> + ); +} + +function MetricsEmptyState() { + return ( + <Flex align="center" justify="center" mih="70vh"> + <Box maw="25rem"> + <EmptyState + title={t`Metrics help you summarize and analyze your data effortlessly.`} + message={ + <Text mt="sm" maw="25rem"> + {t`Metrics are like pre-defined calculations: create your aggregations once, save them as metrics, and use them whenever you need to analyze your data.`} + </Text> + } + illustrationElement={<img src={NoResults} />} + /> + </Box> + </Flex> + ); +} diff --git a/frontend/src/metabase/browse/components/BrowseMetrics.unit.spec.tsx b/frontend/src/metabase/browse/components/BrowseMetrics.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0941d4c431828e98c588efe99e575f1b22efdbf5 --- /dev/null +++ b/frontend/src/metabase/browse/components/BrowseMetrics.unit.spec.tsx @@ -0,0 +1,255 @@ +import { + setupRecentViewsEndpoints, + setupSearchEndpoints, + setupSettingsEndpoints, +} from "__support__/server-mocks"; +import { renderWithProviders, screen, within } from "__support__/ui"; +import { + createMockCollection, + createMockSearchResult, +} from "metabase-types/api/mocks"; +import { createMockSetupState } from "metabase-types/store/mocks"; + +import { createMockMetricResult, createMockRecentMetric } from "../test-utils"; +import type { MetricResult, RecentMetric } from "../types"; + +import { BrowseMetrics } from "./BrowseMetrics"; + +type SetupOpts = { + metricCount?: number; + recentMetricCount?: number; +}; + +function setup({ + metricCount = Infinity, + recentMetricCount = 5, +}: SetupOpts = {}) { + const mockMetricResults = mockMetrics.map(createMockMetricResult); + const mockRecentMetrics = mockMetrics.map(metric => + createMockRecentMetric(metric as RecentMetric), + ); + + const metrics = mockMetricResults.slice(0, metricCount); + const recentMetrics = mockRecentMetrics.slice(0, recentMetricCount); + + setupSettingsEndpoints([]); + setupSearchEndpoints(metrics.map(createMockSearchResult)); + setupRecentViewsEndpoints(recentMetrics); + + return renderWithProviders(<BrowseMetrics />, { + storeInitialState: { + setup: createMockSetupState({ + locale: { name: "English", code: "en" }, + }), + }, + }); +} + +const defaultRootCollection = createMockCollection({ + id: "root", + name: "Our analytics", +}); + +const collectionAlpha = createMockCollection({ id: 99, name: "Alpha" }); +const collectionBeta = createMockCollection({ + id: 1, + name: "Beta", + effective_ancestors: [collectionAlpha], +}); +const collectionCharlie = createMockCollection({ + id: 2, + name: "Charlie", + effective_ancestors: [collectionAlpha, collectionBeta], +}); +const collectionDelta = createMockCollection({ + id: 3, + name: "Delta", + effective_ancestors: [collectionAlpha, collectionBeta, collectionCharlie], +}); +const collectionZulu = createMockCollection({ + id: 4, + name: "Zulu", + effective_ancestors: [ + collectionAlpha, + collectionBeta, + collectionCharlie, + collectionDelta, + ], +}); +const collectionAngstrom = createMockCollection({ + id: 5, + name: "Ångström", + effective_ancestors: [ + collectionAlpha, + collectionBeta, + collectionCharlie, + collectionDelta, + collectionZulu, + ], +}); +const collectionOzgur = createMockCollection({ + id: 6, + name: "Özgür", + effective_ancestors: [ + collectionAlpha, + collectionBeta, + collectionCharlie, + collectionDelta, + collectionZulu, + collectionAngstrom, + ], +}); +const collectionGrande = createMockCollection({ + id: 7, + name: "Grande", + effective_ancestors: [ + collectionAlpha, + collectionBeta, + collectionCharlie, + collectionDelta, + collectionZulu, + collectionAngstrom, + collectionOzgur, + ], +}); + +const mockMetrics: Partial<MetricResult>[] = [ + { + id: 0, + collection: collectionAlpha, + }, + { + id: 1, + collection: collectionAlpha, + }, + { + id: 2, + collection: collectionAlpha, + }, + { + id: 3, + collection: collectionBeta, + }, + { + id: 4, + collection: collectionBeta, + }, + { + id: 5, + collection: collectionBeta, + }, + { + id: 6, + collection: collectionCharlie, + }, + { + id: 7, + collection: collectionCharlie, + }, + { + id: 8, + collection: collectionCharlie, + }, + { + id: 9, + collection: collectionDelta, + }, + { + id: 10, + collection: collectionDelta, + }, + { + id: 11, + collection: collectionDelta, + }, + { + id: 12, + collection: collectionZulu, + }, + { + id: 13, + collection: collectionZulu, + }, + { + id: 14, + collection: collectionZulu, + }, + { + id: 15, + collection: collectionAngstrom, + }, + { + id: 16, + collection: collectionAngstrom, + }, + { + id: 17, + collection: collectionAngstrom, + }, + { + id: 18, + collection: collectionOzgur, + }, + { + id: 19, + collection: collectionOzgur, + }, + { + id: 20, + collection: collectionOzgur, + }, + { + id: 21, + collection: defaultRootCollection, // Our analytics + }, + { + id: 22, + collection: defaultRootCollection, // Our analytics + }, + ...new Array(100).fill(null).map((_, i) => ({ + id: i + 300, + collection: collectionGrande, + })), +].map((partialMetric: Partial<MetricResult>) => ({ + name: `Metric ${partialMetric.id}`, + collection: defaultRootCollection, + last_editor_common_name: "Bobby", + last_edited_at: "2000-01-01T00:00:00.000Z", + ...partialMetric, +})); + +describe("BrowseMetrics", () => { + it("displays an empty message when no metrics are found", async () => { + setup({ metricCount: 0 }); + expect( + await screen.findByText( + "Metrics help you summarize and analyze your data effortlessly.", + ), + ).toBeInTheDocument(); + }); + + it("displays the Our Analytics collection if it has a metric", async () => { + setup({ metricCount: 25 }); + const table = await screen.findByRole("table", { + name: /Table of metrics/, + }); + expect(table).toBeInTheDocument(); + expect( + within(table).getAllByTestId("path-for-collection: Our analytics"), + ).toHaveLength(2); + expect(within(table).getByText("Metric 20")).toBeInTheDocument(); + expect(within(table).getByText("Metric 21")).toBeInTheDocument(); + expect(within(table).getByText("Metric 22")).toBeInTheDocument(); + }); + + it("displays collection breadcrumbs", async () => { + setup({ metricCount: 5 }); + const table = await screen.findByRole("table", { + name: /Table of metrics/, + }); + expect(within(table).getByText("Metric 1")).toBeInTheDocument(); + expect( + within(table).getAllByTestId("path-for-collection: Alpha"), + ).toHaveLength(3); + }); +}); diff --git a/frontend/src/metabase/browse/components/BrowseModels.tsx b/frontend/src/metabase/browse/components/BrowseModels.tsx index 49acf1d69c139744685927c45c37cb8635d7bccf..94e62e4c21eec54549dce340d37c35b8d9d65563 100644 --- a/frontend/src/metabase/browse/components/BrowseModels.tsx +++ b/frontend/src/metabase/browse/components/BrowseModels.tsx @@ -95,7 +95,11 @@ export const BrowseModels = () => { > <Title order={1} color="text-dark"> <Group spacing="sm"> - <Icon size={24} color={"var(--mb-color-brand)"} name="model" /> + <Icon + size={24} + color="var(--mb-color-icon-primary)" + name="model" + /> {t`Models`} </Group> </Title> @@ -135,7 +139,6 @@ export const BrowseModels = () => { recentModelsResult.isLoading || modelsResult.isLoading } style={{ flex: 1 }} - delay={0} loader={<RecentModels skeleton />} > <RecentModels models={recentModels} /> @@ -144,7 +147,6 @@ export const BrowseModels = () => { error={modelsResult.error} loading={modelsResult.isLoading} style={{ flex: 1 }} - delay={0} loader={<ModelsTable skeleton />} > <ModelsTable models={filteredModels} /> diff --git a/frontend/src/metabase/browse/components/ModelsTable.styled.tsx b/frontend/src/metabase/browse/components/BrowseTable.styled.tsx similarity index 51% rename from frontend/src/metabase/browse/components/ModelsTable.styled.tsx rename to frontend/src/metabase/browse/components/BrowseTable.styled.tsx index 2a6f451d48761b58d09e5d2c2fa2cfd9f9f0c154..01fa856e0e24c385f2a2f7fcfb55beb690d501e8 100644 --- a/frontend/src/metabase/browse/components/ModelsTable.styled.tsx +++ b/frontend/src/metabase/browse/components/BrowseTable.styled.tsx @@ -6,34 +6,45 @@ import { hideResponsively, } from "metabase/components/ItemsTable/BaseItemsTable.styled"; import type { ResponsiveProps } from "metabase/components/ItemsTable/utils"; +import Link from "metabase/core/components/Link"; import { breakpoints } from "metabase/ui/theme"; -export const ModelTableRow = styled.tr<{ skeleton?: boolean }>` +export const TableRow = styled.tr<{ skeleton?: boolean }>` :focus { outline: 2px solid var(--mb-color-focus); } ${props => props.skeleton ? ` - :hover { background-color: unset ! important; } - td { cursor: unset ! important; } - ` + :hover { background-color: unset ! important; } + td { cursor: unset ! important; } + ` : `cursor: pointer;`} `; -export const ModelNameLink = styled(ItemLink)` +export const NameLink = styled(ItemLink)` padding-inline-start: 0.6rem; padding-block: 0.5rem; `; -export const ModelCell = styled.td<ResponsiveProps>` +export const Cell = styled.td<ResponsiveProps>` td& { padding: 0.25em 0.5rem 0.25em 0.5rem; } + + &:focus-within, + &:focus { + outline: 2px solid var(--mb-color-focus); + + a { + outline: none; + } + } + ${hideResponsively} `; -export const ModelNameColumn = styled(TableColumn)` +export const NameColumn = styled(TableColumn)` width: 356px; @container ${props => props.containerName} (max-width: ${breakpoints.md}) { @@ -44,3 +55,19 @@ export const ModelNameColumn = styled(TableColumn)` width: 200px; } `; + +export const CollectionTableCell = styled(Cell)` + td& { + padding: 0; + } +`; + +export const CollectionLink = styled(Link)` + display: block; + padding: 1em 0.5em; + box-sizing: border-box; + + &:hover { + color: var(--mb-color-icon-primary) !important; + } +`; diff --git a/frontend/src/metabase/browse/components/EllipsifiedWithMarkdownTooltip.tsx b/frontend/src/metabase/browse/components/EllipsifiedWithMarkdownTooltip.tsx deleted file mode 100644 index fe3458f6314387573bc31de0fa1568f5f7bd8525..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/browse/components/EllipsifiedWithMarkdownTooltip.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Ellipsified } from "metabase/core/components/Ellipsified"; -import Markdown from "metabase/core/components/Markdown"; - -export const EllipsifiedWithMarkdownTooltip = ({ - children, -}: { - children: string; -}) => { - return ( - <Ellipsified - tooltip={ - <Markdown disallowHeading unstyleLinks lineClamp={12}> - {children} - </Markdown> - } - > - {children} - </Ellipsified> - ); -}; diff --git a/frontend/src/metabase/browse/components/MetricsTable.tsx b/frontend/src/metabase/browse/components/MetricsTable.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7a140e3bd1b20698b7d99194ecd0df47553a6c1e --- /dev/null +++ b/frontend/src/metabase/browse/components/MetricsTable.tsx @@ -0,0 +1,295 @@ +import { type MouseEvent, useCallback, useState } from "react"; +import { push } from "react-router-redux"; +import { t } from "ttag"; + +import { getCollectionName } from "metabase/collections/utils"; +import { EllipsifiedCollectionPath } from "metabase/common/components/EllipsifiedPath/EllipsifiedCollectionPath"; +import { useLocale } from "metabase/common/hooks/use-locale/use-locale"; +import EntityItem from "metabase/components/EntityItem"; +import { SortableColumnHeader } from "metabase/components/ItemsTable/BaseItemsTable"; +import { + ItemNameCell, + MaybeItemLink, + TBody, + Table, + TableColumn, +} from "metabase/components/ItemsTable/BaseItemsTable.styled"; +import { Columns } from "metabase/components/ItemsTable/Columns"; +import type { ResponsiveProps } from "metabase/components/ItemsTable/utils"; +import { MarkdownPreview } from "metabase/core/components/MarkdownPreview"; +import { useDispatch } from "metabase/lib/redux"; +import * as Urls from "metabase/lib/urls"; +import { FixedSizeIcon, Flex, Skeleton } from "metabase/ui"; +import { Repeat } from "metabase/ui/components/feedback/Skeleton/Repeat"; +import { SortDirection, type SortingOptions } from "metabase-types/api/sorting"; + +import type { MetricResult } from "../types"; + +import { + Cell, + CollectionLink, + CollectionTableCell, + NameColumn, + TableRow, +} from "./BrowseTable.styled"; +import { getMetricDescription, sortModelOrMetric } from "./utils"; + +type MetricsTableProps = { + metrics?: MetricResult[]; + skeleton?: boolean; +}; + +const DEFAULT_SORTING_OPTIONS: SortingOptions = { + sort_column: "name", + sort_direction: SortDirection.Asc, +}; + +export const itemsTableContainerName = "ItemsTableContainer"; + +const nameProps = { + containerName: itemsTableContainerName, +}; + +const descriptionProps: ResponsiveProps = { + hideAtContainerBreakpoint: "sm", + containerName: itemsTableContainerName, +}; + +const collectionProps: ResponsiveProps = { + hideAtContainerBreakpoint: "xs", + containerName: itemsTableContainerName, +}; + +export function MetricsTable({ + skeleton = false, + metrics = [], +}: MetricsTableProps) { + const [sortingOptions, setSortingOptions] = useState<SortingOptions>( + DEFAULT_SORTING_OPTIONS, + ); + + const locale = useLocale(); + const sortedMetrics = sortModelOrMetric(metrics, sortingOptions, locale); + + const handleSortingOptionsChange = skeleton ? undefined : setSortingOptions; + + /** The name column has an explicitly set width. The remaining columns divide the remaining width. This is the percentage allocated to the collection column */ + const collectionWidth = 38.5; + const descriptionWidth = 100 - collectionWidth; + + return ( + <Table aria-label={skeleton ? undefined : t`Table of metrics`}> + <colgroup> + {/* <col> for Name column */} + <NameColumn {...nameProps} /> + + {/* <col> for Collection column */} + <TableColumn {...collectionProps} width={`${collectionWidth}%`} /> + + {/* <col> for Description column */} + <TableColumn {...descriptionProps} width={`${descriptionWidth}%`} /> + + <Columns.RightEdge.Col /> + </colgroup> + <thead> + <tr> + <SortableColumnHeader + name="name" + sortingOptions={sortingOptions} + onSortingOptionsChange={handleSortingOptionsChange} + {...nameProps} + style={{ paddingInlineStart: ".625rem" }} + columnHeaderProps={{ + style: { paddingInlineEnd: ".5rem" }, + }} + > + {t`Name`} + </SortableColumnHeader> + <SortableColumnHeader + name="collection" + sortingOptions={sortingOptions} + onSortingOptionsChange={handleSortingOptionsChange} + {...collectionProps} + columnHeaderProps={{ + style: { + paddingInline: ".5rem", + }, + }} + > + {t`Collection`} + </SortableColumnHeader> + <SortableColumnHeader + name="description" + sortingOptions={sortingOptions} + onSortingOptionsChange={handleSortingOptionsChange} + {...descriptionProps} + columnHeaderProps={{ + style: { + paddingInline: ".5rem", + }, + }} + > + {t`Description`} + </SortableColumnHeader> + <Columns.RightEdge.Header /> + </tr> + </thead> + <TBody> + {skeleton ? ( + <Repeat times={7}> + <MetricRow /> + </Repeat> + ) : ( + sortedMetrics.map((metric: MetricResult) => ( + <MetricRow metric={metric} key={metric.id} /> + )) + )} + </TBody> + </Table> + ); +} + +function MetricRow({ metric }: { metric?: MetricResult }) { + const dispatch = useDispatch(); + + const handleClick = useCallback( + (event: MouseEvent) => { + if (!metric) { + return; + } + + // do not trigger click when selecting text + const selection = document.getSelection(); + if (selection?.type === "Range") { + event.stopPropagation(); + return; + } + + const { id, name } = metric; + const url = Urls.metric({ id, name }); + const subpathSafeUrl = Urls.getSubpathSafeUrl(url); + + // TODO: metabase/metabse#47713 + // trackMetricClick(metric.id); + + event.preventDefault(); + event.stopPropagation(); + + if ((event.ctrlKey || event.metaKey) && event.button === 0) { + Urls.openInNewTab(subpathSafeUrl); + } else { + dispatch(push(url)); + } + }, + [metric, dispatch], + ); + + return ( + <TableRow onClick={handleClick}> + <NameCell metric={metric} /> + <CollectionCell metric={metric} /> + <DescriptionCell metric={metric} /> + <Columns.RightEdge.Cell /> + </TableRow> + ); +} + +function SkeletonText() { + return <Skeleton natural h="16.8px" />; +} + +function stopPropagation(event: MouseEvent) { + event.stopPropagation(); +} + +function preventDefault(event: MouseEvent) { + event.preventDefault(); +} + +function NameCell({ metric }: { metric?: MetricResult }) { + const headingId = `metric-${metric?.id ?? "dummy"}-heading`; + + return ( + <ItemNameCell + data-testid="metric-name" + aria-labelledby={headingId} + {...nameProps} + > + <MaybeItemLink + to={ + metric ? Urls.metric({ id: metric.id, name: metric.name }) : undefined + } + style={{ + // To align the icons with "Name" in the <th> + paddingInlineStart: "1.4rem", + paddingInlineEnd: ".5rem", + }} + onClick={preventDefault} + > + {metric ? ( + <EntityItem.Name + name={metric?.name || ""} + variant="list" + id={headingId} + /> + ) : ( + <SkeletonText /> + )} + </MaybeItemLink> + </ItemNameCell> + ); +} + +function CollectionCell({ metric }: { metric?: MetricResult }) { + const collectionName = metric?.collection + ? getCollectionName(metric.collection) + : t`Untitled collection`; + + const content = ( + <Flex gap="sm"> + <FixedSizeIcon name="folder" /> + + {metric ? ( + <EllipsifiedCollectionPath collection={metric.collection} /> + ) : ( + <SkeletonText /> + )} + </Flex> + ); + + return ( + <CollectionTableCell + data-testid={`path-for-collection: ${collectionName}`} + {...collectionProps} + > + {metric?.collection ? ( + <CollectionLink + to={Urls.collection(metric.collection)} + onClick={stopPropagation} + > + {content} + </CollectionLink> + ) : ( + content + )} + </CollectionTableCell> + ); +} + +function DescriptionCell({ metric }: { metric?: MetricResult }) { + return ( + <Cell {...descriptionProps}> + {metric ? ( + <MarkdownPreview + lineClamp={12} + allowedElements={["strong", "em"]} + oneLine + > + {getMetricDescription(metric) || ""} + </MarkdownPreview> + ) : ( + <SkeletonText /> + )} + </Cell> + ); +} diff --git a/frontend/src/metabase/browse/components/ModelsTable.module.css b/frontend/src/metabase/browse/components/ModelsTable.module.css deleted file mode 100644 index f29119d547afe2c2cd394305494d1847877b0312..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/browse/components/ModelsTable.module.css +++ /dev/null @@ -1,5 +0,0 @@ -.collectionLink { - :hover { - color: var(--mb-color-brand) !important; - } -} diff --git a/frontend/src/metabase/browse/components/ModelsTable.tsx b/frontend/src/metabase/browse/components/ModelsTable.tsx index 5ce8f905ca18cc9439d76dea9b70886d03c4fcc4..815dd8619795274863ab907bac71b803fa17a9bf 100644 --- a/frontend/src/metabase/browse/components/ModelsTable.tsx +++ b/frontend/src/metabase/browse/components/ModelsTable.tsx @@ -1,4 +1,4 @@ -import { type CSSProperties, type PropsWithChildren, useState } from "react"; +import { type MouseEvent, useCallback, useState } from "react"; import { push } from "react-router-redux"; import { t } from "ttag"; @@ -17,18 +17,10 @@ import { import { Columns } from "metabase/components/ItemsTable/Columns"; import type { ResponsiveProps } from "metabase/components/ItemsTable/utils"; import { Ellipsified } from "metabase/core/components/Ellipsified"; -import Link from "metabase/core/components/Link"; +import { MarkdownPreview } from "metabase/core/components/MarkdownPreview"; import { useDispatch } from "metabase/lib/redux"; import * as Urls from "metabase/lib/urls"; -import { - Box, - FixedSizeIcon, - Flex, - Icon, - type IconName, - type IconProps, - Skeleton, -} from "metabase/ui"; +import { FixedSizeIcon, Flex, Icon, Skeleton } from "metabase/ui"; import { Repeat } from "metabase/ui/components/feedback/Skeleton/Repeat"; import { SortDirection, type SortingOptions } from "metabase-types/api/sorting"; @@ -36,14 +28,14 @@ import { trackModelClick } from "../analytics"; import type { ModelResult } from "../types"; import { getIcon } from "../utils"; -import { EllipsifiedWithMarkdownTooltip } from "./EllipsifiedWithMarkdownTooltip"; -import S from "./ModelsTable.module.css"; import { - ModelCell, - ModelNameColumn, - ModelTableRow, -} from "./ModelsTable.styled"; -import { getModelDescription, sortModels } from "./utils"; + Cell, + CollectionLink, + CollectionTableCell, + NameColumn, + TableRow, +} from "./BrowseTable.styled"; +import { getModelDescription, sortModelOrMetric } from "./utils"; export interface ModelsTableProps { models?: ModelResult[]; @@ -77,7 +69,7 @@ export const ModelsTable = ({ ); const locale = useLocale(); - const sortedModels = sortModels(models, sortingOptions, locale); + const sortedModels = sortModelOrMetric(models, sortingOptions, locale); /** The name column has an explicitly set width. The remaining columns divide the remaining width. This is the percentage allocated to the collection column */ const collectionWidth = 38.5; @@ -93,7 +85,7 @@ export const ModelsTable = ({ <Table aria-label={skeleton ? undefined : t`Table of models`}> <colgroup> {/* <col> for Name column */} - <ModelNameColumn containerName={itemsTableContainerName} /> + <NameColumn containerName={itemsTableContainerName} /> {/* <col> for Collection column */} <TableColumn {...collectionProps} width={`${collectionWidth}%`} /> @@ -146,11 +138,11 @@ export const ModelsTable = ({ <TBody> {skeleton ? ( <Repeat times={7}> - <TBodyRowSkeleton /> + <ModelRow /> </Repeat> ) : ( sortedModels.map((model: ModelResult) => ( - <TBodyRow model={model} key={`${model.model}-${model.id}`} /> + <ModelRow model={model} key={model.id} /> )) )} </TBody> @@ -158,156 +150,144 @@ export const ModelsTable = ({ ); }; -const TBodyRow = ({ - model, - skeleton, -}: { - model: ModelResult; - skeleton?: boolean; -}) => { - const icon = getIcon(model); +function SkeletonText() { + return <Skeleton natural h="16.8px" />; +} + +function stopPropagation(event: MouseEvent) { + event.stopPropagation(); +} + +function preventDefault(event: MouseEvent) { + event.preventDefault(); +} + +const ModelRow = ({ model }: { model?: ModelResult }) => { const dispatch = useDispatch(); - const { id, name } = model; - return ( - <ModelTableRow - onClick={(e: React.MouseEvent) => { - if (skeleton) { - return; - } - const url = Urls.model({ id, name }); - const subpathSafeUrl = Urls.getSubpathSafeUrl(url); + const handleClick = useCallback( + (event: MouseEvent) => { + if (!model) { + return; + } - if ((e.ctrlKey || e.metaKey) && e.button === 0) { - Urls.openInNewTab(subpathSafeUrl); - } else { - dispatch(push(url)); - } - }} - tabIndex={0} - key={model.id} - > - {/* Name */} - <NameCell - model={model} - icon={icon} - onClick={() => { - if (skeleton) { - return; - } - trackModelClick(model.id); - }} - /> - - {/* Collection */} - <ModelCell - data-testid={`path-for-collection: ${ - model.collection - ? getCollectionName(model.collection) - : t`Untitled collection` - }`} - {...collectionProps} - > - <Link - className={S.collectionLink} - to={Urls.collection(model.collection)} - onClick={e => e.stopPropagation()} - > - <Flex gap="sm"> - <FixedSizeIcon name="folder" /> - <Box w="calc(100% - 1.5rem)"> - <EllipsifiedCollectionPath collection={model.collection} /> - </Box> - </Flex> - </Link> - </ModelCell> - - {/* Description */} - <ModelCell {...descriptionProps}> - <EllipsifiedWithMarkdownTooltip> - {getModelDescription(model) || ""} - </EllipsifiedWithMarkdownTooltip> - </ModelCell> + // do not trigger click when selecting text + const selection = document.getSelection(); + if (selection?.type === "Range") { + event.stopPropagation(); + return; + } + + const { id, name } = model; + const url = Urls.model({ id, name }); + const subpathSafeUrl = Urls.getSubpathSafeUrl(url); + + trackModelClick(model.id); + + event.preventDefault(); + event.stopPropagation(); + + if ((event.ctrlKey || event.metaKey) && event.button === 0) { + Urls.openInNewTab(subpathSafeUrl); + } else { + dispatch(push(url)); + } + }, + [model, dispatch], + ); - {/* Adds a border-radius to the table */} + return ( + <TableRow onClick={handleClick}> + <NameCell model={model} /> + <CollectionCell model={model} /> + <DescriptionCell model={model} /> <Columns.RightEdge.Cell /> - </ModelTableRow> + </TableRow> ); }; -const NameCell = ({ - model, - testIdPrefix = "table", - onClick, - icon, - children, -}: PropsWithChildren<{ - model?: ModelResult; - testIdPrefix?: string; - onClick?: () => void; - icon: IconProps; -}>) => { +function NameCell({ model }: { model?: ModelResult }) { const headingId = `model-${model?.id || "dummy"}-heading`; + const icon = getIcon(model); return ( - <ItemNameCell - data-testid={`${testIdPrefix}-name`} - aria-labelledby={headingId} - > + <ItemNameCell data-testid="model-name" aria-labelledby={headingId}> <MaybeItemLink to={model ? Urls.model({ id: model.id, name: model.name }) : undefined} - onClick={onClick} style={{ // To align the icons with "Name" in the <th> paddingInlineStart: "1.4rem", paddingInlineEnd: ".5rem", }} + onClick={preventDefault} > <Icon size={16} {...icon} - color={"var(--mb-color-brand)"} + color="var(--mb-color-icon-primary)" style={{ flexShrink: 0 }} /> - {children || ( + { <EntityItem.Name name={model?.name || ""} variant="list" id={headingId} /> - )} + } </MaybeItemLink> </ItemNameCell> ); -}; +} -const CellTextSkeleton = () => { - return <Skeleton natural h="16.8px" />; -}; +function CollectionCell({ model }: { model?: ModelResult }) { + const collectionName = model?.collection + ? getCollectionName(model.collection) + : t`Untitled collection`; + + const content = ( + <Flex gap="sm"> + <FixedSizeIcon name="folder" /> + + {model ? ( + <EllipsifiedCollectionPath collection={model.collection} /> + ) : ( + <SkeletonText /> + )} + </Flex> + ); -const TBodyRowSkeleton = ({ style }: { style?: CSSProperties }) => { - const icon = { name: "model" as IconName }; return ( - <ModelTableRow skeleton style={style}> - {/* Name */} - <NameCell icon={icon}> - <CellTextSkeleton /> - </NameCell> - - {/* Collection */} - <ModelCell {...collectionProps}> - <Flex gap=".5rem"> - <FixedSizeIcon name="folder" /> - <CellTextSkeleton /> - </Flex> - </ModelCell> - - {/* Description */} - <ModelCell {...descriptionProps}> - <CellTextSkeleton /> - </ModelCell> - - {/* Adds a border-radius to the table */} - <Columns.RightEdge.Cell /> - </ModelTableRow> + <CollectionTableCell + data-testid={`path-for-collection: ${collectionName}`} + {...collectionProps} + > + {model?.collection ? ( + <CollectionLink + to={Urls.collection(model.collection)} + onClick={stopPropagation} + > + {content} + </CollectionLink> + ) : ( + content + )} + </CollectionTableCell> ); -}; +} + +function DescriptionCell({ model }: { model?: ModelResult }) { + return ( + <Cell {...descriptionProps}> + {model ? ( + <MarkdownPreview + lineClamp={12} + allowedElements={["strong", "em"]} + oneLine + > + {getModelDescription(model) || ""} + </MarkdownPreview> + ) : ( + <SkeletonText /> + )} + </Cell> + ); +} diff --git a/frontend/src/metabase/browse/components/utils.tsx b/frontend/src/metabase/browse/components/utils.tsx index 2aa67029d5bf686dc7d49eb71f8f2dabc5a29f9d..3b0c9a34b401c6059c2fc655ffa220db7fc6efd7 100644 --- a/frontend/src/metabase/browse/components/utils.tsx +++ b/frontend/src/metabase/browse/components/utils.tsx @@ -4,33 +4,45 @@ import { getCollectionPathAsString } from "metabase/collections/utils"; import type { SearchResult } from "metabase-types/api"; import { SortDirection, type SortingOptions } from "metabase-types/api/sorting"; -import type { ModelResult } from "../types"; +import type { MetricResult, ModelResult } from "../types"; + +export type ModelOrMetricResult = ModelResult | MetricResult; export const isModel = (item: SearchResult) => item.model === "dataset"; -export const getModelDescription = (item: SearchResult) => { - if (item.collection && isModel(item) && !item.description?.trim()) { +export const getModelDescription = (item: ModelResult) => { + if (item.collection && !item.description?.trim()) { return t`A model`; } else { return item.description; } }; +export const isMetric = (item: SearchResult) => item.model === "metric"; + +export const getMetricDescription = (item: MetricResult) => { + if (item.collection && !item.description?.trim()) { + return t`A metric`; + } + + return item.description; +}; + const getValueForSorting = ( - model: ModelResult, + model: ModelResult | MetricResult, sort_column: keyof ModelResult, ): string => { if (sort_column === "collection") { - return getCollectionPathAsString(model.collection); + return getCollectionPathAsString(model.collection) ?? ""; } else { - return model[sort_column]; + return model[sort_column] ?? ""; } }; export const isValidSortColumn = ( sort_column: string, ): sort_column is keyof ModelResult => { - return ["name", "collection"].includes(sort_column); + return ["name", "collection", "description"].includes(sort_column); }; export const getSecondarySortColumn = ( @@ -39,36 +51,36 @@ export const getSecondarySortColumn = ( return sort_column === "name" ? "collection" : "name"; }; -export const sortModels = ( - models: ModelResult[], +export function sortModelOrMetric<T extends ModelOrMetricResult>( + modelsOrMetrics: T[], sortingOptions: SortingOptions, localeCode: string = "en", -) => { +) { const { sort_column, sort_direction } = sortingOptions; if (!isValidSortColumn(sort_column)) { console.error("Invalid sort column", sort_column); - return models; + return modelsOrMetrics; } const compare = (a: string, b: string) => a.localeCompare(b, localeCode, { sensitivity: "base" }); - return [...models].sort((modelA, modelB) => { - const a = getValueForSorting(modelA, sort_column); - const b = getValueForSorting(modelB, sort_column); + return [...modelsOrMetrics].sort((modelOrMetricA, modelOrMetricB) => { + const a = getValueForSorting(modelOrMetricA, sort_column); + const b = getValueForSorting(modelOrMetricB, sort_column); let result = compare(a, b); if (result === 0) { const sort_column2 = getSecondarySortColumn(sort_column); - const a2 = getValueForSorting(modelA, sort_column2); - const b2 = getValueForSorting(modelB, sort_column2); + const a2 = getValueForSorting(modelOrMetricA, sort_column2); + const b2 = getValueForSorting(modelOrMetricB, sort_column2); result = compare(a2, b2); } return sort_direction === SortDirection.Asc ? result : -result; }); -}; +} /** Find the maximum number of recently viewed models to show. * This is roughly proportional to the number of models the user diff --git a/frontend/src/metabase/browse/components/utils.unit.spec.tsx b/frontend/src/metabase/browse/components/utils.unit.spec.tsx index 80cd31e2972b9fbaebee830373c8aef4d76ff9df..8039bab10611a432ae7b5d59a6c03c1da8a45903 100644 --- a/frontend/src/metabase/browse/components/utils.unit.spec.tsx +++ b/frontend/src/metabase/browse/components/utils.unit.spec.tsx @@ -4,7 +4,7 @@ import { SortDirection } from "metabase-types/api/sorting"; import { createMockModelResult } from "../test-utils"; import type { ModelResult } from "../types"; -import { getMaxRecentModelCount, sortModels } from "./utils"; +import { getMaxRecentModelCount, sortModelOrMetric } from "./utils"; describe("sortModels", () => { let id = 0; @@ -44,7 +44,7 @@ describe("sortModels", () => { sort_column: "name", sort_direction: SortDirection.Asc, } as const; - const sorted = sortModels(mockSearchResults, sortingOptions); + const sorted = sortModelOrMetric(mockSearchResults, sortingOptions); expect(sorted?.map(model => model.name)).toEqual(["A", "B", "C"]); }); @@ -53,7 +53,7 @@ describe("sortModels", () => { sort_column: "name", sort_direction: SortDirection.Desc, } as const; - const sorted = sortModels(mockSearchResults, sortingOptions); + const sorted = sortModelOrMetric(mockSearchResults, sortingOptions); expect(sorted?.map(model => model.name)).toEqual(["C", "B", "A"]); }); @@ -62,7 +62,7 @@ describe("sortModels", () => { sort_column: "collection", sort_direction: SortDirection.Asc, } as const; - const sorted = sortModels(mockSearchResults, sortingOptions); + const sorted = sortModelOrMetric(mockSearchResults, sortingOptions); expect(sorted?.map(model => model.name)).toEqual(["B", "A", "C"]); }); @@ -71,7 +71,7 @@ describe("sortModels", () => { sort_column: "collection", sort_direction: SortDirection.Desc, } as const; - const sorted = sortModels(mockSearchResults, sortingOptions); + const sorted = sortModelOrMetric(mockSearchResults, sortingOptions); expect(sorted?.map(model => model.name)).toEqual(["C", "A", "B"]); }); @@ -98,7 +98,7 @@ describe("sortModels", () => { sort_column: "collection", sort_direction: SortDirection.Asc, } as const; - const sorted = sortModels(mockSearchResults, sortingOptions); + const sorted = sortModelOrMetric(mockSearchResults, sortingOptions); expect(sorted).toEqual([ modelMap["model named B, with collection path D / E / F"], modelMap["model named Bz, with collection path D / E / F"], @@ -113,7 +113,7 @@ describe("sortModels", () => { sort_column: "collection", sort_direction: SortDirection.Desc, } as const; - const sorted = sortModels(mockSearchResults, sortingOptions); + const sorted = sortModelOrMetric(mockSearchResults, sortingOptions); expect(sorted).toEqual([ modelMap["model named C, with collection path Z"], modelMap["model named C, with collection path Y"], @@ -165,7 +165,7 @@ describe("sortModels", () => { // When sorting in Swedish, z comes before ä const swedishLocaleCode = "sv"; - const sorted = sortModels( + const sorted = sortModelOrMetric( swedishResults, sortingOptions, swedishLocaleCode, diff --git a/frontend/src/metabase/browse/test-utils.ts b/frontend/src/metabase/browse/test-utils.ts index c3b0e8c7635df326386a8be0673f193d902d16ee..38ee0947246e2cabf0ed92458fb8c0b0d4a5e1d3 100644 --- a/frontend/src/metabase/browse/test-utils.ts +++ b/frontend/src/metabase/browse/test-utils.ts @@ -4,7 +4,12 @@ import { createMockSearchResult, } from "metabase-types/api/mocks"; -import type { ModelResult, RecentModel } from "./types"; +import type { + MetricResult, + ModelResult, + RecentMetric, + RecentModel, +} from "./types"; export const createMockModelResult = ( model: Partial<ModelResult> = {}, @@ -15,3 +20,16 @@ export const createMockRecentModel = ( model: Partial<RecentCollectionItem>, ): RecentModel => createMockRecentCollectionItem({ ...model, model: "dataset" }) as RecentModel; + +export const createMockMetricResult = ( + metric: Partial<MetricResult> = {}, +): MetricResult => + createMockSearchResult({ ...metric, model: "metric" }) as MetricResult; + +export const createMockRecentMetric = ( + metric: Partial<RecentMetric>, +): RecentMetric => + createMockRecentCollectionItem({ + ...metric, + model: "metric", + }) as RecentMetric; diff --git a/frontend/src/metabase/browse/types.tsx b/frontend/src/metabase/browse/types.tsx index d689a551985ae0832eebe869ff854ea131e35194..7ecc3a879b6ab0dc61742e997a7a8746788412ee 100644 --- a/frontend/src/metabase/browse/types.tsx +++ b/frontend/src/metabase/browse/types.tsx @@ -19,3 +19,10 @@ export const isRecentModel = (item: RecentItem): item is RecentModel => * This type is needed so that our filtering functions can * filter arrays of models retrieved from either endpoint. */ export type FilterableModel = ModelResult | RecentModel; + +/** Metric retrieved through the search endpoint */ +export type MetricResult = SearchResult<number, "metric">; + +export interface RecentMetric extends RecentCollectionItem { + model: "metric"; +} diff --git a/frontend/src/metabase/common/hooks/use-fetch-metrics.tsx b/frontend/src/metabase/common/hooks/use-fetch-metrics.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c8bcc6944a9385cd922e4bed654052503156ca32 --- /dev/null +++ b/frontend/src/metabase/common/hooks/use-fetch-metrics.tsx @@ -0,0 +1,10 @@ +import { useSearchQuery } from "metabase/api"; +import type { SearchRequest } from "metabase-types/api"; + +export const useFetchMetrics = (req: Partial<SearchRequest> = {}) => { + const modelsResult = useSearchQuery({ + models: ["metric"], + ...req, + }); + return modelsResult; +}; diff --git a/frontend/src/metabase/core/components/MarkdownPreview/MarkdownPreview.module.css b/frontend/src/metabase/core/components/MarkdownPreview/MarkdownPreview.module.css new file mode 100644 index 0000000000000000000000000000000000000000..b9fb0044f96f48dadcb4972032c6bcc48c89f00d --- /dev/null +++ b/frontend/src/metabase/core/components/MarkdownPreview/MarkdownPreview.module.css @@ -0,0 +1,47 @@ +.preview { + address, + article, + aside, + blockquote, + canvas, + dd, + div, + dl, + dt, + fieldset, + figcaption, + figure, + footer, + form, + h1, + h2, + h3, + h4, + h5, + h6, + header, + hr, + li, + main, + nav, + noscript, + ol, + p, + pre, + section, + table, + tfoot, + ul, + video { + /** + * The truncation detection only works when all the elements in the markdown preview + * are inline. Because we cannot control the types of elements being rendered, we force them all to + * inline here. + */ + display: inline-block; + } +} + +.preview.oneLine { + white-space: normal; +} diff --git a/frontend/src/metabase/core/components/MarkdownPreview/MarkdownPreview.tsx b/frontend/src/metabase/core/components/MarkdownPreview/MarkdownPreview.tsx index e85346a4a4c66b6c82e8d9724b4d36811849556b..36762dd2f73f182a87b4cac983bb627279aa2ff3 100644 --- a/frontend/src/metabase/core/components/MarkdownPreview/MarkdownPreview.tsx +++ b/frontend/src/metabase/core/components/MarkdownPreview/MarkdownPreview.tsx @@ -1,3 +1,4 @@ +import classNames from "classnames"; import type { ComponentProps, LegacyRef } from "react"; import { useIsTruncated } from "metabase/hooks/use-is-truncated"; @@ -5,18 +6,26 @@ import { useIsTruncated } from "metabase/hooks/use-is-truncated"; import Markdown from "../Markdown"; import Tooltip from "../Tooltip"; +import C from "./MarkdownPreview.module.css"; + interface Props { children: string; className?: string; tooltipMaxWidth?: ComponentProps<typeof Tooltip>["maxWidth"]; + lineClamp?: number; + allowedElements?: string[]; + oneLine?: boolean; } -const ALLOWED_ELEMENTS: string[] = []; +const DEFAULT_ALLOWED_ELEMENTS: string[] = []; export const MarkdownPreview = ({ children, className, tooltipMaxWidth, + lineClamp, + allowedElements = DEFAULT_ALLOWED_ELEMENTS, + oneLine, }: Props) => { const { isTruncated, ref } = useIsTruncated(); @@ -35,15 +44,15 @@ export const MarkdownPreview = ({ placement="bottom" isEnabled={isTruncated} tooltip={ - <Markdown dark disallowHeading unstyleLinks> + <Markdown dark disallowHeading unstyleLinks lineClamp={lineClamp}> {children} </Markdown> } > <div ref={setReactMarkdownRef}> <Markdown - allowedElements={ALLOWED_ELEMENTS} - className={className} + allowedElements={allowedElements} + className={classNames(C.preview, className, oneLine && C.oneLine)} unwrapDisallowed lineClamp={1} > diff --git a/frontend/src/metabase/core/components/MarkdownPreview/MarkdownPreview.unit.spec.tsx b/frontend/src/metabase/core/components/MarkdownPreview/MarkdownPreview.unit.spec.tsx index 1f56c2f49730780effb683edbd879b10ddb58b75..624ca9f344e0640f5732fc1aada07a581fff1b5b 100644 --- a/frontend/src/metabase/core/components/MarkdownPreview/MarkdownPreview.unit.spec.tsx +++ b/frontend/src/metabase/core/components/MarkdownPreview/MarkdownPreview.unit.spec.tsx @@ -19,13 +19,19 @@ const MARKDOWN = [ const MARKDOWN_AS_TEXT = [HEADING_1_TEXT, HEADING_2_TEXT, PARAGRAPH_TEXT].join( " ", ); +const MARKDOWN_WITH_ITALICS = `markdown with *italics*`; interface SetupOpts { markdown?: string; + allowedElements?: string[]; } -const setup = ({ markdown = MARKDOWN }: SetupOpts = {}) => { - render(<MarkdownPreview>{markdown}</MarkdownPreview>); +const setup = ({ markdown = MARKDOWN, allowedElements }: SetupOpts = {}) => { + render( + <MarkdownPreview allowedElements={allowedElements}> + {markdown} + </MarkdownPreview>, + ); }; describe("MarkdownPreview", () => { @@ -80,4 +86,13 @@ describe("MarkdownPreview", () => { expect(image).toHaveAttribute("src", "https://example.com/img.jpg"); }); }); + + it("should allow enabling certain elements", () => { + setup({ + markdown: MARKDOWN_WITH_ITALICS, + allowedElements: ["em"], + }); + + expect(screen.getByText("italics")).toBeInTheDocument(); + }); }); diff --git a/frontend/src/metabase/css/core/colors.module.css b/frontend/src/metabase/css/core/colors.module.css index 87ec497a2d4d982b49a0f7f5728d92c21e491b36..e4cb6af767510b0983b623be4fbf6bf57fdbbf7e 100644 --- a/frontend/src/metabase/css/core/colors.module.css +++ b/frontend/src/metabase/css/core/colors.module.css @@ -71,6 +71,7 @@ --mb-color-background-disabled: var(--mb-base-color-gray-10); --mb-color-background-inverse: var(--mb-color-bg-black); --mb-color-background-brand: var(--mb-base-color-brand-40); + --mb-color-icon-primary: var(--mb-base-color-brand-40); /** * Base colors diff --git a/frontend/src/metabase/nav/containers/MainNavbar/MainNavbarContainer/BrowseNavSection.tsx b/frontend/src/metabase/nav/containers/MainNavbar/MainNavbarContainer/BrowseNavSection.tsx index 7103883592129971b0d7a01dfc3c52f3c8ad0d2f..e599866d5e71cd2a210b7caa8c1a425fefe81bc0 100644 --- a/frontend/src/metabase/nav/containers/MainNavbar/MainNavbarContainer/BrowseNavSection.tsx +++ b/frontend/src/metabase/nav/containers/MainNavbar/MainNavbarContainer/BrowseNavSection.tsx @@ -9,6 +9,7 @@ import { Flex, Skeleton } from "metabase/ui"; import { PaddedSidebarLink, SidebarHeading } from "../MainNavbar.styled"; import type { SelectedItem } from "../types"; +import { useHasMetrics } from "../utils"; export const BrowseNavSection = ({ nonEntityItem, @@ -21,6 +22,7 @@ export const BrowseNavSection = ({ }) => { const BROWSE_MODELS_URL = "/browse/models"; const BROWSE_DATA_URL = "/browse/databases"; + const BROWSE_METRICS_URL = "/browse/metrics"; const { hasModels, @@ -29,11 +31,18 @@ export const BrowseNavSection = ({ } = useHasModels(); const noModelsExist = hasModels === false; + const { + hasMetrics, + isLoading: areMetricsLoading, + error: metricsError, + } = useHasMetrics(); + const noMetricsExist = hasMetrics === false; + const [expandBrowse = true, setExpandBrowse] = useUserSetting( "expand-browse-in-nav", ); - if (noModelsExist && !hasDataAccess) { + if (noModelsExist && noMetricsExist && !hasDataAccess) { return null; } @@ -52,12 +61,7 @@ export const BrowseNavSection = ({ <DelayedLoadingAndErrorWrapper loading={areModelsLoading} error={modelsError} - loader={ - <Flex py="sm" px="md" h="32.67px" gap="sm" align="center"> - <Skeleton radius="md" h="md" w="md" /> - <Skeleton radius="xs" w="4rem" h="1.2rem" /> - </Flex> - } + loader={<SidebarSkeleton />} delay={0} > {!noModelsExist && ( @@ -83,6 +87,34 @@ export const BrowseNavSection = ({ {t`Databases`} </PaddedSidebarLink> )} + + <DelayedLoadingAndErrorWrapper + loading={areMetricsLoading} + error={metricsError} + loader={<SidebarSkeleton />} + delay={0} + > + {!noMetricsExist && ( + <PaddedSidebarLink + icon="metric" + url={BROWSE_METRICS_URL} + isSelected={nonEntityItem?.url?.startsWith(BROWSE_METRICS_URL)} + onClick={onItemSelect} + aria-label={t`Browse metrics`} + > + {t`Metrics`} + </PaddedSidebarLink> + )} + </DelayedLoadingAndErrorWrapper> </CollapseSection> ); }; + +function SidebarSkeleton() { + return ( + <Flex py="sm" px="md" h="32.67px" gap="sm" align="center"> + <Skeleton radius="md" h="md" w="md" /> + <Skeleton radius="xs" w="4rem" h="1.2rem" /> + </Flex> + ); +} diff --git a/frontend/src/metabase/nav/containers/MainNavbar/utils/index.ts b/frontend/src/metabase/nav/containers/MainNavbar/utils/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fcfd614989089b0c1cf015a2f540da97facbf146 --- /dev/null +++ b/frontend/src/metabase/nav/containers/MainNavbar/utils/index.ts @@ -0,0 +1 @@ +export * from "./useHasMetrics"; diff --git a/frontend/src/metabase/nav/containers/MainNavbar/utils/useHasMetrics.ts b/frontend/src/metabase/nav/containers/MainNavbar/utils/useHasMetrics.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9c8fdf744406dd53441aba5f4fa51824a141123 --- /dev/null +++ b/frontend/src/metabase/nav/containers/MainNavbar/utils/useHasMetrics.ts @@ -0,0 +1,20 @@ +import { useFetchMetrics } from "metabase/common/hooks/use-fetch-metrics"; +import type { SearchRequest } from "metabase-types/api"; + +export const useHasMetrics = (req: Partial<SearchRequest> = {}) => { + const { data, isLoading, error } = useFetchMetrics({ + limit: 0, + filter_items_in_personal_collection: "exclude", + model_ancestors: false, + ...req, + }); + + const availableModels = data?.available_models ?? []; + const hasMetrics = availableModels.includes("metric"); + + return { + hasMetrics, + isLoading, + error, + }; +}; diff --git a/frontend/src/metabase/routes.jsx b/frontend/src/metabase/routes.jsx index 0199f21d114eede8183edde6249973b94d398c3f..8955d21d482482f288628f994b87b23b3533c611 100644 --- a/frontend/src/metabase/routes.jsx +++ b/frontend/src/metabase/routes.jsx @@ -52,6 +52,7 @@ import { Setup } from "metabase/setup/components/Setup"; import getCollectionTimelineRoutes from "metabase/timelines/collections/routes"; import { BrowseDatabases } from "./browse/components/BrowseDatabases"; +import { BrowseMetrics } from "./browse/components/BrowseMetrics"; import { BrowseModels } from "./browse/components/BrowseModels"; import BrowseSchemas from "./browse/components/BrowseSchemas"; import { BrowseTables } from "./browse/components/BrowseTables"; @@ -206,6 +207,7 @@ export const getRoutes = store => { <Route path="browse"> <IndexRedirect to="/browse/models" /> + <Route path="metrics" component={BrowseMetrics} /> <Route path="models" component={BrowseModels} /> <Route path="databases" component={BrowseDatabases} /> <Route path="databases/:slug" component={BrowseSchemas} /> diff --git a/resources/frontend_client/app/assets/img/metrics_bot.svg b/resources/frontend_client/app/assets/img/metrics_bot.svg new file mode 100644 index 0000000000000000000000000000000000000000..6e845dcb5e0dd7cbb73b319e428f35d1dcbaa7d4 --- /dev/null +++ b/resources/frontend_client/app/assets/img/metrics_bot.svg @@ -0,0 +1,25 @@ +<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_8327_1165)"> +<circle cx="48" cy="48" r="48" fill="#4C5773" fill-opacity="0.05"/> +<rect x="24" y="31" width="48" height="34" rx="4" fill="white" stroke="#949AAB" stroke-width="1.25" stroke-linejoin="round"/> +<rect x="29.5" y="36.5" width="37" height="20" rx="1.5" fill="white" stroke="#949AAB" stroke-width="1.25" stroke-linejoin="round"/> +<path d="M62.1605 60.7143C62.1605 61.3159 61.6728 61.8036 61.0712 61.8036C60.4696 61.8036 59.9819 61.3159 59.9819 60.7143C59.9819 60.1127 60.4696 59.625 61.0712 59.625C61.6728 59.625 62.1605 60.1127 62.1605 60.7143Z" stroke="#949AAB" stroke-width="1.25"/> +<path d="M66.6605 60.7143C66.6605 61.3159 66.1728 61.8036 65.5712 61.8036C64.9696 61.8036 64.4819 61.3159 64.4819 60.7143C64.4819 60.1127 64.9696 59.625 65.5712 59.625C66.1728 59.625 66.6605 60.1127 66.6605 60.7143Z" stroke="#949AAB" stroke-width="1.25"/> +<circle cx="41" cy="43.4996" r="1.5" fill="#949AAB"/> +<circle cx="55" cy="43.4996" r="1.5" fill="#949AAB"/> +<path d="M50.25 47.4996C50.25 48.9907 49.2426 49.7496 48 49.7496C46.7574 49.7496 45.75 48.9907 45.75 47.4996" stroke="#949AAB" stroke-width="1.25"/> +<rect x="58.4795" y="26.667" width="24" height="16" transform="rotate(15 58.4795 26.667)" fill="black" fill-opacity="0.1"/> +<rect x="58.4795" y="22.667" width="24" height="16" transform="rotate(15 58.4795 22.667)" fill="white" stroke="#949AAB" stroke-width="1.25" stroke-linejoin="round"/> +<path opacity="0.5" d="M58.9786 36.259L63.3415 32.9911L64.9128 35.2609L68.9983 29.7745L70.5697 33.6711L72.9591 34.3113L76.7258 31.8448" stroke="#949AAB" stroke-width="1.25" stroke-linejoin="round"/> +<path d="M14.7071 63.2929L31.2929 46.7071C31.9229 46.0771 33 46.5233 33 47.4142V64C33 64.5523 32.5523 65 32 65H15.4142C14.5233 65 14.0771 63.9229 14.7071 63.2929Z" fill="black" fill-opacity="0.1"/> +<path d="M14.7071 59.2929L31.2929 42.7071C31.9229 42.0771 33 42.5233 33 43.4142V60C33 60.5523 32.5523 61 32 61H15.4142C14.5233 61 14.0771 59.9229 14.7071 59.2929Z" fill="white" stroke="#949AAB" stroke-width="1.25" stroke-linejoin="round"/> +<path d="M19.75 54.75L21.8713 56.8713" stroke="#949AAB" stroke-width="1.25"/> +<path d="M23.2856 51.2148L25.407 53.3362" stroke="#949AAB" stroke-width="1.25"/> +<path d="M26.8213 47.6787L28.9426 49.8" stroke="#949AAB" stroke-width="1.25"/> +</g> +<defs> +<clipPath id="clip0_8327_1165"> +<rect width="96" height="96" fill="white"/> +</clipPath> +</defs> +</svg>