diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewBanner/ModerationReviewBanner.jsx b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewBanner/ModerationReviewBanner.jsx index 27c1ee07914c09bcea753390f8538ce7cfba1ba1..3c35c60942644d19b7be0d2b03ad11d8f3a58d42 100644 --- a/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewBanner/ModerationReviewBanner.jsx +++ b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewBanner/ModerationReviewBanner.jsx @@ -7,7 +7,7 @@ import Icon from "metabase/components/Icon"; import { color, alpha } from "metabase/lib/colors"; import { getUser } from "metabase/selectors/user"; -import { getRelativeTimeAbbreviated } from "metabase/lib/time"; +import { getRelativeTime } from "metabase/lib/time"; import { getTextForReviewBanner, getIconForReview, @@ -54,9 +54,7 @@ export function ModerationReviewBanner({ moderator, currentUser, ); - const relativeCreationTime = getRelativeTimeAbbreviated( - moderationReview.created_at, - ); + const relativeCreationTime = getRelativeTime(moderationReview.created_at); const { name: iconName, color: iconColor } = getIconForReview(moderationReview); diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewBanner/index.ts b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewBanner/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b7c95573bebf339486e637152339dc543bf5b7b7 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewBanner/index.ts @@ -0,0 +1 @@ +export { default } from "./ModerationReviewBanner"; diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewIcon/ModerationReviewIcon.styled.tsx b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewIcon/ModerationReviewIcon.styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e745963f178997aaa5f19f3331e1096c80a4d281 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewIcon/ModerationReviewIcon.styled.tsx @@ -0,0 +1,7 @@ +import styled from "@emotion/styled"; +import { color } from "metabase/lib/colors"; + +export const TooltipTime = styled.time` + color: ${color("text-medium")}; + font-size: 0.875em; +`; diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewIcon/ModerationReviewIcon.tsx b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewIcon/ModerationReviewIcon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..af12488fc62ff9899ead7311517c39815c4e5370 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewIcon/ModerationReviewIcon.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { color } from "metabase/lib/colors"; +import { getRelativeTime } from "metabase/lib/time"; +import Icon from "metabase/components/Icon"; +import Tooltip from "metabase/components/Tooltip"; +import { ModerationReview, User } from "metabase-types/api"; +import { getIconForReview, getModeratorDisplayText } from "../../service"; +import { TooltipTime } from "./ModerationReviewIcon.styled"; + +export interface ModerationReviewIconProps { + review: ModerationReview; + moderator?: User; + currentUser: User; +} + +const ModerationReviewIcon = ({ + review, + moderator, + currentUser, +}: ModerationReviewIconProps): JSX.Element => { + const { name: iconName, color: iconColor } = getIconForReview(review); + + const tooltip = moderator && ( + <div> + <div>{getModeratorDisplayText(moderator, currentUser)}</div> + <TooltipTime dateTime={review.created_at}> + {getRelativeTime(review.created_at)} + </TooltipTime> + </div> + ); + + return ( + <Tooltip tooltip={tooltip}> + <Icon name={iconName} color={color(iconColor)} /> + </Tooltip> + ); +}; + +export default ModerationReviewIcon; diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewIcon/ModerationReviewIcon.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewIcon/ModerationReviewIcon.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..144451bd8461e868e2b3dc1e9287cd2bd080f03b --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewIcon/ModerationReviewIcon.unit.spec.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + createMockModerationReview, + createMockUser, +} from "metabase-types/api/mocks"; +import ModerationReviewIcon, { + ModerationReviewIconProps, +} from "./ModerationReviewIcon"; + +describe("ModerationReviewIcon", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(2022, 1, 7)); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("should render correctly when moderator is loading", () => { + const props = getProps(); + + render(<ModerationReviewIcon {...props} />); + + expect(screen.getByLabelText("verified icon")).toBeInTheDocument(); + }); + + it("should show a tooltip on hover when moderator is loaded", () => { + const props = getProps({ + review: createMockModerationReview({ + moderator_id: 1, + created_at: "2021-01-01T20:10:30.200", + }), + moderator: createMockUser({ id: 1 }), + currentUser: createMockUser({ id: 1 }), + }); + + render(<ModerationReviewIcon {...props} />); + userEvent.hover(screen.getByLabelText("verified icon")); + + expect(screen.getByText("You verified this")).toBeInTheDocument(); + expect(screen.getByText("a year ago")).toBeInTheDocument(); + }); +}); + +const getProps = ( + opts?: Partial<ModerationReviewIconProps>, +): ModerationReviewIconProps => ({ + review: createMockModerationReview(), + currentUser: createMockUser(), + ...opts, +}); diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewIcon/index.ts b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewIcon/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a2c8fb7c77692ba75447a504d45f55b2f5f39edd --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewIcon/index.ts @@ -0,0 +1 @@ +export { default } from "./ModerationReviewIcon"; diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationStatusIcon/index.ts b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationStatusIcon/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff7724b254ac65fef0c57ea63b00b9287f64feea --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationStatusIcon/index.ts @@ -0,0 +1 @@ +export { default } from "./ModerationStatusIcon"; diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/components/QuestionModerationButton/index.ts b/enterprise/frontend/src/metabase-enterprise/moderation/components/QuestionModerationButton/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b1dd8548a94cebc3f290e10fbca78bd6180e4ade --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/moderation/components/QuestionModerationButton/index.ts @@ -0,0 +1 @@ +export { default } from "./QuestionModerationButton"; diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/components/QuestionModerationIcon/QuestionModerationIcon.tsx b/enterprise/frontend/src/metabase-enterprise/moderation/components/QuestionModerationIcon/QuestionModerationIcon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f874a1052311801aeab111c32a051a34e830b4fc --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/moderation/components/QuestionModerationIcon/QuestionModerationIcon.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import Question from "metabase-lib/lib/Question"; +import { getLatestModerationReview } from "../../service"; +import ModerationReviewIcon from "../../containers/ModerationReviewIcon"; + +export interface QuestionModerationIconProps { + question: Question; +} + +const QuestionModerationIcon = ({ + question, +}: QuestionModerationIconProps): JSX.Element | null => { + const review = getLatestModerationReview(question.getModerationReviews()); + + if (review) { + return <ModerationReviewIcon review={review} />; + } else { + return null; + } +}; + +export default QuestionModerationIcon; diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/components/QuestionModerationIcon/index.ts b/enterprise/frontend/src/metabase-enterprise/moderation/components/QuestionModerationIcon/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d20bd4a9d6ad98598a8be3b3dfb941523b3c399d --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/moderation/components/QuestionModerationIcon/index.ts @@ -0,0 +1 @@ +export { default } from "./QuestionModerationIcon"; diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/components/QuestionModerationSection/index.ts b/enterprise/frontend/src/metabase-enterprise/moderation/components/QuestionModerationSection/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc12608b18b0a97ffd852f472e599a60d19ffc5b --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/moderation/components/QuestionModerationSection/index.ts @@ -0,0 +1 @@ +export { default } from "./QuestionModerationSection"; diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/containers/ModerationReviewIcon/ModerationReviewIcon.tsx b/enterprise/frontend/src/metabase-enterprise/moderation/containers/ModerationReviewIcon/ModerationReviewIcon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..783ffacddfc1f431b32941a641b1f9243bf2828a --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/moderation/containers/ModerationReviewIcon/ModerationReviewIcon.tsx @@ -0,0 +1,27 @@ +import { connect } from "react-redux"; +import _ from "underscore"; +import Users from "metabase/entities/users"; +import { getUser } from "metabase/selectors/user"; +import { ModerationReview } from "metabase-types/api"; +import { State } from "metabase-types/store"; +import ModerationReviewIcon from "../../components/ModerationReviewIcon"; + +interface ModerationReviewIconProps { + review: ModerationReview; +} + +const mapStateToProps = (state: State) => ({ + currentUser: getUser(state), +}); + +const userProps = { + id: (state: State, props: ModerationReviewIconProps) => + props.review.moderator_id, + entityAlias: "moderator", + loadingAndErrorWrapper: false, +}; + +export default _.compose( + Users.load(userProps), + connect(mapStateToProps), +)(ModerationReviewIcon); diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/containers/ModerationReviewIcon/index.ts b/enterprise/frontend/src/metabase-enterprise/moderation/containers/ModerationReviewIcon/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a2c8fb7c77692ba75447a504d45f55b2f5f39edd --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/moderation/containers/ModerationReviewIcon/index.ts @@ -0,0 +1 @@ +export { default } from "./ModerationReviewIcon"; diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/index.js b/enterprise/frontend/src/metabase-enterprise/moderation/index.js index 1412a15b1afdb638aac4c232e536692e602809f0..c060b33c8f7a75b33f27c0ce2679d3a472fe67d4 100644 --- a/enterprise/frontend/src/metabase-enterprise/moderation/index.js +++ b/enterprise/frontend/src/metabase-enterprise/moderation/index.js @@ -3,14 +3,14 @@ import { t } from "ttag"; import { PLUGIN_MODERATION } from "metabase/plugins"; import { hasPremiumFeature } from "metabase-enterprise/settings"; -import QuestionModerationSection from "./components/QuestionModerationSection/QuestionModerationSection"; -import QuestionModerationButton from "./components/QuestionModerationButton/QuestionModerationButton"; -import ModerationReviewBanner from "./components/ModerationReviewBanner/ModerationReviewBanner"; -import ModerationStatusIcon from "./components/ModerationStatusIcon/ModerationStatusIcon"; +import QuestionModerationIcon from "./components/QuestionModerationIcon"; +import QuestionModerationSection from "./components/QuestionModerationSection"; +import QuestionModerationButton from "./components/QuestionModerationButton"; +import ModerationReviewBanner from "./components/ModerationReviewBanner"; +import ModerationStatusIcon from "./components/ModerationStatusIcon"; import { MODERATION_STATUS, - getStatusIconForQuestion, getStatusIcon, getModerationTimelineEvents, verifyItem, @@ -22,11 +22,11 @@ import { if (hasPremiumFeature("content_management")) { Object.assign(PLUGIN_MODERATION, { isEnabled: () => true, + QuestionModerationIcon, QuestionModerationSection, QuestionModerationButton, ModerationReviewBanner, ModerationStatusIcon, - getStatusIconForQuestion, getStatusIcon, getModerationTimelineEvents, getMenuItems: (model, isModerator, reload) => { diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/service.js b/enterprise/frontend/src/metabase-enterprise/moderation/service.js index 2d6639f5b2aca3eca6d7660996abe373ffb4b4ae..bf537ce6886bca9a63fb24f9e610823ca01d8f85 100644 --- a/enterprise/frontend/src/metabase-enterprise/moderation/service.js +++ b/enterprise/frontend/src/metabase-enterprise/moderation/service.js @@ -62,11 +62,10 @@ export function getTextForReviewBanner( moderator, currentUser, ) { - const moderatorName = getModeratorDisplayName(moderator, currentUser); const { status } = moderationReview; if (status === "verified") { - const bannerText = t`${moderatorName} verified this`; + const bannerText = getModeratorDisplayText(moderator, currentUser); const tooltipText = t`Remove verification`; return { bannerText, tooltipText }; } @@ -74,19 +73,24 @@ export function getTextForReviewBanner( return {}; } -function getModeratorDisplayName(user, currentUser) { - const { id: userId, common_name } = user || {}; +export function getModeratorDisplayName(moderator, currentUser) { + const { id: moderatorId, common_name } = moderator || {}; const { id: currentUserId } = currentUser || {}; - if (currentUserId != null && userId === currentUserId) { + if (currentUserId != null && moderatorId === currentUserId) { return t`You`; - } else if (userId != null) { + } else if (moderatorId != null) { return common_name; } else { return t`A moderator`; } } +export function getModeratorDisplayText(moderator, currentUser) { + const moderatorName = getModeratorDisplayName(moderator, currentUser); + return t`${moderatorName} verified this`; +} + // a `status` of `null` represents the removal of a review, since we can't delete reviews export function isRemovedReviewStatus(status) { return String(status) === "null"; diff --git a/frontend/src/metabase-types/api/card.ts b/frontend/src/metabase-types/api/card.ts index 89769b9089c0476e071f7ac816ceaf3bf0304370..1b910ae11a2b7da9ca7841e7f27ed37129c59ca9 100644 --- a/frontend/src/metabase-types/api/card.ts +++ b/frontend/src/metabase-types/api/card.ts @@ -1,3 +1,12 @@ export type VisualizationSettings = { [key: string]: any; }; + +export interface ModerationReview { + moderator_id: number; + status: ModerationReviewStatus | null; + created_at: string; + most_recent: boolean; +} + +export type ModerationReviewStatus = "verified"; diff --git a/frontend/src/metabase-types/api/index.ts b/frontend/src/metabase-types/api/index.ts index bf776a3367b6437470e1477dde055f4d2b58945c..8364d41e44fba52d05eab17618708a16e699c899 100644 --- a/frontend/src/metabase-types/api/index.ts +++ b/frontend/src/metabase-types/api/index.ts @@ -1,6 +1,7 @@ export * from "./activity"; export * from "./automagic-dashboards"; export * from "./bookmark"; +export * from "./card"; export * from "./collection"; export * from "./dashboard"; export * from "./database"; diff --git a/frontend/src/metabase-types/api/mocks/card.ts b/frontend/src/metabase-types/api/mocks/card.ts new file mode 100644 index 0000000000000000000000000000000000000000..deed6e5220a491341ceb00681dc115354d20236c --- /dev/null +++ b/frontend/src/metabase-types/api/mocks/card.ts @@ -0,0 +1,11 @@ +import { ModerationReview } from "metabase-types/api"; + +export const createMockModerationReview = ( + opts?: Partial<ModerationReview>, +): ModerationReview => ({ + moderator_id: 1, + status: "verified", + created_at: "2015-01-01T20:10:30.200", + most_recent: true, + ...opts, +}); diff --git a/frontend/src/metabase-types/api/mocks/index.ts b/frontend/src/metabase-types/api/mocks/index.ts index eaa71fb13bcb872761e06b94c6f8fd82eb76208f..da5f4b52d9244b9336c39a7992b2458d4ede5019 100644 --- a/frontend/src/metabase-types/api/mocks/index.ts +++ b/frontend/src/metabase-types/api/mocks/index.ts @@ -1,5 +1,6 @@ export * from "./activity"; export * from "./automagic-dashboards"; +export * from "./card"; export * from "./collection"; export * from "./dashboard"; export * from "./database"; diff --git a/frontend/src/metabase/plugins/index.ts b/frontend/src/metabase/plugins/index.ts index ff445c5af2685d106cc8937a57b83886c28532fb..288939e623690209bb5319537ff7493e2a71d455 100644 --- a/frontend/src/metabase/plugins/index.ts +++ b/frontend/src/metabase/plugins/index.ts @@ -120,11 +120,11 @@ export const PLUGIN_COLLECTION_COMPONENTS = { export const PLUGIN_MODERATION = { isEnabled: () => false, + QuestionModerationIcon: PluginPlaceholder, QuestionModerationSection: PluginPlaceholder, QuestionModerationButton: PluginPlaceholder, ModerationReviewBanner: PluginPlaceholder, ModerationStatusIcon: PluginPlaceholder, - getStatusIconForQuestion: object, getStatusIcon: object, getModerationTimelineEvents: array, getMenuItems: ( diff --git a/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.jsx b/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.jsx index 93a56a1dceec099ed76c525715e2cb3e8842c3b9..ce4fa6f0b010dfebdd2d45eeecc6d789fba94942 100644 --- a/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.jsx +++ b/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.jsx @@ -2,12 +2,7 @@ import React from "react"; import { t } from "ttag"; import PropTypes from "prop-types"; import { PLUGIN_MODERATION } from "metabase/plugins"; -import { color } from "metabase/lib/colors"; -import { - HeaderRoot, - HeaderReviewIcon, - HeaderTitle, -} from "./SavedQuestionHeaderButton.styled"; +import { HeaderRoot, HeaderTitle } from "./SavedQuestionHeaderButton.styled"; SavedQuestionHeaderButton.propTypes = { className: PropTypes.string, @@ -15,12 +10,7 @@ SavedQuestionHeaderButton.propTypes = { onSave: PropTypes.func, }; -const ICON_SIZE = 16; - -function SavedQuestionHeaderButton({ className, question, onSave }) { - const { name: reviewIconName, color: reviewIconColor } = - PLUGIN_MODERATION.getStatusIconForQuestion(question); - +function SavedQuestionHeaderButton({ question, onSave }) { return ( <HeaderRoot> <HeaderTitle @@ -29,13 +19,7 @@ function SavedQuestionHeaderButton({ className, question, onSave }) { onChange={onSave} data-testid="saved-question-header-title" /> - {reviewIconName && ( - <HeaderReviewIcon - name={reviewIconName} - color={color(reviewIconColor)} - size={ICON_SIZE} - /> - )} + <PLUGIN_MODERATION.QuestionModerationIcon question={question} /> </HeaderRoot> ); } diff --git a/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.styled.jsx b/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.styled.jsx index 518bd98b920558de19a3eb8ba8ce08e082332ab3..3464e2369a4d62b8ad7f485eb532d0e5751fc601 100644 --- a/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.styled.jsx +++ b/frontend/src/metabase/query_builder/components/SavedQuestionHeaderButton/SavedQuestionHeaderButton.styled.jsx @@ -1,10 +1,10 @@ import styled from "@emotion/styled"; -import Icon from "metabase/components/Icon"; import EditableText from "metabase/core/components/EditableText"; export const HeaderRoot = styled.div` display: flex; align-items: center; + gap: 0.25rem; `; export const HeaderTitle = styled(EditableText)` @@ -12,7 +12,3 @@ export const HeaderTitle = styled(EditableText)` font-weight: 700; line-height: 1.5rem; `; - -export const HeaderReviewIcon = styled(Icon)` - padding-left: 0.25rem; -`;