diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewBanner/ModerationReview.module.css b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewBanner/ModerationReview.module.css new file mode 100644 index 0000000000000000000000000000000000000000..18a5b79177cbc41b17c4c11d293eb7f771f04c4f --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewBanner/ModerationReview.module.css @@ -0,0 +1,3 @@ +.IconMargin { + margin-top: 0.25rem; +} diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewBanner/ModerationReviewBanner.tsx b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewBanner/ModerationReviewBanner.tsx index 8884087eed33b3d475748609346594cc0f61282c..e9c8c51ce5b10e15cfc4b95a1a9b6d35f18bcc4a 100644 --- a/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewBanner/ModerationReviewBanner.tsx +++ b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewBanner/ModerationReviewBanner.tsx @@ -5,7 +5,7 @@ import { skipToken, useGetUserQuery } from "metabase/api"; import { alpha, color } from "metabase/lib/colors"; import { useSelector } from "metabase/lib/redux"; import { getRelativeTime } from "metabase/lib/time"; -import { Flex, Icon, Text as UIText } from "metabase/ui"; +import { FixedSizeIcon, Flex, Icon, Text as UIText } from "metabase/ui"; import { getIconForReview, getLatestModerationReview, @@ -14,13 +14,13 @@ import { import type Question from "metabase-lib/v1/Question"; import type { ModerationReview } from "metabase-types/api"; +import Styles from "./ModerationReview.module.css"; import { Container, Text, TextContainer, Time, } from "./ModerationReviewBanner.styled"; - const ICON_BUTTON_SIZE = 16; interface ModerationReviewBannerProps { @@ -93,8 +93,13 @@ export const ModerationReviewText = ({ question }: { question: Question }) => { ); return ( - <Flex gap="sm" align="center"> - <Icon name={iconName} color={color(iconColor)} size={ICON_BUTTON_SIZE} /> + <Flex gap="sm" align="top"> + <FixedSizeIcon + name={iconName} + color={color(iconColor)} + size={ICON_BUTTON_SIZE} + className={Styles.IconMargin} + /> <UIText> {bannerText} {relativeCreationTime} </UIText> diff --git a/frontend/src/metabase/browse/containers/TableBrowser/TableBrowser.jsx b/frontend/src/metabase/browse/containers/TableBrowser/TableBrowser.jsx index fe86b97690ba0b0a8b9ca22c8e490565113b4b30..f762df7e2a1105c5328731b08b6bbbeaa85c025a 100644 --- a/frontend/src/metabase/browse/containers/TableBrowser/TableBrowser.jsx +++ b/frontend/src/metabase/browse/containers/TableBrowser/TableBrowser.jsx @@ -35,7 +35,7 @@ const getSchemaName = props => { const getReloadInterval = (_state, _props, tables = []) => tables.some(t => isSyncInProgress(t)) ? RELOAD_INTERVAL : 0; -const getTableUrl = (table, metadata) => { +export const getTableUrl = (table, metadata) => { const metadataTable = metadata?.table(table.id); return ML_Urls.getUrl(metadataTable?.newQuestion(), { clean: false }); }; diff --git a/frontend/src/metabase/common/components/Sidesheet/SidesheetTabPanelContainer.tsx b/frontend/src/metabase/common/components/Sidesheet/SidesheetTabPanelContainer.tsx index 9da663fdb59712fff48853ba1245541f59b39dec..114976f9a0260f95e9ee60ed3694fddef4e142c5 100644 --- a/frontend/src/metabase/common/components/Sidesheet/SidesheetTabPanelContainer.tsx +++ b/frontend/src/metabase/common/components/Sidesheet/SidesheetTabPanelContainer.tsx @@ -10,7 +10,7 @@ import Styles from "./sidesheet.module.css"; export const SidesheetTabPanelContainer = ( props: MantineStyleSystemProps & { children: React.ReactNode }, ) => ( - <Box className={Styles.OverflowAuto} p="lg" {...props}> + <Box className={Styles.OverflowAuto} p="xl" {...props}> <div>{props.children}</div> </Box> ); diff --git a/frontend/src/metabase/components/EntityMenu/EntityMenu.jsx b/frontend/src/metabase/components/EntityMenu/EntityMenu.jsx index 8608192fabc1430ebe9b85e22822800856f81b0c..8c139e6505a58c42c42e44714ccbfee7020efc2e 100644 --- a/frontend/src/metabase/components/EntityMenu/EntityMenu.jsx +++ b/frontend/src/metabase/components/EntityMenu/EntityMenu.jsx @@ -5,7 +5,7 @@ import { Component, createRef } from "react"; import EntityMenuItem from "metabase/components/EntityMenuItem"; import EntityMenuTrigger from "metabase/components/EntityMenuTrigger"; import CS from "metabase/css/core/index.css"; -import { Popover } from "metabase/ui"; +import { Divider, Popover } from "metabase/ui"; /** * @deprecated: use Menu from "metabase/ui" @@ -98,6 +98,14 @@ class EntityMenu extends Component { const key = item.key ?? item.title; + if (item.separator) { + return ( + <li key={key}> + <Divider m="sm" /> + </li> + ); + } + if (item.content) { return ( <li key={key} data-testid={item.testId}> diff --git a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.tsx b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.tsx index fec2025b3ccd7d0e604047db0429baf646d3a919..78fce5da5ae7adca0716a411891bcabebfc76dae 100644 --- a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.tsx +++ b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/DashboardInfoSidebar.tsx @@ -1,9 +1,10 @@ import type { FocusEvent, SetStateAction } from "react"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useMount } from "react-use"; import { t } from "ttag"; import ErrorBoundary from "metabase/ErrorBoundary"; +import { isInstanceAnalyticsCollection } from "metabase/collections/utils"; import { Sidesheet, SidesheetCard, @@ -70,6 +71,13 @@ export function DashboardInfoSidebar({ query: { model_type: "dashboard", model_id: dashboard.id }, }); + const isIADashboard = useMemo( + () => + dashboard.collection && + isInstanceAnalyticsCollection(dashboard?.collection), + [dashboard.collection], + ); + const currentUser = useSelector(getUser); const dispatch = useDispatch(); @@ -123,9 +131,11 @@ export function DashboardInfoSidebar({ defaultValue={Tab.Overview} className={SidesheetS.FlexScrollContainer} > - <Tabs.List mx="lg"> + <Tabs.List mx="xl"> <Tabs.Tab value={Tab.Overview}>{t`Overview`}</Tabs.Tab> - <Tabs.Tab value={Tab.History}>{t`History`}</Tabs.Tab> + {!isIADashboard && ( + <Tabs.Tab value={Tab.History}>{t`History`}</Tabs.Tab> + )} </Tabs.List> <SidesheetTabPanelContainer> <Tabs.Panel value={Tab.Overview}> diff --git a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/tests/premium.unit.spec.ts b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/tests/premium.unit.spec.ts index 959b17e035e4420969d17b11d4c98e58ae15f4ed..842ab93431ebaf17b0c3311bee4b7ca2123de88b 100644 --- a/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/tests/premium.unit.spec.ts +++ b/frontend/src/metabase/dashboard/components/DashboardInfoSidebar/tests/premium.unit.spec.ts @@ -1,11 +1,16 @@ import userEvent from "@testing-library/user-event"; import { screen } from "__support__/ui"; +import { + createMockCollection, + createMockDashboard, +} from "metabase-types/api/mocks"; import { setupEnterprise } from "./setup"; const tokenFeatures = { cache_granular_controls: true, + audit_app: true, }; describe("DashboardInfoSidebar > premium enterprise", () => { @@ -30,4 +35,17 @@ describe("DashboardInfoSidebar > premium enterprise", () => { expect(await screen.findByText("Caching settings")).toBeInTheDocument(); }); + + it("should hide history for instance analytics dashboard", async () => { + await setupEnterprise( + { + dashboard: createMockDashboard({ + collection: createMockCollection({ type: "instance-analytics" }), + }), + }, + tokenFeatures, + ); + + expect(screen.queryByText("History")).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/QuestionActions/QuestionActions.tsx b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/QuestionActions/QuestionActions.tsx index 7c78fd9217c4f532b139a4ea94ea157dd58f539b..8706f567c52c1ff51f8481d093fe45c7ed06e63a 100644 --- a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/QuestionActions/QuestionActions.tsx +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/QuestionActions/QuestionActions.tsx @@ -125,6 +125,15 @@ export const QuestionActions = ({ const extraButtons = []; + if (isQuestion || isMetric) { + extraButtons.push({ + title: t`Add to dashboard`, + icon: "add_to_dash", + action: () => onOpenModal(MODAL_TYPES.ADD_TO_DASHBOARD), + testId: ADD_TO_DASH_TESTID, + }); + } + if ( isMetabotEnabled && isModel && @@ -168,15 +177,26 @@ export const QuestionActions = ({ } } - if (isQuestion || isMetric) { - extraButtons.push({ - title: t`Add to dashboard`, - icon: "add_to_dash", - action: () => onOpenModal(MODAL_TYPES.ADD_TO_DASHBOARD), - testId: ADD_TO_DASH_TESTID, - }); + if (hasCollectionPermissions) { + if (isQuestion) { + extraButtons.push({ + title: t`Turn into a model`, + icon: "model", + action: handleTurnToModel, + testId: TURN_INTO_DATASET_TESTID, + }); + } + if (isModel) { + extraButtons.push({ + title: t`Turn back to saved question`, + icon: "insight", + action: onTurnModelIntoQuestion, + }); + } } + extraButtons.push(...PLUGIN_QUERY_BUILDER_HEADER.extraButtons(question)); + if (enableSettingsSidebar) { extraButtons.push({ title: t`Edit settings`, @@ -187,6 +207,10 @@ export const QuestionActions = ({ } if (hasCollectionPermissions) { + extraButtons.push({ + separator: true, + key: "move-separator", + }); extraButtons.push({ title: t`Move`, icon: "move", @@ -205,26 +229,10 @@ export const QuestionActions = ({ } if (hasCollectionPermissions) { - if (isQuestion) { - extraButtons.push({ - title: t`Turn into a model`, - icon: "model", - action: handleTurnToModel, - testId: TURN_INTO_DATASET_TESTID, - }); - } - if (isModel) { - extraButtons.push({ - title: t`Turn back to saved question`, - icon: "insight", - action: onTurnModelIntoQuestion, - }); - } - } - - extraButtons.push(...PLUGIN_QUERY_BUILDER_HEADER.extraButtons(question)); - - if (hasCollectionPermissions) { + extraButtons.push({ + separator: true, + key: "trash-separator", + }); extraButtons.push({ title: t`Move to trash`, icon: "trash", 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 984a9c9cf9b08ebbefb0c7d3664979b2068dca05..5cd10c0ea1db6ebc6ec81908dc9e4b7e7320a0b6 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,13 +2,19 @@ import cx from "classnames"; import { useState } from "react"; import { c, t } from "ttag"; +import { getTableUrl } from "metabase/browse/containers/TableBrowser/TableBrowser"; 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, Icon, Text } from "metabase/ui"; +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"; @@ -21,8 +27,8 @@ export const QuestionDetails = ({ question }: { question: Question }) => { <> <SidesheetCardSection title={t`Creator and last editor`}> {lastEditInfo && ( - <Flex gap="sm" align="center"> - <Icon name="ai" /> + <Flex gap="sm" align="top"> + <Icon name="ai" className={SidebarStyles.IconMargin} /> <Text> {c("{0} is a date/time and {1} is a person's name").jt`${( <DateTime @@ -35,8 +41,8 @@ export const QuestionDetails = ({ question }: { question: Question }) => { </Flex> )} - <Flex gap="sm" align="center"> - <Icon name="pencil" /> + <Flex gap="sm" align="top"> + <Icon name="pencil" className={SidebarStyles.IconMargin} /> <Text> {c("{0} is a date/time and {1} is a person's name").jt`${( <DateTime unit="day" value={createdAt} key="date" /> @@ -45,9 +51,20 @@ export const QuestionDetails = ({ question }: { question: Question }) => { </Flex> </SidesheetCardSection> <SidesheetCardSection title={t`Saved in`}> - <Flex gap="sm" align="center"> - <Icon name="folder" /> - <Text>{question.collection()?.name}</Text> + <Flex gap="sm" align="top" color="var(--mb-color-brand)"> + <Icon + name="folder" + color="var(--mb-color-brand)" + className={SidebarStyles.IconMargin} + /> + <Text> + <Link + to={`/collection/${question.collection()?.id}`} + variant="brand" + > + {question.collection()?.name} + </Link> + </Text> </Flex> </SidesheetCardSection> <SharingDisplay question={question} /> @@ -58,21 +75,40 @@ export const QuestionDetails = ({ question }: { 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>{sourceInfo.db.name}</Text> + <Text> + <Link + to={`/browse/databases/${sourceInfo.db.id}`} + variant="brand" + > + {sourceInfo.db.name} + </Link> + </Text> {"/"} </> )} - <Text>{sourceInfo?.display_name}</Text> + <Text> + <Link to={sourceUrl} variant="brand"> + {sourceInfo?.display_name} + </Link> + </Text> </Flex> </SidesheetCardSection> ); @@ -102,7 +138,7 @@ function SharingDisplay({ question }: { question: Question }) { className={cx( Styles.cursorPointer, Styles.textBrandHover, - SidebarStyles.LinkIcon, + SidebarStyles.IconMargin, )} /> } diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/QuestionInfoSidebar.module.css b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/QuestionInfoSidebar.module.css index 70ef3c3b41e20ee3524272110bad25abca6d12d7..3341dec5cbdc46c2a17931db08714ba19e2bbda2 100644 --- a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/QuestionInfoSidebar.module.css +++ b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/QuestionInfoSidebar.module.css @@ -13,6 +13,6 @@ padding: 1px; } -.LinkIcon { - margin-top: 0.2rem; +.IconMargin { + margin-top: 0.25rem; } diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/QuestionInfoSidebar.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/QuestionInfoSidebar.tsx index 44e9b9673ed5a79b850adad62051ba4973b21d30..51b697a26431b3692b6c0cdd5d73fa32a9056044 100644 --- a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/QuestionInfoSidebar.tsx +++ b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/QuestionInfoSidebar.tsx @@ -1,7 +1,8 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useMount } from "react-use"; import { t } from "ttag"; +import { isInstanceAnalyticsCollection } from "metabase/collections/utils"; import { Sidesheet, SidesheetCard, @@ -40,6 +41,11 @@ export const QuestionInfoSidebar = ({ } }; + const isIAQuestion = useMemo( + () => isInstanceAnalyticsCollection(question.collection()), + [question], + ); + const dispatch = useDispatch(); const handleClose = () => dispatch(onCloseQuestionInfo()); @@ -59,14 +65,15 @@ export const QuestionInfoSidebar = ({ isOpen={isOpen} removeBodyPadding data-testid="question-info-sidebar" + size="md" > <Tabs defaultValue="overview" className={SidesheetStyles.FlexScrollContainer} > - <Tabs.List mx="lg"> + <Tabs.List mx="xl"> <Tabs.Tab value="overview">{t`Overview`}</Tabs.Tab> - <Tabs.Tab value="history">{t`History`}</Tabs.Tab> + {!isIAQuestion && <Tabs.Tab value="history">{t`History`}</Tabs.Tab>} </Tabs.List> <SidesheetTabPanelContainer> <Tabs.Panel value="overview"> diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/tests/enterprise.unit.spec.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/tests/enterprise.unit.spec.tsx index 9553c12b13c45d4f082dc59aba92e45d160838d2..c247440b3caf9b5dd2a73d0a15e787d570a67f2b 100644 --- a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/tests/enterprise.unit.spec.tsx +++ b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/tests/enterprise.unit.spec.tsx @@ -14,9 +14,9 @@ const setupEnterprise = (opts: SetupOpts) => { }); }; -describe("QuestionInfoSidebar", () => { +describe("QuestionInfoSidebar > enterprise", () => { describe("moderation reviews", () => { - it("should not show the verification badge", async () => { + it("should not show the verification badge without content verification feature", async () => { const card = createMockCard({ moderation_reviews: [ createMockModerationReview({ status: "verified" }), diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/tests/premium.unit.spec.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/tests/premium.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..adf80d2cc59159cdc2a59039b393b5047c87941c --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionInfoSidebar/tests/premium.unit.spec.tsx @@ -0,0 +1,67 @@ +import { screen } from "__support__/ui"; +import { + createMockCard, + createMockCollection, + createMockModerationReview, + createMockSettings, + createMockTokenFeatures, +} from "metabase-types/api/mocks"; + +import type { SetupOpts } from "./setup"; +import { setup } from "./setup"; + +const setupEnterprise = (opts: SetupOpts) => { + return setup({ + ...opts, + settings: createMockSettings({ + "token-features": createMockTokenFeatures({ + content_verification: true, + cache_granular_controls: true, + audit_app: true, + }), + }), + hasEnterprisePlugins: true, + }); +}; + +describe("QuestionInfoSidebar > premium", () => { + describe("content verification", () => { + it("should show the verification badge for verified content", async () => { + const card = createMockCard({ + moderation_reviews: [ + createMockModerationReview({ status: "verified" }), + ], + }); + await setupEnterprise({ card }); + expect(screen.getByText(/verified this/)).toBeInTheDocument(); + }); + + it("should not show the verification badge for unverified content", async () => { + const card = createMockCard(); + await setupEnterprise({ card }); + expect(screen.queryByText(/verified this/)).not.toBeInTheDocument(); + }); + }); + + describe("analytics content", () => { + it("should show the history section for non analytics content", async () => { + await setupEnterprise({ + card: createMockCard({ + collection: createMockCollection(), + }), + }); + + expect(await screen.findByText("History")).toBeInTheDocument(); + }); + }); + + it("should not show the history section for instance analytics question", async () => { + await setupEnterprise({ + card: createMockCard({ + collection: createMockCollection({ type: "instance-analytics" }), + }), + }); + + expect(screen.queryByText("History")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionSettingsSidebar/QuestionSettingsSidebar.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionSettingsSidebar/QuestionSettingsSidebar.tsx index 90b0324c48cf71c8b08cfe845d99615331dbae10..5df10ab8993f94d263a3760faa956b41cc01efa7 100644 --- a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionSettingsSidebar/QuestionSettingsSidebar.tsx +++ b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionSettingsSidebar/QuestionSettingsSidebar.tsx @@ -3,6 +3,7 @@ import { useMount } from "react-use"; import { match } from "ts-pattern"; import { t } from "ttag"; +import { isInstanceAnalyticsCollection } from "metabase/collections/utils"; import { Sidesheet, SidesheetCard } from "metabase/common/components/Sidesheet"; import { useDispatch } from "metabase/lib/redux"; import { PLUGIN_CACHING, PLUGIN_MODEL_PERSISTENCE } from "metabase/plugins"; @@ -84,6 +85,12 @@ export const QuestionSettingsSidebar = ({ }; export const shouldShowQuestionSettingsSidebar = (question: Question) => { + const isIAQuestion = isInstanceAnalyticsCollection(question.collection()); + + if (isIAQuestion) { + return false; + } + const isCacheableQuestion = PLUGIN_CACHING.isGranularCachingEnabled() && PLUGIN_CACHING.hasQuestionCacheSection(question);