diff --git a/client/src/core/client/admin/components/ModerateCard/ModerateCard.css b/client/src/core/client/admin/components/ModerateCard/ModerateCard.css index 24c7c0dbef..5a80c0d519 100644 --- a/client/src/core/client/admin/components/ModerateCard/ModerateCard.css +++ b/client/src/core/client/admin/components/ModerateCard/ModerateCard.css @@ -111,6 +111,15 @@ $moderateCardLinkTextColor: $colors-teal-700; padding-bottom: var(--spacing-1); } +.moderationReasonDropdown { + opacity: 1; +} + +.moderationReasonCard { + padding: 0; + margin: 0; +} + .storyLabel { color: var(--palette-grey-500); font-size: var(--font-size-1); diff --git a/client/src/core/client/admin/components/ModerateCard/ModerateCard.tsx b/client/src/core/client/admin/components/ModerateCard/ModerateCard.tsx index e340c5ed1c..860508e3a3 100644 --- a/client/src/core/client/admin/components/ModerateCard/ModerateCard.tsx +++ b/client/src/core/client/admin/components/ModerateCard/ModerateCard.tsx @@ -23,14 +23,20 @@ import { import { Button, Card, + ClickOutside, + Dropdown, Flex, HorizontalGutter, + Popover, TextLink, Timestamp, } from "coral-ui/components/v2"; import { StarRating } from "coral-ui/components/v3"; +import { RejectCommentReasonInput } from "coral-stream/__generated__/RejectCommentMutation.graphql"; + import { CommentContent, InReplyTo, UsernameButton } from "../Comment"; +import ModerationReason from "../ModerationReason/ModerationReason"; import ApproveButton from "./ApproveButton"; import CommentAuthorContainer from "./CommentAuthorContainer"; import FeatureButton from "./FeatureButton"; @@ -95,6 +101,8 @@ interface Props { suspectWords?: Readonly[]>; isArchived?: boolean; isArchiving?: boolean; + dsaFeaturesEnabled: boolean; + onReason: (reason: RejectCommentReasonInput) => void; } const ModerateCard: FunctionComponent = ({ @@ -137,6 +145,8 @@ const ModerateCard: FunctionComponent = ({ suspectWords, isArchived, isArchiving, + onReason, + dsaFeaturesEnabled, }) => { const div = useRef(null); @@ -341,22 +351,51 @@ const ModerateCard: FunctionComponent = ({ )} - + { + return ( + + + + + + ); + }} + placement="bottom-start" + > + {({ toggleVisibility, visible, ref }) => { + return ( + + ); + }} + t.code === GQLTAG.FEATURED); } +interface RejectionState { + showModerationReason: boolean; + rejecting: boolean; + reason?: RejectCommentInput["reason"]; +} + +type RejectionAction = + | { action: "INDICATE_REJECT" } + | { action: "CONFIRM_REASON"; reason: RejectCommentInput["reason"] } + | { action: "REJECTION_COMPLETE" }; + +type RejectionReducer = Reducer; + const ModerateCardContainer: FunctionComponent = ({ comment, settings, @@ -94,10 +111,11 @@ const ModerateCardContainer: FunctionComponent = ({ const { match, router } = useRouter(); - const [{ moderationQueueSort }] = + const [{ moderationQueueSort, dsaFeaturesEnabled }] = useLocal(graphql` fragment ModerateCardContainerLocal on Local { moderationQueueSort + dsaFeaturesEnabled } `); @@ -109,6 +127,27 @@ const ModerateCardContainer: FunctionComponent = ({ ); const [showBanModal, setShowBanModal] = useState(false); + const [{ rejecting, reason: rejectionReason }, dispatch] = + useReducer( // TODO use dispatch + (state, input) => { + switch (input.action) { + case "INDICATE_REJECT": + return dsaFeaturesEnabled + ? { showModerationReason: true, rejecting: false } + : { showModerationReason: false, rejecting: true }; + case "CONFIRM_REASON": + return { + showModerationReason: false, + rejecting: true, + reason: input.reason, + }; + case "REJECTION_COMPLETE": + return { showModerationReason: false, rejecting: false }; + } + }, + { showModerationReason: false, rejecting: false } + ); + const handleApprove = useCallback(async () => { if (!comment.revision) { return; @@ -159,11 +198,15 @@ const ModerateCardContainer: FunctionComponent = ({ await rejectComment({ commentID: comment.id, commentRevisionID: comment.revision.id, + reason: rejectionReason, storyID, siteID, section, orderBy: moderationQueueSort, }); + + dispatch({ action: "REJECTION_COMPLETE" }); + if (loadNext) { loadNext(); } @@ -179,6 +222,7 @@ const ModerateCardContainer: FunctionComponent = ({ loadNext, moderationQueueSort, onModerated, + rejectionReason, ]); const handleFeature = useCallback(() => { @@ -276,6 +320,12 @@ const ModerateCardContainer: FunctionComponent = ({ const handleBanConfirm = useCallback(() => setShowBanModal(false), []); + useEffect(() => { + if (rejecting) { + void handleReject(); + } + }, [handleReject, rejecting]); + // Only highlight comments that have been flagged for containing a banned or // suspect word. const highlight = useMemo(() => { @@ -319,7 +369,7 @@ const ModerateCardContainer: FunctionComponent = ({ featured={isFeatured(comment)} viewContextHref={comment.permalink} onApprove={handleApprove} - onReject={handleReject} + onReject={() => dispatch({ action: "INDICATE_REJECT" })} onFeature={onFeature} onUsernameClick={onUsernameClicked} onConversationClick={ @@ -357,6 +407,8 @@ const ModerateCardContainer: FunctionComponent = ({ } isArchived={comment.story.isArchived} isArchiving={comment.story.isArchiving} + dsaFeaturesEnabled={!!dsaFeaturesEnabled} + onReason={(reason) => dispatch({ action: "CONFIRM_REASON", reason })} /> {comment.author && ( diff --git a/client/src/core/client/admin/components/ModerateCard/RejectButton.css b/client/src/core/client/admin/components/ModerateCard/RejectButton.css index 2acecfb24a..8eac232e71 100644 --- a/client/src/core/client/admin/components/ModerateCard/RejectButton.css +++ b/client/src/core/client/admin/components/ModerateCard/RejectButton.css @@ -6,9 +6,7 @@ $moderateCardButtonOutlineRejectColor: var(--palette-error-500); border-radius: var(--round-corners); width: 65px; height: 50px; - display: flex; - justify-content: center; - align-items: center; + position: relative; color: $moderateCardButtonOutlineRejectColor; &:not(:disabled):active { background-color: $moderateCardButtonOutlineRejectColor; @@ -16,6 +14,11 @@ $moderateCardButtonOutlineRejectColor: var(--palette-error-500); } } +.open { + background-color: var(--palette-error-400); + color: var(--palette-text-000); +} + .readOnly { background-color: transparent; border-color: $colors-grey-300); @@ -42,6 +45,17 @@ $moderateCardButtonOutlineRejectColor: var(--palette-error-500); border-color: $moderateCardButtonOutlineRejectColor; } -.icon { +.xIcon { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); color: inherit; } + +.arrowIcon { + position: absolute; + left: 75%; + top: 25%; + transform: translate(-50%, 25%); +} diff --git a/client/src/core/client/admin/components/ModerateCard/RejectButton.tsx b/client/src/core/client/admin/components/ModerateCard/RejectButton.tsx index 3bfc1aba47..34af51ee2f 100644 --- a/client/src/core/client/admin/components/ModerateCard/RejectButton.tsx +++ b/client/src/core/client/admin/components/ModerateCard/RejectButton.tsx @@ -3,34 +3,55 @@ import cn from "classnames"; import React, { FunctionComponent } from "react"; import { PropTypesOf } from "coral-framework/types"; -import { RemoveIcon, SvgIcon } from "coral-ui/components/icons"; +import { + ArrowsDownIcon, + ArrowsUpIcon, + RemoveIcon, + SvgIcon, +} from "coral-ui/components/icons"; import { BaseButton } from "coral-ui/components/v2"; +import { withForwardRef } from "coral-ui/hocs"; import styles from "./RejectButton.css"; interface Props extends Omit, "ref"> { + toggle?: boolean; + open?: boolean; invert?: boolean; readOnly?: boolean; + forwardRef: React.Ref; } const RejectButton: FunctionComponent = ({ invert, readOnly, className, + open = false, + toggle = false, + forwardRef, ...rest }) => ( - + + {toggle && ( + + )} ); -export default RejectButton; +export default withForwardRef(RejectButton); diff --git a/client/src/core/client/admin/components/ModerationReason/DetailedExplanation.css b/client/src/core/client/admin/components/ModerationReason/DetailedExplanation.css new file mode 100644 index 0000000000..c47aa37007 --- /dev/null +++ b/client/src/core/client/admin/components/ModerationReason/DetailedExplanation.css @@ -0,0 +1,15 @@ +.detailedExplanation { + margin-bottom: var(--spacing-3); +} + +.explanationLabel { + margin-bottom: var(--spacing-2); +} + +.changeReason { + margin-bottom: var(--spacing-3); +} + +.detailedExplanation > textarea { + resize: none; +} diff --git a/client/src/core/client/admin/components/ModerationReason/DetailedExplanation.tsx b/client/src/core/client/admin/components/ModerationReason/DetailedExplanation.tsx new file mode 100644 index 0000000000..6b0db0c4e2 --- /dev/null +++ b/client/src/core/client/admin/components/ModerationReason/DetailedExplanation.tsx @@ -0,0 +1,77 @@ +import { Localized } from "@fluent/react/compat"; +import cn from "classnames"; +import React, { FunctionComponent } from "react"; + +import { GQLREJECTION_REASON_CODE } from "coral-framework/schema"; +import { Label, RadioButton } from "coral-ui/components/v2"; +import { TextArea } from "coral-ui/components/v3"; +import { Button } from "coral-ui/components/v3/Button/Button"; + +import { unsnake } from "./formatting"; + +import styles from "./DetailedExplanation.css"; +import commonStyles from "./ModerationReason.css"; + +export interface Props { + onChange: (value: string) => void; + code: GQLREJECTION_REASON_CODE; + value: string | null; + onBack: () => void; +} + +const DetailedExplanation: FunctionComponent = ({ + code, + value, + onChange, + onBack, +}) => { + return ( + <> + + + {unsnake(code)} + + + + + + + + + + + + +