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

In question sidesheet, improve the 'Based on' section (#48315)

parent c5ac891d
No related branches found
No related tags found
No related merge requests found
Showing
with 340 additions and 51 deletions
......@@ -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",
......
......@@ -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);
......
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);
});
});
......@@ -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;
......
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>
);
};
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",
);
});
});
import type { IconData } from "metabase/lib/icon";
export interface QuestionSource {
href: string;
name: string;
model?: string;
iconProps?: IconData;
}
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;
};
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