diff --git a/frontend/src/metabase/lib/icon.ts b/frontend/src/metabase/lib/icon.ts index 2c4919a9016583704b79868c78c078a7095bba99..7013d100547e9de903a7d29569cb9087b09b3060 100644 --- a/frontend/src/metabase/lib/icon.ts +++ b/frontend/src/metabase/lib/icon.ts @@ -9,7 +9,7 @@ import type { SearchModel, } from "metabase-types/api"; -type IconModel = SearchModel | CollectionItemModel | "schema"; +export type IconModel = SearchModel | CollectionItemModel | "schema"; export type ObjectWithModel = { id?: unknown; @@ -22,7 +22,7 @@ export type ObjectWithModel = { is_personal?: boolean; }; -const modelIconMap: Record<IconModel, IconName> = { +export const modelIconMap: Record<IconModel, IconName> = { collection: "folder", database: "database", table: "table", diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/QuestionDataSource/utils.js b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/QuestionDataSource/utils.js index 9088da66f4a25a426975ee96b61ca45ed21558db..227175fb8f877d67e67b960f91a1acaba7a22cd0 100644 --- a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/QuestionDataSource/utils.js +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/QuestionDataSource/utils.js @@ -16,7 +16,12 @@ import { HeadBreadcrumbs } from "../HeaderBreadcrumbs"; import { IconWrapper, TablesDivider } from "./QuestionDataSource.styled"; -export function getDataSourceParts({ question, subHead, isObjectDetail }) { +export function getDataSourceParts({ + question, + subHead, + isObjectDetail, + formatTableAsComponent = true, +}) { if (!question) { return []; } @@ -39,6 +44,7 @@ export function getDataSourceParts({ question, subHead, isObjectDetail }) { icon: !subHead ? "database" : undefined, name: database.displayName(), href: database.id >= 0 && Urls.browseDatabase(database), + model: "database", }); } @@ -49,6 +55,7 @@ export function getDataSourceParts({ question, subHead, isObjectDetail }) { const isBasedOnSavedQuestion = isVirtualCardId(table.id); if (!isBasedOnSavedQuestion) { parts.push({ + model: "schema", name: table.schema_name, href: database.id >= 0 && Urls.browseSchema(table), }); @@ -81,14 +88,22 @@ export function getDataSourceParts({ question, subHead, isObjectDetail }) { }), ].filter(isNotNull); - parts.push( + const part = formatTableAsComponent ? ( <QuestionTableBadges tables={allTables} subHead={subHead} hasLink={hasTableLink} isLast={!isObjectDetail} - />, + /> + ) : ( + { + name: table.displayName(), + href: hasTableLink ? getTableURL(table) : "", + model: table.type ?? "table", + } ); + + parts.push(part); } return parts.filter(part => isValidElement(part) || part.name || part.icon); diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/QuestionDataSource/utils.unit.spec.ts b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/QuestionDataSource/utils.unit.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..e57594e4090a1d7809403dbe643481f653d9b916 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/QuestionDataSource/utils.unit.spec.ts @@ -0,0 +1,88 @@ +import { isValidElement } from "react"; + +import { createMockMetadata } from "__support__/metadata"; +import Question from "metabase-lib/v1/Question"; +import type { Card } from "metabase-types/api"; +import { + createMockCard, + createMockDatabase, + createMockTable, +} from "metabase-types/api/mocks"; +import { createSampleDatabase } from "metabase-types/api/mocks/presets"; + +import { getDataSourceParts } from "./utils"; + +const MULTI_SCHEMA_DB_ID = 2; +const MULTI_SCHEMA_TABLE1_ID = 100; +const MULTI_SCHEMA_TABLE2_ID = 101; + +function getMetadata() { + return createMockMetadata({ + databases: [ + createSampleDatabase(), + createMockDatabase({ + id: MULTI_SCHEMA_DB_ID, + tables: [ + createMockTable({ + id: MULTI_SCHEMA_TABLE1_ID, + db_id: MULTI_SCHEMA_DB_ID, + schema: "first_schema", + }), + createMockTable({ + id: MULTI_SCHEMA_TABLE2_ID, + db_id: MULTI_SCHEMA_DB_ID, + schema: "second_schema", + }), + ], + }), + ], + }); +} + +const createMockQuestion = (opts?: Partial<Card>) => + new Question(createMockCard(opts), getMetadata()); + +/** These tests cover new logic in the getDataSourceParts utility that is not covered in QuestionDataSource.unit.spec.js */ +describe("getDataSourceParts", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + it("returns an array of Records if formatTableAsComponent is false", () => { + const parts = getDataSourceParts({ + question: createMockQuestion(), + subHead: false, + isObjectDetail: true, + formatTableAsComponent: false, + }); + expect(parts).toHaveLength(2); + const partsArray = parts as any[]; + expect(partsArray[0]).toEqual({ + icon: "database", + name: "Sample Database", + href: "/browse/databases/1-sample-database", + model: "database", + }); + expect(partsArray[1].name).toEqual("Products"); + expect(partsArray[1].model).toEqual("table"); + expect(partsArray[1].href).toMatch(/^\/question#[a-zA-Z0-9]{50}/); + expect(Object.keys(partsArray[1])).toHaveLength(3); + }); + + it("returns an array with the table formatted as a component if formatTableAsComponent is true", () => { + const parts = getDataSourceParts({ + question: createMockQuestion(), + subHead: false, + isObjectDetail: true, + formatTableAsComponent: true, + }); + expect(parts).toHaveLength(2); + const partsArray = parts as any[]; + expect(partsArray[0]).toEqual({ + icon: "database", + name: "Sample Database", + href: "/browse/databases/1-sample-database", + model: "database", + }); + expect(isValidElement(partsArray[1])).toBe(true); + }); +}); diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/QuestionDetails.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/QuestionDetails.tsx index fca6c9a29e479b32cb6b9de1101d9abe960a4bcc..be1534814f9326ea667f983b2b5b33a94a9a8907 100644 --- a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/QuestionDetails.tsx +++ b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/QuestionDetails.tsx @@ -2,22 +2,19 @@ import cx from "classnames"; import { useState } from "react"; import { c, t } from "ttag"; -import { getTableUrl } from "metabase/browse/containers/TableBrowser/TableBrowser"; import { getCollectionName } from "metabase/collections/utils"; import { SidesheetCardSection } from "metabase/common/components/Sidesheet"; import DateTime from "metabase/components/DateTime"; import Link from "metabase/core/components/Link"; import Styles from "metabase/css/core/index.css"; -import { useSelector } from "metabase/lib/redux"; import * as Urls from "metabase/lib/urls"; import { getUserName } from "metabase/lib/user"; -import { getMetadata } from "metabase/selectors/metadata"; import { QuestionPublicLinkPopover } from "metabase/sharing/components/PublicLinkPopover"; import { Box, Flex, FixedSizeIcon as Icon, Text } from "metabase/ui"; import type Question from "metabase-lib/v1/Question"; -import type { Database } from "metabase-types/api"; import SidebarStyles from "./QuestionInfoSidebar.module.css"; +import { QuestionSources } from "./components/QuestionSources"; export const QuestionDetails = ({ question }: { question: Question }) => { const lastEditInfo = question.lastEditInfo(); @@ -74,52 +71,11 @@ export const QuestionDetails = ({ question }: { question: Question }) => { </Flex> </SidesheetCardSection> <SharingDisplay question={question} /> - <SourceDisplay question={question} /> + <QuestionSources question={question} /> </> ); }; -function SourceDisplay({ question }: { question: Question }) { - const sourceInfo = question.legacyQueryTable(); - const metadata = useSelector(getMetadata); - - if (!sourceInfo) { - return null; - } - - const model = String(sourceInfo.id).includes("card__") ? "card" : "table"; - - const sourceUrl = - model === "card" - ? Urls.browseDatabase(sourceInfo.db as Database) - : getTableUrl(sourceInfo, metadata); - - return ( - <SidesheetCardSection title={t`Based on`}> - <Flex gap="sm" align="center"> - {sourceInfo.db && ( - <> - <Text> - <Link - to={`/browse/databases/${sourceInfo.db.id}`} - variant="brand" - > - {sourceInfo.db.name} - </Link> - </Text> - {"/"} - </> - )} - <Text> - <Link to={sourceUrl} variant="brand"> - {sourceInfo?.display_name} - </Link> - </Text> - </Flex> - </SidesheetCardSection> - ); -} - function SharingDisplay({ question }: { question: Question }) { const publicUUID = question.publicUUID(); const embeddingEnabled = question._card.enable_embedding; diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/components/QuestionSources.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/components/QuestionSources.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7fa2bba3cc6e124639e32a663b56f15d165a56cf --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/components/QuestionSources.tsx @@ -0,0 +1,56 @@ +import { Fragment, useMemo } from "react"; +import { c } from "ttag"; + +import { SidesheetCardSection } from "metabase/common/components/Sidesheet"; +import Link from "metabase/core/components/Link"; +import { Flex, FixedSizeIcon as Icon } from "metabase/ui"; +import type Question from "metabase-lib/v1/Question"; + +import { getDataSourceParts } from "../../../ViewHeader/components/QuestionDataSource/utils"; + +import type { QuestionSource } from "./types"; +import { getIconPropsForSource } from "./utils"; + +export const QuestionSources = ({ question }: { question: Question }) => { + const sources = getDataSourceParts({ + question, + subHead: false, + isObjectDetail: true, + formatTableAsComponent: false, + }) as unknown as QuestionSource[]; + + const sourcesWithIcons: QuestionSource[] = useMemo(() => { + return sources.map(source => ({ + ...source, + iconProps: getIconPropsForSource(source), + })); + }, [sources]); + + if (!sources.length) { + return null; + } + + const title = c( + "This is a heading that appears above the names of the database, table, and/or question that a question is based on -- the 'sources' for the question. Feel free to translate this heading as though it said 'Based on these sources', if you think that would make more sense in your language.", + ).t`Based on`; + + return ( + <SidesheetCardSection title={title}> + <Flex gap="sm" align="flex-start"> + {sourcesWithIcons.map(({ href, name, iconProps }, index) => ( + <Fragment key={`${href}-${name}`}> + <Link to={href} variant="brand"> + <Flex gap="sm" lh="1.25rem" maw="20rem"> + {iconProps ? ( + <Icon mt={2} c="text-dark" {...iconProps} /> + ) : null} + {name} + </Flex> + </Link> + {index < sources.length - 1 && <Flex lh="1.25rem">{"/"}</Flex>} + </Fragment> + ))} + </Flex> + </SidesheetCardSection> + ); +}; diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/components/QuestionSources.unit.spec.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/components/QuestionSources.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d12e56ed2bf0549c39171d8f665051a5fefbf803 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/components/QuestionSources.unit.spec.tsx @@ -0,0 +1,145 @@ +import { Route } from "react-router"; +import _ from "underscore"; + +import { mockSettings } from "__support__/settings"; +import { createMockEntitiesState } from "__support__/store"; +import { renderWithProviders, screen, within } from "__support__/ui"; +import { modelIconMap } from "metabase/lib/icon"; +import { checkNotNull } from "metabase/lib/types"; +import { getMetadata } from "metabase/selectors/metadata"; +import { convertSavedQuestionToVirtualTable } from "metabase-lib/v1/metadata/utils/saved-questions"; +import type { Card, NormalizedTable } from "metabase-types/api"; +import { createMockCard, createMockSettings } from "metabase-types/api/mocks"; +import { createSampleDatabase } from "metabase-types/api/mocks/presets"; +import { createMockState } from "metabase-types/store/mocks"; + +import { QuestionSources } from "./QuestionSources"; + +interface SetupOpts { + card?: Card; + sourceCard?: Card; +} + +const setup = async ({ + card = createMockCard(), + sourceCard, +}: SetupOpts = {}) => { + const state = createMockState({ + settings: mockSettings(createMockSettings()), + entities: createMockEntitiesState({ + databases: [createSampleDatabase()], + questions: _.compact([card, sourceCard]), + }), + }); + + // 😫 all this is necessary to test a card as a question source + if (sourceCard) { + const virtualTable = convertSavedQuestionToVirtualTable(sourceCard); + + state.entities = { + ...state.entities, + tables: { + ...(state.entities.tables as Record<number, NormalizedTable>), + [virtualTable.id]: virtualTable, + }, + databases: { + [state.entities.databases[1].id]: { + ...state.entities.databases[1], + tables: [ + ...(state.entities.databases[1].tables ?? []), + virtualTable.id, + ], + }, + }, + }; + } + + const metadata = getMetadata(state); + const question = checkNotNull(metadata.question(card.id)); + + return renderWithProviders( + <Route + path="/" + component={() => <QuestionSources question={question} />} + />, + { + withRouter: true, + }, + ); +}; + +describe("QuestionSources", () => { + it("should show table source information", async () => { + const card = createMockCard({ + name: "Question", + }); + setup({ card }); + const databaseLink = await screen.findByRole("link", { + name: /Sample Database/i, + }); + expect( + await within(databaseLink).findByLabelText("database icon"), + ).toBeInTheDocument(); + expect(databaseLink).toHaveAttribute( + "href", + "/browse/databases/1-sample-database", + ); + expect(screen.getByText("/")).toBeInTheDocument(); + const tableLink = await screen.findByRole("link", { name: /Products/i }); + expect(tableLink).toBeInTheDocument(); + expect( + await within(tableLink).findByLabelText(`table icon`), + ).toBeInTheDocument(); + expect(tableLink).toHaveAttribute( + "href", + expect.stringMatching(/^\/question#[a-zA-Z0-9]{20}/), + ); + }); + + it("should show card source information", async () => { + const card = createMockCard({ + name: "My Question", + dataset_query: { + type: "query", + database: 1, + query: { + "source-table": "card__2", + }, + }, + }); + + const sourceCard = createMockCard({ + name: "My Source Question", + id: 2, + }); + + await setup({ card, sourceCard }); + + const databaseLink = await screen.findByRole("link", { + name: /Sample Database/i, + }); + + expect( + await within(databaseLink).findByLabelText("database icon"), + ).toBeInTheDocument(); + expect(databaseLink).toHaveAttribute( + "href", + "/browse/databases/1-sample-database", + ); + + expect(screen.getByText("/")).toBeInTheDocument(); + + const questionLink = await screen.findByRole("link", { + name: /My Source Question/i, + }); + expect( + await within(questionLink).findByLabelText( + `${modelIconMap["card"]} icon`, + ), + ).toBeInTheDocument(); + expect(questionLink).toHaveAttribute( + "href", + "/question/2-my-source-question", + ); + }); +}); diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/components/types.ts b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/components/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f42bc97578c4810cd874b357f4b8841e1dda8c1 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/components/types.ts @@ -0,0 +1,8 @@ +import type { IconData } from "metabase/lib/icon"; + +export interface QuestionSource { + href: string; + name: string; + model?: string; + iconProps?: IconData; +} diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/components/utils.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/components/utils.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dd3b0538ca0d7fe2e4486486b135441387ab2335 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/components/utils.tsx @@ -0,0 +1,21 @@ +import { match } from "ts-pattern"; + +import { type IconData, type IconModel, getIcon } from "metabase/lib/icon"; + +import type { QuestionSource } from "./types"; + +export const getIconPropsForSource = ( + source: QuestionSource, +): IconData | undefined => { + const iconModel: IconModel | undefined = match(source.model) + .with("question", () => "card" as const) + .with("model", () => "dataset" as const) + .with("database", () => "database" as const) + + .with("metric", () => "metric" as const) + .with("schema", () => undefined) + .otherwise(() => "table" as const); + + const iconProps = iconModel ? getIcon({ model: iconModel }) : undefined; + return iconProps; +};