mirror of
https://github.com/wassname/talk.git
synced 2026-06-27 19:33:06 +08:00
[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:
+3
-3
@@ -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;
|
||||
+2
-2
@@ -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;
|
||||
+1
-1
@@ -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"
|
||||
>
|
||||
|
||||
+14
@@ -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(
|
||||
|
||||
+5
-1
@@ -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}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
+9
@@ -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(
|
||||
|
||||
+5
-1
@@ -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>
|
||||
|
||||
+2
-1
@@ -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));
|
||||
|
||||
+15
-6
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+21
@@ -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",
|
||||
|
||||
+29
-25
@@ -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"
|
||||
|
||||
+21
-17
@@ -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>
|
||||
)}
|
||||
|
||||
+4
-11
@@ -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>
|
||||
|
||||
+10
-10
@@ -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 (
|
||||
|
||||
+10
-10
@@ -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 (
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user