diff --git a/frontend/src/metabase/collections/components/CollectionInfoSidebar/CollectionInfoSidebar.tsx b/frontend/src/metabase/collections/components/CollectionInfoSidebar/CollectionInfoSidebar.tsx index 1bf1e46607c41afabbc2f46b796f8c0c38bbba6c..05911e5869a20bb3cc5f866aa0ef6863eed75ccf 100644 --- a/frontend/src/metabase/collections/components/CollectionInfoSidebar/CollectionInfoSidebar.tsx +++ b/frontend/src/metabase/collections/components/CollectionInfoSidebar/CollectionInfoSidebar.tsx @@ -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); diff --git a/frontend/src/metabase/common/components/Sidesheet/SidesheetCard.tsx b/frontend/src/metabase/common/components/Sidesheet/SidesheetCard.tsx index 24a150ec251cab025a0e64e8f7c12dd565e2c48c..04e626c3988d8a3dcf9933de549342e452e391a2 100644 --- a/frontend/src/metabase/common/components/Sidesheet/SidesheetCard.tsx +++ b/frontend/src/metabase/common/components/Sidesheet/SidesheetCard.tsx @@ -1,28 +1,43 @@ 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} + /> +); diff --git a/frontend/src/metabase/common/components/Sidesheet/SidesheetCardSection.tsx b/frontend/src/metabase/common/components/Sidesheet/SidesheetCardSection.tsx index ac23fc67fe2696f4bfbde9b2f5b73a7d309d74e1..0dfcf2b1f0f285cba860310da00eaa6b8e0ef627 100644 --- a/frontend/src/metabase/common/components/Sidesheet/SidesheetCardSection.tsx +++ b/frontend/src/metabase/common/components/Sidesheet/SidesheetCardSection.tsx @@ -1,7 +1,9 @@ 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> ); diff --git a/frontend/src/metabase/components/EntityIdCard/EntityIdCard.module.css b/frontend/src/metabase/components/EntityIdCard/EntityIdCard.module.css index 8e8dfe681a83dc8873395c9d43715f299cc94b17..f8195fbbd4ec10576427f56bd39a84de0d45f5ee 100644 --- a/frontend/src/metabase/components/EntityIdCard/EntityIdCard.module.css +++ b/frontend/src/metabase/components/EntityIdCard/EntityIdCard.module.css @@ -18,9 +18,7 @@ color: var(--mb-color-brand); } - background-color: var(--mb-color-border); border-radius: 100%; - padding: 2px; cursor: pointer; &:focus { diff --git a/frontend/src/metabase/components/EntityIdCard/EntityIdCard.tsx b/frontend/src/metabase/components/EntityIdCard/EntityIdCard.tsx index 534bf777c070a0a74b8c55af0d69ac817e83f879..35d1856e44ff7a5975d409debbf8406afc067231 100644 --- a/frontend/src/metabase/components/EntityIdCard/EntityIdCard.tsx +++ b/frontend/src/metabase/components/EntityIdCard/EntityIdCard.tsx @@ -1,49 +1,83 @@ 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> ); } diff --git a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardEntityIdCard/DashboardEntityIdCard.tsx b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardEntityIdCard/DashboardEntityIdCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9683a8679a2ea4b657faee6eb7683ebc4b5a5c28 --- /dev/null +++ b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardEntityIdCard/DashboardEntityIdCard.tsx @@ -0,0 +1,60 @@ +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> + ); +}; diff --git a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardEntityIdCard/index.ts b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardEntityIdCard/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1888e8709264121e2008b6fe526bec8eae4ddd1c --- /dev/null +++ b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardEntityIdCard/index.ts @@ -0,0 +1 @@ +export { DashboardEntityIdCard } from "./DashboardEntityIdCard"; diff --git a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardEntityIdCard/tests/common.unit.spec.tsx b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardEntityIdCard/tests/common.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a5a9bda14c6e0b337f117cc91b0aa6158f659b8a --- /dev/null +++ b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardEntityIdCard/tests/common.unit.spec.tsx @@ -0,0 +1,8 @@ +import { setup } from "./setup"; + +describe("DashboardEntityIdCard (OSS)", () => { + it("should return null", async () => { + const { container } = setup(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardEntityIdCard/tests/enterprise.unit.spec.tsx b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardEntityIdCard/tests/enterprise.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b8217a9840ac4145b202e5d69262695dc1fe4781 --- /dev/null +++ b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardEntityIdCard/tests/enterprise.unit.spec.tsx @@ -0,0 +1,8 @@ +import { setup } from "./setup"; + +describe("DashboardEntityIdCard (EE without token)", () => { + it("should return null", async () => { + const { container } = setup({ shouldSetupEnterprisePlugins: true }); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardEntityIdCard/tests/premium.unit.spec.tsx b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardEntityIdCard/tests/premium.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6b88b06e995ef865cf3d2ec5f78e45ec01137499 --- /dev/null +++ b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardEntityIdCard/tests/premium.unit.spec.tsx @@ -0,0 +1,71 @@ +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(); + }); +}); diff --git a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardEntityIdCard/tests/setup.tsx b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardEntityIdCard/tests/setup.tsx new file mode 100644 index 0000000000000000000000000000000000000000..642b1ab6097f90708afabfd6755b2dfd22b72d52 --- /dev/null +++ b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardEntityIdCard/tests/setup.tsx @@ -0,0 +1,20 @@ +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, + ); +}; diff --git a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.tsx b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.tsx index 84e8ab9742403c6d7e862e22140257184652873e..100605d245157f3ad753847829a9ca3b25259f99 100644 --- a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.tsx +++ b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.tsx @@ -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> ); }; diff --git a/frontend/test/__support__/components/mantineSelect.ts b/frontend/test/__support__/components/mantineSelect.ts new file mode 100644 index 0000000000000000000000000000000000000000..4acd046024a175a475eeaa78fc3334bbd7b65a5d --- /dev/null +++ b/frontend/test/__support__/components/mantineSelect.ts @@ -0,0 +1,39 @@ +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, + }; +}; diff --git a/frontend/test/__support__/components/mantineSelect.unit.spec.tsx b/frontend/test/__support__/components/mantineSelect.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fc725771732b9c7264d9257ffb779c7a712e7d29 --- /dev/null +++ b/frontend/test/__support__/components/mantineSelect.unit.spec.tsx @@ -0,0 +1,128 @@ +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"/, + ); + }); +});