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

Show dashboard tab entity ids in dashboard info sidesheet (#48385)

parent 71c506a4
No related branches found
No related tags found
No related merge requests found
Showing
with 431 additions and 54 deletions
......@@ -21,7 +21,7 @@ export const CollectionInfoSidebar = ({
const [isOpen, setIsOpen] = useState(false);
useMount(() => {
// this component is not rendered until it is "open"
// This component is not rendered until it is "open"
// but we want to set isOpen after it mounts to get
// pretty animations
setIsOpen(true);
......
import type React from "react";
import CS from "metabase/css/core/index.css";
import { Paper, type PaperProps, Stack, Title } from "metabase/ui";
import {
Paper,
type PaperProps,
Stack,
type StackProps,
Title,
type TitleProps,
} from "metabase/ui";
type SidesheetCardProps = {
export type SidesheetCardProps = {
title?: React.ReactNode;
children: React.ReactNode;
stackProps?: StackProps;
} & PaperProps;
export const SidesheetCard = ({
title,
children,
stackProps,
...paperProps
}: SidesheetCardProps) => {
return (
<Paper p="lg" withBorder shadow="none" {...paperProps}>
{title && (
<Title lh={1} mb=".75rem" size="sm" color="text-light" order={4}>
{title}
</Title>
)}
<Stack spacing="md" className={CS.textMedium}>
{title && <SidesheetCardTitle>{title}</SidesheetCardTitle>}
<Stack spacing="md" className={CS.textMedium} {...stackProps}>
{children}
</Stack>
</Paper>
);
};
export const SidesheetCardTitle = (props: TitleProps) => (
<Title
lh={1}
mb=".75rem"
c="var(--mb-color-text-light)"
order={4}
{...props}
/>
);
import type React from "react";
import CS from "metabase/css/core/index.css";
import { Box, type MantineStyleSystemProps, Title } from "metabase/ui";
import { Box, type MantineStyleSystemProps } from "metabase/ui";
import { SidesheetCardTitle } from "./SidesheetCard";
interface SidesheetCardSectionProps {
title?: string;
......@@ -16,11 +18,7 @@ export const SidesheetCardSection = ({
}: SidesheetCardSectionProps) => {
return (
<Box {...styleProps}>
{title && (
<Title mb="sm" size="sm" color="text-light">
{title}
</Title>
)}
{title && <SidesheetCardTitle>{title}</SidesheetCardTitle>}
<Box className={CS.textMedium}>{children}</Box>
</Box>
);
......
......@@ -18,9 +18,7 @@
color: var(--mb-color-brand);
}
background-color: var(--mb-color-border);
border-radius: 100%;
padding: 2px;
cursor: pointer;
&:focus {
......
import { t } from "ttag";
import { SidesheetCard } from "metabase/common/components/Sidesheet";
import {
SidesheetCard,
type SidesheetCardProps,
SidesheetCardTitle,
} from "metabase/common/components/Sidesheet";
import { useDocsUrl, useHasTokenFeature } from "metabase/common/hooks";
import { CopyButton } from "metabase/components/CopyButton";
import Link from "metabase/core/components/Link";
import { Flex, Group, Icon, Paper, Popover, Text } from "metabase/ui";
import {
Flex,
Group,
Icon,
Paper,
Popover,
Stack,
type StackProps,
Text,
type TitleProps,
} from "metabase/ui";
import Styles from "./EntityIdCard.module.css";
const EntityIdTitle = () => {
const EntityIdTitle = (props?: TitleProps) => {
const { url: docsLink, showMetabaseLinks } = useDocsUrl(
"installation-and-operation/serialization",
);
return (
<Group spacing="sm">
{t`Entity ID`}
<Popover position="top-start">
<Popover.Target>
<Icon tabIndex={0} name="info" className={Styles.InfoIcon} />
</Popover.Target>
<Popover.Dropdown>
<Paper p="md" maw="13rem">
<Text size="sm">
{t`When using serialization, replace the sequential ID with this global entity ID to have stable URLs across environments. Also useful when troubleshooting serialization.`}{" "}
{showMetabaseLinks && (
<>
<Link
target="_new"
to={docsLink}
style={{ color: "var(--mb-color-brand)" }}
>
Learn more
</Link>
</>
)}
</Text>
</Paper>
</Popover.Dropdown>
</Popover>
</Group>
<SidesheetCardTitle mb={0} {...props}>
<Group spacing="sm">
{t`Entity ID`}
<Popover position="top-start">
<Popover.Target>
<Icon tabIndex={0} name="info" className={Styles.InfoIcon} />
</Popover.Target>
<Popover.Dropdown>
<Paper p="md" maw="13rem">
<Text size="sm">
{t`When using serialization, replace the sequential ID with this global entity ID to have stable URLs across environments. Also useful when troubleshooting serialization.`}{" "}
{showMetabaseLinks && (
<>
<Link
target="_new"
to={docsLink}
style={{ color: "var(--mb-color-brand)" }}
>
Learn more
</Link>
</>
)}
</Text>
</Paper>
</Popover.Dropdown>
</Popover>
</Group>
</SidesheetCardTitle>
);
};
export function EntityIdCard({ entityId }: { entityId: string }) {
export const EntityIdDisplay = ({
entityId,
...props
}: { entityId: string } & StackProps) => {
return (
<Stack spacing="md" {...props}>
<EntityIdTitle />
<Flex gap="sm">
<Text lh="1rem">{entityId}</Text>
<CopyButton className={Styles.CopyButton} value={entityId} />
</Flex>
</Stack>
);
};
export function EntityIdCard({
entityId,
...props
}: { entityId: string } & Omit<SidesheetCardProps, "children">) {
const hasSerialization = useHasTokenFeature("serialization");
// exposing this is useless without serialization, so, let's not.
......@@ -52,11 +86,8 @@ export function EntityIdCard({ entityId }: { entityId: string }) {
}
return (
<SidesheetCard title={<EntityIdTitle />} pb="1.25rem">
<Flex gap="sm">
<Text lh="1rem">{entityId}</Text>
<CopyButton className={Styles.CopyButton} value={entityId} />
</Flex>
<SidesheetCard pb="1.25rem" {...props}>
<EntityIdDisplay entityId={entityId} />
</SidesheetCard>
);
}
import { useState } from "react";
import { t } from "ttag";
import {
SidesheetCard,
SidesheetCardTitle,
} from "metabase/common/components/Sidesheet";
import { useHasTokenFeature } from "metabase/common/hooks";
import { CopyButton } from "metabase/components/CopyButton";
import { EntityIdDisplay } from "metabase/components/EntityIdCard";
import S from "metabase/components/EntityIdCard/EntityIdCard.module.css";
import { Divider, Flex, Select, Stack } from "metabase/ui";
import type { Dashboard } from "metabase-types/api";
export const DashboardEntityIdCard = ({
dashboard,
}: {
dashboard: Dashboard;
}) => {
const { tabs } = dashboard;
// The id of the tab currently selected in the dropdown
const [tabId, setTabId] = useState<string | null>(
tabs?.length ? tabs[0].id.toString() : null,
);
if (!useHasTokenFeature("serialization")) {
return null;
}
const tabEntityId = tabs?.find(tab => tab.id.toString() === tabId)?.entity_id;
return (
<SidesheetCard>
<EntityIdDisplay entityId={dashboard.entity_id} />
{tabEntityId && (
<>
<Divider w="100%" />
<Stack spacing="xs">
<SidesheetCardTitle>{t`Specific tab IDs`}</SidesheetCardTitle>
<Flex gap="md" align="center">
<Select
value={tabId}
onChange={value => setTabId(value)}
data={tabs?.map(tab => ({
value: tab.id.toString(),
label: tab.name,
}))}
w="15rem"
/>
<Flex gap="sm" wrap="nowrap">
{tabEntityId}
<CopyButton className={S.CopyButton} value={tabEntityId} />
</Flex>
</Flex>
</Stack>
</>
)}
</SidesheetCard>
);
};
export { DashboardEntityIdCard } from "./DashboardEntityIdCard";
import { setup } from "./setup";
describe("DashboardEntityIdCard (OSS)", () => {
it("should return null", async () => {
const { container } = setup();
expect(container).toBeEmptyDOMElement();
});
});
import { setup } from "./setup";
describe("DashboardEntityIdCard (EE without token)", () => {
it("should return null", async () => {
const { container } = setup({ shouldSetupEnterprisePlugins: true });
expect(container).toBeEmptyDOMElement();
});
});
import userEvent from "@testing-library/user-event";
import { viewMantineSelectOptions } from "__support__/components/mantineSelect";
import { type RenderWithProvidersOptions, screen } from "__support__/ui";
import type { BaseEntityId, Dashboard } from "metabase-types/api";
import {
createMockDashboard,
createMockDashboardTab,
} from "metabase-types/api/mocks";
import { setup as baseSetup } from "./setup";
const setup = ({
dashboard = createMockDashboard(),
...renderOptions
}: {
dashboard?: Dashboard;
} & RenderWithProvidersOptions = {}) => {
return baseSetup({
dashboard,
withFeatures: ["serialization"],
...renderOptions,
});
};
describe("DashboardEntityIdCard (EE with token)", () => {
it("should display all tabs from the dashboard as options in the Select", async () => {
const dashboard = createMockDashboard({
tabs: [
createMockDashboardTab({
id: 1,
name: "Tab 1",
entity_id: "tab-1-entity-id" as BaseEntityId,
}),
createMockDashboardTab({
id: 2,
name: "Tab 2",
entity_id: "tab-2-entity-id" as BaseEntityId,
}),
createMockDashboardTab({
id: 3,
name: "Tab 3",
entity_id: "tab-3-entity-id" as BaseEntityId,
}),
],
});
setup({ dashboard });
const firstClickOnSelect = await viewMantineSelectOptions();
expect(firstClickOnSelect.displayedOption.value).toBe("Tab 1");
expect(firstClickOnSelect.optionTextContents).toEqual([
"Tab 1",
"Tab 2",
"Tab 3",
]);
expect(await screen.findByText("tab-1-entity-id")).toBeInTheDocument();
await userEvent.click(firstClickOnSelect.optionElements[1]);
expect(await screen.findByText("tab-2-entity-id")).toBeInTheDocument();
const secondClickOnSelect = await viewMantineSelectOptions();
expect(secondClickOnSelect.displayedOption.value).toBe("Tab 2");
await userEvent.click(secondClickOnSelect.optionElements[2]);
expect(await screen.findByText("tab-3-entity-id")).toBeInTheDocument();
});
it("does not display a select, if there are no tabs", () => {
setup();
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
});
});
import {
type RenderWithProvidersOptions,
renderWithProviders,
} from "__support__/ui";
import type { Dashboard } from "metabase-types/api";
import { createMockDashboard } from "metabase-types/api/mocks";
import { DashboardEntityIdCard } from "../DashboardEntityIdCard";
export const setup = ({
dashboard = createMockDashboard(),
...renderOptions
}: {
dashboard?: Dashboard;
} & RenderWithProvidersOptions = {}) => {
return renderWithProviders(
<DashboardEntityIdCard dashboard={dashboard} />,
renderOptions,
);
};
......@@ -15,7 +15,6 @@ import SidesheetS from "metabase/common/components/Sidesheet/sidesheet.module.cs
import { Timeline } from "metabase/common/components/Timeline";
import { getTimelineEvents } from "metabase/common/components/Timeline/utils";
import { useRevisionListQuery } from "metabase/common/hooks";
import { EntityIdCard } from "metabase/components/EntityIdCard";
import { revertToRevision, updateDashboard } from "metabase/dashboard/actions";
import { DASHBOARD_DESCRIPTION_MAX_LENGTH } from "metabase/dashboard/constants";
import { useDispatch, useSelector } from "metabase/lib/redux";
......@@ -24,6 +23,7 @@ import { Stack, Tabs, Text } from "metabase/ui";
import type { Dashboard, Revision, User } from "metabase-types/api";
import { DashboardDetails } from "./DashboardDetails";
import { DashboardEntityIdCard } from "./DashboardEntityIdCard";
interface DashboardInfoSidebarProps {
dashboard: Dashboard;
......@@ -172,7 +172,7 @@ const OverviewTab = ({
<SidesheetCard>
<DashboardDetails dashboard={dashboard} />
</SidesheetCard>
<EntityIdCard entityId={dashboard.entity_id} />
<DashboardEntityIdCard dashboard={dashboard} />
</Stack>
);
};
......
import userEvent from "@testing-library/user-event";
import { screen, within } from "__support__/ui";
export type ViewMantineSelectOptionsParams = {
/** This function will identify the root element of the select with
* screen.findByRole("combobox") but you can also supply that root element
* via this parameter */
root?: HTMLElement;
/** If supplied, this function will find the root element of the select with
* await within(findWithinElement).findByRole("combobox") */
findWithinElement?: HTMLElement;
};
/** Clicks a Mantine <Select> component, views its options, and returns info about them */
export const viewMantineSelectOptions = async ({
findWithinElement,
root,
}: ViewMantineSelectOptionsParams = {}) => {
root ??= findWithinElement
? await within(findWithinElement).findByRole("combobox")
: await screen.findByRole("combobox");
// The click listener is not on the combobox itself but on an <input> inside it
const displayedOption = (await within(root).findByRole(
"searchbox",
)) as HTMLInputElement;
await userEvent.click(displayedOption);
const listbox = await screen.findByRole("listbox");
const optionElements = await within(listbox).findAllByRole("option");
const optionTextContents = optionElements.map(option => option.textContent);
return {
optionElements,
optionTextContents,
displayedOption,
};
};
import { render, screen, within } from "__support__/ui";
import { Select } from "metabase/ui";
import { viewMantineSelectOptions } from "./mantineSelect";
describe("viewMantineSelectOptions", () => {
it("fetches options from the <Select> component", async () => {
render(
<Select
value="option2"
data={[
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
]}
/>,
);
const { optionElements, optionTextContents, displayedOption } =
await viewMantineSelectOptions();
expect(optionElements.length).toBe(3);
expect(optionElements[0]).toHaveTextContent("Option 1");
expect(optionElements[1]).toHaveTextContent("Option 2");
expect(optionElements[2]).toHaveTextContent("Option 3");
expect(optionTextContents).toEqual(["Option 1", "Option 2", "Option 3"]);
expect(displayedOption.value).toBe("Option 2");
});
it("identifies the <Select> component within a provided element, and returns information about its options", async () => {
render(
<>
<Select
value="select1-option2"
data={[
{ value: "select1-option1", label: "First Select, option 1" },
{ value: "select1-option2", label: "First Select, option 2" },
{ value: "select1-option3", label: "First Select, option 3" },
]}
/>
<div data-testid="second-select-container">
<Select
value="select2-option2"
data={[
{
value: "select2-option1",
label: "Second Select, option 1",
},
{
value: "select2-option2",
label: "Second Select, option 2",
},
{
value: "select2-option3",
label: "Second Select, option 3",
},
]}
/>
</div>
</>,
);
const secondSelectContainer = await screen.findByTestId(
"second-select-container",
);
const { optionElements, optionTextContents, displayedOption } =
await viewMantineSelectOptions({
findWithinElement: secondSelectContainer,
});
expect(optionElements.length).toBe(3);
expect(optionTextContents).toContain("Second Select, option 1");
expect(optionTextContents).toContain("Second Select, option 2");
expect(optionTextContents).toContain("Second Select, option 3");
expect(displayedOption.value).toBe("Second Select, option 2");
});
it("fetches options from the Select component with a given root element", async () => {
render(
<>
<Select
value="select1-option2"
data={[
{ value: "select1-option1", label: "First Select, option 1" },
{ value: "select1-option2", label: "First Select, option 2" },
{ value: "select1-option3", label: "First Select, option 3" },
]}
/>
<div data-testid="second-select-container">
<Select
value="select2-option2"
data={[
{
value: "select2-option1",
label: "Second Select, option 1",
},
{
value: "select2-option2",
label: "Second Select, option 2",
},
{
value: "select2-option3",
label: "Second Select, option 3",
},
]}
/>
</div>
</>,
);
const secondSelectRoot = await within(
await screen.findByTestId("second-select-container"),
).findByRole("combobox");
const { optionElements, optionTextContents, displayedOption } =
await viewMantineSelectOptions({
root: secondSelectRoot,
});
expect(optionElements.length).toBe(3);
expect(optionTextContents).toContain("Second Select, option 1");
expect(optionTextContents).toContain("Second Select, option 2");
expect(optionTextContents).toContain("Second Select, option 3");
expect(displayedOption.value).toBe("Second Select, option 2");
});
it("throws an error if the <Select> is not found", async () => {
await expect(viewMantineSelectOptions()).rejects.toThrow(
/Unable to find.*role="combobox"/,
);
});
});
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