diff --git a/package-lock.json b/package-lock.json index c1d0a5b5a..f4bd8a780 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20678,6 +20678,11 @@ "safe-buffer": "^5.0.1" } }, + "keymaster": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/keymaster/-/keymaster-1.6.2.tgz", + "integrity": "sha1-4a5U0OqUiPn2C2a2aPAumhlGxus=" + }, "killable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", diff --git a/package.json b/package.json index ec314e6c2..b30409dd0 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "jsonwebtoken": "^8.3.0", "juice": "^5.2.0", "jwks-rsa": "^1.3.0", + "keymaster": "^1.6.2", "linkifyjs": "^2.1.8", "lodash": "^4.17.15", "lru-cache": "^5.1.1", diff --git a/src/core/client/admin/components/ModerateCard/BanCommentUserMutation.ts b/src/core/client/admin/components/ModerateCard/BanCommentUserMutation.ts new file mode 100644 index 000000000..fe4858f15 --- /dev/null +++ b/src/core/client/admin/components/ModerateCard/BanCommentUserMutation.ts @@ -0,0 +1,53 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { BanCommentUserMutation as MutationTypes } from "coral-admin/__generated__/BanCommentUserMutation.graphql"; +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +const clientMutationId = 0; + +const BanCommentUserMutation = createMutation( + "banUser", + (environment: Environment, input: MutationInput) => { + return commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation BanCommentUserMutation($input: BanUserInput!) { + banUser(input: $input) { + user { + id + status { + current + } + } + clientMutationId + } + } + `, + variables: { + input: { + ...input, + clientMutationId: clientMutationId.toString(), + }, + }, + updater: store => { + const user = store.get(input.userID); + if (user) { + const comments = user.getLinkedRecords("comments"); + if (comments) { + comments.forEach(comment => { + if (comment) { + comment.setLinkedRecord(user, "author"); + } + }); + } + } + }, + }); + } +); + +export default BanCommentUserMutation; diff --git a/src/core/client/admin/components/ModerateCard/CommentAuthorContainer.css b/src/core/client/admin/components/ModerateCard/CommentAuthorContainer.css new file mode 100644 index 000000000..8e19d96fc --- /dev/null +++ b/src/core/client/admin/components/ModerateCard/CommentAuthorContainer.css @@ -0,0 +1,3 @@ +.authorStatus { + padding-right: var(--spacing-2); +} \ No newline at end of file diff --git a/src/core/client/admin/components/ModerateCard/CommentAuthorContainer.tsx b/src/core/client/admin/components/ModerateCard/CommentAuthorContainer.tsx new file mode 100644 index 000000000..98aaf0ee1 --- /dev/null +++ b/src/core/client/admin/components/ModerateCard/CommentAuthorContainer.tsx @@ -0,0 +1,45 @@ +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent } from "react"; + +import { CommentAuthorContainer_comment as CommentData } from "coral-admin/__generated__/CommentAuthorContainer_comment.graphql"; +import { graphql, withFragmentContainer } from "coral-framework/lib/relay"; +import { Tag } from "coral-ui/components"; + +import styles from "./CommentAuthorContainer.css"; + +interface Props { + comment: CommentData; +} + +const CommentAuthorContainer: FunctionComponent = ({ comment }) => { + if (!comment.author || !comment.author.status.ban.active) { + return null; + } + return ( + <> + +
+ BANNED +
+
+ + ); +}; + +const enhanced = withFragmentContainer({ + comment: graphql` + fragment CommentAuthorContainer_comment on Comment { + author { + id + username + status { + ban { + active + } + } + } + } + `, +})(CommentAuthorContainer); + +export default enhanced; diff --git a/src/core/client/admin/components/ModerateCard/ModerateCard.css b/src/core/client/admin/components/ModerateCard/ModerateCard.css index 92444ae8f..9ffff678f 100644 --- a/src/core/client/admin/components/ModerateCard/ModerateCard.css +++ b/src/core/client/admin/components/ModerateCard/ModerateCard.css @@ -8,7 +8,7 @@ } .username { - margin-right: var(--mini-unit); + margin-right: var(--spacing-1); padding: var(--spacing-1); margin-left: calc(-1 * var(--spacing-1)); line-height: calc(16rem / var(--rem-base)); @@ -89,6 +89,10 @@ transition: background 100ms, box-shadow 100ms; } +.root:focus { + outline: none; +} + .dangling { background-color: var(--palette-grey-lightest); box-shadow: none; @@ -164,4 +168,13 @@ .edited { color: var(--palette-grey-lighter); padding-left: var(--spacing-2) -} \ No newline at end of file +} + +.selected { + box-shadow: 1px 4px 15px rgba(0, 0, 0, 0.25); +} + +.authorStatus { + padding-right: var(--spacing-2); +} + diff --git a/src/core/client/admin/components/ModerateCard/ModerateCard.spec.tsx b/src/core/client/admin/components/ModerateCard/ModerateCard.spec.tsx index c07300053..850dec2b1 100644 --- a/src/core/client/admin/components/ModerateCard/ModerateCard.spec.tsx +++ b/src/core/client/admin/components/ModerateCard/ModerateCard.spec.tsx @@ -16,8 +16,8 @@ const baseProps: PropTypesOf = { body: "content", edited: false, inReplyTo: null, - comment: {}, settings: {}, + comment: {}, status: "undecided", featured: false, viewContextHref: "http://localhost/comment", @@ -26,7 +26,9 @@ const baseProps: PropTypesOf = { onApprove: noop, onReject: noop, onFeature: noop, + onBan: noop, onUsernameClick: noop, + onFocusOrClick: noop, showStory: false, moderatedBy: null, }; diff --git a/src/core/client/admin/components/ModerateCard/ModerateCard.tsx b/src/core/client/admin/components/ModerateCard/ModerateCard.tsx index dcd5f6e6d..8c5a88532 100644 --- a/src/core/client/admin/components/ModerateCard/ModerateCard.tsx +++ b/src/core/client/admin/components/ModerateCard/ModerateCard.tsx @@ -1,7 +1,15 @@ import cn from "classnames"; import { Localized } from "fluent-react/compat"; -import React, { FunctionComponent, useCallback } from "react"; +import key from "keymaster"; +import { noop } from "lodash"; +import React, { + FunctionComponent, + useCallback, + useEffect, + useRef, +} from "react"; +import { HOTKEYS } from "coral-admin/constants"; import { PropTypesOf } from "coral-framework/types"; import { BaseButton, @@ -14,6 +22,7 @@ import { } from "coral-ui/components"; import ApproveButton from "./ApproveButton"; +import CommentAuthorContainer from "./CommentAuthorContainer"; import CommentContent from "./CommentContent"; import FeatureButton from "./FeatureButton"; import InReplyTo from "./InReplyTo"; @@ -32,7 +41,8 @@ interface Props { id: string; username: string | null; } | null; - comment: PropTypesOf["comment"]; + comment: PropTypesOf["comment"] & + PropTypesOf["comment"]; settings: PropTypesOf["settings"]; status: "approved" | "rejected" | "undecided"; featured: boolean; @@ -48,8 +58,10 @@ interface Props { onReject: () => void; onFeature: () => void; onUsernameClick: (id?: string) => void; + onFocusOrClick: () => void; mini?: boolean; hideUsername?: boolean; + selected?: boolean; /** * If set to true, it means this comment is about to be removed * from the queue. This will trigger some styling changes to @@ -58,6 +70,9 @@ interface Props { dangling?: boolean; deleted?: boolean; edited: boolean; + selectPrev?: () => void; + selectNext?: () => void; + onBan: () => void; } const ModerateCard: FunctionComponent = ({ @@ -83,11 +98,52 @@ const ModerateCard: FunctionComponent = ({ storyHref, onModerateStory, moderatedBy, + selected, + onFocusOrClick, mini = false, hideUsername = false, deleted = false, edited, + selectNext, + selectPrev, + onBan, }) => { + const div = useRef(null); + useEffect(() => { + if (selected) { + if (selectNext) { + key(HOTKEYS.NEXT, id, selectNext); + } + if (selectPrev) { + key(HOTKEYS.PREV, id, selectPrev); + } + if (onBan) { + key(HOTKEYS.BAN, id, onBan); + } + key(HOTKEYS.APPROVE, id, onApprove); + key(HOTKEYS.REJECT, id, onReject); + + // The the scope such that only events attached to the ${id} scope will + // be honored. + key.setScope(id); + + return () => { + // Remove all events that are set in the ${id} scope. + key.deleteScope(id); + }; + } else { + // Remove all events that were set in the ${id} scope. + key.deleteScope(id); + } + + return noop; + }, [selected, id]); + + useEffect(() => { + if (selected && div && div.current) { + div.current.focus(); + } + }, [selected]); const commentBody = deleted ? ( @@ -112,9 +168,14 @@ const ModerateCard: FunctionComponent = ({ styles.root, { [styles.borderless]: mini }, { [styles.dangling]: dangling }, - { [styles.deleted]: deleted } + { [styles.deleted]: deleted }, + { [styles.selected]: selected } )} + ref={div} + tabIndex={0} data-testid={`moderate-comment-${id}`} + id={`moderate-comment-${id}`} + onClick={onFocusOrClick} >
@@ -132,7 +193,8 @@ const ModerateCard: FunctionComponent = ({ {username} )} - {createdAt} + + {createdAt} {edited && ( diff --git a/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx b/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx index 378a24e78..9c595df83 100644 --- a/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx +++ b/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx @@ -1,5 +1,5 @@ import { Match, Router, withRouter } from "found"; -import React, { FunctionComponent, useCallback } from "react"; +import React, { FunctionComponent, useCallback, useState } from "react"; import { graphql } from "react-relay"; import { @@ -18,8 +18,11 @@ import { withFragmentContainer, withMutation, } from "coral-framework/lib/relay"; +import { GQLUSER_STATUS } from "coral-framework/schema"; import { GQLTAG } from "coral-framework/schema"; +import BanModal from "coral-admin/components/UserStatus/BanModal"; +import BanCommentUserMutation from "./BanCommentUserMutation"; import FeatureCommentMutation from "./FeatureCommentMutation"; import ModerateCard from "./ModerateCard"; import ModeratedByContainer from "./ModeratedByContainer"; @@ -33,6 +36,7 @@ interface Props { rejectComment: MutationProp; featureComment: MutationProp; unfeatureComment: MutationProp; + banUser: MutationProp; danglingLogic: (status: COMMENT_STATUS) => boolean; match: Match; router: Router; @@ -40,6 +44,11 @@ interface Props { mini?: boolean; hideUsername?: boolean; onUsernameClicked?: (userID: string) => void; + onSetSelected?: () => void; + selected?: boolean; + selectPrev?: () => void; + selectNext?: () => void; + loadNext?: (() => void) | null; } function getStatus(comment: ModerateCardContainer_comment) { @@ -71,30 +80,43 @@ const ModerateCardContainer: FunctionComponent = ({ unfeatureComment, mini, hideUsername, + selected, + selectPrev, + selectNext, onUsernameClicked: usernameClicked, + onSetSelected: setSelected, + banUser, + loadNext, }) => { - const handleApprove = useCallback(() => { + const [showBanModal, setShowBanModal] = useState(false); + const handleApprove = useCallback(async () => { if (!comment.revision) { return; } - approveComment({ + await approveComment({ commentID: comment.id, commentRevisionID: comment.revision.id, storyID: match.params.storyID, }); + if (loadNext) { + loadNext(); + } }, [approveComment, comment, match]); - const handleReject = useCallback(() => { + const handleReject = useCallback(async () => { if (!comment.revision) { return; } - rejectComment({ + await rejectComment({ commentID: comment.id, commentRevisionID: comment.revision.id, storyID: match.params.storyID, }); + if (loadNext) { + loadNext(); + } }, [rejectComment, comment, match]); const handleFeature = useCallback(() => { @@ -146,6 +168,35 @@ const ModerateCardContainer: FunctionComponent = ({ [router, comment] ); + const onFocusOrClick = useCallback(() => { + if (setSelected) { + setSelected(); + } + }, [selected, comment]); + + const handleBanModalClose = useCallback(() => { + setShowBanModal(false); + }, []); + + const openBanModal = useCallback(() => { + if ( + !comment.author || + comment.author.status.current.includes(GQLUSER_STATUS.BANNED) + ) { + return; + } + setShowBanModal(true); + }, [comment]); + + const handleBanConfirm = useCallback( + async (message: string) => { + if (comment.author) { + await banUser({ userID: comment.author.id, message }); + } + setShowBanModal(false); + }, + [comment] + ); return ( <> @@ -171,6 +222,10 @@ const ModerateCardContainer: FunctionComponent = ({ onReject={handleReject} onFeature={onFeature} onUsernameClick={onUsernameClicked} + selected={selected} + selectPrev={selectPrev} + selectNext={selectNext} + onBan={openBanModal} moderatedBy={ = ({ comment={comment} /> } + onFocusOrClick={onFocusOrClick} showStory={showStoryInfo} storyTitle={ (comment.story.metadata && comment.story.metadata.title) || ( @@ -192,6 +248,16 @@ const ModerateCardContainer: FunctionComponent = ({ edited={comment.editing.edited} /> + ); }; @@ -203,6 +269,9 @@ const enhanced = withFragmentContainer({ author { id username + status { + current + } } statusLiveUpdated createdAt @@ -234,6 +303,7 @@ const enhanced = withFragmentContainer({ deleted ...MarkersContainer_comment ...ModeratedByContainer_comment + ...CommentAuthorContainer_comment } `, settings: graphql` @@ -252,10 +322,12 @@ const enhanced = withFragmentContainer({ `, })( withRouter( - withMutation(ApproveCommentMutation)( - withMutation(RejectCommentMutation)( - withMutation(FeatureCommentMutation)( - withMutation(UnfeatureCommentMutation)(ModerateCardContainer) + withMutation(BanCommentUserMutation)( + withMutation(ApproveCommentMutation)( + withMutation(RejectCommentMutation)( + withMutation(FeatureCommentMutation)( + withMutation(UnfeatureCommentMutation)(ModerateCardContainer) + ) ) ) ) diff --git a/src/core/client/admin/components/ModerateCard/__snapshots__/ModerateCard.spec.tsx.snap b/src/core/client/admin/components/ModerateCard/__snapshots__/ModerateCard.spec.tsx.snap index abb8e8283..d3978292f 100644 --- a/src/core/client/admin/components/ModerateCard/__snapshots__/ModerateCard.spec.tsx.snap +++ b/src/core/client/admin/components/ModerateCard/__snapshots__/ModerateCard.spec.tsx.snap @@ -1,9 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`renders approved correctly 1`] = ` -
+ 2018-11-29T16:01:51.897Z @@ -112,13 +117,16 @@ exports[`renders approved correctly 1`] = ` - + `; exports[`renders correctly 1`] = ` -
+ 2018-11-29T16:01:51.897Z @@ -227,13 +237,16 @@ exports[`renders correctly 1`] = ` - + `; exports[`renders dangling correctly 1`] = ` -
+ 2018-11-29T16:01:51.897Z @@ -342,13 +357,16 @@ exports[`renders dangling correctly 1`] = ` - + `; exports[`renders rejected correctly 1`] = ` -
+ 2018-11-29T16:01:51.897Z @@ -457,13 +477,16 @@ exports[`renders rejected correctly 1`] = ` - + `; exports[`renders reply correctly 1`] = ` -
+ 2018-11-29T16:01:51.897Z @@ -582,13 +607,16 @@ exports[`renders reply correctly 1`] = ` - + `; exports[`renders story info 1`] = ` -
+ 2018-11-29T16:01:51.897Z @@ -726,13 +756,16 @@ exports[`renders story info 1`] = ` - + `; exports[`renders tombstoned when comment is deleted 1`] = ` -
+ 2018-11-29T16:01:51.897Z @@ -847,5 +882,5 @@ exports[`renders tombstoned when comment is deleted 1`] = ` - + `; diff --git a/src/core/client/admin/constants.ts b/src/core/client/admin/constants.ts index 1cec8b46c..e5baeb8ef 100644 --- a/src/core/client/admin/constants.ts +++ b/src/core/client/admin/constants.ts @@ -1 +1,12 @@ export const REDIRECT_PATH_KEY = "adminRedirectPath"; +export const HOTKEYS = { + NEXT: "j", + PREV: "k", + APPROVE: "d", + REJECT: "f", + SWITCH_QUEUE: "t", + ZEN: "z", + BAN: "b", + SEARCH: "ctrl+f", + GUIDE: "shift+/", +}; diff --git a/src/core/client/admin/routes/Moderate/HotkeysModal.css b/src/core/client/admin/routes/Moderate/HotkeysModal.css new file mode 100644 index 000000000..e86bfa497 --- /dev/null +++ b/src/core/client/admin/routes/Moderate/HotkeysModal.css @@ -0,0 +1,17 @@ +.root { + min-width: 475px; +} + +.hotKeyContainer { + width: 65px; +} + +.hotKey { + composes: bodyCopy from "coral-ui/shared/typography.css"; + background-color: #f2f2f2; + border: 1px solid #bbbebf; + border-radius: 3px; + padding: var(--spacing-1); + display: inline-block; + line-height: 1; +} \ No newline at end of file diff --git a/src/core/client/admin/routes/Moderate/HotkeysModal.tsx b/src/core/client/admin/routes/Moderate/HotkeysModal.tsx new file mode 100644 index 000000000..ea26daa26 --- /dev/null +++ b/src/core/client/admin/routes/Moderate/HotkeysModal.tsx @@ -0,0 +1,132 @@ +import { + Card, + CardCloseButton, + Flex, + HorizontalGutter, + Modal, + Typography, +} from "coral-ui/components"; +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent } from "react"; + +import styles from "./HotkeysModal.css"; + +interface Props { + open: boolean; + onClose: () => void; +} + +const HotkeysModal: FunctionComponent = ({ open, onClose }) => { + return ( + + {({ firstFocusableRef }) => ( + + + + + Keyboard shortcuts + + + + + + Navigation shortcuts + + + +
+
j
+
+ + Next comment + +
+ +
+
k
+
+ + Previous comment + +
+ +
+
ctrl+f
+
+ + Open search + +
+ +
+
1..4
+
+ + Jump to specific queue + +
+ +
+
t
+
+ + Switch queues + +
+ +
+
z
+
+ + Toggle single-comment view + +
+ +
+
?
+
+ + Toggle shortcuts help + +
+
+ + + + Moderation decisions + + + +
+
d
+
+ + Approve + +
+ +
+
f
+
+ + Reject + +
+ +
+
b
+
+ + Ban comment author + +
+
+
+
+
+ )} +
+ ); +}; + +export default HotkeysModal; diff --git a/src/core/client/admin/routes/Moderate/Moderate.tsx b/src/core/client/admin/routes/Moderate/Moderate.tsx index 443ab85cb..652a7f6bb 100644 --- a/src/core/client/admin/routes/Moderate/Moderate.tsx +++ b/src/core/client/admin/routes/Moderate/Moderate.tsx @@ -1,9 +1,17 @@ -import React, { FunctionComponent } from "react"; +import key from "keymaster"; +import React, { + FunctionComponent, + useCallback, + useEffect, + useState, +} from "react"; import MainLayout from "coral-admin/components/MainLayout"; +import { HOTKEYS } from "coral-admin/constants"; import { PropTypesOf } from "coral-framework/types"; import { SubBar } from "coral-ui/components/SubBar"; +import HotkeysModal from "./HotkeysModal"; import ModerateNavigationContainer from "./ModerateNavigation"; import ModerateSearchBarContainer from "./ModerateSearchBar"; @@ -24,20 +32,40 @@ const Moderate: FunctionComponent = ({ story, allStories, children, -}) => ( -
- - - - -
- -
{children}
-
-
-); +}) => { + const [showHotkeysModal, setShowHotkeysModal] = useState(false); + const closeModal = useCallback(() => { + setShowHotkeysModal(false); + }, []); + const toggleModal = useCallback(() => { + setShowHotkeysModal(!showHotkeysModal); + }, [showHotkeysModal]); + + useEffect(() => { + // Attach the modal toggle when the GUIDE button is pressed. + key(HOTKEYS.GUIDE, toggleModal); + return () => { + // Detach the modal toggle if we have to rebind it. + key.unbind(HOTKEYS.GUIDE); + }; + }, [toggleModal]); + + return ( +
+ + + + +
+ +
{children}
+
+ +
+ ); +}; export default Moderate; diff --git a/src/core/client/admin/routes/Moderate/ModerateNavigation/Navigation.tsx b/src/core/client/admin/routes/Moderate/ModerateNavigation/Navigation.tsx index 3270eefbc..d2dbe5e1f 100644 --- a/src/core/client/admin/routes/Moderate/ModerateNavigation/Navigation.tsx +++ b/src/core/client/admin/routes/Moderate/ModerateNavigation/Navigation.tsx @@ -1,5 +1,8 @@ +import { HOTKEYS } from "coral-admin/constants"; import { Localized } from "fluent-react/compat"; -import React, { FunctionComponent } from "react"; +import { Match, Router, withRouter } from "found"; +import key from "keymaster"; +import React, { FunctionComponent, useEffect, useMemo } from "react"; import { getModerationLink } from "coral-admin/helpers"; import { Counter, Icon, SubBarNavigation } from "coral-ui/components"; @@ -11,6 +14,8 @@ interface Props { reportedCount?: number; pendingCount?: number; storyID?: string | null; + router: Router; + match: Match; } const Navigation: FunctionComponent = ({ @@ -18,48 +23,86 @@ const Navigation: FunctionComponent = ({ reportedCount, pendingCount, storyID, -}) => ( - - - flag - - Reported - - {reportedCount !== undefined && ( - - {reportedCount} - - )} - - - access_time - - Pending - - {pendingCount !== undefined && ( - - {pendingCount} - - )} - - - forum - - Unmoderated - - {unmoderatedCount !== undefined && ( - - {unmoderatedCount} - - )} - - - cancel - - Rejected - - - -); + router, + match, +}) => { + const moderationLinks = useMemo(() => { + return [ + getModerationLink("reported", storyID), + getModerationLink("pending", storyID), + getModerationLink("unmoderated", storyID), + getModerationLink("rejected", storyID), + ]; + }, [storyID]); -export default Navigation; + useEffect(() => { + key(HOTKEYS.SWITCH_QUEUE, () => { + const current = match.location.pathname; + const index = moderationLinks.indexOf(current); + if (index >= 0) { + if (index === moderationLinks.length - 1) { + router.replace(moderationLinks[0]); + } else { + router.replace(moderationLinks[index + 1]); + } + } + }); + for (let i = 0; i < moderationLinks.length; i++) { + key(`${i + 1}`, () => { + router.replace(moderationLinks[i]); + }); + } + return () => { + key.unbind(HOTKEYS.SWITCH_QUEUE); + for (let i = 0; i < moderationLinks.length; i++) { + key.unbind(`${i + 1}`); + } + }; + }, [match, moderationLinks]); + + return ( + + + flag + + Reported + + {reportedCount !== undefined && ( + + {reportedCount} + + )} + + + access_time + + Pending + + {pendingCount !== undefined && ( + + {pendingCount} + + )} + + + forum + + Unmoderated + + {unmoderatedCount !== undefined && ( + + {unmoderatedCount} + + )} + + + cancel + + Rejected + + + + ); +}; + +export default withRouter(Navigation); diff --git a/src/core/client/admin/routes/Moderate/ModerateNavigation/__snapshots__/Navigation.spec.tsx.snap b/src/core/client/admin/routes/Moderate/ModerateNavigation/__snapshots__/Navigation.spec.tsx.snap index 37686dae1..cb6c63e4e 100644 --- a/src/core/client/admin/routes/Moderate/ModerateNavigation/__snapshots__/Navigation.spec.tsx.snap +++ b/src/core/client/admin/routes/Moderate/ModerateNavigation/__snapshots__/Navigation.spec.tsx.snap @@ -1,138 +1,13 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`renders correctly 1`] = ` - - - - flag - - - - Reported - - - - - - access_time - - - - Pending - - - - - - forum - - - - Unmoderated - - - - - - cancel - - - - Rejected - - - - + + [Function] + `; exports[`renders correctly with counts 1`] = ` - - - - flag - - - - Reported - - - - 4 - - - - - access_time - - - - Pending - - - - 0 - - - - - forum - - - - Unmoderated - - - - 3 - - - - - cancel - - - - Rejected - - - - + + [Function] + `; diff --git a/src/core/client/admin/routes/Moderate/ModerateSearchBar/Bar.tsx b/src/core/client/admin/routes/Moderate/ModerateSearchBar/Bar.tsx index 00cd2e6c8..59cdf0dcb 100644 --- a/src/core/client/admin/routes/Moderate/ModerateSearchBar/Bar.tsx +++ b/src/core/client/admin/routes/Moderate/ModerateSearchBar/Bar.tsx @@ -1,5 +1,12 @@ +import { HOTKEYS } from "coral-admin/constants"; import { Localized } from "fluent-react/compat"; -import React, { FunctionComponent, useCallback } from "react"; +import key from "keymaster"; +import React, { + FunctionComponent, + useCallback, + useEffect, + useRef, +} from "react"; import { Form } from "react-final-form"; import { Backdrop, Icon, Popover, SubBar } from "coral-ui/components"; @@ -39,6 +46,7 @@ const Bar: FunctionComponent = ({ title, options, onSearch }) => { ({ search }: { search: string }) => onSearch && onSearch(search), [onSearch] ); + const searchInput = useRef(null); const blurOnEscProps = useBlurOnEsc(focused); const [ mappedOptions, @@ -46,6 +54,13 @@ const Bar: FunctionComponent = ({ title, options, onSearch }) => { keyboardNavigationHandlers, ] = useComboBox("moderate-searchBar-listBoxOption", options); + useEffect(() => { + key(HOTKEYS.SEARCH, () => { + if (searchInput && searchInput.current) { + searchInput.current.focus(); + } + }); + }, [searchInput.current]); const contextOptions = mappedOptions .filter(o => o.group === "CONTEXT") .map(o => o.element); @@ -148,6 +163,7 @@ const Bar: FunctionComponent = ({ title, options, onSearch }) => {
{ title: string; className?: string; focused?: boolean; + forwardRef?: Ref; } /** @@ -23,6 +25,7 @@ const Field: FunctionComponent = ({ className, onBlur, onChange, + forwardRef, ...rest }) => { return ( @@ -63,6 +66,7 @@ const Field: FunctionComponent = ({ aria-label="Search or jump to story..." autoComplete="off" spellCheck={false} + ref={forwardRef} onBlur={evt => { // Reset value when blurring. input.onChange(""); @@ -92,4 +96,4 @@ const Field: FunctionComponent = ({ ); }; -export default Field; +export default withForwardRef(Field); diff --git a/src/core/client/admin/routes/Moderate/Queue/Queue.css b/src/core/client/admin/routes/Moderate/Queue/Queue.css index b7fce6c41..0ca998416 100644 --- a/src/core/client/admin/routes/Moderate/Queue/Queue.css +++ b/src/core/client/admin/routes/Moderate/Queue/Queue.css @@ -3,22 +3,11 @@ position: relative; } -.exitTransition { - opacity: 1; - transition: 300ms opacity; -} - -.exitTransitionActive { - opacity: 0; -} - -.exitTransitionDone { - opacity: 0; -} .viewNewButtonContainer { position: absolute; width: 100%; } + .viewNewButton { position: absolute; z-index: 10; diff --git a/src/core/client/admin/routes/Moderate/Queue/Queue.tsx b/src/core/client/admin/routes/Moderate/Queue/Queue.tsx index 638e7f39b..d22c8e43e 100644 --- a/src/core/client/admin/routes/Moderate/Queue/Queue.tsx +++ b/src/core/client/admin/routes/Moderate/Queue/Queue.tsx @@ -1,12 +1,14 @@ +import { HOTKEYS } from "coral-admin/constants"; import { Localized } from "fluent-react/compat"; import React, { FunctionComponent, useCallback, useState } from "react"; -import { CSSTransition, TransitionGroup } from "react-transition-group"; import AutoLoadMore from "coral-admin/components/AutoLoadMore"; import ModerateCardContainer from "coral-admin/components/ModerateCard"; import UserHistoryDrawer from "coral-admin/components/UserHistoryDrawer"; import { Button, Flex, HorizontalGutter } from "coral-ui/components"; +import { useHotkey } from "coral-ui/hooks"; import { PropTypesOf } from "coral-ui/types"; +import QueueWrapper from "./QueueWrapper"; import styles from "./Queue.css"; @@ -41,18 +43,55 @@ const Queue: FunctionComponent = ({ }) => { const [userDrawerVisible, setUserDrawerVisible] = useState(false); const [userDrawerId, setUserDrawerID] = useState(""); + const [selectedComment, setSelectedComment] = useState(0); + const [singleView, setSingleView] = useState(false); + + const toggleView = useCallback(() => { + if (!singleView) { + setSelectedComment(0); + } + setSingleView(!singleView); + }, [singleView]); + + useHotkey(HOTKEYS.ZEN, toggleView); + + const selectNext = useCallback(() => { + const index = selectedComment || 0; + const nextComment = comments[index + 1]; + if (nextComment) { + setSelectedComment(index + 1); + const container: HTMLElement | null = document.getElementById( + `moderate-comment-${nextComment.id}` + ); + if (container) { + container.scrollIntoView(); + } + } + }, [comments, selectedComment]); + + const selectPrev = useCallback(() => { + const index = selectedComment || 0; + const prevComment = comments[index - 1]; + if (prevComment) { + setSelectedComment(index - 1); + const container: HTMLElement | null = document.getElementById( + `moderate-comment-${prevComment.id}` + ); + if (container) { + container.scrollIntoView(); + } + } + }, [comments, selectedComment]); + + const onShowUserDrawer = useCallback((userID: string) => { + setUserDrawerID(userID); + setUserDrawerVisible(true); + }, []); - const onShowUserDrawer = useCallback( - (userID: string) => { - setUserDrawerID(userID); - setUserDrawerVisible(true); - }, - [setUserDrawerVisible, setUserDrawerID] - ); const onHideUserDrawer = useCallback(() => { setUserDrawerVisible(false); setUserDrawerID(""); - }, [setUserDrawerVisible, setUserDrawerID]); + }, []); return ( @@ -70,31 +109,27 @@ const Queue: FunctionComponent = ({ )} - - {comments - // FIXME (Nick/Wyatt): Investigate why comments are coming back null - .filter(c => Boolean(c)) - .map(c => ( - - - - ))} - + + ( + setSelectedComment(i)} + selected={selectedComment === i} + selectPrev={selectPrev} + selectNext={selectNext} + /> + )} + /> + {hasMore && ( ["comment"] + >; + card: ( + c: { id: string } & PropTypesOf["comment"], + i: number + ) => ReactNode; +} + +const QueueWrapper: FunctionComponent = ({ + singleView, + card, + comments, +}) => { + const commentsQueue = singleView ? [comments[0]] : comments; + if (singleView) { + return ( + <> + {commentsQueue + // FIXME (Nick/Wyatt): Investigate why comments are coming back null + .filter(c => Boolean(c)) + .map((c, i) => card(c, i))} + + ); + } + return ( + + {commentsQueue + // FIXME (Nick/Wyatt): Investigate why comments are coming back null + .filter(c => Boolean(c)) + .map((c, i) => ( + + {card(c, i)} + + ))} + + ); +}; + +export default QueueWrapper; diff --git a/src/core/client/admin/routes/Moderate/Queue/__snapshots__/Queue.spec.tsx.snap b/src/core/client/admin/routes/Moderate/Queue/__snapshots__/Queue.spec.tsx.snap index ec66251e1..518bd38ba 100644 --- a/src/core/client/admin/routes/Moderate/Queue/__snapshots__/Queue.spec.tsx.snap +++ b/src/core/client/admin/routes/Moderate/Queue/__snapshots__/Queue.spec.tsx.snap @@ -5,11 +5,10 @@ exports[`renders correctly with load more 1`] = ` className="Queue-root" size="double" > - - +
`; diff --git a/src/core/client/admin/test/moderate/__snapshots__/regularQueue.spec.tsx.snap b/src/core/client/admin/test/moderate/__snapshots__/regularQueue.spec.tsx.snap index 8c19904a5..e150ac3f1 100644 --- a/src/core/client/admin/test/moderate/__snapshots__/regularQueue.spec.tsx.snap +++ b/src/core/client/admin/test/moderate/__snapshots__/regularQueue.spec.tsx.snap @@ -15,8 +15,11 @@ exports[`approves comment in reported queue: count should be 1 1`] = ` exports[`approves comment in reported queue: dangling 1`] = `