diff --git a/e2e/test/scenarios/metrics/browse.cy.spec.ts b/e2e/test/scenarios/metrics/browse.cy.spec.ts index 9adb50a704046bf6ab408afda50b21046d7fda5e..e88c7e629bf53af6eea32679bdf6e24d45f9275b 100644 --- a/e2e/test/scenarios/metrics/browse.cy.spec.ts +++ b/e2e/test/scenarios/metrics/browse.cy.spec.ts @@ -14,6 +14,7 @@ import { getSidebarSectionTitle, main, navigationSidebar, + openNavigationSidebar, popover, restore, setTokenFeatures, @@ -145,40 +146,6 @@ const ALL_METRICS = [ NON_NUMERIC_METRIC, ]; -function createMetrics( - metrics: StructuredQuestionDetailsWithName[] = ALL_METRICS, -) { - metrics.forEach(metric => createQuestion(metric)); -} - -function metricsTable() { - return cy.findByLabelText("Table of metrics").should("be.visible"); -} - -function findMetric(name: string) { - return metricsTable().findByText(name).should("be.visible"); -} - -function getMetricsTableItem(index: number) { - return metricsTable().findAllByTestId("metric-name").eq(index); -} - -function shouldHaveBookmark(name: string) { - getSidebarSectionTitle(/Bookmarks/).should("be.visible"); - navigationSidebar().findByText(name).should("be.visible"); -} - -function shouldNotHaveBookmark(name: string) { - getSidebarSectionTitle(/Bookmarks/).should("not.exist"); - navigationSidebar().findByText(name).should("not.exist"); -} - -function checkMetricValueAndTooltipExist(value: string, label: string) { - metricsTable().findByText(value).should("be.visible"); - metricsTable().findByText(value).realHover(); - tooltip().should("contain", label); -} - const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; describe("scenarios > browse > metrics", () => { @@ -467,6 +434,93 @@ describe("scenarios > browse > metrics", () => { }); }); + describe("verified metrics", () => { + describeEE("on enterprise", () => { + beforeEach(() => { + cy.signInAsAdmin(); + setTokenFeatures("all"); + }); + + it("should not the verified metrics filter when there are no verified metrics", () => { + createMetrics(); + cy.visit("/browse/metrics"); + + cy.findByLabelText("Filters").should("not.exist"); + }); + + it("should show the verified metrics filter when there are verified metrics", () => { + cy.intercept( + "PUT", + "/api/setting/browse-filter-only-verified-metrics", + ).as("setSetting"); + + createMetrics([ORDERS_SCALAR_METRIC, ORDERS_SCALAR_MODEL_METRIC]); + cy.visit("/browse/metrics"); + + findMetric(ORDERS_SCALAR_METRIC.name).should("be.visible"); + findMetric(ORDERS_SCALAR_MODEL_METRIC.name).should("be.visible"); + + verifyMetric(ORDERS_SCALAR_METRIC); + + findMetric(ORDERS_SCALAR_METRIC.name).should("be.visible"); + findMetric(ORDERS_SCALAR_MODEL_METRIC.name).should("not.exist"); + + toggleVerifiedMetricsFilter(); + cy.get<{ request: Request }>("@setSetting").should(xhr => { + expect(xhr.request.body).to.deep.equal({ value: false }); + }); + + findMetric(ORDERS_SCALAR_METRIC.name).should("be.visible"); + findMetric(ORDERS_SCALAR_MODEL_METRIC.name).should("be.visible"); + + toggleVerifiedMetricsFilter(); + cy.get<{ request: Request }>("@setSetting").should(xhr => { + expect(xhr.request.body).to.deep.equal({ value: true }); + }); + cy.wait("@setSetting"); + + findMetric(ORDERS_SCALAR_METRIC.name).should("be.visible"); + findMetric(ORDERS_SCALAR_MODEL_METRIC.name).should("not.exist"); + + unverifyMetric(ORDERS_SCALAR_METRIC); + + findMetric(ORDERS_SCALAR_METRIC.name).should("be.visible"); + findMetric(ORDERS_SCALAR_MODEL_METRIC.name).should("be.visible"); + }); + + it("should respect the user setting on wether or not to only show verified metrics", () => { + cy.intercept("GET", "/api/session/properties", req => { + req.continue(res => { + res.body["browse-filter-only-verified-metrics"] = true; + res.send(); + }); + }); + + createMetrics([ORDERS_SCALAR_METRIC, ORDERS_SCALAR_MODEL_METRIC]); + cy.visit("/browse/metrics"); + verifyMetric(ORDERS_SCALAR_METRIC); + + cy.findByLabelText("Filters").should("be.visible").click(); + popover() + .findByLabelText("Show verified metrics only") + .should("be.checked"); + + cy.intercept("GET", "/api/session/properties", req => { + req.continue(res => { + res.body["browse-filter-only-verified-metrics"] = true; + res.send(); + }); + }); + + cy.visit("/browse/metrics"); + cy.findByLabelText("Filters").should("be.visible").click(); + popover() + .findByLabelText("Show verified metrics only") + .should("not.be.checked"); + }); + }); + }); + describe("temporal metric value", () => { it("should show the last value of a temporal metric", () => { cy.signInAsAdmin(); @@ -555,3 +609,69 @@ describe("scenarios > browse > metrics", () => { }); }); }); + +function createMetrics( + metrics: StructuredQuestionDetailsWithName[] = ALL_METRICS, +) { + metrics.forEach(metric => createQuestion(metric)); +} + +function metricsTable() { + return cy.findByLabelText("Table of metrics").should("be.visible"); +} + +function findMetric(name: string) { + return metricsTable().findByText(name); +} + +function getMetricsTableItem(index: number) { + return metricsTable().findAllByTestId("metric-name").eq(index); +} + +function shouldHaveBookmark(name: string) { + getSidebarSectionTitle(/Bookmarks/).should("be.visible"); + navigationSidebar().findByText(name).should("be.visible"); +} + +function shouldNotHaveBookmark(name: string) { + getSidebarSectionTitle(/Bookmarks/).should("not.exist"); + navigationSidebar().findByText(name).should("not.exist"); +} + +function checkMetricValueAndTooltipExist(value: string, label: string) { + metricsTable().findByText(value).should("be.visible"); + metricsTable().findByText(value).realHover(); + tooltip().should("contain", label); +} + +function verifyMetric(metric: StructuredQuestionDetailsWithName) { + metricsTable().findByText(metric.name).should("be.visible").click(); + + cy.button("Move, trash, and more...").click(); + popover().findByText("Verify this metric").click(); + + openNavigationSidebar(); + + navigationSidebar() + .findByRole("listitem", { name: "Browse metrics" }) + .click(); +} + +function unverifyMetric(metric: StructuredQuestionDetailsWithName) { + metricsTable().findByText(metric.name).should("be.visible").click(); + + cy.button("Move, trash, and more...").click(); + popover().findByText("Remove verification").click(); + + openNavigationSidebar(); + + navigationSidebar() + .findByRole("listitem", { name: "Browse metrics" }) + .click(); +} + +function toggleVerifiedMetricsFilter() { + cy.findByLabelText("Filters").should("be.visible").click(); + popover().findByText("Show verified metrics only").click(); + cy.findByLabelText("Filters").should("be.visible").click(); +} diff --git a/enterprise/frontend/src/metabase-enterprise/content_verification/index.ts b/enterprise/frontend/src/metabase-enterprise/content_verification/index.ts index d8aafe087ebd8b14c45543ef5bb7e6b3bcb7c6d3..a5c206fcb98b94d49974a472837f0f93083e80eb 100644 --- a/enterprise/frontend/src/metabase-enterprise/content_verification/index.ts +++ b/enterprise/frontend/src/metabase-enterprise/content_verification/index.ts @@ -3,6 +3,7 @@ import { hasPremiumFeature } from "metabase-enterprise/settings"; import { ModelFilterControls } from "./ModelFilterControls"; import { VerifiedFilter } from "./VerifiedFilter"; +import { MetricFilterControls, getDefaultMetricFilters } from "./metrics"; import { availableModelFilters, useModelFilterSettings } from "./utils"; if (hasPremiumFeature("content_verification")) { @@ -11,5 +12,9 @@ if (hasPremiumFeature("content_verification")) { ModelFilterControls, availableModelFilters, useModelFilterSettings, + + contentVerificationEnabled: true, + getDefaultMetricFilters, + MetricFilterControls, }); } diff --git a/enterprise/frontend/src/metabase-enterprise/content_verification/metrics.tsx b/enterprise/frontend/src/metabase-enterprise/content_verification/metrics.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7a78ad6f1511bbedc8c06bdb0818aaa265318ea6 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/content_verification/metrics.tsx @@ -0,0 +1,85 @@ +import { type ChangeEvent, useCallback } from "react"; +import { t } from "ttag"; + +import type { + MetricFilterControlsProps, + MetricFilterSettings, +} from "metabase/browse/utils"; +import { useUserSetting } from "metabase/common/hooks"; +import { getSetting } from "metabase/selectors/settings"; +import { Button, Icon, Paper, Popover, Switch, Text } from "metabase/ui"; +import type { State } from "metabase-types/store"; + +const USER_SETTING_KEY = "browse-filter-only-verified-metrics"; + +export function getDefaultMetricFilters(state: State): MetricFilterSettings { + return { + verified: getSetting(state, USER_SETTING_KEY) ?? false, + }; +} + +// This component is similar to the ModelFilterControls component from ./ModelFilterControls.tsx +// merging them might be a good idea in the future. +export const MetricFilterControls = ({ + metricFilters, + setMetricFilters, +}: MetricFilterControlsProps) => { + const areAnyFiltersActive = Object.values(metricFilters).some(Boolean); + + const [_, setUserSetting] = useUserSetting(USER_SETTING_KEY); + + const handleVerifiedFilterChange = useCallback( + function (evt: ChangeEvent<HTMLInputElement>) { + setMetricFilters({ ...metricFilters, verified: evt.target.checked }); + setUserSetting(evt.target.checked); + }, + [metricFilters, setMetricFilters, setUserSetting], + ); + + return ( + <Popover position="bottom-end"> + <Popover.Target> + <Button + p="sm" + lh={0} + variant="subtle" + color="text-dark" + pos="relative" + aria-label={t`Filters`} + > + {areAnyFiltersActive && <Dot />} + <Icon name="filter" /> + </Button> + </Popover.Target> + <Popover.Dropdown p="lg"> + <Switch + label={ + <Text + align="end" + weight="bold" + >{t`Show verified metrics only`}</Text> + } + role="switch" + checked={Boolean(metricFilters.verified)} + onChange={handleVerifiedFilterChange} + labelPosition="left" + /> + </Popover.Dropdown> + </Popover> + ); +}; + +const Dot = () => { + return ( + <Paper + pos="absolute" + right="0px" + top="7px" + radius="50%" + bg={"var(--mb-color-brand)"} + w="sm" + h="sm" + data-testid="filter-dot" + /> + ); +}; diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/actions.js b/enterprise/frontend/src/metabase-enterprise/moderation/actions.js deleted file mode 100644 index 3d7d6f72580ab53ceea0b8c0305688c37dcc08a7..0000000000000000000000000000000000000000 --- a/enterprise/frontend/src/metabase-enterprise/moderation/actions.js +++ /dev/null @@ -1,32 +0,0 @@ -import { createThunkAction } from "metabase/lib/redux"; -import { softReloadCard } from "metabase/query_builder/actions"; - -import { removeReview, verifyItem } from "./service"; - -export const VERIFY_CARD = "metabase-enterprise/moderation/VERIFY_CARD"; -export const verifyCard = createThunkAction( - VERIFY_CARD, - (cardId, text) => async (dispatch, getState) => { - await verifyItem({ - itemId: cardId, - itemType: "card", - text, - }); - - return dispatch(softReloadCard()); - }, -); - -export const REMOVE_CARD_REVIEW = - "metabase-enterprise/moderation/REMOVE_CARD_REVIEW"; -export const removeCardReview = createThunkAction( - REMOVE_CARD_REVIEW, - cardId => async (dispatch, getState) => { - await removeReview({ - itemId: cardId, - itemType: "card", - }); - - return dispatch(softReloadCard()); - }, -); diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/components/QuestionModerationButton/QuestionModerationButton.tsx b/enterprise/frontend/src/metabase-enterprise/moderation/components/QuestionModerationButton/QuestionModerationButton.tsx index 7ac58b59be818eb49c7174946390788b3928c454..202c398dbb3ed80246a19278d5611f50a8fbae2b 100644 --- a/enterprise/frontend/src/metabase-enterprise/moderation/components/QuestionModerationButton/QuestionModerationButton.tsx +++ b/enterprise/frontend/src/metabase-enterprise/moderation/components/QuestionModerationButton/QuestionModerationButton.tsx @@ -2,10 +2,7 @@ import * as React from "react"; import { connect } from "react-redux"; import { t } from "ttag"; -import { - removeCardReview, - verifyCard, -} from "metabase-enterprise/moderation/actions"; +import { useEditItemVerificationMutation } from "metabase/api"; import { getIsModerator } from "metabase-enterprise/moderation/selectors"; import { MODERATION_STATUS, @@ -32,27 +29,18 @@ const mapStateToProps = (state: State, props: Props) => ({ isModerator: getIsModerator(state, props), }); -const mapDispatchToProps = { - verifyCard, - removeCardReview, -}; - // eslint-disable-next-line import/no-default-export -- deprecated usage -export default connect( - mapStateToProps, - mapDispatchToProps, -)(QuestionModerationButton); +export default connect(mapStateToProps)(QuestionModerationButton); const { name: verifiedIconName } = getStatusIcon(MODERATION_STATUS.verified); function QuestionModerationButton({ question, - verifyCard, - removeCardReview, isModerator, VerifyButton = DefaultVerifyButton, verifyButtonProps = {}, }: Props) { + const [editItemVerification] = useEditItemVerificationMutation(); const latestModerationReview = getLatestModerationReview( question.getModerationReviews(), ); @@ -60,12 +48,20 @@ function QuestionModerationButton({ const onVerify = () => { const id = question.id(); - verifyCard(id); + editItemVerification({ + status: "verified", + moderated_item_id: id, + moderated_item_type: "card", + }); }; const onRemoveModerationReview = () => { const id = question.id(); - removeCardReview(id); + editItemVerification({ + status: null, + moderated_item_id: id, + moderated_item_type: "card", + }); }; return ( diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/components/QuestionModerationSection/QuestionModerationSection.jsx b/enterprise/frontend/src/metabase-enterprise/moderation/components/QuestionModerationSection/QuestionModerationSection.jsx index 0e8c6ede4506ed160f7ff6e19bd5f78dcc77830f..2d1db5e17ead093eda20d0dd4083f66a680fa6d8 100644 --- a/enterprise/frontend/src/metabase-enterprise/moderation/components/QuestionModerationSection/QuestionModerationSection.jsx +++ b/enterprise/frontend/src/metabase-enterprise/moderation/components/QuestionModerationSection/QuestionModerationSection.jsx @@ -2,10 +2,7 @@ import PropTypes from "prop-types"; import { Fragment } from "react"; import { connect } from "react-redux"; -import { - removeCardReview, - verifyCard, -} from "metabase-enterprise/moderation/actions"; +import { useEditItemVerificationMutation } from "metabase/api"; import { getIsModerator } from "metabase-enterprise/moderation/selectors"; import { getLatestModerationReview } from "metabase-enterprise/moderation/service"; @@ -16,22 +13,13 @@ import { VerifyButton as DefaultVerifyButton } from "./QuestionModerationSection const mapStateToProps = (state, props) => ({ isModerator: getIsModerator(state, props), }); -const mapDispatchToProps = { - verifyCard, - removeCardReview, -}; -export default connect( - mapStateToProps, - mapDispatchToProps, -)(QuestionModerationSection); +export default connect(mapStateToProps)(QuestionModerationSection); QuestionModerationSection.VerifyButton = DefaultVerifyButton; QuestionModerationSection.propTypes = { question: PropTypes.object.isRequired, - verifyCard: PropTypes.func.isRequired, - removeCardReview: PropTypes.func.isRequired, isModerator: PropTypes.bool.isRequired, reviewBannerClassName: PropTypes.string, VerifyButton: PropTypes.func, @@ -39,17 +27,22 @@ QuestionModerationSection.propTypes = { function QuestionModerationSection({ question, - removeCardReview, isModerator, reviewBannerClassName, }) { + const [editItemVerification] = useEditItemVerificationMutation(); + const latestModerationReview = getLatestModerationReview( question.getModerationReviews(), ); const onRemoveModerationReview = () => { const id = question.id(); - removeCardReview(id); + editItemVerification({ + status: null, + moderated_item_id: id, + moderated_item_type: "card", + }); }; return ( diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/index.js b/enterprise/frontend/src/metabase-enterprise/moderation/index.js index 71fd411199b10bc12b4c1124985cd24dc4f9f2ea..c01dbc036c10d414cfa69a1c85f12edcd94556f8 100644 --- a/enterprise/frontend/src/metabase-enterprise/moderation/index.js +++ b/enterprise/frontend/src/metabase-enterprise/moderation/index.js @@ -1,5 +1,6 @@ import { t } from "ttag"; +import { useEditItemVerificationMutation } from "metabase/api"; import { PLUGIN_MODERATION } from "metabase/plugins"; import { hasPremiumFeature } from "metabase-enterprise/settings"; @@ -18,8 +19,6 @@ import { getQuestionIcon, getStatusIcon, isItemVerified, - removeReview, - verifyItem, } from "./service"; import { getVerifyQuestionTitle } from "./utils"; @@ -35,7 +34,9 @@ if (hasPremiumFeature("content_verification")) { getStatusIcon, getQuestionIcon, getModerationTimelineEvents, - getMenuItems: (model, isModerator, reload) => { + + useMenuItems(model, isModerator, reload) { + const [editItemVerification] = useEditItemVerificationMutation(); const id = model.id(); const { name: verifiedIconName } = getStatusIcon( MODERATION_STATUS.verified, @@ -54,10 +55,19 @@ if (hasPremiumFeature("content_verification")) { icon: isVerified ? "close" : verifiedIconName, action: async () => { if (isVerified) { - await removeReview({ itemId: id, itemType: "card" }); + await editItemVerification({ + moderated_item_id: id, + moderated_item_type: "card", + status: null, + }); } else { - await verifyItem({ itemId: id, itemType: "card" }); + await editItemVerification({ + moderated_item_id: id, + moderated_item_type: "card", + status: "verified", + }); } + reload(); }, testId: isVerified diff --git a/frontend/src/metabase-types/api/index.ts b/frontend/src/metabase-types/api/index.ts index 64577c42afee39f5704f6cc54c11d0f7af088320..c9605738a199d796b4bebe23471273aa504482e0 100644 --- a/frontend/src/metabase-types/api/index.ts +++ b/frontend/src/metabase-types/api/index.ts @@ -16,6 +16,7 @@ export * from "./group"; export * from "./metabot"; export * from "./models"; export * from "./modelIndexes"; +export * from "./moderation"; export * from "./notifications"; export * from "./pagination"; export * from "./parameters"; diff --git a/frontend/src/metabase-types/api/moderation.ts b/frontend/src/metabase-types/api/moderation.ts new file mode 100644 index 0000000000000000000000000000000000000000..25aa46973a70de2116d9d4c891b77bcc48cd0de0 --- /dev/null +++ b/frontend/src/metabase-types/api/moderation.ts @@ -0,0 +1,6 @@ +export type VerifyItemRequest = { + status: "verified" | null; + moderated_item_id: number; + moderated_item_type: "card"; + text?: string; +}; diff --git a/frontend/src/metabase-types/api/settings.ts b/frontend/src/metabase-types/api/settings.ts index 7c0f3c36490ea22bed5ce2e4be74adec3b230c3f..f2acb57b3e664758225133b0d9837f5bfbdaa7e3 100644 --- a/frontend/src/metabase-types/api/settings.ts +++ b/frontend/src/metabase-types/api/settings.ts @@ -335,6 +335,7 @@ export type UserSettings = { "expand-browse-in-nav"?: boolean; "expand-bookmarks-in-nav"?: boolean; "browse-filter-only-verified-models"?: boolean; + "browse-filter-only-verified-metrics"?: boolean; "show-updated-permission-modal": boolean; "show-updated-permission-banner": boolean; }; diff --git a/frontend/src/metabase/api/index.ts b/frontend/src/metabase/api/index.ts index e7e7120f8184edb158c99e2a5a8badff5dd93349..82b6bf5443c98bbcee5e4cde732895f8d682cd34 100644 --- a/frontend/src/metabase/api/index.ts +++ b/frontend/src/metabase/api/index.ts @@ -15,6 +15,7 @@ export * from "./entity-id"; export * from "./field"; export * from "./login-history"; export * from "./model-index"; +export * from "./moderation"; export * from "./parameters"; export * from "./permission"; export * from "./persist"; diff --git a/frontend/src/metabase/api/moderation.ts b/frontend/src/metabase/api/moderation.ts new file mode 100644 index 0000000000000000000000000000000000000000..66d09eb51816b0e4a3bffc921179dccc37470834 --- /dev/null +++ b/frontend/src/metabase/api/moderation.ts @@ -0,0 +1,27 @@ +import type { VerifyItemRequest } from "metabase-types/api"; + +import { Api } from "./api"; +import { invalidateTags, provideModeratedItemTags } from "./tags"; + +export const contentVerificationApi = Api.injectEndpoints({ + endpoints: builder => ({ + editItemVerification: builder.mutation<void, VerifyItemRequest>({ + query: req => ({ + method: "POST", + url: "/api/moderation-review", + body: req, + }), + invalidatesTags: ( + _res, + error, + { moderated_item_id, moderated_item_type }, + ) => + invalidateTags( + error, + provideModeratedItemTags(moderated_item_type, moderated_item_id), + ), + }), + }), +}); + +export const { useEditItemVerificationMutation } = contentVerificationApi; diff --git a/frontend/src/metabase/api/tags/utils.ts b/frontend/src/metabase/api/tags/utils.ts index c761fbdc22062b0b530a5195ee919f4082ca089d..70c55bec38db5454308b1b13c82edb1c76641933 100644 --- a/frontend/src/metabase/api/tags/utils.ts +++ b/frontend/src/metabase/api/tags/utils.ts @@ -213,6 +213,13 @@ export function provideModelIndexListTags( ]; } +export function provideModeratedItemTags( + itemType: TagType, + itemId: number, +): TagDescription<TagType>[] { + return [listTag(itemType), idTag(itemType, itemId)]; +} + export function provideChannelTags( channel: NotificationChannel, ): TagDescription<TagType>[] { diff --git a/frontend/src/metabase/browse/components/BrowseMetrics.tsx b/frontend/src/metabase/browse/components/BrowseMetrics.tsx index 9a3a4157bf6a7f36f481e48fd6ab126be2daa4a1..4a8c48f0421aaece1d309bbac7e3ab9534e61122 100644 --- a/frontend/src/metabase/browse/components/BrowseMetrics.tsx +++ b/frontend/src/metabase/browse/components/BrowseMetrics.tsx @@ -1,12 +1,17 @@ +import { useState } from "react"; import { t } from "ttag"; import NoResults from "assets/img/metrics_bot.svg"; +import { skipToken } from "metabase/api"; import { useFetchMetrics } from "metabase/common/hooks/use-fetch-metrics"; import EmptyState from "metabase/components/EmptyState"; import { DelayedLoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper/DelayedLoadingAndErrorWrapper"; +import { useSelector } from "metabase/lib/redux"; +import { PLUGIN_CONTENT_VERIFICATION } from "metabase/plugins"; import { Box, Flex, Group, Icon, Stack, Text, Title } from "metabase/ui"; import type { MetricResult } from "../types"; +import type { MetricFilterSettings } from "../utils"; import { BrowseContainer, @@ -16,14 +21,18 @@ import { } from "./BrowseContainer.styled"; import { MetricsTable } from "./MetricsTable"; +const { + contentVerificationEnabled, + MetricFilterControls, + getDefaultMetricFilters, +} = PLUGIN_CONTENT_VERIFICATION; + export function BrowseMetrics() { - const metricsResult = useFetchMetrics({ - filter_items_in_personal_collection: "exclude", - model_ancestors: false, - }); - const metrics = metricsResult.data?.data as MetricResult[] | undefined; + const [metricFilters, setMetricFilters] = useMetricFilterSettings(); + const { isLoading, error, metrics, hasVerifiedMetrics } = + useFilteredMetrics(metricFilters); - const isEmpty = !metricsResult.isLoading && !metrics?.length; + const isEmpty = !isLoading && !metrics?.length; return ( <BrowseContainer> @@ -46,6 +55,12 @@ export function BrowseMetrics() { {t`Metrics`} </Group> </Title> + {hasVerifiedMetrics && ( + <MetricFilterControls + metricFilters={metricFilters} + setMetricFilters={setMetricFilters} + /> + )} </Flex> </BrowseSection> </BrowseHeader> @@ -56,8 +71,8 @@ export function BrowseMetrics() { <MetricsEmptyState /> ) : ( <DelayedLoadingAndErrorWrapper - error={metricsResult.error} - loading={metricsResult.isLoading} + error={error} + loading={isLoading} style={{ flex: 1 }} loader={<MetricsTable skeleton />} > @@ -88,3 +103,77 @@ function MetricsEmptyState() { </Flex> ); } + +function useMetricFilterSettings() { + const defaultMetricFilters = useSelector(getDefaultMetricFilters); + return useState(defaultMetricFilters); +} + +function useHasVerifiedMetrics() { + const result = useFetchMetrics( + contentVerificationEnabled + ? { + filter_items_in_personal_collection: "exclude", + model_ancestors: false, + limit: 0, + verified: true, + } + : skipToken, + ); + + if (!contentVerificationEnabled) { + return { + isLoading: false, + error: null, + result: false, + }; + } + + const total = result.data?.total ?? 0; + + return { + isLoading: result.isLoading, + error: result.error, + result: total > 0, + }; +} + +function useFilteredMetrics(metricFilters: MetricFilterSettings) { + const hasVerifiedMetrics = useHasVerifiedMetrics(); + + const filters = cleanMetricFilters(metricFilters, hasVerifiedMetrics.result); + + const metricsResult = useFetchMetrics( + hasVerifiedMetrics.isLoading || hasVerifiedMetrics.error + ? skipToken + : { + filter_items_in_personal_collection: "exclude", + model_ancestors: false, + ...filters, + }, + ); + + const isLoading = hasVerifiedMetrics.isLoading || metricsResult.isLoading; + const error = hasVerifiedMetrics.error || metricsResult.error; + const metrics = metricsResult.data?.data as MetricResult[] | undefined; + + return { + isLoading, + error, + hasVerifiedMetrics: hasVerifiedMetrics.result, + metrics, + }; +} + +function cleanMetricFilters( + metricFilters: MetricFilterSettings, + hasVerifiedMetrics: boolean, +) { + const filters = { ...metricFilters }; + if (!hasVerifiedMetrics || !filters.verified) { + // we cannot pass false or undefined to the backend + // delete the key instead + delete filters.verified; + } + return filters; +} diff --git a/frontend/src/metabase/browse/utils.ts b/frontend/src/metabase/browse/utils.ts index c44dc6b9ff99700697a11d79484b3a06de3a14c4..4d0f22bf4100ba3d52fd3061a4e50a5e8e81e2d2 100644 --- a/frontend/src/metabase/browse/utils.ts +++ b/frontend/src/metabase/browse/utils.ts @@ -39,6 +39,15 @@ export type ModelFilterControlsProps = { setActualModelFilters: Dispatch<SetStateAction<ActualModelFilters>>; }; +export type MetricFilterSettings = { + verified?: boolean; +}; + +export type MetricFilterControlsProps = { + metricFilters: MetricFilterSettings; + setMetricFilters: (settings: MetricFilterSettings) => void; +}; + /** Mapping of filter names to true if the filter is active * or false if it is inactive */ export type ActualModelFilters = Record<string, boolean>; diff --git a/frontend/src/metabase/common/hooks/use-fetch-metrics.tsx b/frontend/src/metabase/common/hooks/use-fetch-metrics.tsx index c8bcc6944a9385cd922e4bed654052503156ca32..68f8c2a40ba30759165de0f119795c146a6f85d9 100644 --- a/frontend/src/metabase/common/hooks/use-fetch-metrics.tsx +++ b/frontend/src/metabase/common/hooks/use-fetch-metrics.tsx @@ -1,10 +1,16 @@ -import { useSearchQuery } from "metabase/api"; +import { skipToken, useSearchQuery } from "metabase/api"; import type { SearchRequest } from "metabase-types/api"; -export const useFetchMetrics = (req: Partial<SearchRequest> = {}) => { - const modelsResult = useSearchQuery({ - models: ["metric"], - ...req, - }); +export const useFetchMetrics = ( + req: Partial<SearchRequest> | typeof skipToken = {}, +) => { + const modelsResult = useSearchQuery( + req === skipToken + ? req + : { + models: ["metric"], + ...req, + }, + ); return modelsResult; }; diff --git a/frontend/src/metabase/plugins/index.ts b/frontend/src/metabase/plugins/index.ts index 5fedbd87c5194b3ec719798c7e71c0de4b14fec1..f73face54ac2b97823d6de8eea27581aae700249 100644 --- a/frontend/src/metabase/plugins/index.ts +++ b/frontend/src/metabase/plugins/index.ts @@ -27,6 +27,8 @@ import type { ADMIN_SETTINGS_SECTIONS } from "metabase/admin/settings/selectors" import type { ActualModelFilters, AvailableModelFilters, + MetricFilterControlsProps, + MetricFilterSettings, ModelFilterControlsProps, } from "metabase/browse/utils"; import { getIconBase } from "metabase/lib/icon"; @@ -375,7 +377,7 @@ export const PLUGIN_MODERATION = { _usersById: Record<string, UserListResult>, _currentUser: User | null, ) => [] as RevisionOrModerationEvent[], - getMenuItems: ( + useMenuItems: ( _question?: Question, _isModerator?: boolean, _reload?: () => void, @@ -517,6 +519,12 @@ export const PLUGIN_CONTENT_VERIFICATION = { ActualModelFilters, Dispatch<SetStateAction<ActualModelFilters>>, ], + + contentVerificationEnabled: false, + getDefaultMetricFilters: (_state: State): MetricFilterSettings => ({ + verified: false, + }), + MetricFilterControls: (_props: MetricFilterControlsProps) => null, }; export const PLUGIN_DASHBOARD_HEADER = { 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 8706f567c52c1ff51f8481d093fe45c7ed06e63a..8ab29bb639af14eb55ea4e06bcf9c6b2eabf96be 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 @@ -83,7 +83,7 @@ export const QuestionActions = ({ const dispatch = useDispatch(); - const dispatchSoftReloadCard = () => dispatch(softReloadCard()); + const reload = () => dispatch(softReloadCard()); const onOpenSettingsSidebar = () => dispatch(onOpenQuestionSettings()); const infoButtonColor = isShowingQuestionInfoSidebar @@ -147,13 +147,12 @@ export const QuestionActions = ({ }); } - extraButtons.push( - ...PLUGIN_MODERATION.getMenuItems( - question, - isModerator, - dispatchSoftReloadCard, - ), + const moderationItems = PLUGIN_MODERATION.useMenuItems( + question, + isModerator, + reload, ); + extraButtons.push(...moderationItems); if (hasCollectionPermissions) { if (isModelOrMetric && hasDataPermissions) { diff --git a/src/metabase/models/user.clj b/src/metabase/models/user.clj index 98d2b9366bccde76e59d2d79623e1b41c5236eae..d80a08a2457a3c8e8f989d91fec4e69615bcd201 100644 --- a/src/metabase/models/user.clj +++ b/src/metabase/models/user.clj @@ -521,6 +521,14 @@ :type :boolean :default true) +(defsetting browse-filter-only-verified-metrics + (deferred-tru "User preference for whether the 'Browse metrics' page should be filtered to show only verified metrics.") + :user-local :only + :export? false + :visibility :authenticated + :type :boolean + :default true) + ;;; ## ------------------------------------------ AUDIT LOG ------------------------------------------ (defmethod audit-log/model-details :model/User