[CORL-469] keyboard shortcuts (#2588)

* make comments selectable in queue

* manage focus

* switching focus works

* keyboard navigation sort of works

* scroll into view

* prev, next, approve, reject

* shortcuts for queue switching

* ban modal?

* focus search bar with ctrl f

* keyboards shortcuts modal

* zen mode

* single comment view

* ban users from moderate cards

* clean up ts and lints

* update snaps

* add strings

* update smaps

* remove single comment view shortcut

* add single comment view to moderation queue

* clean up types

* fix deps for useEffect/useCallback hooks

* feat: added toggable help
This commit is contained in:
Tessa Thornton
2019-10-03 23:22:24 +00:00
committed by Wyatt Johnson
parent e56793f2fb
commit 33487be0ce
33 changed files with 979 additions and 317 deletions
+5
View File
@@ -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",
+1
View File
@@ -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",
@@ -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<MutationTypes>) => {
return commitMutationPromiseNormalized<MutationTypes>(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;
@@ -0,0 +1,3 @@
.authorStatus {
padding-right: var(--spacing-2);
}
@@ -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<Props> = ({ comment }) => {
if (!comment.author || !comment.author.status.ban.active) {
return null;
}
return (
<>
<Localized id="commentAuthor-status-banned">
<div className={styles.authorStatus}>
<Tag color="error">BANNED</Tag>
</div>
</Localized>
</>
);
};
const enhanced = withFragmentContainer<Props>({
comment: graphql`
fragment CommentAuthorContainer_comment on Comment {
author {
id
username
status {
ban {
active
}
}
}
}
`,
})(CommentAuthorContainer);
export default enhanced;
@@ -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)
}
}
.selected {
box-shadow: 1px 4px 15px rgba(0, 0, 0, 0.25);
}
.authorStatus {
padding-right: var(--spacing-2);
}
@@ -16,8 +16,8 @@ const baseProps: PropTypesOf<typeof ModerateCardN> = {
body: "content",
edited: false,
inReplyTo: null,
comment: {},
settings: {},
comment: {},
status: "undecided",
featured: false,
viewContextHref: "http://localhost/comment",
@@ -26,7 +26,9 @@ const baseProps: PropTypesOf<typeof ModerateCardN> = {
onApprove: noop,
onReject: noop,
onFeature: noop,
onBan: noop,
onUsernameClick: noop,
onFocusOrClick: noop,
showStory: false,
moderatedBy: null,
};
@@ -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<typeof MarkersContainer>["comment"];
comment: PropTypesOf<typeof MarkersContainer>["comment"] &
PropTypesOf<typeof CommentAuthorContainer>["comment"];
settings: PropTypesOf<typeof MarkersContainer>["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<Props> = ({
@@ -83,11 +98,52 @@ const ModerateCard: FunctionComponent<Props> = ({
storyHref,
onModerateStory,
moderatedBy,
selected,
onFocusOrClick,
mini = false,
hideUsername = false,
deleted = false,
edited,
selectNext,
selectPrev,
onBan,
}) => {
const div = useRef<HTMLDivElement>(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 ? (
<Localized id="moderate-comment-deleted-body">
<Typography>
@@ -112,9 +168,14 @@ const ModerateCard: FunctionComponent<Props> = ({
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}
>
<Flex>
<div className={styles.mainContainer}>
@@ -132,7 +193,8 @@ const ModerateCard: FunctionComponent<Props> = ({
<Username>{username}</Username>
</BaseButton>
)}
<Timestamp className={styles.timestamp}>{createdAt}</Timestamp>
<CommentAuthorContainer comment={comment} />
<Timestamp>{createdAt}</Timestamp>
{edited && (
<Localized id="moderate-comment-edited">
<Typography variant="timestamp" className={styles.edited}>
@@ -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<typeof RejectCommentMutation>;
featureComment: MutationProp<typeof FeatureCommentMutation>;
unfeatureComment: MutationProp<typeof UnfeatureCommentMutation>;
banUser: MutationProp<typeof BanCommentUserMutation>;
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<Props> = ({
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<Props> = ({
[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 (
<>
<FadeInTransition active={Boolean(comment.enteredLive)}>
@@ -171,6 +222,10 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
onReject={handleReject}
onFeature={onFeature}
onUsernameClick={onUsernameClicked}
selected={selected}
selectPrev={selectPrev}
selectNext={selectNext}
onBan={openBanModal}
moderatedBy={
<ModeratedByContainer
onUsernameClicked={onUsernameClicked}
@@ -178,6 +233,7 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
comment={comment}
/>
}
onFocusOrClick={onFocusOrClick}
showStory={showStoryInfo}
storyTitle={
(comment.story.metadata && comment.story.metadata.title) || (
@@ -192,6 +248,16 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
edited={comment.editing.edited}
/>
</FadeInTransition>
<BanModal
username={
comment.author && comment.author.username
? comment.author.username
: ""
}
open={showBanModal}
onClose={handleBanModalClose}
onConfirm={handleBanConfirm}
/>
</>
);
};
@@ -203,6 +269,9 @@ const enhanced = withFragmentContainer<Props>({
author {
id
username
status {
current
}
}
statusLiveUpdated
createdAt
@@ -234,6 +303,7 @@ const enhanced = withFragmentContainer<Props>({
deleted
...MarkersContainer_comment
...ModeratedByContainer_comment
...CommentAuthorContainer_comment
}
`,
settings: graphql`
@@ -252,10 +322,12 @@ const enhanced = withFragmentContainer<Props>({
`,
})(
withRouter(
withMutation(ApproveCommentMutation)(
withMutation(RejectCommentMutation)(
withMutation(FeatureCommentMutation)(
withMutation(UnfeatureCommentMutation)(ModerateCardContainer)
withMutation(BanCommentUserMutation)(
withMutation(ApproveCommentMutation)(
withMutation(RejectCommentMutation)(
withMutation(FeatureCommentMutation)(
withMutation(UnfeatureCommentMutation)(ModerateCardContainer)
)
)
)
)
@@ -1,9 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders approved correctly 1`] = `
<withPropsOnChange(Card)
<ForwardRef(forwardRef)
className="ModerateCard-root"
data-testid="moderate-comment-comment-id"
id="moderate-comment-comment-id"
onClick={[Function]}
tabIndex={0}
>
<ForwardRef(forwardRef)>
<div
@@ -23,8 +26,10 @@ exports[`renders approved correctly 1`] = `
Theon
</Username>
</ForwardRef(forwardRef)>
<Relay(CommentAuthorContainer)
comment={Object {}}
/>
<Timestamp
className="ModerateCard-timestamp"
toggleAbsolute={true}
>
2018-11-29T16:01:51.897Z
@@ -112,13 +117,16 @@ exports[`renders approved correctly 1`] = `
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</withPropsOnChange(Card)>
</ForwardRef(forwardRef)>
`;
exports[`renders correctly 1`] = `
<withPropsOnChange(Card)
<ForwardRef(forwardRef)
className="ModerateCard-root"
data-testid="moderate-comment-comment-id"
id="moderate-comment-comment-id"
onClick={[Function]}
tabIndex={0}
>
<ForwardRef(forwardRef)>
<div
@@ -138,8 +146,10 @@ exports[`renders correctly 1`] = `
Theon
</Username>
</ForwardRef(forwardRef)>
<Relay(CommentAuthorContainer)
comment={Object {}}
/>
<Timestamp
className="ModerateCard-timestamp"
toggleAbsolute={true}
>
2018-11-29T16:01:51.897Z
@@ -227,13 +237,16 @@ exports[`renders correctly 1`] = `
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</withPropsOnChange(Card)>
</ForwardRef(forwardRef)>
`;
exports[`renders dangling correctly 1`] = `
<withPropsOnChange(Card)
<ForwardRef(forwardRef)
className="ModerateCard-root ModerateCard-dangling"
data-testid="moderate-comment-comment-id"
id="moderate-comment-comment-id"
onClick={[Function]}
tabIndex={0}
>
<ForwardRef(forwardRef)>
<div
@@ -253,8 +266,10 @@ exports[`renders dangling correctly 1`] = `
Theon
</Username>
</ForwardRef(forwardRef)>
<Relay(CommentAuthorContainer)
comment={Object {}}
/>
<Timestamp
className="ModerateCard-timestamp"
toggleAbsolute={true}
>
2018-11-29T16:01:51.897Z
@@ -342,13 +357,16 @@ exports[`renders dangling correctly 1`] = `
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</withPropsOnChange(Card)>
</ForwardRef(forwardRef)>
`;
exports[`renders rejected correctly 1`] = `
<withPropsOnChange(Card)
<ForwardRef(forwardRef)
className="ModerateCard-root"
data-testid="moderate-comment-comment-id"
id="moderate-comment-comment-id"
onClick={[Function]}
tabIndex={0}
>
<ForwardRef(forwardRef)>
<div
@@ -368,8 +386,10 @@ exports[`renders rejected correctly 1`] = `
Theon
</Username>
</ForwardRef(forwardRef)>
<Relay(CommentAuthorContainer)
comment={Object {}}
/>
<Timestamp
className="ModerateCard-timestamp"
toggleAbsolute={true}
>
2018-11-29T16:01:51.897Z
@@ -457,13 +477,16 @@ exports[`renders rejected correctly 1`] = `
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</withPropsOnChange(Card)>
</ForwardRef(forwardRef)>
`;
exports[`renders reply correctly 1`] = `
<withPropsOnChange(Card)
<ForwardRef(forwardRef)
className="ModerateCard-root"
data-testid="moderate-comment-comment-id"
id="moderate-comment-comment-id"
onClick={[Function]}
tabIndex={0}
>
<ForwardRef(forwardRef)>
<div
@@ -483,8 +506,10 @@ exports[`renders reply correctly 1`] = `
Theon
</Username>
</ForwardRef(forwardRef)>
<Relay(CommentAuthorContainer)
comment={Object {}}
/>
<Timestamp
className="ModerateCard-timestamp"
toggleAbsolute={true}
>
2018-11-29T16:01:51.897Z
@@ -582,13 +607,16 @@ exports[`renders reply correctly 1`] = `
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</withPropsOnChange(Card)>
</ForwardRef(forwardRef)>
`;
exports[`renders story info 1`] = `
<withPropsOnChange(Card)
<ForwardRef(forwardRef)
className="ModerateCard-root"
data-testid="moderate-comment-comment-id"
id="moderate-comment-comment-id"
onClick={[Function]}
tabIndex={0}
>
<ForwardRef(forwardRef)>
<div
@@ -608,8 +636,10 @@ exports[`renders story info 1`] = `
Theon
</Username>
</ForwardRef(forwardRef)>
<Relay(CommentAuthorContainer)
comment={Object {}}
/>
<Timestamp
className="ModerateCard-timestamp"
toggleAbsolute={true}
>
2018-11-29T16:01:51.897Z
@@ -726,13 +756,16 @@ exports[`renders story info 1`] = `
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</withPropsOnChange(Card)>
</ForwardRef(forwardRef)>
`;
exports[`renders tombstoned when comment is deleted 1`] = `
<withPropsOnChange(Card)
<ForwardRef(forwardRef)
className="ModerateCard-root ModerateCard-deleted"
data-testid="moderate-comment-comment-id"
id="moderate-comment-comment-id"
onClick={[Function]}
tabIndex={0}
>
<ForwardRef(forwardRef)>
<div
@@ -752,8 +785,10 @@ exports[`renders tombstoned when comment is deleted 1`] = `
Theon
</Username>
</ForwardRef(forwardRef)>
<Relay(CommentAuthorContainer)
comment={Object {}}
/>
<Timestamp
className="ModerateCard-timestamp"
toggleAbsolute={true}
>
2018-11-29T16:01:51.897Z
@@ -847,5 +882,5 @@ exports[`renders tombstoned when comment is deleted 1`] = `
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</withPropsOnChange(Card)>
</ForwardRef(forwardRef)>
`;
+11
View File
@@ -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+/",
};
@@ -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;
}
@@ -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<Props> = ({ open, onClose }) => {
return (
<Modal open={open} onClose={onClose} aria-labelledby="banModal-title">
{({ firstFocusableRef }) => (
<Card className={styles.root}>
<CardCloseButton onClick={onClose} ref={firstFocusableRef} />
<HorizontalGutter size="double">
<Localized id="hotkeysModal-title">
<Typography variant="header1">Keyboard shortcuts</Typography>
</Localized>
<Flex itemGutter>
<HorizontalGutter>
<Localized id="hotkeysModal-navigation-shortcuts">
<Typography variant="header3">
Navigation shortcuts
</Typography>
</Localized>
<Flex>
<div className={styles.hotKeyContainer}>
<div className={styles.hotKey}>j</div>
</div>
<Localized id="hotkeysModal-shortcuts-next">
<Typography>Next comment</Typography>
</Localized>
</Flex>
<Flex>
<div className={styles.hotKeyContainer}>
<div className={styles.hotKey}>k</div>
</div>
<Localized id="hotkeysModal-shortcuts-prev">
<Typography>Previous comment</Typography>
</Localized>
</Flex>
<Flex>
<div className={styles.hotKeyContainer}>
<div className={styles.hotKey}>ctrl+f</div>
</div>
<Localized id="hotkeysModal-shortcuts-search">
<Typography>Open search</Typography>
</Localized>
</Flex>
<Flex>
<div className={styles.hotKeyContainer}>
<div className={styles.hotKey}>1..4</div>
</div>
<Localized id="hotkeysModal-shortcuts-jump">
<Typography>Jump to specific queue</Typography>
</Localized>
</Flex>
<Flex>
<div className={styles.hotKeyContainer}>
<div className={styles.hotKey}>t</div>
</div>
<Localized id="hotkeysModal-shortcuts-switch">
<Typography>Switch queues</Typography>
</Localized>
</Flex>
<Flex>
<div className={styles.hotKeyContainer}>
<div className={styles.hotKey}>z</div>
</div>
<Localized id="hotkeysModal-shortcuts-zen">
<Typography>Toggle single-comment view</Typography>
</Localized>
</Flex>
<Flex>
<div className={styles.hotKeyContainer}>
<div className={styles.hotKey}>?</div>
</div>
<Localized id="hotkeysModal-shortcuts-toggle">
<Typography>Toggle shortcuts help</Typography>
</Localized>
</Flex>
</HorizontalGutter>
<HorizontalGutter>
<Localized id="hotkeysModal-moderation-decisions">
<Typography variant="header3">
Moderation decisions
</Typography>
</Localized>
<Flex>
<div className={styles.hotKeyContainer}>
<div className={styles.hotKey}>d</div>
</div>
<Localized id="hotkeysModal-shortcuts-approve">
<Typography>Approve</Typography>
</Localized>
</Flex>
<Flex>
<div className={styles.hotKeyContainer}>
<div className={styles.hotKey}>f</div>
</div>
<Localized id="hotkeysModal-shortcuts-reject">
<Typography>Reject</Typography>
</Localized>
</Flex>
<Flex>
<div className={styles.hotKeyContainer}>
<div className={styles.hotKey}>b</div>
</div>
<Localized id="hotkeysModal-shortcuts-ban">
<Typography>Ban comment author</Typography>
</Localized>
</Flex>
</HorizontalGutter>
</Flex>
</HorizontalGutter>
</Card>
)}
</Modal>
);
};
export default HotkeysModal;
@@ -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<Props> = ({
story,
allStories,
children,
}) => (
<div data-testid="moderate-container">
<ModerateSearchBarContainer story={story} allStories={allStories} />
<SubBar data-testid="moderate-tabBar-container">
<ModerateNavigationContainer
moderationQueues={moderationQueues}
story={story}
/>
</SubBar>
<div className={styles.background} />
<MainLayout data-testid="moderate-main-container">
<main className={styles.main}>{children}</main>
</MainLayout>
</div>
);
}) => {
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 (
<div data-testid="moderate-container">
<ModerateSearchBarContainer story={story} allStories={allStories} />
<SubBar data-testid="moderate-tabBar-container">
<ModerateNavigationContainer
moderationQueues={moderationQueues}
story={story}
/>
</SubBar>
<div className={styles.background} />
<MainLayout data-testid="moderate-main-container">
<main className={styles.main}>{children}</main>
</MainLayout>
<HotkeysModal open={showHotkeysModal} onClose={closeModal} />
</div>
);
};
export default Moderate;
@@ -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<Props> = ({
@@ -18,48 +23,86 @@ const Navigation: FunctionComponent<Props> = ({
reportedCount,
pendingCount,
storyID,
}) => (
<SubBarNavigation>
<NavigationLink to={getModerationLink("reported", storyID)}>
<Icon>flag</Icon>
<Localized id="moderate-navigation-reported">
<span>Reported</span>
</Localized>
{reportedCount !== undefined && (
<Counter data-testid="moderate-navigation-reported-count">
{reportedCount}
</Counter>
)}
</NavigationLink>
<NavigationLink to={getModerationLink("pending", storyID)}>
<Icon>access_time</Icon>
<Localized id="moderate-navigation-pending">
<span>Pending</span>
</Localized>
{pendingCount !== undefined && (
<Counter data-testid="moderate-navigation-pending-count">
{pendingCount}
</Counter>
)}
</NavigationLink>
<NavigationLink to={getModerationLink("unmoderated", storyID)}>
<Icon>forum</Icon>
<Localized id="moderate-navigation-unmoderated">
<span>Unmoderated</span>
</Localized>
{unmoderatedCount !== undefined && (
<Counter data-testid="moderate-navigation-unmoderated-count">
{unmoderatedCount}
</Counter>
)}
</NavigationLink>
<NavigationLink to={getModerationLink("rejected", storyID)}>
<Icon>cancel</Icon>
<Localized id="moderate-navigation-rejected">
<span>Rejected</span>
</Localized>
</NavigationLink>
</SubBarNavigation>
);
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 (
<SubBarNavigation>
<NavigationLink to={moderationLinks[0]}>
<Icon>flag</Icon>
<Localized id="moderate-navigation-reported">
<span>Reported</span>
</Localized>
{reportedCount !== undefined && (
<Counter data-testid="moderate-navigation-reported-count">
{reportedCount}
</Counter>
)}
</NavigationLink>
<NavigationLink to={moderationLinks[1]}>
<Icon>access_time</Icon>
<Localized id="moderate-navigation-pending">
<span>Pending</span>
</Localized>
{pendingCount !== undefined && (
<Counter data-testid="moderate-navigation-pending-count">
{pendingCount}
</Counter>
)}
</NavigationLink>
<NavigationLink to={moderationLinks[2]}>
<Icon>forum</Icon>
<Localized id="moderate-navigation-unmoderated">
<span>Unmoderated</span>
</Localized>
{unmoderatedCount !== undefined && (
<Counter data-testid="moderate-navigation-unmoderated-count">
{unmoderatedCount}
</Counter>
)}
</NavigationLink>
<NavigationLink to={moderationLinks[3]}>
<Icon>cancel</Icon>
<Localized id="moderate-navigation-rejected">
<span>Rejected</span>
</Localized>
</NavigationLink>
</SubBarNavigation>
);
};
export default withRouter(Navigation);
@@ -1,138 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<withPropsOnChange(Navigation)>
<NavigationLink
to="/admin/moderate/reported"
>
<ForwardRef(forwardRef)>
flag
</ForwardRef(forwardRef)>
<Localized
id="moderate-navigation-reported"
>
<span>
Reported
</span>
</Localized>
</NavigationLink>
<NavigationLink
to="/admin/moderate/pending"
>
<ForwardRef(forwardRef)>
access_time
</ForwardRef(forwardRef)>
<Localized
id="moderate-navigation-pending"
>
<span>
Pending
</span>
</Localized>
</NavigationLink>
<NavigationLink
to="/admin/moderate/unmoderated"
>
<ForwardRef(forwardRef)>
forum
</ForwardRef(forwardRef)>
<Localized
id="moderate-navigation-unmoderated"
>
<span>
Unmoderated
</span>
</Localized>
</NavigationLink>
<NavigationLink
to="/admin/moderate/rejected"
>
<ForwardRef(forwardRef)>
cancel
</ForwardRef(forwardRef)>
<Localized
id="moderate-navigation-rejected"
>
<span>
Rejected
</span>
</Localized>
</NavigationLink>
</withPropsOnChange(Navigation)>
<Context.Consumer>
[Function]
</Context.Consumer>
`;
exports[`renders correctly with counts 1`] = `
<withPropsOnChange(Navigation)>
<NavigationLink
to="/admin/moderate/reported"
>
<ForwardRef(forwardRef)>
flag
</ForwardRef(forwardRef)>
<Localized
id="moderate-navigation-reported"
>
<span>
Reported
</span>
</Localized>
<withPropsOnChange(Counter)
data-testid="moderate-navigation-reported-count"
>
4
</withPropsOnChange(Counter)>
</NavigationLink>
<NavigationLink
to="/admin/moderate/pending"
>
<ForwardRef(forwardRef)>
access_time
</ForwardRef(forwardRef)>
<Localized
id="moderate-navigation-pending"
>
<span>
Pending
</span>
</Localized>
<withPropsOnChange(Counter)
data-testid="moderate-navigation-pending-count"
>
0
</withPropsOnChange(Counter)>
</NavigationLink>
<NavigationLink
to="/admin/moderate/unmoderated"
>
<ForwardRef(forwardRef)>
forum
</ForwardRef(forwardRef)>
<Localized
id="moderate-navigation-unmoderated"
>
<span>
Unmoderated
</span>
</Localized>
<withPropsOnChange(Counter)
data-testid="moderate-navigation-unmoderated-count"
>
3
</withPropsOnChange(Counter)>
</NavigationLink>
<NavigationLink
to="/admin/moderate/rejected"
>
<ForwardRef(forwardRef)>
cancel
</ForwardRef(forwardRef)>
<Localized
id="moderate-navigation-rejected"
>
<span>
Rejected
</span>
</Localized>
</NavigationLink>
</withPropsOnChange(Navigation)>
<Context.Consumer>
[Function]
</Context.Consumer>
`;
@@ -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<Props> = ({ title, options, onSearch }) => {
({ search }: { search: string }) => onSearch && onSearch(search),
[onSearch]
);
const searchInput = useRef<HTMLInputElement>(null);
const blurOnEscProps = useBlurOnEsc(focused);
const [
mappedOptions,
@@ -46,6 +54,13 @@ const Bar: FunctionComponent<Props> = ({ 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<Props> = ({ title, options, onSearch }) => {
<div ref={ref}>
<Field
title={title}
ref={searchInput}
{...combineEventHandlers(
focusHandlers,
blurOnEscProps,
@@ -1,6 +1,7 @@
import cn from "classnames";
import { withForwardRef } from "coral-ui/hocs";
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, HTMLAttributes } from "react";
import React, { FunctionComponent, HTMLAttributes, Ref } from "react";
import { Field as FormField } from "react-final-form";
import { BaseButton, Flex, Icon } from "coral-ui/components";
@@ -12,6 +13,7 @@ interface Props extends HTMLAttributes<HTMLInputElement> {
title: string;
className?: string;
focused?: boolean;
forwardRef?: Ref<HTMLInputElement>;
}
/**
@@ -23,6 +25,7 @@ const Field: FunctionComponent<Props> = ({
className,
onBlur,
onChange,
forwardRef,
...rest
}) => {
return (
@@ -63,6 +66,7 @@ const Field: FunctionComponent<Props> = ({
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<Props> = ({
);
};
export default Field;
export default withForwardRef(Field);
@@ -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;
@@ -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<Props> = ({
}) => {
const [userDrawerVisible, setUserDrawerVisible] = useState(false);
const [userDrawerId, setUserDrawerID] = useState("");
const [selectedComment, setSelectedComment] = useState<number | null>(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 (
<HorizontalGutter className={styles.root} size="double">
@@ -70,31 +109,27 @@ const Queue: FunctionComponent<Props> = ({
</Localized>
</Flex>
)}
<TransitionGroup component={null} appear={false} enter={false} exit>
{comments
// FIXME (Nick/Wyatt): Investigate why comments are coming back null
.filter(c => Boolean(c))
.map(c => (
<CSSTransition
key={c.id}
timeout={400}
classNames={{
exit: styles.exitTransition,
exitActive: styles.exitTransitionActive,
exitDone: styles.exitTransitionDone,
}}
>
<ModerateCardContainer
settings={settings}
viewer={viewer}
comment={c}
danglingLogic={danglingLogic}
showStoryInfo={Boolean(allStories)}
onUsernameClicked={onShowUserDrawer}
/>
</CSSTransition>
))}
</TransitionGroup>
<QueueWrapper
comments={comments}
singleView={singleView}
card={(comment, i) => (
<ModerateCardContainer
key={comment.id}
settings={settings}
viewer={viewer}
comment={comment}
danglingLogic={danglingLogic}
showStoryInfo={Boolean(allStories)}
onUsernameClicked={onShowUserDrawer}
onSetSelected={() => setSelectedComment(i)}
selected={selectedComment === i}
selectPrev={selectPrev}
selectNext={selectNext}
/>
)}
/>
{hasMore && (
<Flex justifyContent="center">
<AutoLoadMore
@@ -33,6 +33,7 @@ interface Props {
relay: RelayPaginationProp;
emptyElement: React.ReactElement;
storyID?: string;
count?: string;
}
// TODO: use generated types
@@ -208,10 +209,10 @@ const createQueueRoute = (
export const PendingQueueRoute = createQueueRoute(
GQLMODERATION_QUEUE.PENDING,
graphql`
query QueueRoutePendingQuery($storyID: ID) {
query QueueRoutePendingQuery($storyID: ID, $count: Int) {
moderationQueues(storyID: $storyID) {
pending {
...QueueRoute_queue
...QueueRoute_queue @arguments(count: $count)
}
}
settings {
@@ -0,0 +1,16 @@
.exitTransition {
opacity: 1;
transition: 300ms opacity;
}
.exitTransitionSingle {
opacity: 1;
}
.exitTransitionActive {
opacity: 0;
}
.exitTransitionDone {
opacity: 0;
}
@@ -0,0 +1,59 @@
import ModerateCardContainer from "coral-admin/components/ModerateCard";
import { PropTypesOf } from "coral-ui/types";
import React, { FunctionComponent, ReactNode } from "react";
import { CSSTransition, TransitionGroup } from "react-transition-group";
import styles from "./QueueWrapper.css";
interface Props {
singleView: boolean;
comments: Array<
{ id: string } & PropTypesOf<typeof ModerateCardContainer>["comment"]
>;
card: (
c: { id: string } & PropTypesOf<typeof ModerateCardContainer>["comment"],
i: number
) => ReactNode;
}
const QueueWrapper: FunctionComponent<Props> = ({
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 (
<TransitionGroup component={null} appear={false} enter={false} exit>
{commentsQueue
// FIXME (Nick/Wyatt): Investigate why comments are coming back null
.filter(c => Boolean(c))
.map((c, i) => (
<CSSTransition
key={c.id}
timeout={400}
classNames={{
exit: singleView
? styles.exitTransitionSingle
: styles.exitTransition,
exitActive: styles.exitTransitionActive,
exitDone: styles.exitTransitionDone,
}}
>
{card(c, i)}
</CSSTransition>
))}
</TransitionGroup>
);
};
export default QueueWrapper;
@@ -5,11 +5,10 @@ exports[`renders correctly with load more 1`] = `
className="Queue-root"
size="double"
>
<TransitionGroup
appear={false}
component={null}
enter={false}
exit={true}
<QueueWrapper
card={[Function]}
comments={Array []}
singleView={false}
/>
<ForwardRef(forwardRef)
justifyContent="center"
@@ -32,11 +31,10 @@ exports[`renders correctly without load more 1`] = `
className="Queue-root"
size="double"
>
<TransitionGroup
appear={false}
component={null}
enter={false}
exit={true}
<QueueWrapper
card={[Function]}
comments={Array []}
singleView={false}
/>
<UserHistoryDrawer
onClose={[Function]}
@@ -26,5 +26,9 @@ exports[`renders correctly 1`] = `
className="Moderate-main"
/>
</MainLayout>
<HotkeysModal
onClose={[Function]}
open={false}
/>
</div>
`;
@@ -15,8 +15,11 @@ exports[`approves comment in reported queue: count should be 1 1`] = `
exports[`approves comment in reported queue: dangling 1`] = `
<div
className="Card-root ModerateCard-root ModerateCard-dangling"
className="Card-root ModerateCard-root ModerateCard-dangling ModerateCard-selected"
data-testid="moderate-comment-comment-0"
id="moderate-comment-comment-0"
onClick={[Function]}
tabIndex={0}
>
<div
className="Box-root Flex-root Flex-flex"
@@ -57,7 +60,7 @@ exports[`approves comment in reported queue: dangling 1`] = `
type="button"
>
<time
className="RelativeTime-root Timestamp-text ModerateCard-timestamp"
className="RelativeTime-root Timestamp-text"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
@@ -254,8 +257,11 @@ exports[`rejects comment in reported queue: count should be 1 1`] = `
exports[`rejects comment in reported queue: dangling 1`] = `
<div
className="Card-root ModerateCard-root ModerateCard-dangling"
className="Card-root ModerateCard-root ModerateCard-dangling ModerateCard-selected"
data-testid="moderate-comment-comment-0"
id="moderate-comment-comment-0"
onClick={[Function]}
tabIndex={0}
>
<div
className="Box-root Flex-root Flex-flex"
@@ -296,7 +302,7 @@ exports[`rejects comment in reported queue: dangling 1`] = `
type="button"
>
<time
className="RelativeTime-root Timestamp-text ModerateCard-timestamp"
className="RelativeTime-root Timestamp-text"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
@@ -490,8 +496,11 @@ exports[`renders reported queue with comments 1`] = `
className="Box-root HorizontalGutter-root Queue-root HorizontalGutter-double"
>
<div
className="Card-root ModerateCard-root"
className="Card-root ModerateCard-root ModerateCard-selected"
data-testid="moderate-comment-comment-0"
id="moderate-comment-comment-0"
onClick={[Function]}
tabIndex={0}
>
<div
className="Box-root Flex-root Flex-flex"
@@ -532,7 +541,7 @@ exports[`renders reported queue with comments 1`] = `
type="button"
>
<time
className="RelativeTime-root Timestamp-text ModerateCard-timestamp"
className="RelativeTime-root Timestamp-text"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
@@ -715,6 +724,9 @@ exports[`renders reported queue with comments 1`] = `
<div
className="Card-root ModerateCard-root"
data-testid="moderate-comment-comment-1"
id="moderate-comment-comment-1"
onClick={[Function]}
tabIndex={0}
>
<div
className="Box-root Flex-root Flex-flex"
@@ -755,7 +767,7 @@ exports[`renders reported queue with comments 1`] = `
type="button"
>
<time
className="RelativeTime-root Timestamp-text ModerateCard-timestamp"
className="RelativeTime-root Timestamp-text"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
@@ -952,8 +964,11 @@ exports[`renders reported queue with comments 2`] = `
className="Box-root HorizontalGutter-root Queue-root HorizontalGutter-double"
>
<div
className="Card-root ModerateCard-root"
className="Card-root ModerateCard-root ModerateCard-selected"
data-testid="moderate-comment-comment-0"
id="moderate-comment-comment-0"
onClick={[Function]}
tabIndex={0}
>
<div
className="Box-root Flex-root Flex-flex"
@@ -994,7 +1009,7 @@ exports[`renders reported queue with comments 2`] = `
type="button"
>
<time
className="RelativeTime-root Timestamp-text ModerateCard-timestamp"
className="RelativeTime-root Timestamp-text"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
@@ -1177,6 +1192,9 @@ exports[`renders reported queue with comments 2`] = `
<div
className="Card-root ModerateCard-root"
data-testid="moderate-comment-comment-1"
id="moderate-comment-comment-1"
onClick={[Function]}
tabIndex={0}
>
<div
className="Box-root Flex-root Flex-flex"
@@ -1217,7 +1235,7 @@ exports[`renders reported queue with comments 2`] = `
type="button"
>
<time
className="RelativeTime-root Timestamp-text ModerateCard-timestamp"
className="RelativeTime-root Timestamp-text"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
@@ -1406,6 +1424,9 @@ exports[`renders reported queue with comments and load more 1`] = `
<div
className="Card-root ModerateCard-root"
data-testid="moderate-comment-comment-2"
id="moderate-comment-comment-2"
onClick={[Function]}
tabIndex={0}
>
<div
className="Box-root Flex-root Flex-flex"
@@ -1446,7 +1467,7 @@ exports[`renders reported queue with comments and load more 1`] = `
type="button"
>
<time
className="RelativeTime-root Timestamp-text ModerateCard-timestamp"
className="RelativeTime-root Timestamp-text"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
@@ -15,8 +15,11 @@ exports[`approves comment in rejected queue: count should be 1 1`] = `
exports[`approves comment in rejected queue: dangling 1`] = `
<div
className="Card-root ModerateCard-root ModerateCard-dangling"
className="Card-root ModerateCard-root ModerateCard-dangling ModerateCard-selected"
data-testid="moderate-comment-comment-0"
id="moderate-comment-comment-0"
onClick={[Function]}
tabIndex={0}
>
<div
className="Box-root Flex-root Flex-flex"
@@ -57,7 +60,7 @@ exports[`approves comment in rejected queue: dangling 1`] = `
type="button"
>
<time
className="RelativeTime-root Timestamp-text ModerateCard-timestamp"
className="RelativeTime-root Timestamp-text"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
@@ -251,8 +254,11 @@ exports[`renders rejected queue with comments 1`] = `
className="Box-root HorizontalGutter-root Queue-root HorizontalGutter-double"
>
<div
className="Card-root ModerateCard-root"
className="Card-root ModerateCard-root ModerateCard-selected"
data-testid="moderate-comment-comment-0"
id="moderate-comment-comment-0"
onClick={[Function]}
tabIndex={0}
>
<div
className="Box-root Flex-root Flex-flex"
@@ -293,7 +299,7 @@ exports[`renders rejected queue with comments 1`] = `
type="button"
>
<time
className="RelativeTime-root Timestamp-text ModerateCard-timestamp"
className="RelativeTime-root Timestamp-text"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
@@ -476,6 +482,9 @@ exports[`renders rejected queue with comments 1`] = `
<div
className="Card-root ModerateCard-root"
data-testid="moderate-comment-comment-1"
id="moderate-comment-comment-1"
onClick={[Function]}
tabIndex={0}
>
<div
className="Box-root Flex-root Flex-flex"
@@ -516,7 +525,7 @@ exports[`renders rejected queue with comments 1`] = `
type="button"
>
<time
className="RelativeTime-root Timestamp-text ModerateCard-timestamp"
className="RelativeTime-root Timestamp-text"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
@@ -705,6 +714,9 @@ exports[`renders rejected queue with comments and load more 1`] = `
<div
className="Card-root ModerateCard-root"
data-testid="moderate-comment-comment-2"
id="moderate-comment-comment-2"
onClick={[Function]}
tabIndex={0}
>
<div
className="Box-root Flex-root Flex-flex"
@@ -745,7 +757,7 @@ exports[`renders rejected queue with comments and load more 1`] = `
type="button"
>
<time
className="RelativeTime-root Timestamp-text ModerateCard-timestamp"
className="RelativeTime-root Timestamp-text"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
@@ -2,8 +2,11 @@
exports[`approves single comment 1`] = `
<div
className="Card-root ModerateCard-root"
className="Card-root ModerateCard-root ModerateCard-selected"
data-testid="moderate-comment-comment-0"
id="moderate-comment-comment-0"
onClick={[Function]}
tabIndex={0}
>
<div
className="Box-root Flex-root Flex-flex"
@@ -44,7 +47,7 @@ exports[`approves single comment 1`] = `
type="button"
>
<time
className="RelativeTime-root Timestamp-text ModerateCard-timestamp"
className="RelativeTime-root Timestamp-text"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
@@ -207,8 +210,11 @@ exports[`approves single comment 1`] = `
exports[`rejects single comment 1`] = `
<div
className="Card-root ModerateCard-root"
className="Card-root ModerateCard-root ModerateCard-selected"
data-testid="moderate-comment-comment-0"
id="moderate-comment-comment-0"
onClick={[Function]}
tabIndex={0}
>
<div
className="Box-root Flex-root Flex-flex"
@@ -249,7 +255,7 @@ exports[`rejects single comment 1`] = `
type="button"
>
<time
className="RelativeTime-root Timestamp-text ModerateCard-timestamp"
className="RelativeTime-root Timestamp-text"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
@@ -447,8 +453,11 @@ exports[`renders single comment view 1`] = `
className="Box-root HorizontalGutter-root Queue-root HorizontalGutter-double"
>
<div
className="Card-root ModerateCard-root"
className="Card-root ModerateCard-root ModerateCard-selected"
data-testid="moderate-comment-comment-0"
id="moderate-comment-comment-0"
onClick={[Function]}
tabIndex={0}
>
<div
className="Box-root Flex-root Flex-flex"
@@ -489,7 +498,7 @@ exports[`renders single comment view 1`] = `
type="button"
>
<time
className="RelativeTime-root Timestamp-text ModerateCard-timestamp"
className="RelativeTime-root Timestamp-text"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
+8 -4
View File
@@ -1,6 +1,7 @@
import cn from "classnames";
import { withStyles } from "coral-ui/hocs";
import React from "react";
import { withForwardRef } from "coral-ui/hocs";
import React, { Ref } from "react";
import { FunctionComponent, ReactNode } from "react";
import styles from "./Card.css";
@@ -18,19 +19,22 @@ export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
* Override or extend the styles applied to the component.
*/
classes: typeof styles;
tabIndex?: number;
forwardRef?: Ref<HTMLDivElement>;
}
const Card: FunctionComponent<CardProps> = props => {
const { className, classes, children, ...rest } = props;
const { className, classes, children, forwardRef, ...rest } = props;
const rootClassName = cn(classes.root, className);
return (
<div className={rootClassName} {...rest}>
<div className={rootClassName} {...rest} ref={forwardRef}>
{children}
</div>
);
};
const enhanced = withStyles(styles)(Card);
const enhanced = withForwardRef(withStyles(styles)(Card));
export default enhanced;
+1
View File
@@ -2,3 +2,4 @@ export { default as useFocus } from "./useFocus";
export { default as usePreventFocusLoss } from "./usePreventFocusLoss";
export { default as useBlurOnEsc } from "./useBlurOnEsc";
export { default as useComboBox } from "./useComboBox";
export { default as useHotkey } from "./useHotkey";
+24
View File
@@ -0,0 +1,24 @@
import key from "keymaster";
import { useEffect, useRef } from "react";
type CallbackFn = (event: KeyboardEvent) => void;
interface Ref {
current: any;
}
export default function useHotkey(trigger: string, handler: CallbackFn) {
const savedHandler: Ref = useRef();
useEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(() => {
const eventListener = () => savedHandler.current();
key(trigger, eventListener);
return () => {
key.unbind(trigger);
};
}, [trigger]);
}
+20
View File
@@ -825,3 +825,23 @@ forgotPassword-emailAddressLabel = Email address
forgotPassword-emailAddressTextField =
.placeholder = Email Address
forgotPassword-sendEmailButton = Send email
commentAuthor-status-banned = Banned
hotkeysModal-title = Keyboard shortcuts
hotkeysModal-navigation-shortcuts = Navigation shortcuts
hotkeysModal-shortcuts-next = Next comment
hotkeysModal-shortcuts-prev = Previous comment
hotkeysModal-shortcuts-search = Open search
hotkeysModal-shortcuts-jump = Jump to specific queue
hotkeysModal-shortcuts-switch = Switch queues
hotkeysModal-shortcuts-toggle = Toggle shortcuts help
hotkeysModal-shortcuts-single-view = Single comment view
hotkeysModal-moderation-decisions = Moderation decisions
hotkeysModal-shortcuts-approve = Approve
hotkeysModal-shortcuts-reject = Reject
hotkeysModal-shortcuts-ban = Ban comment author
hotkeysModal-shortcuts-zen = Toggle single-comment view
+52
View File
@@ -0,0 +1,52 @@
interface KeymasterEvent {
key: string;
method: KeyHandler;
mods: number[];
scope: string;
shortcut: string;
}
type KeyHandler = (
keyboardEvent: KeyboardEvent,
keymasterEvent: KeymasterEvent
) => void;
interface FilterEvent {
target?: {
tagName?: string;
};
srcElement?: {
tagName?: string;
};
}
interface Keymaster {
(key: string, callback: KeyHandler): void;
(key: string, scope: string, callback: KeyHandler): void;
shift: boolean;
alt: boolean;
option: boolean;
ctrl: boolean;
control: boolean;
command: boolean;
setScope(scopeName: string): void;
getScope(): string;
deleteScope(scopeName: string): void;
noConflict(): void;
unbind(key: string, scopeName?: string): void;
isPressed(keyCode: number): boolean;
getPressedKeyCodes(): number[];
filter(event: FilterEvent): void;
}
declare let key: Keymaster;
declare module "keymaster" {
export = key;
}