Skip to content
Snippets Groups Projects
Unverified Commit 7ffdd997 authored by Romeo Van Snick's avatar Romeo Van Snick Committed by GitHub
Browse files

Verified metrics (#47886)

* Support skipToken in useFetchMetrics

* WIP

* Add user setting for verified metrics

* Add verified metrics to the VERIFIED_CONTENT plugin

* Use metric plugins for verified metrics

* Move helpers down

* Do not filter for verified metrics if there are none, regardless of user setting

* Add label to filter button

* Revalidate search when card gets (un-)verified

* Add e2e test for verified metrics

* Avoid checking for verified metrics on when content-verification is not enabled

* Fix broken test

* Simplify content verification plugin structure for metrics

* Add content moderation types and API helpers

* Update content moderation plugin to make use of the moderation api in metabase/api

* Fix lint

* Fix missing list tag

* Store verified metric filter preference

* Move all helpers to the bottom

* Ensure the user setting gets updated when chaning the filter

* Add comment about component similarity

* Undo changes to embedding-sdk
parent 9d36cc14
Branches
Tags
No related merge requests found
Showing
with 465 additions and 126 deletions
......@@ -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();
}
......@@ -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,
});
}
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"
/>
);
};
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());
},
);
......@@ -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 (
......
......@@ -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 (
......
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
......
......@@ -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";
......
export type VerifyItemRequest = {
status: "verified" | null;
moderated_item_id: number;
moderated_item_type: "card";
text?: string;
};
......@@ -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;
};
......
......@@ -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";
......
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;
......@@ -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>[] {
......
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;
}
......@@ -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>;
......
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;
};
......@@ -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 = {
......
......@@ -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) {
......
......@@ -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
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment