Skip to content
Snippets Groups Projects
Unverified Commit b7fa2f34 authored by Dalton's avatar Dalton Committed by GitHub
Browse files

Add the ability to verify/unverify questions (#17030)

* rmv old bucm icons and remove verified fill color

* add moderation action section to sidebar

* add moderation review icon to the saved question header button

* hide moderation section when is not a moderator

* add UI for ModerationReviewBanner

* Backend for moderation-review

- create table moderation_review. Same as before but also has a
  "most_recent" boolean flag for the most recent moderation for easy
  lookup
- POST /moderation-review/ . Status can be "verified" or nil
- must be an admin to post
- No PUT or edit route yet. Not sure if this is even
  necessary. _MAYBE_ to edit the text, but certainly not for the
  status, ids, etc. If there's to be history, let's build some history
- Ensure we never have more than 10 reviews. Adding a new review will
  delete the older ones, mark all old ones as not `most_recent`, and
  add the newest one as `most_recent true`
- Ensure the card actually exists before creating the mod review
- Since admin only at this time, don't need to check moderate
  permission or view permission
- When hydrating ensure reviews are ordered by id desc. Should mimic
  the created_at desc

* fix moderation review banner tooltip offset

* disable verification button when already verified

* rmv iconOnly prop because it seems to do nothing

* update getLatestModerationReview to rely on most_recent boolean

* Return 400 on invalid status to post /moderation-review

the schema was using keywords on the left hand side rather than the
symbols. Required a change to the docstring generator, when it made a
docstring for enums, it would call (sort (:vs enum)) and need to
string em.

* Add ModerationReview model to models.clj and copy infra

* hydrate moderation reviews on cards

* clean up + wire up to BE + ensure mod buttons don't show for normal users

* rmv unused moderation redux logic from QuestionDetailsSidebarPanel

* finish writing unit tests for FE

* ensure getIconForReview returns an object

* enable/disable verify button tooltip when unverified/verified

* add e2e tests

* fix tests

* styling tweaks

* more styling on moderationReviewBanner

* add function for abbreviated timestamp

* increase fontsize of timestamp back to 12

* fix tooltip offset

* ensure custom locale is separate from 'en' and not used for other languages

* Deletion moderation reviews when deleting cards

i had actually thought this was a much larger problem. But it turns
out we almost never delete cards (thanks comment!). And so we won't
really generate a lot of garbage.

I was worried that since we aren't using actual foreign keys but just
`moderated_item_type "card"` and `moderated_item_id 2` we would have
deleted cards with these moderation reviews but that is not the case
as the cards aren't deleted.

* hide verify disabled button when a question is verified

* update test to use queryByTestId

* Hydrate moderation reviews on cards on ordered cards

* Handle mysql's lack of offset functionality

mysql cannot handle just a `offset` clause, it also needs a limit

clause grammar from
https://dev.mysql.com/doc/refman/8.0/en/select.html:

[LIMIT {[offset,] row_count | row_count OFFSET offset}]

select id, name from metabase_field offset 5;         -- errors
select id, name from metabase_field limit 2 offset 5; -- works

Since our numbers are so small here there is no worry and just do the
offset in memory rather than jump through hoops for different dbs.

* Batch hydrate moderation reviews

* Don't let /api/user/:userId failure conceal moderation banner

* fix moderation cy tests

* work around possible bug in toucan hydration

dashboards hydrate ordered cards
(hydrate [:ordered_cards [:card :moderation_reviews] :series])

Ordered_cards are dashboard_cards which have an optional card_id. But
toucan hydration doesn't filter out the nils as they go down. It seems
toucan returns a nil card, and then when hydrating the
moderation_review, passes the collection of all "cards" including the
nil ones into the hydration function for moderation_reviews

This feels like a bug to me

* Cleanup moderation warnings

* Docstring in moderation review

* include hoisted moderated_status on cards in collections api

* Expect unverified in test :wrench:



Co-authored-by: default avatardan sutton <dan@dpsutton.com>
Co-authored-by: default avatarMaz Ameli <maz@metabase.com>
Co-authored-by: default avataralxnddr <alxnddr@gmail.com>
parent dee19dfb
No related branches found
No related tags found
No related merge requests found
Showing
with 820 additions and 10 deletions
import { createThunkAction } from "metabase/lib/redux";
import { verifyItem, removeReview } from "./service";
import { softReloadCard } from "metabase/query_builder/actions";
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());
},
);
import React from "react";
import PropTypes from "prop-types";
import { t } from "ttag";
import { isItemVerified } from "metabase-enterprise/moderation/service";
import { Container, Label, VerifyButton } from "./ModerationActions.styled";
import Tooltip from "metabase/components/Tooltip";
export default ModerationActions;
ModerationActions.propTypes = {
className: PropTypes.string,
onVerify: PropTypes.func,
moderationReview: PropTypes.object,
};
function ModerationActions({ moderationReview, className, onVerify }) {
const isVerified = isItemVerified(moderationReview);
const hasActions = !!onVerify;
return hasActions ? (
<Container className={className}>
<Label>{t`Moderation`}</Label>
{!isVerified && (
<Tooltip tooltip={t`Verify this`}>
<VerifyButton
data-testid="moderation-verify-action"
onClick={onVerify}
/>
</Tooltip>
)}
</Container>
) : null;
}
import styled from "styled-components";
import { color } from "metabase/lib/colors";
import { getVerifiedIcon } from "metabase-enterprise/moderation/service";
const { icon: verifiedIcon, iconColor: verifiedIconColor } = getVerifiedIcon();
import Button from "metabase/components/Button";
export const Container = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;
export const Label = styled.h5`
font-size: 14px;
color: ${color("text-medium")};
flex: 1;
`;
export const VerifyButton = styled(Button).attrs({
icon: verifiedIcon,
iconSize: 20,
})`
border: none;
&:hover {
color: ${color(verifiedIconColor)};
}
&:disabled {
color: ${color("text-medium")};
}
`;
import React from "react";
import ModerationActions from "./ModerationActions";
import { render, screen } from "@testing-library/react";
describe("ModerationActions", () => {
describe("when the user is not a moderator", () => {
it("should not render", () => {
const { queryByTestId } = render(
<ModerationActions isModerator={false} />,
);
expect(queryByTestId("moderation-verify-action")).toBeNull();
expect(screen.queryByText("Moderation")).toBeNull();
});
});
describe("when a moderator clicks on the verify button", () => {
it("should call the onVerify prop", () => {
const onVerify = jest.fn();
const { getByTestId } = render(
<ModerationActions isModerator onVerify={onVerify} />,
);
getByTestId("moderation-verify-action").click();
expect(onVerify).toHaveBeenCalled();
});
});
});
import React from "react";
import PropTypes from "prop-types";
import _ from "underscore";
import { connect } from "react-redux";
import { color, alpha } from "metabase/lib/colors";
import { getUser } from "metabase/selectors/user";
import { getRelativeTimeAbbreviated } from "metabase/lib/time";
import {
getTextForReviewBanner,
getIconForReview,
} from "metabase-enterprise/moderation/service";
import User from "metabase/entities/users";
import {
Container,
Text,
Time,
IconButton,
StatusIcon,
} from "./ModerationReviewBanner.styled";
import Tooltip from "metabase/components/Tooltip";
const ICON_BUTTON_SIZE = 20;
const TOOLTIP_X_OFFSET = ICON_BUTTON_SIZE / 4;
const mapStateToProps = (state, props) => ({
currentUser: getUser(state),
});
export default _.compose(
User.load({
id: (state, props) => props.moderationReview.moderator_id,
loadingAndErrorWrapper: false,
}),
connect(mapStateToProps),
)(ModerationReviewBanner);
ModerationReviewBanner.propTypes = {
moderationReview: PropTypes.object.isRequired,
user: PropTypes.object,
currentUser: PropTypes.object.isRequired,
onRemove: PropTypes.func,
};
export function ModerationReviewBanner({
moderationReview,
user: moderator,
currentUser,
onRemove,
}) {
const [isHovering, setIsHovering] = React.useState(false);
const [isActive, setIsActive] = React.useState(false);
const { bannerText, tooltipText } = getTextForReviewBanner(
moderationReview,
moderator,
currentUser,
);
const relativeCreationTime = getRelativeTimeAbbreviated(
moderationReview.created_at,
);
const { icon, iconColor } = getIconForReview(moderationReview);
const showClose = isHovering || isActive;
return (
<Container
backgroundColor={alpha(iconColor, 0.2)}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<Tooltip
targetOffsetX={TOOLTIP_X_OFFSET}
tooltip={onRemove && tooltipText}
>
{onRemove ? (
<IconButton
data-testid="moderation-remove-review-action"
onFocus={() => setIsActive(true)}
onBlur={() => setIsActive(false)}
icon={showClose ? "close" : icon}
color={color(showClose ? "text-medium" : iconColor)}
onClick={onRemove}
iconSize={ICON_BUTTON_SIZE}
/>
) : (
<StatusIcon
name={icon}
color={color(iconColor)}
size={ICON_BUTTON_SIZE}
/>
)}
</Tooltip>
<Text>{bannerText}</Text>
<Time dateTime={moderationReview.created_at}>{relativeCreationTime}</Time>
</Container>
);
}
import styled from "styled-components";
import { color } from "metabase/lib/colors";
import Button from "metabase/components/Button";
import Icon from "metabase/components/Icon";
export const Container = styled.div`
padding: 1rem 1rem 1rem 0.5rem;
background-color: ${props => props.backgroundColor};
display: flex;
justify-content: space-between;
align-items: center;
column-gap: 0.5rem;
border-radius: 8px;
`;
export const Text = styled.span`
flex: 1;
font-size: 14px;
font-weight: 700;
`;
export const Time = styled.time`
color: ${color("text-medium")};
font-size: 12px;
`;
export const IconButton = styled(Button)`
padding: 0 0 0 0.5rem !important;
border: none;
background-color: transparent;
&:hover {
background-color: transparent;
color: ${color("danger")};
}
`;
export const StatusIcon = styled(Icon)`
padding: 0 0.5rem;
`;
import React from "react";
import { ModerationReviewBanner } from "./ModerationReviewBanner";
import { render, fireEvent } from "@testing-library/react";
const VERIFIED_ICON_SELECTOR = ".Icon-verified";
const CLOSE_ICON_SELECTOR = ".Icon-close";
const moderationReview = {
status: "verified",
moderator_id: 1,
created_at: Date.now(),
};
const moderator = { id: 1, display_name: "Foo" };
const currentUser = { id: 2, display_name: "Bar" };
describe("ModerationReviewBanner", () => {
it("should show text concerning the given review", () => {
const { getByText } = render(
<ModerationReviewBanner
moderationReview={moderationReview}
user={moderator}
currentUser={currentUser}
/>,
);
expect(getByText("Foo verified this")).toBeTruthy();
});
describe("when not provided an onRemove prop", () => {
let getByRole;
let container;
beforeEach(() => {
const wrapper = render(
<ModerationReviewBanner
moderationReview={moderationReview}
user={moderator}
currentUser={currentUser}
/>,
);
getByRole = wrapper.getByRole;
container = wrapper.container;
});
it("should render a status icon, not a button", () => {
expect(() => getByRole("button")).toThrow();
});
it("should render with the icon relevant to the review's status", () => {
expect(container.querySelector(VERIFIED_ICON_SELECTOR)).toBeTruthy();
});
});
describe("when provided an onRemove callback prop", () => {
let onRemove;
let container;
let getByRole;
beforeEach(() => {
onRemove = jest.fn();
const wrapper = render(
<ModerationReviewBanner
moderationReview={moderationReview}
user={moderator}
currentUser={currentUser}
onRemove={onRemove}
/>,
);
container = wrapper.container;
getByRole = wrapper.getByRole;
});
it("should render a button", () => {
expect(getByRole("button")).toBeTruthy();
});
it("should render the button with the icon relevant to the review's status", () => {
expect(container.querySelector(VERIFIED_ICON_SELECTOR)).toBeTruthy();
});
it("should render the button as a close icon when the user is hovering their mouse over the banner", () => {
const banner = container.firstChild;
fireEvent.mouseEnter(banner);
expect(container.querySelector(CLOSE_ICON_SELECTOR)).toBeTruthy();
fireEvent.mouseLeave(banner);
expect(container.querySelector(VERIFIED_ICON_SELECTOR)).toBeTruthy();
});
it("should render the button as a close icon when the user focuses the button", () => {
fireEvent.focus(getByRole("button"));
expect(container.querySelector(CLOSE_ICON_SELECTOR)).toBeTruthy();
fireEvent.blur(getByRole("button"));
expect(container.querySelector(VERIFIED_ICON_SELECTOR)).toBeTruthy();
});
it("should render the button as a close icon when focused, even when the mouse leaves the banner", () => {
const banner = container.firstChild;
fireEvent.mouseEnter(banner);
fireEvent.focus(getByRole("button"));
expect(container.querySelector(CLOSE_ICON_SELECTOR)).toBeTruthy();
fireEvent.mouseLeave(banner);
expect(container.querySelector(CLOSE_ICON_SELECTOR)).toBeTruthy();
});
});
});
import React from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";
import { getLatestModerationReview } from "metabase-enterprise/moderation/service";
import { getIsModerator } from "metabase-enterprise/moderation/selectors";
import {
verifyCard,
removeCardReview,
} from "metabase-enterprise/moderation/actions";
import { BorderedModerationActions } from "./QuestionModerationSection.styled";
import ModerationReviewBanner from "../ModerationReviewBanner/ModerationReviewBanner";
const mapStateToProps = (state, props) => ({
isModerator: getIsModerator(state, props),
});
const mapDispatchToProps = {
verifyCard,
removeCardReview,
};
export default connect(
mapStateToProps,
mapDispatchToProps,
)(QuestionModerationSection);
QuestionModerationSection.propTypes = {
question: PropTypes.object.isRequired,
verifyCard: PropTypes.func.isRequired,
removeCardReview: PropTypes.func.isRequired,
isModerator: PropTypes.bool.isRequired,
};
function QuestionModerationSection({
question,
verifyCard,
removeCardReview,
isModerator,
}) {
const latestModerationReview = getLatestModerationReview(
question.getModerationReviews(),
);
const onVerify = () => {
const id = question.id();
verifyCard(id);
};
const onRemoveModerationReview = () => {
const id = question.id();
removeCardReview(id);
};
return (
<React.Fragment>
<BorderedModerationActions
moderationReview={latestModerationReview}
onVerify={isModerator && onVerify}
/>
{latestModerationReview && (
<ModerationReviewBanner
moderationReview={latestModerationReview}
onRemove={isModerator && onRemoveModerationReview}
/>
)}
</React.Fragment>
);
}
import styled from "styled-components";
import { color } from "metabase/lib/colors";
import ModerationActions from "../ModerationActions/ModerationActions";
export const BorderedModerationActions = styled(ModerationActions)`
border-top: 1px solid ${color("border")};
padding-top: 1rem;
`;
export const ACTIONS = {
verified: {
type: "verified",
icon: "verified",
color: "brand",
},
};
import { PLUGIN_MODERATION } from "metabase/plugins";
import QuestionModerationSection from "./components/QuestionModerationSection/QuestionModerationSection";
import { getStatusIconForReviews } from "./service";
Object.assign(PLUGIN_MODERATION, {
QuestionModerationSection,
getStatusIconForReviews,
});
import { getUserIsAdmin } from "metabase/selectors/user";
export const getIsModerator = (state, props) => {
return getUserIsAdmin(state, props);
};
import { t } from "ttag";
import _ from "underscore";
import { ModerationReviewApi } from "metabase/services";
import { ACTIONS } from "./constants";
export function verifyItem({ text, itemId, itemType }) {
return ModerationReviewApi.create({
status: "verified",
moderated_item_id: itemId,
moderated_item_type: itemType,
text,
});
}
export function removeReview({ itemId, itemType }) {
return ModerationReviewApi.create({
status: null,
moderated_item_id: itemId,
moderated_item_type: itemType,
});
}
export function getVerifiedIcon() {
const { icon, color } = ACTIONS["verified"];
return { icon, iconColor: color };
}
export function getIconForReview(review) {
if (review && review.status !== null) {
const { status } = review;
const { icon, color } = ACTIONS[status] || {};
return { icon, iconColor: color };
}
return {};
}
export function getTextForReviewBanner(
moderationReview,
moderator,
currentUser,
) {
const moderatorName = getModeratorDisplayName(moderator, currentUser);
const { status } = moderationReview;
if (status === "verified") {
const bannerText = t`${moderatorName} verified this`;
const tooltipText = t`Remove verification`;
return { bannerText, tooltipText };
}
return {};
}
function getModeratorDisplayName(user, currentUser) {
const { id: userId, display_name } = user || {};
const { id: currentUserId } = currentUser || {};
if (currentUserId != null && userId === currentUserId) {
return t`You`;
} else if (userId != null) {
return display_name;
} else {
return t`A moderator`;
}
}
export function isItemVerified(review) {
return review != null && review.status === "verified";
}
export function getLatestModerationReview(reviews) {
const review = _.findWhere(reviews, {
most_recent: true,
});
// since we can't delete reviews, consider a most recent review with a status of null to mean there is no review
if (review && review.status !== null) {
return review;
}
}
export function getStatusIconForReviews(reviews) {
const review = getLatestModerationReview(reviews);
return getIconForReview(review);
}
import {
verifyItem,
removeReview,
getVerifiedIcon,
getIconForReview,
getTextForReviewBanner,
isItemVerified,
getLatestModerationReview,
getStatusIconForReviews,
} from "./service";
jest.mock("metabase/services", () => ({
ModerationReviewApi: {
create: jest.fn(() => Promise.resolve({ id: 123 })),
},
}));
import { ModerationReviewApi } from "metabase/services";
describe("moderation/service", () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe("verifyItem", () => {
it("should create a new moderation review", async () => {
const review = await verifyItem({
itemId: 123,
itemType: "card",
text: "bar",
});
expect(ModerationReviewApi.create).toHaveBeenCalledWith({
status: "verified",
moderated_item_id: 123,
moderated_item_type: "card",
text: "bar",
});
expect(review).toEqual({ id: 123 });
});
});
describe("removeReview", () => {
it("should create a new moderation review with a null status", async () => {
const review = await removeReview({
itemId: 123,
itemType: "card",
});
expect(ModerationReviewApi.create).toHaveBeenCalledWith({
status: null,
moderated_item_id: 123,
moderated_item_type: "card",
});
expect(review).toEqual({ id: 123 });
});
});
describe("getVerifiedIcon", () => {
it("should return verified icon name/color", () => {
expect(getVerifiedIcon()).toEqual({
icon: "verified",
iconColor: "brand",
});
});
});
describe("getIconForReview", () => {
it("should return icon name/color for given review", () => {
expect(getIconForReview({ status: "verified" })).toEqual(
getVerifiedIcon(),
);
});
it("should be an empty object for a null review", () => {
expect(getIconForReview({ status: null })).toEqual({});
});
});
describe("getTextForReviewBanner", () => {
it("should return text for a verified review", () => {
expect(getTextForReviewBanner({ status: "verified" })).toEqual({
bannerText: "A moderator verified this",
tooltipText: "Remove verification",
});
});
it("should include the moderator name", () => {
expect(
getTextForReviewBanner(
{ status: "verified" },
{
display_name: "Foo",
id: 1,
},
{ id: 2 },
),
).toEqual({
bannerText: "Foo verified this",
tooltipText: "Remove verification",
});
});
it("should handle the moderator being the current user", () => {
expect(
getTextForReviewBanner(
{ status: "verified" },
{
display_name: "Foo",
id: 1,
},
{ id: 1 },
),
).toEqual({
bannerText: "You verified this",
tooltipText: "Remove verification",
});
});
});
describe("isItemVerified", () => {
it("should return true for a verified review", () => {
expect(isItemVerified({ status: "verified" })).toBe(true);
});
it("should return false for a null review", () => {
expect(isItemVerified({ status: null })).toBe(false);
});
it("should return false for no review", () => {
expect(isItemVerified()).toBe(false);
});
});
describe("getLatestModerationReview", () => {
it("should return the review flagged as most recent", () => {
const reviews = [
{ id: 1, status: "verified" },
{ id: 2, status: "verified", most_recent: true },
{ id: 3, status: null },
];
expect(getLatestModerationReview(reviews)).toEqual({
id: 2,
status: "verified",
most_recent: true,
});
});
it("should return undefined when there is no review flagged as most recent", () => {
const reviews = [
{ id: 1, status: "verified" },
{ id: 2, status: "verified" },
{ id: 3, status: null },
];
expect(getLatestModerationReview(reviews)).toEqual(undefined);
expect(getLatestModerationReview([])).toEqual(undefined);
});
it("should return undefined when there is a review with a status of null flagged as most recent", () => {
const reviews = [
{ id: 1, status: "verified" },
{ id: 2, status: "verified" },
{ id: 3, status: null, most_recent: true },
];
expect(getLatestModerationReview(reviews)).toEqual(undefined);
});
});
});
describe("getStatusIconForReviews", () => {
it('should return the status icon for the most recent "real" review', () => {
const reviews = [
{ id: 1, status: "verified" },
{ id: 2, status: "verified", most_recent: true },
{ id: 3, status: null },
];
expect(getStatusIconForReviews(reviews)).toEqual(getVerifiedIcon());
});
it("should return undefined for no review", () => {
const reviews = [
{ id: 1, status: "verified" },
{ id: 2, status: "verified" },
{ id: 3, status: null, most_recent: true },
];
expect(getLatestModerationReview(reviews)).toEqual(undefined);
expect(getLatestModerationReview([])).toEqual(undefined);
});
});
......@@ -18,3 +18,4 @@ import "./embedding";
import "./store";
import "./snippets";
import "./sharing";
import "./moderation";
import _ from "underscore";
import { chain, assoc, dissoc, assocIn } from "icepick";
import { chain, assoc, dissoc, assocIn, getIn } from "icepick";
// NOTE: the order of these matters due to circular dependency issues
import StructuredQuery, {
......@@ -1045,6 +1045,10 @@ export default class Question {
const query = this.isNative() ? this._parameterValues : undefined;
return question.getUrl({ originalQuestion: this, query });
}
getModerationReviews() {
return getIn(this, ["_card", "moderation_reviews"]) || [];
}
}
window.Question = Question;
......
......@@ -100,10 +100,6 @@ export const ICON_PATHS = {
chevronleft: "M20 1 L24 5 L14 16 L24 27 L20 31 L6 16 z",
chevronright: "M12 1 L26 16 L12 31 L8 27 L18 16 L8 5 z ",
chevronup: "M1 20 L16 6 L31 20 L27 24 L16 14 L5 24 z",
clarification: {
svg:
'<rect width="32" height="32" rx="16" fill="#E1D9E8"/><path d="M11.2 8.307a9.244 9.244 0 0 1 1.024-.742c.375-.23.777-.431 1.203-.602.427-.179.883-.316 1.37-.41a7.894 7.894 0 0 1 1.6-.153c.802 0 1.523.107 2.163.32.649.213 1.199.52 1.651.922.452.392.798.87 1.037 1.433.247.555.371 1.174.371 1.856 0 .649-.09 1.212-.269 1.69-.179.47-.405.879-.678 1.229-.273.35-.576.652-.909.908a40.2 40.2 0 0 1-.934.717c-.29.214-.546.427-.768.64a1.46 1.46 0 0 0-.41.717l-.358 1.792h-2.714l-.281-2.06c-.069-.419-.026-.782.128-1.089.153-.316.362-.597.627-.845.273-.256.576-.495.909-.716.332-.23.644-.474.934-.73a4.12 4.12 0 0 0 .73-.87c.204-.325.307-.709.307-1.152 0-.512-.17-.918-.512-1.216-.333-.308-.794-.461-1.383-.461-.452 0-.832.047-1.139.14-.298.094-.559.201-.78.32-.214.112-.402.214-.564.308a.963.963 0 0 1-.486.14.881.881 0 0 1-.82-.473L11.2 8.307zm2.355 14.938c0-.307.056-.593.167-.858.119-.264.277-.495.473-.691.205-.196.444-.35.717-.46.273-.12.572-.18.896-.18.316 0 .61.06.883.18.273.11.512.264.717.46.205.196.363.427.474.691.119.265.179.55.179.858 0 .307-.06.597-.18.87a2.038 2.038 0 0 1-.473.691c-.205.197-.444.35-.717.461a2.323 2.323 0 0 1-.883.167c-.324 0-.623-.056-.896-.167a2.245 2.245 0 0 1-.717-.46 2.31 2.31 0 0 1-.473-.692 2.289 2.289 0 0 1-.167-.87z" fill="#A989C5"/>',
},
click:
"M5.38519 1C2.41104 1 0 3.41103 0 6.38519V26.779C0 29.7532 2.41103 32.1642 5.38519 32.1642H13.3818V27.7911H5.38519C4.82624 27.7911 4.37313 27.338 4.37313 26.779V6.38519C4.37313 5.82624 4.82624 5.37313 5.38519 5.37313H22.779C23.338 5.37313 23.7911 5.82624 23.7911 6.38519V7.98451H28.1642V6.38519C28.1642 3.41103 25.7532 1 22.779 1H5.38519ZM12 10.6L17.5213 29.1906L21.8777 24.8341L27.6436 30.6L32 26.2436L26.2341 20.4777L30.5906 16.1213L12 10.6Z",
clipboard:
......@@ -396,7 +392,7 @@ export const ICON_PATHS = {
"M32,3.85760518 C32,5.35923081 31.5210404,6.55447236 30.5631068,7.4433657 C29.6051732,8.33225903 28.4358214,8.77669903 27.0550162,8.77669903 C26.2265331,8.77669903 25.4110072,8.67314019 24.6084142,8.46601942 C23.8058212,8.25889864 23.111114,8.05178097 22.5242718,7.84466019 C22.2481108,8.03452091 21.8425054,8.44875625 21.3074434,9.08737864 C20.7723814,9.72600104 20.1682882,10.5026923 19.4951456,11.4174757 C20.116508,14.0582656 20.6170423,15.9352695 20.9967638,17.0485437 C21.3764852,18.1618179 21.7389411,19.2880202 22.0841424,20.4271845 C22.3775635,21.3419679 22.8090586,22.0582498 23.3786408,22.5760518 C23.9482229,23.0938537 24.8457328,23.3527508 26.0711974,23.3527508 C26.5199591,23.3527508 27.0809028,23.2664518 27.7540453,23.0938511 C28.4271878,22.9212505 28.9795016,22.7486524 29.4110032,22.5760518 L28.8414239,24.9061489 C27.1326775,25.6310716 25.6397043,26.1574957 24.3624595,26.4854369 C23.0852148,26.8133781 21.9460676,26.9773463 20.9449838,26.9773463 C20.2200611,26.9773463 19.5037792,26.9083071 18.7961165,26.7702265 C18.0884539,26.632146 17.4412111,26.3818788 16.8543689,26.0194175 C16.2157465,25.6396961 15.6763776,25.1650514 15.236246,24.5954693 C14.7961143,24.0258871 14.4207135,23.2319361 14.1100324,22.2135922 C13.9029116,21.5749698 13.7130537,20.850058 13.5404531,20.038835 C13.3678524,19.2276119 13.1952544,18.51133 13.0226537,17.8899676 C12.5221118,18.6321504 12.1596559,19.1844642 11.9352751,19.5469256 C11.7108942,19.9093869 11.3829579,20.4185512 10.9514563,21.0744337 C9.5879112,23.1629015 8.4056145,24.6515597 7.40453074,25.5404531 C6.40344699,26.4293464 5.20389049,26.8737864 3.80582524,26.8737864 C2.75296129,26.8737864 1.85545139,26.5199604 1.11326861,25.8122977 C0.371085825,25.1046351 0,24.1812355 0,23.0420712 C0,21.5059254 0.478959612,20.2934241 1.4368932,19.4045307 C2.3948268,18.5156374 3.56417864,18.0711974 4.94498382,18.0711974 C5.77346693,18.0711974 6.56741799,18.1704413 7.32686084,18.368932 C8.08630369,18.5674228 8.80258563,18.7874853 9.47572816,19.0291262 C9.73462913,18.8220054 10.1359196,18.4164 10.6796117,17.8122977 C11.2233037,17.2081955 11.814452,16.4573939 12.4530744,15.5598706 C11.8834923,13.2470219 11.4174775,11.5037815 11.0550162,10.3300971 C10.6925548,9.15641269 10.321469,7.99137579 9.94174757,6.83495146 C9.63106641,5.90290796 9.18231146,5.18231107 8.59546926,4.67313916 C8.00862706,4.16396725 7.12837696,3.90938511 5.95469256,3.90938511 C5.43689061,3.90938511 4.85868712,3.99999909 4.22006472,4.18122977 C3.58144233,4.36246045 3.04638835,4.53074356 2.61488673,4.68608414 L3.18446602,2.35598706 C4.73787184,1.66558447 6.20927029,1.14779029 7.5987055,0.802588997 C8.98814071,0.457387702 10.1488627,0.284789644 11.0809061,0.284789644 C11.9266493,0.284789644 12.6515612,0.345198964 13.2556634,0.466019417 C13.8597657,0.586839871 14.4983785,0.845736958 15.171521,1.24271845 C15.7928834,1.62243987 16.3322523,2.10139948 16.789644,2.67961165 C17.2470357,3.25782382 17.6224365,4.04745994 17.9158576,5.04854369 C18.1229784,5.73894628 18.3128362,6.45522822 18.4854369,7.197411 C18.6580375,7.93959379 18.8047459,8.5782066 18.9255663,9.11326861 C19.2880277,8.56094654 19.6634285,7.99137294 20.0517799,7.40453074 C20.4401314,6.81768854 20.7723827,6.29989437 21.0485437,5.85113269 C22.3775687,3.76266485 23.5684953,2.2653767 24.6213592,1.3592233 C25.6742232,0.453069903 26.8651498,0 28.1941748,0 C29.2815588,0 30.1876986,0.358140971 30.9126214,1.07443366 C31.6375441,1.79072634 32,2.71844091 32,3.85760518 L32,3.85760518 Z",
verified: {
svg:
'<path fill-rule="evenodd" clip-rule="evenodd" d="M32 16.64a6.397 6.397 0 0 0-3.15-5.514 6.4 6.4 0 0 0-7.42-8.115A6.396 6.396 0 0 0 16 0a6.396 6.396 0 0 0-5.43 3.01 6.4 6.4 0 0 0-7.42 8.115A6.397 6.397 0 0 0 0 16.64c0 2.25 1.162 4.23 2.919 5.371a6.4 6.4 0 0 0 7.652 6.979A6.396 6.396 0 0 0 16 32a6.396 6.396 0 0 0 5.429-3.01 6.4 6.4 0 0 0 7.652-6.979A6.395 6.395 0 0 0 32 16.64z" fill="#D3E7F8"/><path fill-rule="evenodd" clip-rule="evenodd" d="M8.32 16.852l2.392-2.485 3.649 3.769 7.312-7.576 2.4 2.584-9.71 10.02-6.043-6.312z" fill="#509EE3"/>',
'<path fill-rule="evenodd" clip-rule="evenodd" d="M32 16.64a6.397 6.397 0 0 0-3.15-5.514 6.4 6.4 0 0 0-7.42-8.115A6.396 6.396 0 0 0 16 0a6.396 6.396 0 0 0-5.43 3.01 6.4 6.4 0 0 0-7.42 8.115A6.397 6.397 0 0 0 0 16.64c0 2.25 1.162 4.23 2.919 5.371a6.4 6.4 0 0 0 7.652 6.979A6.396 6.396 0 0 0 16 32a6.396 6.396 0 0 0 5.429-3.01 6.4 6.4 0 0 0 7.652-6.979A6.395 6.395 0 0 0 32 16.64z M8.32 16.852l2.392-2.485 3.649 3.769 7.312-7.576 2.4 2.584-9.71 10.02-6.043-6.312z" />',
},
view_archive: {
path:
......@@ -408,10 +404,6 @@ export const ICON_PATHS = {
"M12.3069589,4.52260192 C14.3462632,1.2440969 17.653446,1.24541073 19.691933,4.52260192 L31.2249413,23.0637415 C33.2642456,26.3422466 31.7889628,29 27.9115531,29 L4.08733885,29 C0.218100769,29 -1.26453645,26.3409327 0.77395061,23.0637415 L12.3069589,4.52260192 Z M18.0499318,23.0163223 C18.0499772,23.0222378 18.05,23.0281606 18.05,23.0340907 C18.05,23.3266209 17.9947172,23.6030345 17.8840476,23.8612637 C17.7737568,24.1186089 17.6195847,24.3426723 17.4224081,24.5316332 C17.2266259,24.7192578 16.998292,24.8660439 16.7389806,24.9713892 C16.4782454,25.0773129 16.1979962,25.1301134 15.9,25.1301134 C15.5950083,25.1301134 15.3111795,25.0774024 15.0502239,24.9713892 C14.7901813,24.8657469 14.5629613,24.7183609 14.3703047,24.5298034 C14.177545,24.3411449 14.0258626,24.1177208 13.9159524,23.8612637 C13.8052827,23.6030345 13.75,23.3266209 13.75,23.0340907 C13.75,22.7411889 13.8054281,22.4661013 13.9165299,22.2109786 C14.0264627,21.9585404 14.1779817,21.7374046 14.3703047,21.5491736 C14.5621821,21.3613786 14.7883231,21.2126553 15.047143,21.1034656 C15.3089445,20.9930181 15.593871,20.938068 15.9,20.938068 C16.1991423,20.938068 16.4804862,20.9931136 16.7420615,21.1034656 C17.0001525,21.2123478 17.2274115,21.360472 17.4224081,21.5473437 C17.6191428,21.7358811 17.7731504,21.957652 17.88347,22.2109786 C17.9124619,22.2775526 17.9376628,22.3454862 17.9590769,22.414741 C18.0181943,22.5998533 18.05,22.7963729 18.05,23 C18.05,23.0054459 18.0499772,23.0108867 18.0499318,23.0163223 L18.0499318,23.0163223 Z M17.7477272,14.1749999 L17.7477272,8.75 L14.1170454,8.75 L14.1170454,14.1749999 C14.1170454,14.8471841 14.1572355,15.5139742 14.2376219,16.1753351 C14.3174838,16.8323805 14.4227217,17.5019113 14.5533248,18.1839498 L14.5921937,18.3869317 L17.272579,18.3869317 L17.3114479,18.1839498 C17.442051,17.5019113 17.5472889,16.8323805 17.6271507,16.1753351 C17.7075371,15.5139742 17.7477272,14.8471841 17.7477272,14.1749999 Z",
attrs: { fillRule: "evenodd" },
},
warning_colorized: {
svg:
'<path fill-rule="evenodd" clip-rule="evenodd" d="M11.764 4.36c1.957-3.146 6.535-3.146 8.492 0l11.002 17.687c2.072 3.33-.323 7.64-4.246 7.64H5.008c-3.922 0-6.317-4.31-4.246-7.64L11.765 4.359zm6.414 19.002v.019c0 .309-.059.601-.175.874-.117.272-.28.509-.488.709a2.269 2.269 0 0 1-.723.465 2.336 2.336 0 0 1-.887.168c-.322 0-.623-.056-.898-.168a2.179 2.179 0 0 1-1.2-1.174 2.199 2.199 0 0 1-.175-.874c0-.31.059-.601.176-.87.116-.268.276-.501.48-.7a2.279 2.279 0 0 1 1.617-.646c.316 0 .614.058.89.174.273.116.513.272.72.47a2.153 2.153 0 0 1 .663 1.535v.018zm-.32-15.083v5.735c0 .711-.042 1.416-.127 2.115a31.535 31.535 0 0 1-.334 2.124l-.041.214h-2.834l-.04-.214a31.537 31.537 0 0 1-.335-2.124c-.085-.699-.127-1.404-.127-2.115V8.28h3.838z" fill="#F2A86F"/>',
},
waterfall: {
path: "M12 0h8v13h-8V0zM0 13h8v19H0V13zM32 0h-8v21h8V0z",
attrs: { fillRule: "evenodd" },
......
import moment from "moment-timezone";
import { t } from "ttag";
addAbbreviatedLocale();
// when you define a custom locale, moment automatically makes it the active global locale,
// so we need to return to the user's initial locale.
// also, you can't define a custom locale on a local instance
function addAbbreviatedLocale() {
const initialLocale = moment.locale();
moment.locale("en-abbreviated", {
relativeTime: {
future: "in %s",
past: "%s",
s: "%d s",
ss: "%d s",
m: "%d m",
mm: "%d m",
h: "%d h",
hh: "%d h",
d: "%d d",
dd: "%d d",
w: "%d wk",
ww: "%d wks",
M: "a mth",
MM: "%d mths",
y: "%d y",
yy: "%d y",
},
});
moment.locale(initialLocale);
}
const NUMERIC_UNIT_FORMATS = {
// workaround for https://github.com/metabase/metabase/issues/1992
year: value =>
......@@ -123,3 +155,15 @@ export function formatFrame(frame) {
export function getRelativeTime(timestamp) {
return moment(timestamp).fromNow();
}
export function getRelativeTimeAbbreviated(timestamp) {
const locale = moment().locale();
if (locale === "en") {
const ts = moment(timestamp);
ts.locale("en-abbreviated");
return ts.fromNow();
}
return getRelativeTime(timestamp);
}
import { t } from "ttag";
import PluginPlaceholder from "metabase/plugins/components/PluginPlaceholder";
// Plugin integration points. All exports must be objects or arrays so they can be mutated by plugins.
const object = () => ({});
// functions called when the application is started
export const PLUGIN_APP_INIT_FUCTIONS = [];
......@@ -76,3 +78,8 @@ export const PLUGIN_COLLECTIONS = {
export const PLUGIN_COLLECTION_COMPONENTS = {
CollectionAuthorityLevelIcon: PluginPlaceholder,
};
export const PLUGIN_MODERATION = {
QuestionModerationSection: PluginPlaceholder,
getStatusIconForReviews: object,
};
import React from "react";
import PropTypes from "prop-types";
import { PLUGIN_MODERATION } from "metabase/plugins";
import { HeaderButton } from "./SavedQuestionHeaderButton.styled";
const { getStatusIconForReviews } = PLUGIN_MODERATION;
export default SavedQuestionHeaderButton;
SavedQuestionHeaderButton.propTypes = {
......@@ -13,11 +16,18 @@ SavedQuestionHeaderButton.propTypes = {
};
function SavedQuestionHeaderButton({ className, question, onClick, isActive }) {
const {
icon: reviewIcon,
iconColor: reviewIconColor,
} = getStatusIconForReviews(question.getModerationReviews());
return (
<HeaderButton
className={className}
onClick={onClick}
iconRight="chevrondown"
icon={reviewIcon}
leftIconColor={reviewIconColor}
isActive={isActive}
iconSize={20}
data-testid="saved-question-header-button"
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment