[CORL-1134] Site Moderators (#2995)

* feat: added server support for site moderators

* feat: added unscoped property to @auth directive

* feat: added frontend support for site moderator feature

* feat: added site moderator support to stream

* fix: linting

* fix: updated snapshots
This commit is contained in:
Wyatt Johnson
2020-07-13 18:19:22 +00:00
committed by GitHub
parent 00e074d49d
commit 7b2cdbad49
125 changed files with 2663 additions and 972 deletions
@@ -1,9 +1,9 @@
$premod-modal-text: var(--v2-colors-mono-500);
$modal-body-text: var(--v2-colors-mono-500);
.bodyText {
.root {
font-size: var(--v2-font-size-3);
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-regular);
line-height: var(--v2-line-height-body-short);
color: $premod-modal-text;
color: $modal-body-text;
}
@@ -0,0 +1,9 @@
import React, { FunctionComponent } from "react";
import styles from "./ModalBodyText.css";
const ModalBodyText: FunctionComponent = ({ children }) => (
<p className={styles.root}>{children}</p>
);
export default ModalBodyText;
@@ -1,10 +1,10 @@
$status-modal-text: var(--v2-colors-mono-500);
$modal-text: var(--v2-colors-mono-500);
.root {
font-size: var(--v2-font-size-5);
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-semi-bold);
line-height: var(--v2-line-height-title);
color: $status-modal-text;
color: $modal-text;
margin: 0;
}
@@ -0,0 +1,16 @@
import React, { FunctionComponent, HTMLAttributes } from "react";
import styles from "./ModalHeader.css";
const ModalHeader: FunctionComponent<HTMLAttributes<HTMLHeadingElement>> = ({
children,
...rest
}) => {
return (
<h2 {...rest} className={styles.root}>
{children}
</h2>
);
};
export default ModalHeader;
@@ -2,7 +2,7 @@ import React, { FunctionComponent } from "react";
import styles from "./ModalHeaderUsername.css";
const ModalHeaderUsername: FunctionComponent<{}> = ({ children }) => {
const ModalHeaderUsername: FunctionComponent = ({ children }) => {
return <strong className={styles.root}>{children}</strong>;
};
@@ -16,8 +16,19 @@ $moderateCardButtonOutlineApproveColor: var(--v2-colors-green-500);
}
}
.readOnly {
background-color: transparent;
border-color: var(--v2-colors-grey-300);
color: var(--v2-colors-grey-300);
&:hover {
cursor: not-allowed;
}
}
.invert {
background-color: $moderateCardButtonOutlineApproveColor;
border-color: $moderateCardButtonOutlineApproveColor;
color: var(--v2-colors-pure-white);
}
@@ -9,10 +9,12 @@ import styles from "./ApproveButton.css";
interface Props extends Omit<PropTypesOf<typeof BaseButton>, "ref"> {
invert?: boolean;
readOnly?: boolean;
}
const ApproveButton: FunctionComponent<Props> = ({
invert,
readOnly,
className,
...rest
}) => (
@@ -21,6 +23,7 @@ const ApproveButton: FunctionComponent<Props> = ({
{...rest}
className={cn(className, styles.root, {
[styles.invert]: invert,
[styles.readOnly]: readOnly,
})}
aria-label="Approve"
>
@@ -24,8 +24,7 @@
}
&:disabled {
cursor: default;
cursor: not-allowed;
background-color: transparent;
border-color: var(--v2-colors-grey-300);
color: var(--v2-colors-grey-300);
@@ -120,10 +120,6 @@ $moderateCardLinkTextColor: var(--v2-colors-teal-700);
text-transform: uppercase;
}
.storyTitle {
color: $moderateCardStoryTitleColor;
}
.commentOn {
font-size: var(--v2-font-size-2);
font-family: var(--v2-font-family-primary);
@@ -136,6 +132,7 @@ $moderateCardLinkTextColor: var(--v2-colors-teal-700);
}
.storyTitle {
color: $moderateCardStoryTitleColor;
font-weight: var(--v2-font-weight-primary-semi-bold);
}
@@ -150,10 +147,6 @@ $moderateCardLinkTextColor: var(--v2-colors-teal-700);
height: 40px;
}
.deleted {
background: var(--v2-palette-grey-lightest);
}
.timestamp {
color: $moderateCardTimestampColor;
font-family: var(--v2-font-family-primary);
@@ -186,6 +179,7 @@ $moderateCardLinkTextColor: var(--v2-colors-teal-700);
}
.deleted {
background: var(--v2-palette-grey-lightest);
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-regular);
font-size: var(--v2-font-size-2);
@@ -6,6 +6,7 @@ import React, {
FunctionComponent,
useCallback,
useEffect,
useMemo,
useRef,
} from "react";
@@ -71,6 +72,12 @@ interface Props {
*/
dangling?: boolean;
deleted?: boolean;
/**
* If set to true, it means that this comment cannot be moderated by the
* current user.
*/
readOnly?: boolean;
edited: boolean;
selectPrev?: () => void;
selectNext?: () => void;
@@ -108,6 +115,7 @@ const ModerateCard: FunctionComponent<Props> = ({
mini = false,
hideUsername = false,
deleted = false,
readOnly = false,
edited,
selectNext,
selectPrev,
@@ -115,6 +123,7 @@ const ModerateCard: FunctionComponent<Props> = ({
isQA,
}) => {
const div = useRef<HTMLDivElement>(null);
useEffect(() => {
if (selected) {
if (selectNext) {
@@ -143,31 +152,39 @@ const ModerateCard: FunctionComponent<Props> = ({
}
return noop;
}, [selected, id]);
}, [selected, id, selectNext, selectPrev, onBan, onApprove, onReject]);
useEffect(() => {
if (selected && div && div.current) {
div.current.focus();
}
}, [selected]);
const commentBody = deleted ? (
<Localized id="moderate-comment-deleted-body">
<div className={styles.deleted}>
This comment is no longer available. The commenter has deleted their
account.
</div>
</Localized>
) : (
body
}, [selected, div]);
const commentBody = useMemo(
() =>
deleted ? (
<Localized id="moderate-comment-deleted-body">
<div className={styles.deleted}>
This comment is no longer available. The commenter has deleted their
account.
</div>
</Localized>
) : (
body
),
[deleted, body]
);
const commentAuthorClick = useCallback(() => {
onUsernameClick();
}, [onUsernameClick]);
const commentParentAuthorClick = useCallback(() => {
if (inReplyTo) {
onUsernameClick(inReplyTo.id);
}
}, [onUsernameClick, inReplyTo]);
return (
<Card
className={cn(
@@ -209,7 +226,7 @@ const ModerateCard: FunctionComponent<Props> = ({
<FeatureButton
featured={featured}
onClick={onFeature}
enabled={!deleted && !isQA}
enabled={!deleted && !isQA && !readOnly}
/>
</Flex>
{inReplyTo && inReplyTo.username && (
@@ -304,14 +321,24 @@ const ModerateCard: FunctionComponent<Props> = ({
<RejectButton
onClick={onReject}
invert={status === "rejected"}
disabled={status === "rejected" || dangling || deleted}
className={cn({ [styles.miniButton]: mini })}
disabled={
status === "rejected" || dangling || deleted || readOnly
}
readOnly={readOnly}
className={cn({
[styles.miniButton]: mini,
})}
/>
<ApproveButton
onClick={onApprove}
invert={status === "approved"}
disabled={status === "approved" || dangling || deleted}
className={cn({ [styles.miniButton]: mini })}
disabled={
status === "approved" || dangling || deleted || readOnly
}
readOnly={readOnly}
className={cn({
[styles.miniButton]: mini,
})}
/>
</Flex>
{moderatedBy}
@@ -1,5 +1,10 @@
import { Match, Router, withRouter } from "found";
import React, { FunctionComponent, useCallback, useState } from "react";
import React, {
FunctionComponent,
useCallback,
useMemo,
useState,
} from "react";
import { graphql } from "react-relay";
import NotAvailable from "coral-admin/components/NotAvailable";
@@ -11,18 +16,20 @@ import {
import FadeInTransition from "coral-framework/components/FadeInTransition";
import { getModerationLink } from "coral-framework/helpers";
import parseModerationOptions from "coral-framework/helpers/parseModerationOptions";
import { useMutation, withFragmentContainer } from "coral-framework/lib/relay";
import {
MutationProp,
withFragmentContainer,
withMutation,
} from "coral-framework/lib/relay";
import { GQLSTORY_MODE, GQLTAG, GQLUSER_STATUS } from "coral-framework/schema";
GQLFEATURE_FLAG,
GQLSTORY_MODE,
GQLTAG,
GQLUSER_STATUS,
} from "coral-framework/schema";
import {
COMMENT_STATUS,
ModerateCardContainer_comment,
} from "coral-admin/__generated__/ModerateCardContainer_comment.graphql";
import { ModerateCardContainer_settings } from "coral-admin/__generated__/ModerateCardContainer_settings.graphql";
import { ModerateCardContainer_viewer } from "coral-admin/__generated__/ModerateCardContainer_viewer.graphql";
import BanCommentUserMutation from "./BanCommentUserMutation";
import FeatureCommentMutation from "./FeatureCommentMutation";
@@ -31,13 +38,9 @@ import ModeratedByContainer from "./ModeratedByContainer";
import UnfeatureCommentMutation from "./UnfeatureCommentMutation";
interface Props {
viewer: ModerateCardContainer_viewer;
comment: ModerateCardContainer_comment;
settings: ModerateCardContainer_settings;
approveComment: MutationProp<typeof ApproveCommentMutation>;
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;
@@ -71,14 +74,11 @@ function isFeatured(comment: ModerateCardContainer_comment) {
const ModerateCardContainer: FunctionComponent<Props> = ({
comment,
settings,
viewer,
danglingLogic,
showStoryInfo,
match,
router,
approveComment,
rejectComment,
featureComment,
unfeatureComment,
mini,
hideUsername,
selected,
@@ -87,15 +87,36 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
onUsernameClicked: usernameClicked,
onConversationClicked: conversationClicked,
onSetSelected: setSelected,
banUser,
loadNext,
}) => {
const approveComment = useMutation(ApproveCommentMutation);
const rejectComment = useMutation(RejectCommentMutation);
const featureComment = useMutation(FeatureCommentMutation);
const unfeatureComment = useMutation(UnfeatureCommentMutation);
const banUser = useMutation(BanCommentUserMutation);
const scoped = useMemo(
() =>
settings.featureFlags.includes(GQLFEATURE_FLAG.SITE_MODERATOR) &&
!!viewer.moderationScopes?.scoped,
[settings, viewer]
);
const readOnly = useMemo(() => scoped && !comment.canModerate, [
scoped,
comment,
]);
const [showBanModal, setShowBanModal] = useState(false);
const handleApprove = useCallback(async () => {
if (!comment.revision) {
return;
}
if (readOnly) {
return;
}
const { storyID, siteID, section } = parseModerationOptions(match);
await approveComment({
@@ -108,13 +129,17 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
if (loadNext) {
loadNext();
}
}, [approveComment, comment, match]);
}, [approveComment, comment, match, readOnly]);
const handleReject = useCallback(async () => {
if (!comment.revision) {
return;
}
if (readOnly) {
return;
}
const { storyID, siteID, section } = parseModerationOptions(match);
await rejectComment({
@@ -127,13 +152,17 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
if (loadNext) {
loadNext();
}
}, [rejectComment, comment, match]);
}, [rejectComment, comment, match, readOnly]);
const handleFeature = useCallback(() => {
if (!comment.revision) {
return;
}
if (readOnly) {
return;
}
const { storyID, siteID, section } = parseModerationOptions(match);
void featureComment({
@@ -143,24 +172,31 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
siteID,
section,
});
}, [featureComment, comment, match]);
}, [featureComment, comment, match, readOnly]);
const handleUnfeature = useCallback(() => {
if (readOnly) {
return;
}
void unfeatureComment({
commentID: comment.id,
storyID: match.params.storyID,
});
}, [unfeatureComment, comment, match]);
}, [unfeatureComment, comment, match, readOnly]);
const onFeature = useCallback(() => {
const featured = isFeatured(comment);
if (readOnly) {
return;
}
const featured = isFeatured(comment);
if (featured) {
handleUnfeature();
} else {
handleFeature();
}
}, [comment]);
}, [comment, readOnly, handleFeature, handleUnfeature]);
const onUsernameClicked = useCallback(
(id?: string) => {
@@ -197,7 +233,7 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
const handleBanModalClose = useCallback(() => {
setShowBanModal(false);
}, []);
}, [setShowBanModal]);
const openBanModal = useCallback(() => {
if (
@@ -206,8 +242,9 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
) {
return;
}
setShowBanModal(true);
}, [comment]);
}, [comment, setShowBanModal]);
const handleBanConfirm = useCallback(
async (rejectExistingComments: boolean, message: string) => {
@@ -220,17 +257,22 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
}
setShowBanModal(false);
},
[comment]
[comment, banUser, setShowBanModal]
);
// Only highlight comments that have been flagged for containing a banned or
// suspect word.
const highlight = comment.revision
? comment.revision.actionCounts.flag.reasons.COMMENT_DETECTED_BANNED_WORD +
comment.revision.actionCounts.flag.reasons
.COMMENT_DETECTED_SUSPECT_WORD >
0
: false;
const highlight = useMemo(
() =>
comment.revision
? comment.revision.actionCounts.flag.reasons
.COMMENT_DETECTED_BANNED_WORD +
comment.revision.actionCounts.flag.reasons
.COMMENT_DETECTED_SUSPECT_WORD >
0
: false,
[comment]
);
return (
<>
@@ -284,6 +326,7 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
hideUsername={hideUsername}
deleted={comment.deleted ? comment.deleted : false}
edited={comment.editing.edited}
readOnly={readOnly}
isQA={comment.story.settings.mode === GQLSTORY_MODE.QA}
/>
</FadeInTransition>
@@ -341,6 +384,7 @@ const enhanced = withFragmentContainer<Props>({
username
}
}
canModerate
story {
id
metadata {
@@ -374,18 +418,13 @@ const enhanced = withFragmentContainer<Props>({
...MarkersContainer_settings
}
`,
})(
withRouter(
withMutation(BanCommentUserMutation)(
withMutation(ApproveCommentMutation)(
withMutation(RejectCommentMutation)(
withMutation(FeatureCommentMutation)(
withMutation(UnfeatureCommentMutation)(ModerateCardContainer)
)
)
)
)
)
);
viewer: graphql`
fragment ModerateCardContainer_viewer on User {
moderationScopes {
scoped
}
}
`,
})(withRouter(ModerateCardContainer));
export default enhanced;
@@ -16,8 +16,19 @@ $moderateCardButtonOutlineRejectColor: var(--v2-colors-red-500);
}
}
.readOnly {
background-color: transparent;
border-color: var(--v2-colors-grey-300);
color: var(--v2-colors-grey-300);
&:hover {
cursor: not-allowed;
}
}
.invert {
background-color: $moderateCardButtonOutlineRejectColor;
border-color: $moderateCardButtonOutlineRejectColor;
color: var(--v2-colors-pure-white);
}
@@ -9,10 +9,12 @@ import styles from "./RejectButton.css";
interface Props extends Omit<PropTypesOf<typeof BaseButton>, "ref"> {
invert?: boolean;
readOnly?: boolean;
}
const RejectButton: FunctionComponent<Props> = ({
invert,
readOnly,
className,
...rest
}) => (
@@ -21,6 +23,7 @@ const RejectButton: FunctionComponent<Props> = ({
{...rest}
className={cn(className, styles.root, {
[styles.invert]: invert,
[styles.readOnly]: readOnly,
})}
aria-label="Reject"
>
@@ -122,12 +122,14 @@ exports[`renders approved correctly 1`] = `
disabled={false}
invert={false}
onClick={[Function]}
readOnly={false}
/>
<ApproveButton
className=""
disabled={true}
invert={true}
onClick={[Function]}
readOnly={false}
/>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
@@ -257,12 +259,14 @@ exports[`renders correctly 1`] = `
disabled={false}
invert={false}
onClick={[Function]}
readOnly={false}
/>
<ApproveButton
className=""
disabled={false}
invert={false}
onClick={[Function]}
readOnly={false}
/>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
@@ -392,12 +396,14 @@ exports[`renders dangling correctly 1`] = `
disabled={true}
invert={false}
onClick={[Function]}
readOnly={false}
/>
<ApproveButton
className=""
disabled={true}
invert={false}
onClick={[Function]}
readOnly={false}
/>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
@@ -527,12 +533,14 @@ exports[`renders rejected correctly 1`] = `
disabled={true}
invert={true}
onClick={[Function]}
readOnly={false}
/>
<ApproveButton
className=""
disabled={false}
invert={false}
onClick={[Function]}
readOnly={false}
/>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
@@ -671,12 +679,14 @@ exports[`renders reply correctly 1`] = `
disabled={false}
invert={false}
onClick={[Function]}
readOnly={false}
/>
<ApproveButton
className=""
disabled={false}
invert={false}
onClick={[Function]}
readOnly={false}
/>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
@@ -844,12 +854,14 @@ exports[`renders story info 1`] = `
disabled={false}
invert={false}
onClick={[Function]}
readOnly={false}
/>
<ApproveButton
className=""
disabled={false}
invert={false}
onClick={[Function]}
readOnly={false}
/>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
@@ -987,12 +999,14 @@ exports[`renders tombstoned when comment is deleted 1`] = `
disabled={true}
invert={false}
onClick={[Function]}
readOnly={false}
/>
<ApproveButton
className=""
disabled={true}
invert={false}
onClick={[Function]}
readOnly={false}
/>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
@@ -5,22 +5,24 @@ import { GQLUSER_ROLE, GQLUSER_ROLE_RL } from "coral-framework/schema";
interface Props {
container?: React.ReactElement<any> | React.ComponentType<any> | string;
children: GQLUSER_ROLE_RL;
role: GQLUSER_ROLE_RL;
scoped?: boolean;
moderationScopesEnabled: boolean;
}
function createElement(
Container: React.ReactElement<any> | React.ComponentType<any> | string,
children: React.ReactNode
text: string
) {
if (React.isValidElement<any>(Container)) {
return React.cloneElement(Container, { children });
return React.cloneElement(Container, { children: text });
} else {
return <Container>{children}</Container>;
return <Container>{text}</Container>;
}
}
const TranslatedRole: React.FunctionComponent<Props> = (props) => {
switch (props.children) {
switch (props.role) {
case GQLUSER_ROLE.COMMENTER:
return (
<Localized id="role-commenter">
@@ -34,9 +36,25 @@ const TranslatedRole: React.FunctionComponent<Props> = (props) => {
</Localized>
);
case GQLUSER_ROLE.MODERATOR:
if (!props.moderationScopesEnabled) {
return (
<Localized id="role-moderator">
{createElement(props.container!, "Moderator")}
</Localized>
);
}
if (props.scoped) {
return (
<Localized id="role-siteModerator">
{createElement(props.container!, "Site Moderator")}
</Localized>
);
}
return (
<Localized id="role-moderator">
{createElement(props.container!, "Moderator")}
<Localized id="role-organizationModerator">
{createElement(props.container!, "Organization Moderator")}
</Localized>
);
case GQLUSER_ROLE.STAFF:
@@ -47,7 +65,7 @@ const TranslatedRole: React.FunctionComponent<Props> = (props) => {
);
default:
// Unknown role, just use untranslated string.
return createElement(props.container!, props.children);
return createElement(props.container!, props.role);
}
};
@@ -2,7 +2,7 @@ import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useMemo } from "react";
import { graphql } from "react-relay";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { useDateTimeFormatter } from "coral-framework/hooks";
import { withFragmentContainer } from "coral-framework/lib/relay";
import {
CallOut,
@@ -48,7 +48,6 @@ const UserDrawerAccountHistory: FunctionComponent<Props> = ({ user }) => {
</span>
</Localized>
);
const { locales } = useCoralContext();
const combinedHistory = useMemo(() => {
// Collect all the different types of history items.
const history: HistoryRecord[] = [];
@@ -137,7 +136,7 @@ const UserDrawerAccountHistory: FunctionComponent<Props> = ({ user }) => {
// Sort the history so that it's in the right order.
return history.sort((a, b) => b.date.getTime() - a.date.getTime());
}, [user]);
const formatter = new Intl.DateTimeFormat(locales, {
const formatter = useDateTimeFormatter({
year: "numeric",
month: "long",
day: "numeric",
@@ -167,7 +166,7 @@ const UserDrawerAccountHistory: FunctionComponent<Props> = ({ user }) => {
{combinedHistory.map((history, index) => (
<TableRow key={index} className={styles.row}>
<TableCell className={styles.date}>
{formatter.format(history.date)}
{formatter(history.date)}
</TableCell>
<TableCell className={styles.action}>
<AccountHistoryAction {...history} />
@@ -11,6 +11,7 @@ import { Button, CallOut, Divider } from "coral-ui/components/v2";
import { UserHistoryDrawerAllComments_settings } from "coral-admin/__generated__/UserHistoryDrawerAllComments_settings.graphql";
import { UserHistoryDrawerAllComments_user } from "coral-admin/__generated__/UserHistoryDrawerAllComments_user.graphql";
import { UserHistoryDrawerAllComments_viewer } from "coral-admin/__generated__/UserHistoryDrawerAllComments_viewer.graphql";
import { UserHistoryDrawerAllCommentsPaginationQueryVariables } from "coral-admin/__generated__/UserHistoryDrawerAllCommentsPaginationQuery.graphql";
import styles from "./UserHistoryDrawerAllComments.css";
@@ -18,12 +19,14 @@ import styles from "./UserHistoryDrawerAllComments.css";
interface Props {
user: UserHistoryDrawerAllComments_user;
settings: UserHistoryDrawerAllComments_settings;
viewer: UserHistoryDrawerAllComments_viewer;
relay: RelayPaginationProp;
}
const UserHistoryDrawerAllComments: FunctionComponent<Props> = ({
user,
settings,
viewer,
relay,
}) => {
const [loadMore, isLoadingMore] = useLoadMore(relay, 5);
@@ -59,6 +62,7 @@ const UserHistoryDrawerAllComments: FunctionComponent<Props> = ({
<ModerateCardContainer
comment={c}
settings={settings}
viewer={viewer}
danglingLogic={(status) => false}
hideUsername
showStoryInfo
@@ -92,6 +96,11 @@ const enhanced = withPaginationContainer<
...ModerateCardContainer_settings
}
`,
viewer: graphql`
fragment UserHistoryDrawerAllComments_viewer on User {
...ModerateCardContainer_viewer
}
`,
user: graphql`
fragment UserHistoryDrawerAllComments_user on User
@argumentDefinitions(
@@ -28,12 +28,15 @@ const UserHistoryDrawerAllCommentsQuery: FunctionComponent<Props> = ({
settings {
...UserHistoryDrawerAllComments_settings
}
viewer {
...UserHistoryDrawerAllComments_viewer
}
}
`}
variables={{ userID }}
cacheConfig={{ force: true }}
render={({ error, props }) => {
if (!props) {
if (!props || !props.viewer) {
return (
<div className={styles.root}>
<Spinner />
@@ -56,6 +59,7 @@ const UserHistoryDrawerAllCommentsQuery: FunctionComponent<Props> = ({
return (
<UserHistoryDrawerAllComments
settings={props.settings}
viewer={props.viewer}
user={props.user}
/>
);
@@ -4,7 +4,7 @@ import { graphql } from "react-relay";
import { UserStatusChangeContainer } from "coral-admin/components/UserStatus";
import { CopyButton } from "coral-framework/components";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { useDateTimeFormatter } from "coral-framework/hooks";
import { withFragmentContainer } from "coral-framework/lib/relay";
import {
Button,
@@ -16,6 +16,7 @@ import {
import { UserHistoryDrawerContainer_settings } from "coral-admin/__generated__/UserHistoryDrawerContainer_settings.graphql";
import { UserHistoryDrawerContainer_user } from "coral-admin/__generated__/UserHistoryDrawerContainer_user.graphql";
import { UserHistoryDrawerContainer_viewer } from "coral-admin/__generated__/UserHistoryDrawerContainer_viewer.graphql";
import RecentHistoryContainer from "./RecentHistoryContainer";
import Tabs from "./Tabs";
@@ -27,16 +28,17 @@ import styles from "./UserHistoryDrawerContainer.css";
interface Props {
user: UserHistoryDrawerContainer_user;
settings: UserHistoryDrawerContainer_settings;
viewer: UserHistoryDrawerContainer_viewer;
onClose: () => void;
}
const UserHistoryDrawerContainer: FunctionComponent<Props> = ({
settings,
user,
viewer,
onClose,
}) => {
const { locales } = useCoralContext();
const formatter = new Intl.DateTimeFormat(locales, {
const formatter = useDateTimeFormatter({
month: "long",
day: "numeric",
year: "numeric",
@@ -69,6 +71,7 @@ const UserHistoryDrawerContainer: FunctionComponent<Props> = ({
bordered={true}
settings={settings}
user={user}
viewer={viewer}
/>
</div>
<UserStatusDetailsContainer user={user} />
@@ -101,7 +104,7 @@ const UserHistoryDrawerContainer: FunctionComponent<Props> = ({
</Icon>
</Localized>
<span className={styles.userDetailValue}>
{formatter.format(new Date(user.createdAt))}
{formatter(user.createdAt)}
</span>
</Flex>
<Flex alignItems="center" spacing={2}>
@@ -153,6 +156,11 @@ const enhanced = withFragmentContainer<Props>({
}
}
`,
viewer: graphql`
fragment UserHistoryDrawerContainer_viewer on User {
...UserStatusChangeContainer_viewer
}
`,
})(UserHistoryDrawerContainer);
export default enhanced;
@@ -32,12 +32,15 @@ const UserHistoryDrawerQuery: FunctionComponent<Props> = ({
settings {
...UserHistoryDrawerContainer_settings
}
viewer {
...UserHistoryDrawerContainer_viewer
}
}
`}
variables={{ userID }}
cacheConfig={{ force: true }}
render={({ props }: QueryRenderData<QueryTypes>) => {
if (!props) {
if (!props || !props.viewer) {
return (
<div className={styles.root}>
<Spinner />
@@ -62,6 +65,7 @@ const UserHistoryDrawerQuery: FunctionComponent<Props> = ({
onClose={onClose}
user={props.user}
settings={props.settings}
viewer={props.viewer}
/>
);
}}
@@ -11,6 +11,7 @@ import { Button, CallOut, Divider } from "coral-ui/components/v2";
import { UserHistoryDrawerRejectedComments_settings } from "coral-admin/__generated__/UserHistoryDrawerRejectedComments_settings.graphql";
import { UserHistoryDrawerRejectedComments_user } from "coral-admin/__generated__/UserHistoryDrawerRejectedComments_user.graphql";
import { UserHistoryDrawerRejectedComments_viewer } from "coral-admin/__generated__/UserHistoryDrawerRejectedComments_viewer.graphql";
import { UserHistoryDrawerRejectedCommentsPaginationQueryVariables } from "coral-admin/__generated__/UserHistoryDrawerRejectedCommentsPaginationQuery.graphql";
import styles from "./UserHistoryDrawerRejectedComments.css";
@@ -18,6 +19,7 @@ import styles from "./UserHistoryDrawerRejectedComments.css";
const danglingLogic = () => false;
interface Props {
viewer: UserHistoryDrawerRejectedComments_viewer;
user: UserHistoryDrawerRejectedComments_user;
settings: UserHistoryDrawerRejectedComments_settings;
relay: RelayPaginationProp;
@@ -26,6 +28,7 @@ interface Props {
const UserHistoryDrawerRejectedComments: FunctionComponent<Props> = ({
user,
settings,
viewer,
relay,
}) => {
const [loadMore, isLoadingMore] = useLoadMore(relay, 5);
@@ -63,6 +66,7 @@ const UserHistoryDrawerRejectedComments: FunctionComponent<Props> = ({
<ModerateCardContainer
comment={c}
settings={settings}
viewer={viewer}
danglingLogic={danglingLogic}
hideUsername
showStoryInfo
@@ -96,6 +100,11 @@ const enhanced = withPaginationContainer<
...ModerateCardContainer_settings
}
`,
viewer: graphql`
fragment UserHistoryDrawerRejectedComments_viewer on User {
...ModerateCardContainer_viewer
}
`,
user: graphql`
fragment UserHistoryDrawerRejectedComments_user on User
@argumentDefinitions(
@@ -26,12 +26,15 @@ const UserHistoryDrawerRejectedCommentsQuery: FunctionComponent<Props> = ({
settings {
...UserHistoryDrawerRejectedComments_settings
}
viewer {
...UserHistoryDrawerRejectedComments_viewer
}
}
`}
variables={{ userID }}
cacheConfig={{ force: true }}
render={({ error, props }) => {
if (!props) {
if (!props || !props.viewer) {
return (
<div className={styles.root}>
<Spinner />
@@ -54,6 +57,7 @@ const UserHistoryDrawerRejectedCommentsQuery: FunctionComponent<Props> = ({
return (
<UserHistoryDrawerRejectedComments
settings={props.settings}
viewer={props.viewer}
user={props.user}
/>
);
@@ -2,7 +2,7 @@ import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useMemo } from "react";
import { graphql } from "react-relay";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { useDateTimeFormatter } from "coral-framework/hooks";
import { withFragmentContainer } from "coral-framework/lib/relay";
import {
BaseButton,
@@ -33,9 +33,7 @@ const UserStatusDetailsContainer: FunctionComponent<Props> = ({ user }) => {
return user.status.suspension.history.find((item) => item.active);
}, [user]);
const { locales } = useCoralContext();
const formatter = new Intl.DateTimeFormat(locales, {
const formatter = useDateTimeFormatter({
day: "2-digit",
month: "2-digit",
year: "numeric",
@@ -55,12 +53,12 @@ const UserStatusDetailsContainer: FunctionComponent<Props> = ({ user }) => {
<div>
<Localized
id="userDetails-banned-on"
$timestamp={formatter.format(new Date(activeBan.createdAt))}
$timestamp={formatter(activeBan.createdAt)}
strong={<strong />}
>
<p className={styles.root}>
<strong>Banned on </strong>{" "}
{formatter.format(new Date(activeBan.createdAt))}
{formatter(activeBan.createdAt)}
</p>
</Localized>
{activeBan.createdBy && (
@@ -94,25 +92,21 @@ const UserStatusDetailsContainer: FunctionComponent<Props> = ({ user }) => {
<Localized
id="userDetails-suspension-start"
strong={<strong />}
$timestamp={formatter.format(
new Date(activeSuspension.from.start)
)}
$timestamp={formatter(activeSuspension.from.start)}
>
<p className={styles.root}>
<strong>Start: </strong>
{formatter.format(new Date(activeSuspension.from.start))}
{formatter(activeSuspension.from.start)}
</p>
</Localized>
<Localized
strong={<strong />}
$timestamp={formatter.format(
new Date(activeSuspension.from.finish)
)}
$timestamp={formatter(activeSuspension.from.finish)}
id="userDetails-suspension-finish"
>
<p className={styles.root}>
<strong>End: </strong>
{formatter.format(new Date(activeSuspension.from.finish))}
{formatter(activeSuspension.from.finish)}
</p>
</Localized>
</div>
@@ -0,0 +1,5 @@
.root {
max-width: 500px;
padding: var(--v2-spacing-2) var(--v2-spacing-3) var(--v2-spacing-3)
var(--v2-spacing-3);
}
@@ -0,0 +1,120 @@
import { Localized } from "@fluent/react/compat";
import { FORM_ERROR } from "final-form";
import React, { FunctionComponent, useCallback } from "react";
import { Form } from "react-final-form";
import { InvalidRequestError } from "coral-framework/lib/errors";
import {
Button,
CallOut,
Card,
CardCloseButton,
Flex,
HorizontalGutter,
Modal,
} from "coral-ui/components/v2";
import { PropTypesOf } from "coral-ui/types";
import ModalBodyText from "../ModalBodyText";
import ModalHeader from "../ModalHeader";
import ModalHeaderUsername from "../ModalHeaderUsername";
import NotAvailable from "../NotAvailable";
import SiteModeratorModalSiteFieldContainer from "./SiteModeratorModalSiteFieldContainer";
import styles from "./SiteModeratorModal.css";
interface Props {
username: string | null;
open: boolean;
onCancel: () => void;
onFinish: (siteIDs: string[]) => Promise<void>;
selectedSiteIDs?: string[];
query: PropTypesOf<typeof SiteModeratorModalSiteFieldContainer>["query"];
}
const SiteModeratorModal: FunctionComponent<Props> = ({
username,
open,
onFinish,
onCancel,
selectedSiteIDs = [],
query,
}) => {
const onSubmit = useCallback(
async (values: { siteIDs: string[] }) => {
try {
await onFinish(values.siteIDs);
return;
} catch (err) {
if (err instanceof InvalidRequestError) {
return err.invalidArgs;
}
return { [FORM_ERROR]: err.message };
}
},
[onFinish]
);
return (
<Modal open={open} onClose={onCancel}>
{({ firstFocusableRef, lastFocusableRef }) => (
<Card className={styles.root}>
<Flex justifyContent="flex-end">
<CardCloseButton onClick={onCancel} ref={firstFocusableRef} />
</Flex>
<Form
onSubmit={onSubmit}
initialValues={{ siteIDs: selectedSiteIDs }}
>
{({ handleSubmit, submitError, submitting }) => (
<form onSubmit={handleSubmit}>
<HorizontalGutter spacing={3}>
<Localized
id="community-siteModeratorModal-assignSites"
strong={<ModalHeaderUsername />}
$username={username || <NotAvailable />}
>
<ModalHeader>
Assign sites for{" "}
<ModalHeaderUsername>{username}</ModalHeaderUsername>
</ModalHeader>
</Localized>
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
<Localized id="community-siteModeratorModal-assignSitesDescription">
<ModalBodyText>
Site moderators are permitted to make moderation decisions
and issue suspensions on the sites they are assigned.
</ModalBodyText>
</Localized>
<SiteModeratorModalSiteFieldContainer query={query} />
<Flex justifyContent="flex-end" itemGutter="half">
<Localized id="community-siteModeratorModal-cancel">
<Button variant="flat" onClick={onCancel}>
Cancel
</Button>
</Localized>
<Localized id="community-siteModeratorModal-assign">
<Button
type="submit"
disabled={submitting}
ref={lastFocusableRef}
>
Assign
</Button>
</Localized>
</Flex>
</HorizontalGutter>
</form>
)}
</Form>
</Card>
)}
</Modal>
);
};
export default SiteModeratorModal;
@@ -0,0 +1,3 @@
.listGroup {
max-height: 250px;
}
@@ -0,0 +1,93 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { useField } from "react-final-form";
import AutoLoadMore from "coral-admin/components/AutoLoadMore";
import {
CheckBox,
FieldSet,
Flex,
FormField,
Label,
ListGroup,
ListGroupRow,
Spinner,
} from "coral-ui/components/v2";
import styles from "./SiteModeratorModalSiteField.css";
interface Props {
sites: Array<{ id: string; name: string }>;
onLoadMore: () => void;
hasMore: boolean;
disableLoadMore: boolean;
loading: boolean;
}
const SiteModeratorModalSiteField: FunctionComponent<Props> = ({
sites,
onLoadMore,
hasMore,
disableLoadMore,
loading,
}) => {
const { input } = useField<string[]>("siteIDs");
const onChange = useCallback(
(siteID: string, selectedIndex: number) => () => {
const changed = [...input.value];
if (selectedIndex >= 0) {
changed.splice(selectedIndex, 1);
} else {
changed.push(siteID);
}
input.onChange(changed);
},
[input]
);
return (
<FieldSet>
<FormField>
<Localized id="community-siteModeratorModal-selectSites">
<Label>Select sites to moderate</Label>
</Localized>
<ListGroup className={styles.listGroup}>
{sites.map((site) => {
const selectedIndex = input.value.indexOf(site.id);
return (
<ListGroupRow key={site.id}>
<CheckBox
checked={selectedIndex >= 0}
onChange={onChange(site.id, selectedIndex)}
>
{site.name}
</CheckBox>
</ListGroupRow>
);
})}
{!loading && sites.length === 0 && (
<Localized id="community-siteModeratorModal-noSites">
<span>No sites</span>
</Localized>
)}
{loading && (
<Flex justifyContent="center">
<Spinner />
</Flex>
)}
{hasMore && (
<Flex justifyContent="center">
<AutoLoadMore
disableLoadMore={disableLoadMore}
onLoadMore={onLoadMore}
/>
</Flex>
)}
</ListGroup>
</FormField>
</FieldSet>
);
};
export default SiteModeratorModalSiteField;
@@ -0,0 +1,105 @@
import React, { FunctionComponent, useMemo } from "react";
import { graphql, RelayPaginationProp } from "react-relay";
import { IntersectionProvider } from "coral-framework/lib/intersection";
import {
useLoadMore,
useRefetch,
withPaginationContainer,
} from "coral-framework/lib/relay";
import { SiteModeratorModalSiteFieldContainer_query } from "coral-admin/__generated__/SiteModeratorModalSiteFieldContainer_query.graphql";
import { SiteModeratorModalSiteFieldContainerPaginationQueryVariables } from "coral-admin/__generated__/SiteModeratorModalSiteFieldContainerPaginationQuery.graphql";
import SiteModeratorModalSiteField from "./SiteModeratorModalSiteField";
interface Props {
query: SiteModeratorModalSiteFieldContainer_query;
relay: RelayPaginationProp;
}
const SiteModeratorModalSiteFieldContainer: FunctionComponent<Props> = ({
query,
relay,
}) => {
const sites = useMemo(
() => query?.sites.edges.map((edge) => edge.node) || [],
[query?.sites.edges]
);
const [loadMore, isLoadingMore] = useLoadMore(relay, 1);
const [, isRefetching] = useRefetch<
SiteModeratorModalSiteFieldContainerPaginationQueryVariables
>(relay);
return (
<IntersectionProvider>
<SiteModeratorModalSiteField
loading={!query || isRefetching}
sites={sites}
onLoadMore={loadMore}
hasMore={!isRefetching && relay.hasMore()}
disableLoadMore={isLoadingMore}
/>
</IntersectionProvider>
);
};
type FragmentVariables = SiteModeratorModalSiteFieldContainerPaginationQueryVariables;
const enhanced = withPaginationContainer<
Props,
SiteModeratorModalSiteFieldContainerPaginationQueryVariables,
FragmentVariables
>(
{
query: graphql`
fragment SiteModeratorModalSiteFieldContainer_query on Query
@argumentDefinitions(
count: { type: "Int!", defaultValue: 20 }
cursor: { type: "Cursor" }
) {
sites(first: $count, after: $cursor)
@connection(key: "SiteModeratorModalSiteField_sites") {
edges {
node {
id
name
}
}
}
}
`,
},
{
direction: "forward",
getConnectionFromProps(props) {
return props.query && props.query.sites;
},
// This is also the default implementation of `getFragmentVariables` if it isn't provided.
getFragmentVariables(prevVars, totalCount) {
return {
...prevVars,
count: totalCount,
};
},
getVariables(props, { count, cursor }, fragmentVariables) {
return {
count,
cursor,
};
},
query: graphql`
# Pagination query to be fetched upon calling 'loadMore'.
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
query SiteModeratorModalSiteFieldContainerPaginationQuery(
$count: Int!
$cursor: Cursor
) {
...SiteModeratorModalSiteFieldContainer_query
@arguments(count: $count, cursor: $cursor)
}
`,
}
)(SiteModeratorModalSiteFieldContainer);
export default enhanced;
@@ -0,0 +1,47 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { UpdateUserModerationScopesMutation as MutationTypes } from "coral-admin/__generated__/UpdateUserModerationScopesMutation.graphql";
let clientMutationId = 0;
const UpdateUserModerationScopesMutation = createMutation(
"updateUserModerationScopes",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation UpdateUserModerationScopesMutation(
$input: UpdateUserModerationScopesInput!
) {
updateUserModerationScopes(input: $input) {
user {
id
role
moderationScopes {
scoped
sites {
id
name
}
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default UpdateUserModerationScopesMutation;
@@ -1,82 +1,192 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import TranslatedRole from "coral-admin/components/TranslatedRole";
import { useToggleState } from "coral-framework/hooks";
import { GQLUSER_ROLE, GQLUSER_ROLE_RL } from "coral-framework/schema";
import {
Button,
ButtonIcon,
ClickOutside,
Dropdown,
DropdownButton,
Popover,
} from "coral-ui/components/v2";
import { PropTypesOf } from "coral-ui/types";
import { UserRoleChangeContainer_user } from "coral-admin/__generated__/UserRoleChangeContainer_user.graphql";
import SiteModeratorModal from "./SiteModeratorModal";
import UserRoleChangeButton from "./UserRoleChangeButton";
import UserRoleText from "./UserRoleText";
import styles from "./UserRoleChange.css";
interface Props {
onChangeRole: (role: GQLUSER_ROLE_RL) => void;
username: string | null;
onChangeRole: (role: GQLUSER_ROLE_RL) => Promise<void>;
onChangeModerationScopes: (siteIDs: string[]) => Promise<void>;
role: GQLUSER_ROLE_RL;
scoped?: boolean;
moderationScopes: UserRoleChangeContainer_user["moderationScopes"];
moderationScopesEnabled?: boolean;
query: PropTypesOf<typeof SiteModeratorModal>["query"];
}
const UserRoleChange: FunctionComponent<Props> = (props) => (
<Localized id="community-role-popover" attrs={{ description: true }}>
<Popover
id="community-roleChange"
placement="bottom-start"
description="A dropdown to change the user role"
body={({ toggleVisibility }) => (
<ClickOutside onClickOutside={toggleVisibility}>
<Dropdown>
{Object.keys(GQLUSER_ROLE).map((r: GQLUSER_ROLE_RL) => (
<TranslatedRole
key={r}
container={
<DropdownButton
const UserRoleChange: FunctionComponent<Props> = ({
username,
role,
scoped,
onChangeRole,
onChangeModerationScopes,
moderationScopes,
moderationScopesEnabled = false,
query,
}) => {
// Setup state and callbacks for the popover.
const [
isPopoverVisible,
setPopoverVisibility,
togglePopoverVisibility,
] = useToggleState();
/**
* handleChangeRole combines the change role function with the change
* moderation scopes.
*/
const handleChangeRole = useCallback(
async (r: GQLUSER_ROLE_RL, siteIDs: string[] = []) => {
await onChangeRole(r);
if (moderationScopesEnabled) {
await onChangeModerationScopes(siteIDs);
}
},
[onChangeRole, onChangeModerationScopes, moderationScopesEnabled]
);
const onClick = useCallback(
(r: GQLUSER_ROLE_RL, siteIDs: string[] = []) => async () => {
await handleChangeRole(r, siteIDs);
togglePopoverVisibility();
},
[handleChangeRole, togglePopoverVisibility]
);
// Setup state and callbacks for the site moderator modal.
const [
isModalVisible,
setModalVisibility,
toggleModalVisibility,
] = useToggleState();
const onFinishModal = useCallback(
async (siteIDs: string[]) => {
// Set the user as a moderator and then update the siteIDs.
await handleChangeRole(GQLUSER_ROLE.MODERATOR, siteIDs);
// Close the modal.
setModalVisibility(false);
},
[setModalVisibility, handleChangeRole]
);
const selectedSiteIDs = useMemo(
() => moderationScopes?.sites?.map((site) => site.id),
[moderationScopes]
);
return (
<>
{moderationScopesEnabled && (
<SiteModeratorModal
username={username}
open={isModalVisible}
query={query}
selectedSiteIDs={selectedSiteIDs}
onCancel={toggleModalVisibility}
onFinish={onFinishModal}
/>
)}
<Localized id="community-role-popover" attrs={{ description: true }}>
<Popover
id="community-roleChange"
placement="bottom-start"
description="A dropdown to change the user role"
visible={isPopoverVisible}
body={
<ClickOutside onClickOutside={togglePopoverVisibility}>
<Dropdown>
<UserRoleChangeButton
active={role === GQLUSER_ROLE.COMMENTER}
role={GQLUSER_ROLE.COMMENTER}
moderationScopesEnabled={moderationScopesEnabled}
onClick={onClick(GQLUSER_ROLE.COMMENTER)}
/>
<UserRoleChangeButton
active={role === GQLUSER_ROLE.STAFF}
role={GQLUSER_ROLE.STAFF}
moderationScopesEnabled={moderationScopesEnabled}
onClick={onClick(GQLUSER_ROLE.STAFF)}
/>
{moderationScopesEnabled && (
<UserRoleChangeButton
active={scoped && role === GQLUSER_ROLE.MODERATOR}
role={GQLUSER_ROLE.MODERATOR}
scoped
moderationScopesEnabled
onClick={() => {
props.onChangeRole(r);
toggleVisibility();
setModalVisibility(true);
setPopoverVisibility(false);
}}
>
dummy
</DropdownButton>
}
>
{r}
</TranslatedRole>
))}
</Dropdown>
</ClickOutside>
)}
>
{({ toggleVisibility, ref, visible }) => (
<Localized
id="community-changeRoleButton"
attrs={{ "aria-label": true }}
/>
)}
<UserRoleChangeButton
active={
(!moderationScopesEnabled ||
(moderationScopesEnabled && !scoped)) &&
role === GQLUSER_ROLE.MODERATOR
}
role={GQLUSER_ROLE.MODERATOR}
moderationScopesEnabled={moderationScopesEnabled}
onClick={onClick(GQLUSER_ROLE.MODERATOR)}
/>
<UserRoleChangeButton
active={role === GQLUSER_ROLE.ADMIN}
role={GQLUSER_ROLE.ADMIN}
moderationScopesEnabled={moderationScopesEnabled}
onClick={onClick(GQLUSER_ROLE.ADMIN)}
/>
</Dropdown>
</ClickOutside>
}
>
<Button
aria-label="Change role"
className={styles.button}
onClick={toggleVisibility}
uppercase={false}
size="large"
color="mono"
ref={ref}
variant="text"
>
<UserRoleText>{props.role}</UserRoleText>
{
<ButtonIcon size="lg">
{visible ? "arrow_drop_up" : "arrow_drop_down"}
</ButtonIcon>
}
</Button>
</Localized>
)}
</Popover>
</Localized>
);
{({ ref }) => (
<Localized
id="community-changeRoleButton"
attrs={{ "aria-label": true }}
>
<Button
aria-label="Change role"
className={styles.button}
onClick={togglePopoverVisibility}
uppercase={false}
size="large"
color="mono"
ref={ref}
variant="text"
>
<UserRoleText
moderationScopesEnabled={moderationScopesEnabled}
scoped={scoped}
role={role}
/>
<ButtonIcon size="lg">
{isPopoverVisible ? "arrow_drop_up" : "arrow_drop_down"}
</ButtonIcon>
</Button>
</Localized>
)}
</Popover>
</Localized>
</>
);
};
export default UserRoleChange;
@@ -0,0 +1,3 @@
.active {
font-weight: bold;
}
@@ -0,0 +1,38 @@
import cn from "classnames";
import React, { FunctionComponent } from "react";
import TranslatedRole from "coral-admin/components/TranslatedRole";
import { PropTypesOf } from "coral-framework/types";
import { ButtonIcon, DropdownButton } from "coral-ui/components/v2";
import styles from "./UserRoleChangeButton.css";
interface Props extends Omit<PropTypesOf<typeof TranslatedRole>, "container"> {
active?: boolean;
onClick: () => void;
}
const UserRoleChangeButton: FunctionComponent<Props> = ({
active,
onClick,
...props
}) => {
return (
<TranslatedRole
container={
<DropdownButton
className={cn(active && styles.active)}
onClick={onClick}
adornment={
props.scoped && active && <ButtonIcon>settings</ButtonIcon>
}
>
dummy
</DropdownButton>
}
{...props}
/>
);
};
export default UserRoleChangeButton;
@@ -1,14 +1,17 @@
import React, { FunctionComponent, useCallback } from "react";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import { graphql } from "react-relay";
import { Ability, can } from "coral-admin/permissions";
import { useMutation, withFragmentContainer } from "coral-framework/lib/relay";
import { GQLUSER_ROLE_RL } from "coral-framework/schema";
import { GQLFEATURE_FLAG, GQLUSER_ROLE_RL } from "coral-framework/schema";
import { UserRoleChangeContainer_query } from "coral-admin/__generated__/UserRoleChangeContainer_query.graphql";
import { UserRoleChangeContainer_settings } from "coral-admin/__generated__/UserRoleChangeContainer_settings.graphql";
import { UserRoleChangeContainer_user } from "coral-admin/__generated__/UserRoleChangeContainer_user.graphql";
import { UserRoleChangeContainer_viewer } from "coral-admin/__generated__/UserRoleChangeContainer_viewer.graphql";
import ButtonPadding from "../ButtonPadding";
import UpdateUserModerationScopesMutation from "./UpdateUserModerationScopesMutation";
import UpdateUserRoleMutation from "./UpdateUserRoleMutation";
import UserRoleChange from "./UserRoleChange";
import UserRoleText from "./UserRoleText";
@@ -16,35 +19,75 @@ import UserRoleText from "./UserRoleText";
interface Props {
viewer: UserRoleChangeContainer_viewer;
user: UserRoleChangeContainer_user;
settings: UserRoleChangeContainer_settings;
query: UserRoleChangeContainer_query;
}
const UserRoleChangeContainer: FunctionComponent<Props> = (props) => {
const UserRoleChangeContainer: FunctionComponent<Props> = ({
user,
viewer,
settings,
query,
}) => {
const updateUserRole = useMutation(UpdateUserRoleMutation);
const updateUserModerationScopes = useMutation(
UpdateUserModerationScopesMutation
);
const handleOnChangeRole = useCallback(
(role: GQLUSER_ROLE_RL) => {
if (role === props.user.role) {
async (role: GQLUSER_ROLE_RL) => {
if (role === user.role) {
// No role change is needed! User already has the selected role.
return;
}
void updateUserRole({ userID: props.user.id, role });
await updateUserRole({ userID: user.id, role });
},
[props.user.id, props.user.role, updateUserRole]
[user, updateUserRole]
);
const handleOnChangeModerationScopes = useCallback(
async (siteIDs: string[]) => {
await updateUserModerationScopes({
userID: user.id,
moderationScopes: { siteIDs },
});
},
[user]
);
const canChangeRole = useMemo(
() => viewer.id !== user.id && can(viewer, Ability.CHANGE_ROLE),
[viewer, user]
);
const canChangeRole =
props.viewer.id !== props.user.id && can(props.viewer, Ability.CHANGE_ROLE);
const moderationScopesEnabled = useMemo(
() =>
settings.featureFlags.includes(GQLFEATURE_FLAG.SITE_MODERATOR) &&
settings.multisite,
[settings]
);
if (canChangeRole) {
if (!canChangeRole) {
return (
<UserRoleChange
onChangeRole={handleOnChangeRole}
role={props.user.role}
/>
<ButtonPadding>
<UserRoleText
moderationScopesEnabled={moderationScopesEnabled}
scoped={user.moderationScopes?.scoped}
role={user.role}
/>
</ButtonPadding>
);
}
return (
<ButtonPadding>
<UserRoleText>{props.user.role}</UserRoleText>
</ButtonPadding>
<UserRoleChange
username={user.username}
onChangeRole={handleOnChangeRole}
onChangeModerationScopes={handleOnChangeModerationScopes}
role={user.role}
scoped={user.moderationScopes?.scoped}
moderationScopes={user.moderationScopes}
moderationScopesEnabled={moderationScopesEnabled}
query={query}
/>
);
};
@@ -58,7 +101,26 @@ const enhanced = withFragmentContainer<Props>({
user: graphql`
fragment UserRoleChangeContainer_user on User {
id
username
role
moderationScopes {
scoped
sites {
id
name
}
}
}
`,
settings: graphql`
fragment UserRoleChangeContainer_settings on Settings {
multisite
featureFlags
}
`,
query: graphql`
fragment UserRoleChangeContainer_query on Query {
...SiteModeratorModalSiteFieldContainer_query
}
`,
})(UserRoleChangeContainer);
@@ -7,9 +7,7 @@ import { PropTypesOf } from "coral-ui/types";
import styles from "./UserRoleText.css";
interface Props {
children: PropTypesOf<typeof TranslatedRole>["children"];
}
type Props = Omit<PropTypesOf<typeof TranslatedRole>, "container">;
const UserRoleText: FunctionComponent<Props> = (props) => (
<TranslatedRole
@@ -20,9 +18,8 @@ const UserRoleText: FunctionComponent<Props> = (props) => (
})}
/>
}
>
{props.children}
</TranslatedRole>
{...props}
/>
);
export default UserRoleText;
@@ -12,9 +12,9 @@ import {
Textarea,
} from "coral-ui/components/v2";
import ModalHeader from "../ModalHeader";
import ModalHeaderUsername from "../ModalHeaderUsername";
import ChangeStatusModal from "./ChangeStatusModal";
import ChangeStatusModalHeader from "./ChangeStatusModalHeader";
import ModalHeaderUsername from "./ModalHeaderUsername";
import styles from "./BanModal.css";
@@ -66,13 +66,13 @@ const BanModal: FunctionComponent<Props> = ({
<strong>{username || <NotAvailable />}</strong>
))}
>
<ChangeStatusModalHeader id="banModal-title">
<ModalHeader id="banModal-title">
Are you sure you want to ban{" "}
<ModalHeaderUsername>
{username || <NotAvailable />}
</ModalHeaderUsername>
?
</ChangeStatusModalHeader>
</ModalHeader>
</Localized>
<Localized id="community-banModal-consequence">
<p className={styles.bodyText}>
@@ -1,15 +0,0 @@
import React, { FunctionComponent, HTMLAttributes } from "react";
import styles from "./ChangeStatusModalHeader.css";
const ChangeStatusModalHeader: FunctionComponent<HTMLAttributes<
HTMLHeadingElement
>> = ({ children, ...rest }) => {
return (
<h2 {...rest} className={styles.root}>
{children}
</h2>
);
};
export default ChangeStatusModalHeader;
@@ -4,11 +4,10 @@ import React, { FunctionComponent } from "react";
import NotAvailable from "coral-admin/components/NotAvailable";
import { Button, Flex, HorizontalGutter } from "coral-ui/components/v2";
import ModalBodyText from "../ModalBodyText";
import ModalHeader from "../ModalHeader";
import ModalHeaderUsername from "../ModalHeaderUsername";
import ChangeStatusModal from "./ChangeStatusModal";
import ChangeStatusModalHeader from "./ChangeStatusModalHeader";
import ModalHeaderUsername from "./ModalHeaderUsername";
import styles from "./PremodModal.css";
interface Props {
username: string | null;
@@ -36,19 +35,19 @@ const PremodModal: FunctionComponent<Props> = ({
strong={<ModalHeaderUsername />}
$username={username || <NotAvailable />}
>
<ChangeStatusModalHeader id="PremodModal-title">
<ModalHeader id="PremodModal-title">
Are you sure you want to always premoderate{" "}
<ModalHeaderUsername>
{username || <NotAvailable />}
</ModalHeaderUsername>
?
</ChangeStatusModalHeader>
</ModalHeader>
</Localized>
<Localized id="community-premodModal-consequence">
<div className={styles.bodyText}>
<ModalBodyText>
Note: Always premoderating this user will place all of their
comments in the Pre-Moderate queue.
</div>
</ModalBodyText>
</Localized>
<Flex justifyContent="flex-end" itemGutter>
<Localized id="community-premodModal-cancel">
@@ -1,12 +1,18 @@
.textArea {
height: calc(12 * var(--mini-unit));
.radioButton {
margin: 0 var(--spacing-1) 0 0 !important;
}
.subTitle {
font-size: var(--v2-font-size-4);
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-semi-bold);
line-height: var(--v2-line-height-title);
color: $suspend-modal-text;
margin: 0;
.textArea {
width: 100%;
box-sizing: border-box;
height: calc(12 * var(--mini-unit));
padding: calc(0.5 * var(--mini-unit));
}
.header {
margin: 0 0 var(--spacing-3) 0 !important;
}
.footer {
margin-top: var(--spacing-3);
}
@@ -10,11 +10,12 @@ import {
CheckBox,
Flex,
HorizontalGutter,
Label,
RadioButton,
Textarea,
} from "coral-ui/components/v2";
import styles from "./SuspendModal.css";
import styles from "./SuspendForm.css";
interface Props {
username: string | null;
@@ -120,7 +121,7 @@ const SuspendForm: FunctionComponent<Props> = ({
<HorizontalGutter spacing={3}>
<HorizontalGutter spacing={1}>
<Localized id="community-suspendModal-selectDuration">
<p className={styles.subTitle}>Select suspension length</p>
<Label>Select suspension length</Label>
</Localized>
<div>
@@ -1,37 +0,0 @@
$suspend-modal-text: var(--v2-colors-mono-500);
.bodyText {
font-size: var(--v2-font-size-3);
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-regular);
line-height: var(--v2-line-height-body-short);
color: $suspend-modal-text;
}
.subTitle {
font-size: var(--v2-font-size-4);
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-semi-bold);
line-height: var(--v2-line-height-title);
color: $suspend-modal-text;
margin: 0;
}
.radioButton {
margin: 0 var(--spacing-1) 0 0 !important;
}
.textArea {
width: 100%;
box-sizing: border-box;
height: calc(12 * var(--mini-unit));
padding: calc(0.5 * var(--mini-unit));
}
.header {
margin: 0 0 var(--spacing-3) 0 !important;
}
.footer {
margin-top: var(--spacing-3);
}
@@ -6,14 +6,12 @@ import { ScaledUnit } from "coral-common/helpers/i18n";
import { GetMessage, withGetMessage } from "coral-framework/lib/i18n";
import { Button, Flex, HorizontalGutter } from "coral-ui/components/v2";
import ModalBodyText from "../ModalBodyText";
import ModalHeader from "../ModalHeader";
import ModalHeaderUsername from "../ModalHeaderUsername";
import ChangeStatusModal from "./ChangeStatusModal";
import ChangeStatusModalHeader from "./ChangeStatusModalHeader";
import ModalHeaderUsername from "./ModalHeaderUsername";
import SuspendForm from "./SuspendForm";
import styles from "./SuspendModal.css";
interface Props {
username: string | null;
getMessage: GetMessage;
@@ -63,10 +61,10 @@ const SuspendModal: FunctionComponent<Props> = ({
strong={<ModalHeaderUsername />}
$duration={successDuration}
>
<ChangeStatusModalHeader>
<ModalHeader>
<ModalHeaderUsername>{username}</ModalHeaderUsername> has been
suspended for <strong>{successDuration}</strong>
</ChangeStatusModalHeader>
</ModalHeader>
</Localized>
<Flex justifyContent="flex-end" itemGutter="half">
@@ -85,19 +83,19 @@ const SuspendModal: FunctionComponent<Props> = ({
strong={<ModalHeaderUsername />}
$username={username || <NotAvailable />}
>
<ChangeStatusModalHeader id="suspendModal-title">
<ModalHeader id="suspendModal-title">
Suspend{" "}
<ModalHeaderUsername>
{username || <NotAvailable />}
</ModalHeaderUsername>
?
</ChangeStatusModalHeader>
</ModalHeader>
</Localized>
<Localized id="community-suspendModal-consequence">
<div className={styles.bodyText}>
<ModalBodyText>
While suspended, this user will no longer be able to comment,
use reactions, or report comments.
</div>
</ModalBodyText>
</Localized>
<SuspendForm
@@ -14,8 +14,20 @@ import {
import styles from "./UserStatusChange.css";
interface Props {
onBan: () => void;
onRemoveBan: () => void;
/**
* onBan when set to false disables the controls associated with banning a
* user. Otherwise the provided function is called when the control is
* clicked.
*/
onBan: false | (() => void);
/**
* onRemoveBan when set to false disables the controls associated with
* banning a user. Otherwise the provided function is called when the control
* is clicked.
*/
onRemoveBan: false | (() => void);
onSuspend: () => void;
onRemoveSuspension: () => void;
onPremod: () => void;
@@ -54,9 +66,12 @@ const UserStatusChange: FunctionComponent<Props> = ({
<Localized id="community-userStatus-removeUserBan">
<DropdownButton
className={styles.dropdownButton}
disabled={!onRemoveBan}
onClick={() => {
onRemoveBan();
toggleVisibility();
if (onRemoveBan) {
onRemoveBan();
toggleVisibility();
}
}}
>
Remove ban
@@ -66,9 +81,12 @@ const UserStatusChange: FunctionComponent<Props> = ({
<Localized id="community-userStatus-ban">
<DropdownButton
className={styles.dropdownButton}
disabled={!onBan}
onClick={() => {
onBan();
toggleVisibility();
if (onBan) {
onBan();
toggleVisibility();
}
}}
>
Ban
@@ -4,8 +4,9 @@ import { graphql } from "react-relay";
import { useMutation, withFragmentContainer } from "coral-framework/lib/relay";
import { GQLUSER_ROLE } from "coral-framework/schema";
import { UserStatusChangeContainer_settings as SettingsData } from "coral-admin/__generated__/UserStatusChangeContainer_settings.graphql";
import { UserStatusChangeContainer_user as UserData } from "coral-admin/__generated__/UserStatusChangeContainer_user.graphql";
import { UserStatusChangeContainer_settings } from "coral-admin/__generated__/UserStatusChangeContainer_settings.graphql";
import { UserStatusChangeContainer_user } from "coral-admin/__generated__/UserStatusChangeContainer_user.graphql";
import { UserStatusChangeContainer_viewer } from "coral-admin/__generated__/UserStatusChangeContainer_viewer.graphql";
import BanModal from "./BanModal";
import BanUserMutation from "./BanUserMutation";
@@ -20,14 +21,20 @@ import UserStatusChange from "./UserStatusChange";
import UserStatusContainer from "./UserStatusContainer";
interface Props {
user: UserData;
settings: UserStatusChangeContainer_settings;
user: UserStatusChangeContainer_user;
viewer: UserStatusChangeContainer_viewer;
fullWidth?: boolean;
settings: SettingsData;
bordered?: boolean;
}
const UserStatusChangeContainer: FunctionComponent<Props> = (props) => {
const { user, settings, fullWidth, bordered } = props;
const UserStatusChangeContainer: FunctionComponent<Props> = ({
user,
settings,
fullWidth,
bordered,
viewer,
}) => {
const banUser = useMutation(BanUserMutation);
const suspendUser = useMutation(SuspendUserMutation);
const removeUserBan = useMutation(RemoveUserBanMutation);
@@ -119,11 +126,13 @@ const UserStatusChangeContainer: FunctionComponent<Props> = (props) => {
return <UserStatusContainer user={user} />;
}
const scoped = !!viewer.moderationScopes?.scoped;
return (
<>
<UserStatusChange
onBan={handleBan}
onRemoveBan={handleRemoveBan}
onBan={!scoped && handleBan}
onRemoveBan={!scoped && handleRemoveBan}
onSuspend={handleSuspend}
onRemoveSuspension={handleRemoveSuspension}
onPremod={handlePremod}
@@ -150,12 +159,14 @@ const UserStatusChangeContainer: FunctionComponent<Props> = (props) => {
onClose={hidePremod}
onConfirm={handlePremodConfirm}
/>
<BanModal
username={user.username}
open={showBanned}
onClose={handleBanModalClose}
onConfirm={handleBanConfirm}
/>
{!scoped && (
<BanModal
username={user.username}
open={showBanned}
onClose={handleBanModalClose}
onConfirm={handleBanConfirm}
/>
)}
</>
);
};
@@ -187,6 +198,13 @@ const enhanced = withFragmentContainer<Props>({
}
}
`,
viewer: graphql`
fragment UserStatusChangeContainer_viewer on User {
moderationScopes {
scoped
}
}
`,
})(UserStatusChangeContainer);
export default enhanced;
@@ -11,7 +11,7 @@ import {
import InviteForm from "./InviteUsersForm";
import Success from "./Success";
import * as styles from "./InviteUsersModal.css";
import styles from "./InviteUsersModal.css";
interface Props {
onHide: () => void;
@@ -1,9 +0,0 @@
$role-field-text: var(--v2-colors-mono-500);
.legend {
font-size: var(--v2-font-size-3);
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-semi-bold);
line-height: var(--v2-line-height-body-short);
color: $role-field-text;
}
@@ -3,9 +3,7 @@ import React, { FunctionComponent } from "react";
import { Field } from "react-final-form";
import { GQLUSER_ROLE } from "coral-framework/schema";
import { FieldSet, RadioButton } from "coral-ui/components/v2";
import styles from "./RoleField.css";
import { FieldSet, Label, RadioButton } from "coral-ui/components/v2";
interface Props {
disabled: boolean;
@@ -14,7 +12,7 @@ interface Props {
const RoleField: FunctionComponent<Props> = ({ disabled }) => (
<FieldSet>
<Localized id="community-invite-inviteAsLabel">
<legend className={styles.legend}>Invite as:</legend>
<Label>Invite as:</Label>
</Localized>
<div>
<Field name="role" type="radio" value={GQLUSER_ROLE.STAFF}>
@@ -16,8 +16,11 @@ interface Props {
memberSince: string;
user: PropTypesOf<typeof UserRole>["user"] &
PropTypesOf<typeof UserStatus>["user"];
viewer: PropTypesOf<typeof UserRole>["viewer"];
settings: PropTypesOf<typeof UserStatus>["settings"];
viewer: PropTypesOf<typeof UserRole>["viewer"] &
PropTypesOf<typeof UserStatus>["viewer"];
query: PropTypesOf<typeof UserRole>["query"];
settings: PropTypesOf<typeof UserStatus>["settings"] &
PropTypesOf<typeof UserRole>["settings"];
onUsernameClicked?: (userID: string) => void;
deletedAt?: string | null;
}
@@ -32,6 +35,7 @@ const UserRow: FunctionComponent<Props> = ({
onUsernameClicked,
settings,
deletedAt,
query,
}) => {
const usernameClicked = useCallback(() => {
if (!onUsernameClicked) {
@@ -75,10 +79,15 @@ const UserRow: FunctionComponent<Props> = ({
</TableCell>
<TableCell className={styles.memberSinceColumn}>{memberSince}</TableCell>
<TableCell className={styles.roleColumn}>
<UserRole user={user} viewer={viewer} />
<UserRole
user={user}
viewer={viewer}
settings={settings}
query={query}
/>
</TableCell>
<TableCell className={styles.statusColumn}>
<UserStatus user={user} settings={settings} fullWidth />
<UserStatus user={user} settings={settings} viewer={viewer} fullWidth />
</TableCell>
</TableRow>
);
@@ -1,9 +1,10 @@
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { useDateTimeFormatter } from "coral-framework/hooks";
import { withFragmentContainer } from "coral-framework/lib/relay";
import { UserRowContainer_query as QueryData } from "coral-admin/__generated__/UserRowContainer_query.graphql";
import { UserRowContainer_settings as SettingsData } from "coral-admin/__generated__/UserRowContainer_settings.graphql";
import { UserRowContainer_user as UserData } from "coral-admin/__generated__/UserRowContainer_user.graphql";
import { UserRowContainer_viewer as ViewerData } from "coral-admin/__generated__/UserRowContainer_viewer.graphql";
@@ -14,24 +15,29 @@ interface Props {
user: UserData;
viewer: ViewerData;
settings: SettingsData;
query: QueryData;
onUsernameClicked?: (userID: string) => void;
}
const UserRowContainer: FunctionComponent<Props> = (props) => {
const { locales } = useCoralContext();
const formatter = useDateTimeFormatter({
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
return (
<UserRow
user={props.user}
settings={props.settings}
viewer={props.viewer}
query={props.query}
userID={props.user.id}
username={props.user.username}
email={props.user.email}
memberSince={new Intl.DateTimeFormat(locales, {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(new Date(props.user.createdAt))}
memberSince={formatter(props.user.createdAt)}
onUsernameClicked={props.onUsernameClicked}
deletedAt={props.user.deletedAt}
/>
@@ -41,12 +47,14 @@ const UserRowContainer: FunctionComponent<Props> = (props) => {
const enhanced = withFragmentContainer<Props>({
viewer: graphql`
fragment UserRowContainer_viewer on User {
...UserStatusChangeContainer_viewer
...UserRoleChangeContainer_viewer
}
`,
settings: graphql`
fragment UserRowContainer_settings on Settings {
...UserStatusChangeContainer_settings
...UserRoleChangeContainer_settings
}
`,
user: graphql`
@@ -60,6 +68,11 @@ const enhanced = withFragmentContainer<Props>({
deletedAt
}
`,
query: graphql`
fragment UserRowContainer_query on Query {
...UserRoleChangeContainer_query
}
`,
})(UserRowContainer);
export default enhanced;
@@ -23,6 +23,7 @@ import styles from "./UserTable.css";
interface Props {
viewer: PropTypesOf<typeof UserRowContainer>["viewer"] | null;
settings: PropTypesOf<typeof UserRowContainer>["settings"] | null;
query: PropTypesOf<typeof UserRowContainer>["query"] | null;
users: Array<{ id: string } & PropTypesOf<typeof UserRowContainer>["user"]>;
onLoadMore: () => void;
hasMore: boolean;
@@ -33,9 +34,12 @@ interface Props {
const UserTable: FunctionComponent<Props> = ({
viewer,
settings,
query,
...props
}) => {
const [userDrawerUserID, setUserDrawerUserID] = useState("");
const [userDrawerUserID, setUserDrawerUserID] = useState<string | undefined>(
undefined
);
const [userDrawerVisible, setUserDrawerVisible] = useState(false);
const onShowUserDrawer = useCallback(
@@ -48,73 +52,72 @@ const UserTable: FunctionComponent<Props> = ({
const onHideUserDrawer = useCallback(() => {
setUserDrawerVisible(false);
setUserDrawerUserID("");
setUserDrawerUserID(undefined);
}, [setUserDrawerUserID, setUserDrawerVisible]);
return (
<>
<HorizontalGutter size="double">
<Table fullWidth>
<TableHead>
<TableRow>
<Localized id="community-column-username">
<TableCell className={styles.usernameColumn}>
Username
</TableCell>
</Localized>
<Localized id="community-column-email">
<TableCell className={styles.emailColumn}>
Email Address
</TableCell>
</Localized>
<Localized id="community-column-memberSince">
<TableCell className={styles.memberSinceColumn}>
Member Since
</TableCell>
</Localized>
<Localized id="community-column-role">
<TableCell className={styles.roleColumn}>Role</TableCell>
</Localized>
<Localized id="community-column-status">
<TableCell className={styles.statusColumn}>Status</TableCell>
</Localized>
</TableRow>
</TableHead>
<TableBody>
{!props.loading &&
settings &&
viewer &&
props.users.map((u) => (
<UserRowContainer
key={u.id}
user={u}
settings={settings}
viewer={viewer}
onUsernameClicked={onShowUserDrawer}
/>
))}
</TableBody>
</Table>
{!props.loading && props.users.length === 0 && <EmptyMessage />}
{props.loading && (
<Flex justifyContent="center">
<Spinner />
</Flex>
)}
{props.hasMore && (
<Flex justifyContent="center">
<AutoLoadMore
disableLoadMore={props.disableLoadMore}
onLoadMore={props.onLoadMore}
/>
</Flex>
)}
<UserHistoryDrawer
userID={userDrawerUserID}
open={userDrawerVisible}
onClose={onHideUserDrawer}
/>
</HorizontalGutter>
</>
<HorizontalGutter size="double">
<Table fullWidth>
<TableHead>
<TableRow>
<Localized id="community-column-username">
<TableCell className={styles.usernameColumn}>Username</TableCell>
</Localized>
<Localized id="community-column-email">
<TableCell className={styles.emailColumn}>
Email Address
</TableCell>
</Localized>
<Localized id="community-column-memberSince">
<TableCell className={styles.memberSinceColumn}>
Member Since
</TableCell>
</Localized>
<Localized id="community-column-role">
<TableCell className={styles.roleColumn}>Role</TableCell>
</Localized>
<Localized id="community-column-status">
<TableCell className={styles.statusColumn}>Status</TableCell>
</Localized>
</TableRow>
</TableHead>
<TableBody>
{!props.loading &&
settings &&
viewer &&
query &&
props.users.map((user) => (
<UserRowContainer
key={user.id}
user={user}
settings={settings}
viewer={viewer}
query={query}
onUsernameClicked={onShowUserDrawer}
/>
))}
</TableBody>
</Table>
{!props.loading && props.users.length === 0 && <EmptyMessage />}
{props.loading && (
<Flex justifyContent="center">
<Spinner />
</Flex>
)}
{props.hasMore && (
<Flex justifyContent="center">
<AutoLoadMore
disableLoadMore={props.disableLoadMore}
onLoadMore={props.onLoadMore}
/>
</Flex>
)}
<UserHistoryDrawer
userID={userDrawerUserID}
open={userDrawerVisible}
onClose={onHideUserDrawer}
/>
</HorizontalGutter>
);
};
@@ -54,6 +54,7 @@ const UserTableContainer: FunctionComponent<Props> = (props) => {
<UserTable
viewer={props.query && props.query.viewer}
settings={props.query && props.query.settings}
query={props.query}
loading={!props.query || isRefetching}
users={users}
onLoadMore={loadMore}
@@ -105,6 +106,7 @@ const enhanced = withPaginationContainer<
}
}
}
...UserRowContainer_query
}
`,
},
@@ -2,7 +2,7 @@ import { Localized } from "@fluent/react/compat";
import { DateTime } from "luxon";
import React, { FunctionComponent, useMemo } from "react";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { useDateTimeFormatter } from "coral-framework/hooks";
import {
FormField,
FormFieldFooter,
@@ -22,19 +22,20 @@ const Announcement: FunctionComponent<Props> = ({
createdAt,
duration,
}) => {
const { locales } = useCoralContext();
const formatter = useDateTimeFormatter({
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const formattedDate = useMemo(() => {
const disableAt = DateTime.fromISO(createdAt)
.plus({ seconds: duration })
.toJSDate();
return new Intl.DateTimeFormat(locales, {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(disableAt));
}, [createdAt, duration]);
return formatter(disableAt);
}, [createdAt, duration, formatter]);
return (
<FormField>
<FormFieldHeader>
@@ -22,7 +22,7 @@ const SitesConfigContainer: React.FunctionComponent<Props> = (props) => {
() => (props.query ? props.query.sites.edges.map((edge) => edge.node) : []),
[props?.query?.sites.edges]
);
const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10);
const [loadMore, isLoadingMore] = useLoadMore(props.relay, 20);
const [, isRefetching] = useRefetch<
SitesConfigContainerPaginationQueryVariables
>(props.relay);
@@ -92,4 +92,5 @@ const enhanced = withPaginationContainer<
`,
}
)(SitesConfigContainer);
export default enhanced;
@@ -12,11 +12,10 @@ import {
YAxis,
} from "recharts";
import { Flex } from "coral-ui/components/v2";
import { TimeSeriesMetricsJSON } from "coral-common/rest/dashboard/types";
import { useDateTimeFormatter } from "coral-framework/hooks";
import { useImmediateFetch } from "coral-framework/lib/relay/fetch";
import { useUIContext } from "coral-ui/components";
import { Flex } from "coral-ui/components/v2";
import { DashboardBox, DashboardComponentHeading, Loader } from "../components";
import createDashboardFetch from "../createDashboardFetch";
@@ -31,7 +30,6 @@ import CommentActivityTooltip from "./CommentActivityTooltip";
import styles from "./CommentActivity.css";
interface Props {
locales?: string[];
siteID: string;
lastUpdated: string;
}
@@ -41,18 +39,16 @@ const HourlyCommentsMetricsFetch = createDashboardFetch<TimeSeriesMetricsJSON>(
"/dashboard/hourly/comments"
);
const CommentActivity: FunctionComponent<Props> = ({
locales: localesFromProps,
siteID,
lastUpdated,
}) => {
const CommentActivity: FunctionComponent<Props> = ({ siteID, lastUpdated }) => {
const [hourly, loading] = useImmediateFetch(
HourlyCommentsMetricsFetch,
{ siteID },
lastUpdated
);
const { locales: localesFromContext } = useUIContext();
const locales = localesFromProps || localesFromContext || ["en-US"];
const formatter = useDateTimeFormatter({
hour: "numeric",
hour12: true,
});
return (
<DashboardBox>
<Localized id="dashboard-comment-activity-heading">
@@ -81,16 +77,9 @@ const CommentActivity: FunctionComponent<Props> = ({
tick={{ fontSize: 12, fontWeight: 600 }}
tickLine={false}
dy={6}
tickFormatter={(unixTime: number) => {
const formatter = new Intl.DateTimeFormat(locales, {
hour: "numeric",
hour12: true,
});
return formatter
.format(new Date(unixTime))
.toLowerCase()
.replace(" ", "");
}}
tickFormatter={(unixTime: number) =>
formatter(unixTime).toLowerCase().replace(" ", "")
}
/>
<YAxis
allowDecimals={false}
@@ -110,7 +99,7 @@ const CommentActivity: FunctionComponent<Props> = ({
/>
<Tooltip
content={(tooltipProps: TooltipProps) => (
<CommentActivityTooltip {...tooltipProps} locales={locales} />
<CommentActivityTooltip {...tooltipProps} />
)}
/>
</LineChart>
@@ -2,28 +2,27 @@ import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useMemo } from "react";
import { TooltipProps } from "recharts";
import { useDateTimeFormatter } from "coral-framework/hooks";
import styles from "./CommentActivityTooltip.css";
type Props = TooltipProps & {
locales: string[];
};
type Props = TooltipProps;
const CommentActivityTooltip: FunctionComponent<Props> = ({
active,
payload,
label,
locales,
}) => {
const formatter = useDateTimeFormatter({
hour: "2-digit",
minute: "2-digit",
});
const formattedLabel = useMemo(() => {
if (label) {
const formatter = new Intl.DateTimeFormat(locales, {
hour: "2-digit",
minute: "2-digit",
});
return formatter.format(new Date(label)).toLowerCase();
return formatter(label).toLowerCase();
}
return "";
}, [label]);
}, [label, formatter]);
if (active) {
return (
<div className={styles.root}>
@@ -12,7 +12,6 @@ import {
import { TimeSeriesMetricsJSON } from "coral-common/rest/dashboard/types";
import { useImmediateFetch } from "coral-framework/lib/relay/fetch";
import { useUIContext } from "coral-ui/components";
import { DashboardBox, DashboardComponentHeading, Loader } from "../components";
import createDashboardFetch from "../createDashboardFetch";
@@ -27,7 +26,6 @@ import SignupActivityTick from "./SignupActivityTick";
import styles from "./SignupActivity.css";
interface Props {
locales?: string[];
siteID: string;
lastUpdated: string;
}
@@ -37,7 +35,6 @@ const DailySignupMetrics = createDashboardFetch<TimeSeriesMetricsJSON>(
);
const CommenterActivity: FunctionComponent<Props> = ({
locales: localesFromProps,
siteID,
lastUpdated,
}) => {
@@ -46,8 +43,7 @@ const CommenterActivity: FunctionComponent<Props> = ({
{ siteID },
lastUpdated
);
const { locales: localesFromContext } = useUIContext();
const locales = localesFromProps || localesFromContext || ["en-US"];
return (
<DashboardBox>
<Localized id="dashboard-commenters-activity-heading">
@@ -77,7 +73,6 @@ const CommenterActivity: FunctionComponent<Props> = ({
daily.series &&
daily.series.length - 1 === props.index
}
locales={locales}
{...props}
/>
)}
@@ -1,5 +1,7 @@
import React, { FunctionComponent, useMemo } from "react";
import { useDateTimeFormatter } from "coral-framework/hooks";
import {
CHART_COLOR_MONO_100,
CHART_COLOR_MONO_500,
@@ -12,7 +14,6 @@ interface TickPayload {
interface Props {
x: number;
locales: string[];
y: number;
payload: TickPayload;
isToday: boolean;
@@ -22,22 +23,22 @@ const SignupActivityTick: FunctionComponent<Props> = ({
x,
y,
payload,
locales,
isToday,
}) => {
const date = useMemo(() => {
const formatter = new Intl.DateTimeFormat(locales, {
day: "numeric",
month: "numeric",
});
return formatter.format(new Date(payload.value));
}, [payload.value]);
const dayOfWeek = useMemo(() => {
const formatter = new Intl.DateTimeFormat(locales, {
weekday: "short",
});
return formatter.format(new Date(payload.value));
}, [payload.value]);
const dateFormatter = useDateTimeFormatter({
day: "numeric",
month: "numeric",
});
const date = useMemo(() => dateFormatter(payload.value), [
payload.value,
dateFormatter,
]);
const dayOfWeekFormatter = useDateTimeFormatter({
weekday: "short",
});
const dayOfWeek = useMemo(() => dayOfWeekFormatter(payload.value), [
payload.value,
]);
return (
<g transform={`translate(${x},${y})`}>
<text
@@ -1,121 +1,140 @@
import { RouterState, withRouter } from "found";
import React, { Component } from "react";
import React, {
FunctionComponent,
useCallback,
useEffect,
useState,
} from "react";
import { graphql } from "react-relay";
import { SetRedirectPathMutation } from "coral-admin/mutations";
import {
MutationProp,
useMutation,
withFragmentContainer,
withLocalStateContainer,
withMutation,
} from "coral-framework/lib/relay";
import { AccountCompletionContainer_auth as AuthData } from "coral-admin/__generated__/AccountCompletionContainer_auth.graphql";
import { AccountCompletionContainer_viewer as UserData } from "coral-admin/__generated__/AccountCompletionContainer_viewer.graphql";
import { AccountCompletionContainerLocal as Local } from "coral-admin/__generated__/AccountCompletionContainerLocal.graphql";
import { AccountCompletionContainer_auth } from "coral-admin/__generated__/AccountCompletionContainer_auth.graphql";
import { AccountCompletionContainer_viewer } from "coral-admin/__generated__/AccountCompletionContainer_viewer.graphql";
import { AccountCompletionContainerLocal } from "coral-admin/__generated__/AccountCompletionContainerLocal.graphql";
import CompleteAccountMutation from "./CompleteAccountMutation";
import SetAuthViewMutation from "./SetAuthViewMutation";
import SetAuthViewMutation, { SetAuthViewInput } from "./SetAuthViewMutation";
type Props = {
completeAccount: MutationProp<typeof CompleteAccountMutation>;
setAuthView: MutationProp<typeof SetAuthViewMutation>;
local: Local;
auth: AuthData;
viewer: UserData | null;
setRedirectPath: MutationProp<typeof SetRedirectPathMutation>;
} & RouterState;
interface Props extends RouterState {
local: AccountCompletionContainerLocal;
auth: AccountCompletionContainer_auth;
viewer: AccountCompletionContainer_viewer | null;
}
function handleAccountCompletion(props: Props) {
const {
local: { authView, authDuplicateEmail },
viewer,
auth,
setAuthView,
} = props;
if (viewer) {
const AccountCompletionContainer: FunctionComponent<Props> = ({
local,
auth,
viewer,
children,
router,
}) => {
const completeAccount = useMutation(CompleteAccountMutation);
const [checked, setChecked] = useState(false);
const [completed, setCompleted] = useState(false);
const setAuthView = useMutation(SetAuthViewMutation);
const redirect = useCallback(
(view: SetAuthViewInput["view"]) => {
if (local.authView !== view) {
setAuthView({ view });
}
},
[local, setAuthView]
);
useEffect(() => {
// If we've already been marked as completed, stop now.
if (completed) {
return;
}
// Mark that we checked the completion status.
setChecked(true);
// If there is no viewer, exit now!
if (!viewer) {
return;
}
// Check if the user has an email address.
if (!viewer.email) {
// email not set yet.
// The email is not set on the viewer, check to see if the duplicate email
// was detected via the `ADD_EMAIL_ADDRESS` process (indicated via
// `authDuplicateEmail`) or from social login (indicated via
// `viewer.duplicateEmail`). If they're already at the `ADD_EMAIL_ADDRESS`
// view we don't need to change that.
if (
// duplicate email detected during the `ADD_EMAIL_ADDRESS` process.
authDuplicateEmail ||
// detected duplicate email usually coming from a social login.
viewer.duplicateEmail
(local.authDuplicateEmail || viewer.duplicateEmail) &&
local.authView !== "ADD_EMAIL_ADDRESS"
) {
// Duplicate email detected.
if (authView !== "ADD_EMAIL_ADDRESS" && authView !== "LINK_ACCOUNT") {
// `ADD_EMAIL_ADDRESS` view is allowed in case the viewer wants to change the email address.
// otherwise direct to the link account view.
setAuthView({ view: "LINK_ACCOUNT" });
}
} else if (authView !== "ADD_EMAIL_ADDRESS") {
setAuthView({ view: "ADD_EMAIL_ADDRESS" });
redirect("LINK_ACCOUNT");
return;
}
} else if (!viewer.username) {
// username not set yet.
if (authView !== "CREATE_USERNAME") {
// direct to create username view.
setAuthView({ view: "CREATE_USERNAME" });
}
} else if (
// password not set when local auth is enabled.
redirect("ADD_EMAIL_ADDRESS");
return;
}
// Check if the user has a username.
if (!viewer.username) {
// The username is not set on the viewer, ensure we're on the create
// username view.
redirect("CREATE_USERNAME");
return;
}
// Check if the user has a password (and if they need one).
if (
!viewer.profiles.some((p) => p.__typename === "LocalProfile") &&
auth.integrations.local.enabled &&
(auth.integrations.local.targetFilter.admin ||
auth.integrations.local.targetFilter.stream)
auth.integrations.local.targetFilter.admin
) {
if (authView !== "CREATE_PASSWORD") {
// direct to create password view.
setAuthView({ view: "CREATE_PASSWORD" });
}
} else {
// all set, complete account.
void props
.completeAccount({ accessToken: props.local.accessToken! })
.then(() => {
props.router.replace(props.local.redirectPath || "/admin");
});
// account completed.
return true;
// The password is not set on the viewer and the password is configured on
// the admin area.
redirect("CREATE_PASSWORD");
return;
}
}
// account not completed yet.
return false;
}
interface State {
checkedCompletionStatus: boolean;
completed: boolean;
}
class AccountCompletionContainer extends Component<Props, State> {
public state = {
checkedCompletionStatus: false,
completed: false,
};
public componentDidMount() {
const completed = handleAccountCompletion(this.props);
this.setState({ checkedCompletionStatus: true, completed });
}
public componentDidUpdate() {
if (!this.state.completed) {
const completed = handleAccountCompletion(this.props);
if (completed) {
this.setState({ completed });
// Create a callback finish function that will redirect the user once the
// access token has been set in the store.
async function finish() {
try {
await completeAccount({ accessToken: local.accessToken! });
router.replace(local.redirectPath || "/admin");
} catch (err) {
window.console.error(err);
}
}
// Looks like the account is complete! Finish the account setup.
void finish();
// Mark the account as completed.
setCompleted(true);
}, [
completed,
setChecked,
viewer,
auth,
redirect,
local,
router,
completeAccount,
setCompleted,
]);
// If we haven't checked or we haven't completed the account, then render
// nothing.
if (!checked || completed) {
return null;
}
public render() {
const { children } = this.props;
// We skip first frame to check for completion status.
if (!this.state.checkedCompletionStatus || this.state.completed) {
return null;
}
return <>{children}</>;
}
}
return <>{children}</>;
};
const enhanced = withLocalStateContainer(
graphql`
@@ -151,15 +170,7 @@ const enhanced = withLocalStateContainer(
}
}
`,
})(
withMutation(SetAuthViewMutation)(
withMutation(SetRedirectPathMutation)(
withMutation(CompleteAccountMutation)(
withRouter(AccountCompletionContainer)
)
)
)
)
})(withRouter(AccountCompletionContainer))
);
export default enhanced;
@@ -64,4 +64,5 @@ const enhanced = withRouteConfig<LoginRouteQueryResponse>({
`
)(LoginRoute)
);
export default enhanced;
@@ -13,6 +13,7 @@ it("renders correctly", () => {
allStories: true,
moderationQueues: {},
story: {},
viewer: null,
siteID: null,
query: "",
routeParams: {},
@@ -30,13 +30,17 @@ interface Props {
PropTypesOf<typeof ModerateSearchBarContainer>["story"];
query: PropTypesOf<typeof SiteSelectorContainer>["query"] &
PropTypesOf<typeof SectionSelectorContainer>["query"];
viewer: PropTypesOf<typeof SiteSelectorContainer>["viewer"];
moderationQueues: PropTypesOf<
typeof ModerateNavigationContainer
>["moderationQueues"];
allStories: boolean;
siteID: string | null;
section?: SectionFilter | null;
settings: PropTypesOf<typeof ModerateSearchBarContainer>["settings"] | null;
settings:
| (PropTypesOf<typeof ModerateSearchBarContainer>["settings"] &
PropTypesOf<typeof SiteSelectorContainer>["settings"])
| null;
children?: React.ReactNode;
queueName: string;
routeParams: RouteParams;
@@ -46,6 +50,7 @@ const Moderate: FunctionComponent<Props> = ({
moderationQueues,
story,
query,
viewer,
allStories,
children,
queueName,
@@ -71,6 +76,7 @@ const Moderate: FunctionComponent<Props> = ({
key.unbind(HOTKEYS.GUIDE);
};
}, []);
return (
<div data-testid="moderate-container">
<ModerateSearchBarContainer
@@ -82,6 +88,8 @@ const Moderate: FunctionComponent<Props> = ({
<SiteSelectorContainer
queueName={queueName}
query={query}
settings={settings}
viewer={viewer}
siteID={routeParams.siteID || siteID || null}
/>
}
@@ -1,9 +1,11 @@
import { Match, RouteProps, Router, withRouter } from "found";
import React from "react";
import { Match, Router, withRouter } from "found";
import React, { FunctionComponent, useEffect, useMemo } from "react";
import { graphql } from "react-relay";
import { getModerationLink, QUEUE_NAME } from "coral-framework/helpers";
import parseModerationOptions from "coral-framework/helpers/parseModerationOptions";
import { withRouteConfig } from "coral-framework/lib/router";
import { GQLFEATURE_FLAG } from "coral-framework/schema";
import { Spinner } from "coral-ui/components/v2";
import { ModerateContainerQueryResponse } from "coral-admin/__generated__/ModerateContainerQuery.graphql";
@@ -16,64 +18,134 @@ interface RouteParams {
}
interface Props {
data: ModerateContainerQueryResponse;
data: ModerateContainerQueryResponse | null;
router: Router;
match: Match & { params: RouteParams };
}
class ModerateContainer extends React.Component<Props> {
public static routeConfig: RouteProps;
const queueNames: QUEUE_NAME[] = [
"reported",
"pending",
"unmoderated",
"approved",
"rejected",
];
public render() {
const allStories = !this.props.match.params.storyID;
// TODO: (tessalt) get active route in a better way
const queueName = [
const ModerateContainer: FunctionComponent<Props> = ({
data,
match,
router,
children,
}) => {
const allStories = !match.params.storyID;
const queueName = useMemo(
() =>
// TODO: (tessalt) get active route in a better way
queueNames.find((name) => match.location.pathname.includes(name)) ||
"default",
"reported",
"pending",
"unmoderated",
"approved",
"rejected",
].find((name) => {
return this.props.match.location.pathname.includes(name);
});
const { section } = parseModerationOptions(this.props.match);
[match.location.pathname]
);
if (!this.props.data) {
return (
<Moderate
moderationQueues={null}
story={null}
settings={null}
siteID={null}
section={section}
query={this.props.data}
routeParams={this.props.match.params}
queueName={queueName || "default"}
allStories={allStories}
>
<Spinner />
</Moderate>
);
// This guard is used to ensure that the current viewer has permission to
// moderate on this site. It's injected here because we get the result from
// relay here for the site that is referenced if this is supposed to moderate
// a story.
useEffect(() => {
// Wait for the data and viewer to become available.
if (!data || !data.viewer) {
return;
}
// If the feature flag isn't enabled, we don't need to do anything!
if (!data.settings.featureFlags.includes(GQLFEATURE_FLAG.SITE_MODERATOR)) {
return;
}
// If the viewer isn't moderation scoped, nothing we need to do!
if (
!data.viewer.moderationScopes?.scoped ||
!data.viewer.moderationScopes.sites
) {
return;
}
// Grab a reference for the following function to make the type checker
// happy.
const sites = data.viewer.moderationScopes.sites;
const redirect = () =>
router.push(
getModerationLink({
queue: queueName as QUEUE_NAME,
// We'll grab the first site in the moderation scopes (a user can only
// be scoped if there is at least one site).
siteID: sites[0].id,
})
);
// If we've loaded a specific story, we'll have the site on that story too,
// so check if we're allowed to moderate it.
if (data.story && !data.story.site.canModerate) {
redirect();
return;
}
// Get some options from the router.
const { siteID } = parseModerationOptions(match);
// If the viewer is moderation scoped, they cannot moderate under all sites.
if (!siteID) {
redirect();
return;
}
// Check to see if the user is allowed to moderate on this site given that
// they are already scoped. If the current site ID is not found, redirect
// them!
if (!sites.some(({ id }) => id === siteID)) {
redirect();
return;
}
}, [router, match, data]);
// Get some options for the moderate cards.
const { section } = parseModerationOptions(match);
if (!data) {
return (
<Moderate
moderationQueues={this.props.data.moderationQueues}
story={this.props.data.story || null}
siteID={this.props.data.story ? this.props.data.story.site.id : null}
moderationQueues={null}
story={null}
settings={null}
siteID={null}
section={section}
routeParams={this.props.match.params}
query={this.props.data}
query={data}
viewer={null}
routeParams={match.params}
queueName={queueName}
allStories={allStories}
settings={this.props.data.settings}
queueName={queueName || "default"}
>
{this.props.children}
<Spinner />
</Moderate>
);
}
}
return (
<Moderate
moderationQueues={data.moderationQueues}
story={data.story || null}
siteID={data.story?.site.id || null}
section={section}
routeParams={match.params}
query={data}
viewer={data.viewer}
allStories={allStories}
settings={data.settings}
queueName={queueName}
>
{children}
</Moderate>
);
};
const enhanced = withRouteConfig<Props>({
query: graphql`
@@ -83,21 +155,42 @@ const enhanced = withRouteConfig<Props>({
$siteID: ID
$section: SectionFilter
) {
...SiteSelectorContainer_query
...SectionSelectorContainer_query
settings {
...ModerateSearchBarContainer_settings
...SiteSelectorContainer_settings
featureFlags
}
story(id: $storyID) @include(if: $includeStory) {
...ModerateNavigationContainer_story
...ModerateSearchBarContainer_story
site {
id
canModerate
}
}
moderationQueues(storyID: $storyID, siteID: $siteID, section: $section) {
...ModerateNavigationContainer_moderationQueues
}
...SiteSelectorContainer_query
...SectionSelectorContainer_query
viewer {
...SiteSelectorContainer_viewer
id
role
moderationScopes {
scoped
sites {
id
}
}
}
}
`,
cacheConfig: { force: true },
@@ -108,8 +201,7 @@ const enhanced = withRouteConfig<Props>({
storyID,
siteID,
section,
includeStory: Boolean(storyID),
includeSite: Boolean(siteID),
includeStory: !!storyID,
};
},
})(withRouter(ModerateContainer));
@@ -104,12 +104,13 @@ function getStoryDetails(
}
function getContextOptionsWhenModeratingAll(
onClickOrEnter: ListBoxOptionClickOrEnterHandler
onClickOrEnter: ListBoxOptionClickOrEnterHandler,
siteID: string | null
): SearchBarOptions {
return [
{
element: (
<Option href="/admin/moderate">
<Option href={getModerationLink({ siteID })}>
<GoToAriaInfo />
<Localized id="moderate-searchBar-allStories">
<span>All stories</span>
@@ -125,7 +126,8 @@ function getContextOptionsWhenModeratingAll(
function getContextOptionsWhenModeratingStory(
onClickOrEnter: ListBoxOptionClickOrEnterHandler,
settings: SettingsData | null,
story: ModerationQueuesData | null
story: ModerationQueuesData | null,
siteID: string | null
): SearchBarOptions {
if (story === null || settings === null) {
return [];
@@ -144,7 +146,13 @@ function getContextOptionsWhenModeratingStory(
group: "CONTEXT",
},
{
element: <ModerateAllOption href="/admin/moderate" />,
element: (
<ModerateAllOption
href={getModerationLink({
siteID: siteID || (story && story.site.id),
})}
/>
),
onClickOrEnter,
group: "CONTEXT",
},
@@ -269,11 +277,12 @@ function useSearchOptions(
const ModerateSearchBarContainer: React.FunctionComponent<Props> = (props) => {
const linkNavHandler = useLinkNavHandler(props.router);
const contextOptions: PropTypesOf<typeof Bar>["options"] = props.allStories
? getContextOptionsWhenModeratingAll(linkNavHandler)
? getContextOptionsWhenModeratingAll(linkNavHandler, props.siteID)
: getContextOptionsWhenModeratingStory(
linkNavHandler,
props.settings,
props.story
props.story,
props.siteID
);
const [searchOptions, onSearch] = useSearchOptions(
@@ -36,11 +36,16 @@ export class ApprovedQueueRoute extends React.Component<
};
public render() {
if (!this.props.query.viewer) {
return null;
}
const comments = this.props.query.comments.edges.map((edge) => edge.node);
return (
<IntersectionProvider>
<Queue
settings={this.props.query.settings}
viewer={this.props.query.viewer}
comments={comments}
onLoadMore={this.loadMore}
hasLoadMore={this.props.relay.hasMore()}
@@ -111,6 +116,9 @@ const enhanced = (withPaginationContainer<
settings {
...ModerateCardContainer_settings
}
viewer {
...ModerateCardContainer_viewer
}
}
`,
},
@@ -13,6 +13,7 @@ it("renders correctly with load more", () => {
const props: PropTypesOf<typeof QueueN> = {
comments: [],
settings: {},
viewer: {},
onLoadMore: noop,
hasLoadMore: true,
disableLoadMore: false,
@@ -28,6 +29,7 @@ it("renders correctly without load more", () => {
const props: PropTypesOf<typeof QueueN> = {
comments: [],
settings: {},
viewer: {},
onLoadMore: noop,
hasLoadMore: false,
disableLoadMore: false,
@@ -19,6 +19,7 @@ interface Props {
{ id: string } & PropTypesOf<typeof ModerateCardContainer>["comment"]
>;
settings: PropTypesOf<typeof ModerateCardContainer>["settings"];
viewer: PropTypesOf<typeof ModerateCardContainer>["viewer"];
onLoadMore: () => void;
onViewNew?: () => void;
hasLoadMore: boolean;
@@ -32,6 +33,7 @@ interface Props {
const Queue: FunctionComponent<Props> = ({
settings,
comments,
viewer,
hasLoadMore: hasMore,
disableLoadMore,
onLoadMore,
@@ -134,6 +136,7 @@ const Queue: FunctionComponent<Props> = ({
<ModerateCardContainer
key={comment.id}
settings={settings}
viewer={viewer}
comment={comment}
danglingLogic={danglingLogic}
showStoryInfo={Boolean(allStories)}
@@ -1,10 +1,5 @@
import { Localized } from "@fluent/react/compat";
import React, {
FunctionComponent,
useCallback,
useEffect,
useMemo,
} from "react";
import React, { FunctionComponent, useCallback, useEffect } from "react";
import { graphql, GraphQLTaggedNode, RelayPaginationProp } from "react-relay";
import { SectionFilter } from "coral-common/section";
@@ -22,6 +17,7 @@ import { GQLMODERATION_QUEUE } from "coral-framework/schema";
import { QueueRoute_queue } from "coral-admin/__generated__/QueueRoute_queue.graphql";
import { QueueRoute_settings } from "coral-admin/__generated__/QueueRoute_settings.graphql";
import { QueueRoute_viewer } from "coral-admin/__generated__/QueueRoute_viewer.graphql";
import { QueueRoutePaginationPendingQueryVariables } from "coral-admin/__generated__/QueueRoutePaginationPendingQuery.graphql";
import EmptyMessage from "./EmptyMessage";
@@ -36,20 +32,31 @@ interface Props {
queueName: GQLMODERATION_QUEUE;
queue: QueueRoute_queue | null;
settings: QueueRoute_settings | null;
viewer: QueueRoute_viewer | null;
relay: RelayPaginationProp;
emptyElement: React.ReactElement;
storyID?: string | null;
siteID?: string | null;
section?: SectionFilter | null;
count?: string;
}
// TODO: use generated types
const danglingLogic = (status: string) =>
["APPROVED", "REJECTED"].includes(status);
export const QueueRoute: FunctionComponent<Props> = (props) => {
const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10);
export const QueueRoute: FunctionComponent<Props> = ({
isLoading,
queueName,
queue,
settings,
viewer,
relay,
emptyElement,
storyID = null,
siteID = null,
section,
}) => {
const [loadMore, isLoadingMore] = useLoadMore(relay, 10);
const subscribeToQueueCommentEntered = useSubscription(
QueueCommentEnteredSubscription
);
@@ -59,56 +66,62 @@ export const QueueRoute: FunctionComponent<Props> = (props) => {
const viewNew = useMutation(QueueViewNewMutation);
const onViewNew = useCallback(() => {
void viewNew({
queue: props.queueName,
storyID: props.storyID || null,
siteID: props.siteID || null,
section: props.section,
queue: queueName,
storyID,
siteID,
section,
});
}, [props.queueName, props.storyID, props.siteID, viewNew]);
}, [queueName, storyID, siteID, viewNew]);
// Handle subscribing and unsubscribing to the subscriptions.
useEffect(() => {
const vars = {
queue: props.queueName,
storyID: props.storyID || null,
siteID: props.siteID || null,
section: props.section,
queue: queueName,
storyID,
siteID,
section,
};
const disposable = combineDisposables(
subscribeToQueueCommentEntered(vars),
subscribeToQueueCommentLeft(vars)
);
return () => {
disposable.dispose();
};
}, [
props.storyID,
props.siteID,
props.section,
props.queueName,
storyID,
siteID,
section,
queueName,
subscribeToQueueCommentEntered,
subscribeToQueueCommentLeft,
]);
const comments = useMemo(
() => props.queue?.comments.edges.map((edge) => edge.node),
[props.queue?.comments.edges]
);
if (props.isLoading) {
// It's never the case really that the query has loaded but queue or settings
// is null, but this was to appease the type system.
if (isLoading || !queue || !settings || !viewer) {
return <LoadingQueue />;
}
const comments = queue.comments.edges.map((edge) => edge.node);
const viewNewCount =
(props.queue!.comments.viewNewEdges &&
props.queue!.comments.viewNewEdges.length) ||
0;
(queue.comments.viewNewEdges && queue.comments.viewNewEdges.length) || 0;
return (
<IntersectionProvider>
<Queue
comments={comments!}
settings={props.settings!}
comments={comments}
settings={settings}
viewer={viewer}
onLoadMore={loadMore}
hasLoadMore={props.relay.hasMore()}
hasLoadMore={relay.hasMore()}
disableLoadMore={isLoadingMore}
danglingLogic={danglingLogic}
emptyElement={props.emptyElement}
allStories={!props.storyID}
emptyElement={emptyElement}
allStories={!storyID}
viewNewCount={viewNewCount}
onViewNew={onViewNew}
/>
@@ -135,13 +148,14 @@ const createQueueRoute = (
const { storyID, siteID, section } = parseModerationOptions(match);
if (!data || !data.moderationQueues) {
if (!data) {
return (
<Component
isLoading
queueName={queueName}
queue={null}
settings={null}
viewer={null}
emptyElement={emptyElement}
storyID={storyID}
siteID={siteID}
@@ -149,6 +163,7 @@ const createQueueRoute = (
/>
);
}
const queue =
data.moderationQueues[Object.keys(data.moderationQueues)[0]];
@@ -158,6 +173,7 @@ const createQueueRoute = (
queueName={queueName}
queue={queue}
settings={data.settings}
viewer={data.viewer}
emptyElement={emptyElement}
storyID={storyID}
siteID={siteID}
@@ -198,6 +214,11 @@ const createQueueRoute = (
...ModerateCardContainer_settings
}
`,
viewer: graphql`
fragment QueueRoute_viewer on User {
...ModerateCardContainer_viewer
}
`,
},
{
direction: "forward",
@@ -243,6 +264,9 @@ export const PendingQueueRoute = createQueueRoute(
settings {
...QueueRoute_settings
}
viewer {
...QueueRoute_viewer
}
}
`,
graphql`
@@ -286,6 +310,9 @@ export const ReportedQueueRoute = createQueueRoute(
settings {
...QueueRoute_settings
}
viewer {
...QueueRoute_viewer
}
}
`,
graphql`
@@ -329,6 +356,9 @@ export const UnmoderatedQueueRoute = createQueueRoute(
settings {
...QueueRoute_settings
}
viewer {
...QueueRoute_viewer
}
}
`,
graphql`
@@ -36,11 +36,16 @@ export class RejectedQueueRoute extends React.Component<
};
public render() {
if (!this.props.query.viewer) {
return null;
}
const comments = this.props.query.comments.edges.map((edge) => edge.node);
return (
<IntersectionProvider>
<Queue
settings={this.props.query.settings}
viewer={this.props.query.viewer}
comments={comments}
onLoadMore={this.loadMore}
hasLoadMore={this.props.relay.hasMore()}
@@ -111,6 +116,9 @@ const enhanced = (withPaginationContainer<
settings {
...ModerateCardContainer_settings
}
viewer {
...ModerateCardContainer_viewer
}
}
`,
},
@@ -30,14 +30,20 @@ const SingleModerateRoute: FunctionComponent<Props> = (props) => {
};
}, [props.comment, subscribeToSingleModerate]);
if (!props.viewer) {
return null;
}
if (!props.comment) {
return <NotFound />;
}
return (
<SingleModerate>
<Queue
comments={[props.comment]}
settings={props.settings}
viewer={props.viewer}
onLoadMore={noop}
hasLoadMore={false}
disableLoadMore={false}
@@ -57,6 +63,9 @@ const enhanced = withRouteConfig<Props, SingleModerateRouteQueryResponse>({
settings {
...ModerateCardContainer_settings
}
viewer {
...ModerateCardContainer_viewer
}
}
`,
cacheConfig: { force: true },
@@ -11,7 +11,10 @@ import SiteSelectorSite from "./SiteSelectorSite";
import styles from "./SiteSelector.css";
interface Props {
sites: Array<{ id: string } & PropTypesOf<typeof SiteSelectorSite>["site"]>;
scoped: boolean;
sites: ReadonlyArray<
{ id: string } & PropTypesOf<typeof SiteSelectorSite>["site"]
>;
queueName: string;
onLoadMore: () => void;
hasMore: boolean;
@@ -22,6 +25,7 @@ interface Props {
const SiteSelector: FunctionComponent<Props> = ({
sites,
scoped,
queueName,
loading,
onLoadMore,
@@ -40,8 +44,7 @@ const SiteSelector: FunctionComponent<Props> = ({
selected={
<>
{siteID && <SiteSelectorCurrentSiteQuery siteID={siteID} />}
{!siteID && (
{!scoped && !siteID && (
<Localized id="site-selector-all-sites">
<span className={styles.buttonText}>All sites</span>
</Localized>
@@ -50,11 +53,13 @@ const SiteSelector: FunctionComponent<Props> = ({
}
>
<>
<SiteSelectorSite
link={getModerationLink({ queue: queueName as QUEUE_NAME })}
site={null}
active={!siteID}
/>
{!scoped && (
<SiteSelectorSite
link={getModerationLink({ queue: queueName as QUEUE_NAME })}
site={null}
active={!siteID}
/>
)}
{sites.map((s) => (
<SiteSelectorSite
link={getModerationLink({
@@ -1,4 +1,4 @@
import React from "react";
import React, { useMemo } from "react";
import { graphql, RelayPaginationProp } from "react-relay";
import {
@@ -6,30 +6,57 @@ import {
useRefetch,
withPaginationContainer,
} from "coral-framework/lib/relay";
import { GQLFEATURE_FLAG } from "coral-framework/schema";
import { SiteSelectorContainer_query as QueryData } from "coral-admin/__generated__/SiteSelectorContainer_query.graphql";
import { SiteSelectorContainer_query } from "coral-admin/__generated__/SiteSelectorContainer_query.graphql";
import { SiteSelectorContainer_settings } from "coral-admin/__generated__/SiteSelectorContainer_settings.graphql";
import { SiteSelectorContainer_viewer } from "coral-admin/__generated__/SiteSelectorContainer_viewer.graphql";
import { SiteSelectorContainerPaginationQueryVariables } from "coral-admin/__generated__/SiteSelectorContainerPaginationQuery.graphql";
import SiteSelector from "./SiteSelector";
interface Props {
query: QueryData | null;
query: SiteSelectorContainer_query | null;
viewer: SiteSelectorContainer_viewer | null;
settings: SiteSelectorContainer_settings | null;
relay: RelayPaginationProp;
queueName: string;
siteID: string | null;
}
const SiteSelectorContainer: React.FunctionComponent<Props> = (props) => {
const sites = props.query
? props.query.sites.edges.map((edge) => edge.node)
: [];
const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10);
const [, isRefetching] = useRefetch<
SiteSelectorContainerPaginationQueryVariables
>(props.relay);
const { sites, scoped } = useMemo(() => {
// If the viewer is moderation scoped, then only provide those sites.
if (
props.settings &&
props.settings.featureFlags.includes(GQLFEATURE_FLAG.SITE_MODERATOR) &&
props.viewer &&
props.viewer.moderationScopes?.scoped &&
props.viewer.moderationScopes.sites
) {
return { scoped: true, sites: props.viewer.moderationScopes.sites };
}
// As the moderation is not scoped, return the sites from the query instead.
if (props.query) {
return {
scoped: false,
sites: props.query.sites.edges.map((edge) => edge.node),
};
}
return { scoped: false, sites: [] };
}, [props.query, props.viewer, props.settings]);
return (
<SiteSelector
loading={!props.query || isRefetching}
scoped={scoped}
sites={sites}
onLoadMore={loadMore}
hasMore={!isRefetching && props.relay.hasMore()}
@@ -65,6 +92,22 @@ const enhanced = withPaginationContainer<
}
}
`,
viewer: graphql`
fragment SiteSelectorContainer_viewer on User {
moderationScopes {
scoped
sites {
id
...SiteSelectorSite_site
}
}
}
`,
settings: graphql`
fragment SiteSelectorContainer_settings on Settings {
featureFlags
}
`,
},
{
direction: "forward",
@@ -97,4 +140,5 @@ const enhanced = withPaginationContainer<
`,
}
)(SiteSelectorContainer);
export default enhanced;
@@ -22,7 +22,13 @@ exports[`renders correctly 1`] = `
<Relay(SiteSelectorContainer)
query=""
queueName=""
settings={
Object {
"multisite": false,
}
}
siteID={null}
viewer={null}
/>
}
story={Object {}}
@@ -21,6 +21,7 @@ interface Props {
storyID: string;
title: string | null;
author: string | null;
readOnly: boolean;
publishDate: string | null;
story: PropTypesOf<typeof StoryActionsContainer>["story"] &
PropTypesOf<typeof StoryStatusContainer>["story"];
@@ -38,12 +39,16 @@ const UserRow: FunctionComponent<Props> = (props) => (
<TableCell className={styles.titleColumn}>
<HorizontalGutter>
<p>
<Link
to={getModerationLink({ storyID: props.storyID })}
as={TextLink}
>
{props.title || <NotAvailable />}
</Link>
{!props.readOnly ? (
<Link
to={getModerationLink({ storyID: props.storyID })}
as={TextLink}
>
{props.title || <NotAvailable />}
</Link>
) : (
props.title || <NotAvailable />
)}
</p>
{(props.author || props.publishDate) && (
<p className={styles.meta}>
@@ -78,7 +83,9 @@ const UserRow: FunctionComponent<Props> = (props) => (
<StoryStatusContainer story={props.story} />
</TableCell>
<TableCell className={styles.actionsColumn}>
<StoryActionsContainer story={props.story} viewer={props.viewer} />
{!props.readOnly && (
<StoryActionsContainer story={props.story} viewer={props.viewer} />
)}
</TableCell>
</TableRow>
);
@@ -1,7 +1,7 @@
import React, { FunctionComponent } from "react";
import React, { FunctionComponent, useMemo } from "react";
import { graphql } from "react-relay";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { useDateTimeFormatter } from "coral-framework/hooks";
import { withFragmentContainer } from "coral-framework/lib/relay";
import { StoryRowContainer_story as StoryData } from "coral-admin/__generated__/StoryRowContainer_story.graphql";
@@ -16,12 +16,31 @@ interface Props {
}
const StoryRowContainer: FunctionComponent<Props> = (props) => {
const { locales } = useCoralContext();
const formatter = useDateTimeFormatter({
day: "2-digit",
month: "2-digit",
year: "numeric",
hour12: true,
hour: "numeric",
minute: "2-digit",
});
const title = props.story.metadata && props.story.metadata.title;
const author = props.story.metadata && props.story.metadata.author;
const publishedAt = props.story.metadata && props.story.metadata.publishedAt;
const publishedAt = useMemo(() => {
if (!props.story.metadata || !props.story.metadata.publishedAt) {
return null;
}
return formatter(props.story.metadata.publishedAt);
}, [props.story, formatter]);
// A story is readOnly if the viewer can't moderate it.
const readOnly = !props.story.canModerate;
return (
<StoryRow
readOnly={readOnly}
storyID={props.story.id}
title={title}
author={author}
@@ -33,18 +52,7 @@ const StoryRowContainer: FunctionComponent<Props> = (props) => {
totalCount={props.story.commentCounts.totalPublished}
reportedCount={props.story.moderationQueues.reported.count}
pendingCount={props.story.moderationQueues.pending.count}
publishDate={
publishedAt
? new Intl.DateTimeFormat(locales, {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour12: true,
hour: "numeric",
minute: "2-digit",
}).format(new Date(publishedAt))
: null
}
publishDate={publishedAt}
/>
);
};
@@ -79,6 +87,7 @@ const enhanced = withFragmentContainer<Props>({
name
id
}
canModerate
isClosed
...StoryActionsContainer_story
...StoryStatusContainer_story
@@ -291,7 +291,7 @@ exports[`renders community 1`] = `
<td
className="TableCell-root UserRow-memberSinceColumn TableCell-body"
>
07/06/2018
07/06/2018, 6:24 PM
</td>
<td
className="TableCell-root UserRow-roleColumn TableCell-body"
@@ -356,7 +356,7 @@ exports[`renders community 1`] = `
<td
className="TableCell-root UserRow-memberSinceColumn TableCell-body"
>
07/06/2018
07/06/2018, 6:24 PM
</td>
<td
className="TableCell-root UserRow-roleColumn TableCell-body"
@@ -454,7 +454,7 @@ exports[`renders community 1`] = `
<td
className="TableCell-root UserRow-memberSinceColumn TableCell-body"
>
07/06/2018
07/06/2018, 6:24 PM
</td>
<td
className="TableCell-root UserRow-roleColumn TableCell-body"
@@ -552,7 +552,7 @@ exports[`renders community 1`] = `
<td
className="TableCell-root UserRow-memberSinceColumn TableCell-body"
>
07/06/2018
07/06/2018, 6:24 PM
</td>
<td
className="TableCell-root UserRow-roleColumn TableCell-body"
@@ -574,7 +574,7 @@ exports[`renders community 1`] = `
type="button"
>
<span
className="UserRoleText-root UserRoleText-commenter"
className="UserRoleText-root"
>
Commenter
</span>
@@ -29,6 +29,7 @@ import {
disabledLocalRegistration,
emptyCommunityUsers,
settings,
siteConnection,
users,
} from "../fixtures";
@@ -52,6 +53,7 @@ const createTestRenderer = async (
return communityUsers;
},
viewer: () => viewer,
sites: () => siteConnection,
},
}),
params.resolvers
@@ -249,7 +251,6 @@ it("change user role", async () => {
within(popup).getByText("Staff", { selector: "button" }).props.onClick();
});
within(userRow).getByText("Staff");
expect(resolvers.Mutation!.updateUserRole!.called).toBe(true);
});
+7
View File
@@ -275,6 +275,7 @@ export const site = createFixture<GQLSite>({
id: "site-id",
createdAt: "2018-05-06T18:24:00.000Z",
allowedOrigins: ["http://test-site.com"],
canModerate: true,
});
export const sites = createFixtures<GQLSite>([
@@ -283,12 +284,14 @@ export const sites = createFixtures<GQLSite>([
id: "site-1",
createdAt: "2018-07-06T18:24:00.000Z",
allowedOrigins: ["http://test-site.com"],
canModerate: true,
},
{
name: "Second Site",
id: "site-2",
createdAt: "2018-09-06T18:24:00.000Z",
allowedOrigins: ["http://test-2-site.com"],
canModerate: true,
},
]);
@@ -533,6 +536,7 @@ export const stories = createFixtures<GQLStory>([
},
},
site: sites[0],
canModerate: true,
settings: {
mode: GQLSTORY_MODE.COMMENTS,
},
@@ -563,6 +567,7 @@ export const stories = createFixtures<GQLStory>([
},
},
site: sites[1],
canModerate: true,
settings: {
mode: GQLSTORY_MODE.COMMENTS,
},
@@ -593,6 +598,7 @@ export const stories = createFixtures<GQLStory>([
publishedAt: "2018-11-29T16:01:51.897Z",
},
site: sites[1],
canModerate: true,
settings: {
mode: GQLSTORY_MODE.COMMENTS,
},
@@ -632,6 +638,7 @@ export const baseComment = createFixture<GQLComment>({
createdAt: "2018-07-06T18:24:00.000Z",
},
],
canModerate: true,
revision: {
actionCounts: {
flag: {
@@ -270,6 +270,8 @@ describe("specified story", () => {
moderateAllOptions.props.onClick({ button: 0, preventDefault: noop });
// Expect a routing request was made to the right url.
expect(transitionControl.history[0].pathname).toBe("/admin/moderate");
expect(transitionControl.history[0].pathname).toBe(
"/admin/moderate/sites/site-1"
);
});
});
+3
View File
@@ -1,6 +1,9 @@
export { default as useDateTimeFormat } from "./useDateTimeFormat";
export { default as useDateTimeFormatter } from "./useDateTimeFormatter";
export { default as useEffectAfterMount } from "./useEffectAfterMount";
export { default as usePrevious } from "./usePrevious";
export { default as useEffectWhenChanged } from "./useEffectWhenChanged";
export { default as useUUID } from "./useUUID";
export { default as useToken } from "./useToken";
export { default as useResizeObserver } from "./useResizeObserver";
export { default as useToggleState } from "./useToggleState";
@@ -0,0 +1,11 @@
import { useMemo } from "react";
import { useCoralContext } from "coral-framework/lib/bootstrap";
export default function useDateTimeFormat(options: Intl.DateTimeFormatOptions) {
const { locales } = useCoralContext();
return useMemo(() => new Intl.DateTimeFormat(locales, options), [
locales,
options,
]);
}
@@ -0,0 +1,21 @@
import { useCallback } from "react";
import useDateTimeFormat from "./useDateTimeFormat";
export default function useDateTimeFormatter(
options: Intl.DateTimeFormatOptions
) {
const formatter = useDateTimeFormat(options);
const format = useCallback(
(date: string | number | Date) => {
if (typeof date === "string" || typeof date === "number") {
return formatter.format(new Date(date));
}
return formatter.format(date);
},
[formatter]
);
return format;
}
@@ -0,0 +1,12 @@
import { Dispatch, SetStateAction, useCallback, useState } from "react";
function useToggleState(
initialState = false
): [boolean, Dispatch<SetStateAction<boolean>>, () => void] {
const [state, setState] = useState<boolean>(initialState);
const toggleState = useCallback(() => setState((s) => !s), [setState]);
return [state, setState, toggleState];
}
export default useToggleState;
@@ -35,6 +35,7 @@ export class TabBarContainer extends Component<Props> {
const {
local: { activeTab },
viewer,
story,
} = this.props;
return (
@@ -47,7 +48,10 @@ export class TabBarContainer extends Component<Props> {
activeTab={activeTab}
showProfileTab={Boolean(viewer)}
showConfigureTab={
!!viewer && can(viewer, Ability.CHANGE_STORY_CONFIGURATION)
!!viewer &&
!!story &&
story.canModerate &&
can(viewer, Ability.CHANGE_STORY_CONFIGURATION)
}
onTabClick={this.handleSetActiveTab}
/>
@@ -71,6 +75,7 @@ const enhanced = withSetActiveTabMutation(
`,
story: graphql`
fragment TabBarContainer_story on Story {
canModerate
settings {
mode
}
@@ -26,7 +26,7 @@ interface Props {
const ModerateStreamContainer: FunctionComponent<Props> = ({
local: { accessToken },
settings,
story: { id },
story: { id, canModerate },
viewer,
}) => {
const href = useMemo(() => {
@@ -42,7 +42,7 @@ const ModerateStreamContainer: FunctionComponent<Props> = ({
return link;
}, [accessToken, settings, id]);
if (!viewer || !can(viewer, Ability.MODERATE)) {
if (!canModerate || !viewer || !can(viewer, Ability.MODERATE)) {
return null;
}
@@ -80,6 +80,7 @@ const enhanced = withFragmentContainer<Props>({
story: graphql`
fragment ModerateStreamContainer_story on Story {
id
canModerate
}
`,
viewer: graphql`
@@ -22,6 +22,7 @@ function createDefaultProps(add: DeepPartial<Props> = {}): Props {
story: {
url: "http://localhost/story",
isClosed: false,
canModerate: false,
settings: {
mode: "COMMENTS",
},
@@ -271,6 +271,7 @@ export class CommentContainer extends Component<Props, State> {
);
const showCaret =
this.props.viewer &&
this.props.story.canModerate &&
can(this.props.viewer, Ability.MODERATE) &&
!this.props.hideModerationCarat;
@@ -537,6 +538,7 @@ const enhanced = withContext(({ eventEmitter }) => ({ eventEmitter }))(
fragment CommentContainer_story on Story {
url
isClosed
canModerate
settings {
mode
}
@@ -69,6 +69,7 @@ exports[`hide reply button 1`] = `
commentID="comment-id"
story={
Object {
"canModerate": false,
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
@@ -201,6 +202,7 @@ exports[`hide reply button 1`] = `
}
story={
Object {
"canModerate": false,
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
@@ -333,6 +335,7 @@ exports[`hide reply button 1`] = `
}
story={
Object {
"canModerate": false,
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
@@ -458,6 +461,7 @@ exports[`renders body only 1`] = `
commentID="comment-id"
story={
Object {
"canModerate": false,
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
@@ -590,6 +594,7 @@ exports[`renders body only 1`] = `
}
story={
Object {
"canModerate": false,
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
@@ -722,6 +727,7 @@ exports[`renders body only 1`] = `
}
story={
Object {
"canModerate": false,
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
@@ -847,6 +853,7 @@ exports[`renders disabled reply when commenting has been disabled 1`] = `
commentID="comment-id"
story={
Object {
"canModerate": false,
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
@@ -979,6 +986,7 @@ exports[`renders disabled reply when commenting has been disabled 1`] = `
}
story={
Object {
"canModerate": false,
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
@@ -1111,6 +1119,7 @@ exports[`renders disabled reply when commenting has been disabled 1`] = `
}
story={
Object {
"canModerate": false,
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
@@ -1236,6 +1245,7 @@ exports[`renders disabled reply when story is closed 1`] = `
commentID="comment-id"
story={
Object {
"canModerate": false,
"isClosed": true,
"settings": Object {
"mode": "COMMENTS",
@@ -1368,6 +1378,7 @@ exports[`renders disabled reply when story is closed 1`] = `
}
story={
Object {
"canModerate": false,
"isClosed": true,
"settings": Object {
"mode": "COMMENTS",
@@ -1500,6 +1511,7 @@ exports[`renders disabled reply when story is closed 1`] = `
}
story={
Object {
"canModerate": false,
"isClosed": true,
"settings": Object {
"mode": "COMMENTS",
@@ -1629,6 +1641,7 @@ exports[`renders in reply to 1`] = `
commentID="comment-id"
story={
Object {
"canModerate": false,
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
@@ -1773,6 +1786,7 @@ exports[`renders in reply to 1`] = `
}
story={
Object {
"canModerate": false,
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
@@ -1917,6 +1931,7 @@ exports[`renders in reply to 1`] = `
}
story={
Object {
"canModerate": false,
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
@@ -2046,6 +2061,7 @@ exports[`renders username and body 1`] = `
commentID="comment-id"
story={
Object {
"canModerate": false,
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
@@ -2178,6 +2194,7 @@ exports[`renders username and body 1`] = `
}
story={
Object {
"canModerate": false,
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
@@ -2310,6 +2327,7 @@ exports[`renders username and body 1`] = `
}
story={
Object {
"canModerate": false,
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
@@ -2444,6 +2462,7 @@ exports[`shows conversation link 1`] = `
commentID="comment-id"
story={
Object {
"canModerate": false,
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
@@ -2582,6 +2601,7 @@ exports[`shows conversation link 1`] = `
}
story={
Object {
"canModerate": false,
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
@@ -2714,6 +2734,7 @@ exports[`shows conversation link 1`] = `
}
story={
Object {
"canModerate": false,
"isClosed": false,
"settings": Object {
"mode": "COMMENTS",
@@ -1,9 +1,9 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useCallback } from "react";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import { graphql } from "react-relay";
import { SCHEDULED_DELETION_WINDOW_DURATION } from "coral-common/constants";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { useDateTimeFormatter } from "coral-framework/hooks";
import { useMutation, withFragmentContainer } from "coral-framework/lib/relay";
import CLASSES from "coral-stream/classes";
import CancelAccountDeletionMutation from "coral-stream/mutations/CancelAccountDeletionMutation";
@@ -18,16 +18,6 @@ interface Props {
viewer: StreamDeletionRequestCalloutContainer_viewer;
}
const formatter = (locales: string[], date: Date) =>
Intl.DateTimeFormat(locales, {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
}).format(date);
const subtractSeconds = (date: Date, seconds: number) => {
return new Date(date.getTime() - seconds * 1000);
};
@@ -35,26 +25,40 @@ const subtractSeconds = (date: Date, seconds: number) => {
const StreamDeletionRequestCalloutContainer: FunctionComponent<Props> = ({
viewer,
}) => {
const { locales } = useCoralContext();
const formatter = useDateTimeFormatter({
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
});
const cancelAccountDeletion = useMutation(CancelAccountDeletionMutation);
const cancelDeletion = useCallback(() => {
void cancelAccountDeletion();
}, [cancelAccountDeletion]);
const deletionDate = viewer.scheduledDeletionDate
? formatter(locales, new Date(viewer.scheduledDeletionDate))
: null;
const deletionDate = useMemo(
() =>
viewer.scheduledDeletionDate
? formatter(viewer.scheduledDeletionDate)
: null,
[viewer, formatter]
);
const requestDate = viewer.scheduledDeletionDate
? formatter(
locales,
subtractSeconds(
new Date(viewer.scheduledDeletionDate),
SCHEDULED_DELETION_WINDOW_DURATION
)
)
: null;
const requestDate = useMemo(
() =>
viewer.scheduledDeletionDate
? formatter(
subtractSeconds(
new Date(viewer.scheduledDeletionDate),
SCHEDULED_DELETION_WINDOW_DURATION
)
)
: null,
[viewer, formatter]
);
return (
<>
@@ -1,7 +1,7 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useMemo } from "react";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { useDateTimeFormatter } from "coral-framework/hooks";
import { Icon } from "coral-ui/components/v2";
import { CallOut } from "coral-ui/components/v3";
@@ -13,17 +13,15 @@ interface Props {
}
const SuspendedInfo: FunctionComponent<Props> = ({ until, organization }) => {
const { locales } = useCoralContext();
const untilDate = useMemo(() => {
const formatter = new Intl.DateTimeFormat(locales, {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
return formatter.format(new Date(until));
}, [locales, until]);
const formatter = useDateTimeFormatter({
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
const untilDate = useMemo(() => formatter(until), [formatter, until]);
return (
<CallOut
color="negative"
@@ -1,9 +1,9 @@
import { Localized } from "@fluent/react/compat";
import cn from "classnames";
import React, { FunctionComponent, useCallback } from "react";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import { graphql } from "react-relay";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { useDateTimeFormatter } from "coral-framework/hooks";
import { useMutation, withFragmentContainer } from "coral-framework/lib/relay";
import CLASSES from "coral-stream/classes";
import CancelAccountDeletionMutation from "coral-stream/mutations/CancelAccountDeletionMutation";
@@ -21,27 +21,31 @@ interface Props {
const DeletionRequestCalloutContainer: FunctionComponent<Props> = ({
viewer,
}) => {
if (!viewer.scheduledDeletionDate) {
return null;
}
const cancelDeletionMutation = useMutation(CancelAccountDeletionMutation);
const cancelDeletion = useCallback(() => {
void cancelDeletionMutation();
}, [cancelDeletionMutation]);
const { locales } = useCoralContext();
const formatter = useDateTimeFormatter({
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
});
const deletionDate = viewer.scheduledDeletionDate
? Intl.DateTimeFormat(locales, {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
}).format(new Date(viewer.scheduledDeletionDate))
: null;
const deletionDate = useMemo(
() =>
viewer.scheduledDeletionDate
? formatter(viewer.scheduledDeletionDate)
: null,
[viewer, formatter]
);
if (!viewer.scheduledDeletionDate) {
return null;
}
return (
<div
@@ -6,7 +6,7 @@ import { graphql } from "react-relay";
import { DOWNLOAD_LIMIT_TIMEFRAME_DURATION } from "coral-common/constants";
import { reduceSeconds } from "coral-common/helpers/i18n";
import TIME from "coral-common/time";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { useDateTimeFormatter } from "coral-framework/hooks";
import { useMutation, withFragmentContainer } from "coral-framework/lib/relay";
import CLASSES from "coral-stream/classes";
import { Flex, Icon } from "coral-ui/components/v2";
@@ -34,8 +34,16 @@ const DownloadCommentsContainer: FunctionComponent<Props> = ({ viewer }) => {
setShowErrorMessage(true);
}
}, [requestComments, setShowSuccessMessage, setShowErrorMessage]);
const formatter = useDateTimeFormatter({
day: "2-digit",
month: "2-digit",
year: "numeric",
hour12: true,
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
});
const { locales } = useCoralContext();
const lastDownloadedAt = viewer.lastDownloadedAt
? new Date(viewer.lastDownloadedAt)
: null;
@@ -45,15 +53,7 @@ const DownloadCommentsContainer: FunctionComponent<Props> = ({ viewer }) => {
const canDownload =
!lastDownloadedAt || sinceLastDownload >= DOWNLOAD_LIMIT_TIMEFRAME_DURATION;
const tilCanDownload = DOWNLOAD_LIMIT_TIMEFRAME_DURATION - sinceLastDownload;
const formatter = new Intl.DateTimeFormat(locales, {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour12: true,
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
});
const { scaled, unit } = reduceSeconds(tilCanDownload, [
TIME.DAY,
TIME.HOUR,
@@ -98,7 +98,7 @@ const DownloadCommentsContainer: FunctionComponent<Props> = ({ viewer }) => {
{lastDownloadedAt && !showSuccessMessage && (
<Localized
id="profile-account-download-comments-yourMostRecentRequest"
$timeStamp={formatter.format(lastDownloadedAt)}
$timeStamp={formatter(lastDownloadedAt)}
>
<div
className={cn(
@@ -108,7 +108,7 @@ const DownloadCommentsContainer: FunctionComponent<Props> = ({ viewer }) => {
>
Your most recent request was within the last 14 days. You may
request to download your comments again on:{" "}
{formatter.format(lastDownloadedAt)}.
{formatter(lastDownloadedAt)}.
</div>
</Localized>
)}
@@ -14,7 +14,7 @@ import { ALLOWED_USERNAME_CHANGE_TIMEFRAME_DURATION } from "coral-common/constan
import { reduceSeconds } from "coral-common/helpers/i18n";
import TIME from "coral-common/time";
import getAuthenticationIntegrations from "coral-framework/helpers/getAuthenticationIntegrations";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { useDateTimeFormatter } from "coral-framework/hooks";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { useViewerEvent } from "coral-framework/lib/events";
import { streamColorFromMeta } from "coral-framework/lib/form";
@@ -150,9 +150,7 @@ const ChangeUsernameContainer: FunctionComponent<Props> = ({
[updateUsername]
);
const { locales } = useCoralContext();
const formatter = new Intl.DateTimeFormat(locales, {
const formatter = useDateTimeFormatter({
day: "2-digit",
month: "2-digit",
year: "numeric",
@@ -224,18 +222,13 @@ const ChangeUsernameContainer: FunctionComponent<Props> = ({
$value={FREQUENCYSCALED.scaled}
$unit={FREQUENCYSCALED.unit}
$nextUpdate={
canChangeUsernameDate
? formatter.format(canChangeUsernameDate)
: null
canChangeUsernameDate ? formatter(canChangeUsernameDate) : null
}
>
<div className={cn(styles.tooSoon, CLASSES.myUsername.tooSoon)}>
You changed your username within the last {FREQUENCYSCALED.scaled}{" "}
{FREQUENCYSCALED.unit}. You may change your username again on:{" "}
{canChangeUsernameDate
? formatter.format(canChangeUsernameDate)
: null}
.
{canChangeUsernameDate ? formatter(canChangeUsernameDate) : null}.
</div>
</Localized>
</div>
@@ -3,7 +3,7 @@ import cn from "classnames";
import React, { FunctionComponent, useCallback, useState } from "react";
import { graphql } from "react-relay";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { useDateTimeFormatter } from "coral-framework/hooks";
import { useMutation, withFragmentContainer } from "coral-framework/lib/relay";
import CLASSES from "coral-stream/classes";
import CancelAccountDeletionMutation from "coral-stream/mutations/CancelAccountDeletionMutation";
@@ -41,17 +41,17 @@ const DeleteAccountContainer: FunctionComponent<Props> = ({
void cancelAccountDeletion();
}, [cancelAccountDeletion]);
const { locales } = useCoralContext();
const formatter = useDateTimeFormatter({
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
});
const deletionDate = viewer.scheduledDeletionDate
? Intl.DateTimeFormat(locales, {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
}).format(new Date(viewer.scheduledDeletionDate))
? formatter(viewer.scheduledDeletionDate)
: null;
return (
@@ -2,7 +2,7 @@ import { Localized } from "@fluent/react/compat";
import cn from "classnames";
import React, { FunctionComponent, useCallback } from "react";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { useDateTimeFormatter } from "coral-framework/hooks";
import CLASSES from "coral-stream/classes";
import { Flex } from "coral-ui/components/v2";
import { Button } from "coral-ui/components/v3";
@@ -28,17 +28,17 @@ const CompletionPage: FunctionComponent<Props> = ({
onClose();
}, [onClose]);
const { locales } = useCoralContext();
const formatter = useDateTimeFormatter({
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
});
const formattedDate = scheduledDeletionDate
? Intl.DateTimeFormat(locales, {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
}).format(new Date(scheduledDeletionDate))
? formatter(scheduledDeletionDate)
: "";
return (
+2
View File
@@ -122,6 +122,7 @@ export const site = createFixture<GQLSite>({
id: "site-id",
createdAt: "2018-05-06T18:24:00.000Z",
allowedOrigins: ["http://test-site.com"],
canModerate: true,
});
export const settingsWithoutLocalAuth = createFixture<GQLSettings>(
@@ -297,6 +298,7 @@ export const baseStory = createFixture<GQLStory>({
mode: GQLSTORY_MODE.COMMENTS,
experts: [],
},
canModerate: true,
site,
});
@@ -120,6 +120,7 @@ export function createStory(createComments = true) {
metadata: {
title: uuid(),
},
canModerate: true,
isClosed: false,
commentCounts: {
totalPublished: 0,
@@ -1,31 +1,24 @@
import React, { FunctionComponent, useMemo } from "react";
import { Typography, useUIContext } from "coral-ui/components";
import { Typography } from "coral-ui/components";
import { useDateTimeFormatter } from "coral-ui/hooks";
import { PropTypesOf } from "coral-ui/types";
interface Props {
date: string;
className?: string;
locales?: string[];
}
const AbsoluteTime: FunctionComponent<Props> = ({
date,
className,
locales: localesFromProps,
}) => {
const { locales: localesFromContext } = useUIContext();
const locales = localesFromProps || localesFromContext || ["en-US"];
const formatted = useMemo(() => {
const formatter = new Intl.DateTimeFormat(locales, {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
return formatter.format(new Date(date));
}, [locales, date]);
const AbsoluteTime: FunctionComponent<Props> = ({ date, className }) => {
const formatter = useDateTimeFormatter({
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
const formatted = useMemo(() => formatter(date), [formatter, date]);
return (
<Typography className={className} variant="timestamp">
{formatted}
@@ -1,31 +1,24 @@
import React, { FunctionComponent, useMemo } from "react";
import { useUIContext } from "coral-ui/components/v2";
import { useDateTimeFormatter } from "coral-ui/hooks";
import { PropTypesOf } from "coral-ui/types";
interface Props {
date: string;
className?: string;
locales?: string[];
}
const AbsoluteTime: FunctionComponent<Props> = ({
date,
className,
locales: localesFromProps,
}) => {
const { locales: localesFromContext } = useUIContext();
const locales = localesFromProps || localesFromContext || ["en-US"];
const formatted = useMemo(() => {
const formatter = new Intl.DateTimeFormat(locales, {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
return formatter.format(new Date(date));
}, [locales, date]);
const AbsoluteTime: FunctionComponent<Props> = ({ date, className }) => {
const formatter = useDateTimeFormatter({
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
const formatted = useMemo(() => formatter(date), [formatter, date]);
return <span className={className}>{formatted}</span>;
};
@@ -40,7 +40,13 @@ interface ChildrenRenderProps {
}
interface PopoverProps {
body: (props: BodyRenderProps) => React.ReactNode | React.ReactElement<any>;
/**
* body supports render props from the body or a react node itself.
*/
body:
| ((props: BodyRenderProps) => React.ReactNode | React.ReactElement<any>)
| React.ReactNode
| React.ReactElement<any>;
children: (props: ChildrenRenderProps) => React.ReactNode;
description?: string;
id: string;
+2
View File
@@ -2,4 +2,6 @@ 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 useDateTimeFormat } from "./useDateTimeFormat";
export { default as useDateTimeFormatter } from "./useDateTimeFormatter";
export { default as useHotkey } from "./useHotkey";

Some files were not shown because too many files have changed in this diff Show More