mirror of
https://github.com/wassname/talk.git
synced 2026-09-14 11:36:51 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e180cf87d7 | ||
|
|
de976a5397 | ||
|
|
257830a6c5 | ||
|
|
518056f382 | ||
|
|
84936ca35c | ||
|
|
4194b08a12 | ||
|
|
98e6a3ccc7 | ||
|
|
64f5171ff2 | ||
|
|
6711f09a79 | ||
|
|
8966a8201b | ||
|
|
7fc0e2e489 | ||
|
|
7c3c510bbc | ||
|
|
94ece10284 | ||
|
|
ef01f427de | ||
|
|
5db096942a | ||
|
|
4645ed4cd7 | ||
|
|
7fd524cd6a | ||
|
|
3cb5d0ff9f | ||
|
|
44216221a4 | ||
|
|
0f9b2ba114 | ||
|
|
071666e8b8 | ||
|
|
2a9a5099dc | ||
|
|
d0f51d8815 | ||
|
|
b228725370 | ||
|
|
58f2f71743 | ||
|
|
8fd266deeb | ||
|
|
1b73a5fcd7 | ||
|
|
9a58673545 | ||
|
|
2dc63d3a27 |
@@ -13,7 +13,7 @@ job_defaults: &job_defaults
|
||||
environment:
|
||||
<<: *job_environment
|
||||
|
||||
version: 2
|
||||
version: 2.1
|
||||
jobs:
|
||||
# npm_dependencies will install the dependencies used by all other steps.
|
||||
npm_dependencies:
|
||||
@@ -173,7 +173,6 @@ filter_develop: &filter_develop
|
||||
- next
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
build-test:
|
||||
jobs:
|
||||
- docker_tests:
|
||||
|
||||
@@ -5,9 +5,9 @@ how moderation, comment display, and conversation function, creating the
|
||||
opportunity for safer, smarter discussions around your work.
|
||||
[Read more about Coral here](https://coralproject.net/talk).
|
||||
|
||||
Built with <3 by The Coral Project, a part of [Vox Media](https://product.voxmedia.com/).
|
||||
Built with <3 by the Coral team, a part of [Vox Media](https://product.voxmedia.com/).
|
||||
|
||||
Preview Coral easily by running Coral via a Heroku App:
|
||||
Preview Coral easily by running it via a Heroku App:
|
||||
|
||||
[](https://heroku.com/deploy?template=https://github.com/coralproject/talk)
|
||||
|
||||
@@ -28,13 +28,11 @@ You can get started with Coral using our [Documentation](https://docs.coralproje
|
||||
|
||||
## Pre-Launch Guide
|
||||
|
||||
You’ve installed Talk on your server, and you’re preparing to launch it on your site. The real community work starts now, before you go live. You have a unique opportunity pre-launch to set your community up for success. Read our [Talk Community Guide](https://coralproject.net/blog/youve-installed-talk-now-what/).
|
||||
You’ve installed Coral on your server, and you’re preparing to launch it on your site. The real community work starts now, before you go live. You have a unique opportunity pre-launch to set your community up for success. Read our [Community Guides](https://guides.coralproject.net/start-here/) to learn more.
|
||||
|
||||
## More Resources
|
||||
|
||||
- [Our Blog](https://coralproject.net/blog)
|
||||
- [Community Guides for Journalism](https://guides.coralproject.net/)
|
||||
- [More About Us](https://coralproject.net/)
|
||||
- [Learn more about Coral and sign up for a webinar](https://coralproject.net/)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+15
@@ -174,6 +174,16 @@ function when comparing signatures.
|
||||
* date when this event was created.
|
||||
*/
|
||||
createdAt: string;
|
||||
|
||||
/**
|
||||
* tenantID is the ID of the Tenant that this event originated at.
|
||||
*/
|
||||
tenantID: string;
|
||||
|
||||
/**
|
||||
* tenantDomain is the domain that is associated with this Tenant that this event originated at.
|
||||
*/
|
||||
tenantDomain: string;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -199,6 +209,11 @@ function when comparing signatures.
|
||||
* storyURL is the URL of the newly created Story.
|
||||
*/
|
||||
storyURL: string;
|
||||
|
||||
/**
|
||||
* siteID is the Site that the newly created Story was created on.
|
||||
*/
|
||||
siteID: string;
|
||||
}
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
Generated
+23
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coralproject/talk",
|
||||
"version": "6.0.2",
|
||||
"version": "6.1.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@@ -3217,7 +3217,6 @@
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.8.3.tgz",
|
||||
"integrity": "sha512-lrIU4aVbmlM/wQPzhEvzvNJskKyYptuXb0fGC0lTQTupTOYtR2Vqbu6/jf8vTr4M8Wt1nIzxVrSvPI5qESa/xA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"core-js-pure": "^3.0.0",
|
||||
"regenerator-runtime": "^0.13.2"
|
||||
@@ -5760,6 +5759,12 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/xregexp": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/xregexp/-/xregexp-4.3.0.tgz",
|
||||
"integrity": "sha512-3gJTS9gt27pS7U9q5IVqo4YvKSlkf2ck8ish6etuDj6LIRxkL/2Y8RMUtK/QzvE1Yv2zwWV5yemI2BS0GGGFnA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/yargs": {
|
||||
"version": "12.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-12.0.12.tgz",
|
||||
@@ -10721,8 +10726,7 @@
|
||||
"core-js-pure": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.6.4.tgz",
|
||||
"integrity": "sha512-epIhRLkXdgv32xIUFaaAry2wdxZYBi6bgM7cB136dzzXXa+dFyRLTZeLUJxnd8ShrmyVXBub63n2NHo2JAt8Cw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-epIhRLkXdgv32xIUFaaAry2wdxZYBi6bgM7cB136dzzXXa+dFyRLTZeLUJxnd8ShrmyVXBub63n2NHo2JAt8Cw=="
|
||||
},
|
||||
"core-util-is": {
|
||||
"version": "1.0.2",
|
||||
@@ -32077,8 +32081,7 @@
|
||||
"regenerator-runtime": {
|
||||
"version": "0.13.3",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz",
|
||||
"integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw=="
|
||||
},
|
||||
"regenerator-transform": {
|
||||
"version": "0.13.4",
|
||||
@@ -38138,6 +38141,14 @@
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"xregexp": "4.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"xregexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xregexp/-/xregexp-4.0.0.tgz",
|
||||
"integrity": "sha512-PHyM+sQouu7xspQQwELlGwwd05mXUFqwFYfqPO0cC7x4fxyHnnuetmQr6CjJiafIDoH4MogHb9dOoJzR/Y4rFg==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"del": {
|
||||
@@ -39365,10 +39376,12 @@
|
||||
"integrity": "sha512-tGkGJkN8XqCod7OT+EvGYK5Z4SfDQGD30zAa58OcnAa0RRWgzUEK72tkXhsX1FZd+rgnhRxFtmO+ihkp8LHSkw=="
|
||||
},
|
||||
"xregexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xregexp/-/xregexp-4.0.0.tgz",
|
||||
"integrity": "sha512-PHyM+sQouu7xspQQwELlGwwd05mXUFqwFYfqPO0cC7x4fxyHnnuetmQr6CjJiafIDoH4MogHb9dOoJzR/Y4rFg==",
|
||||
"dev": true
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/xregexp/-/xregexp-4.3.0.tgz",
|
||||
"integrity": "sha512-7jXDIFXh5yJ/orPn4SXjuVrWWoi4Cr8jfV1eHv9CixKSbU+jY4mxfrBwAuDvupPNKpMUY+FeIqsVw/JLT9+B8g==",
|
||||
"requires": {
|
||||
"@babel/runtime-corejs3": "^7.8.3"
|
||||
}
|
||||
},
|
||||
"xtend": {
|
||||
"version": "4.0.1",
|
||||
|
||||
+5
-3
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"name": "@coralproject/talk",
|
||||
"version": "6.0.2",
|
||||
"version": "6.1.0",
|
||||
"author": "The Coral Project",
|
||||
"homepage": "https://coralproject.net/",
|
||||
"sideEffects": [
|
||||
"*.css.ts",
|
||||
"*.css"
|
||||
],
|
||||
"repository": {
|
||||
@@ -127,7 +128,6 @@
|
||||
"passport-local": "^1.0.0",
|
||||
"passport-oauth2": "^1.4.0",
|
||||
"passport-strategy": "^1.0.0",
|
||||
"performance-now": "^2.1.0",
|
||||
"permit": "^0.2.4",
|
||||
"prom-client": "^11.3.0",
|
||||
"proxy-agent": "^3.1.1",
|
||||
@@ -140,7 +140,8 @@
|
||||
"tsscmp": "^1.0.6",
|
||||
"url-regex": "^5.0.0",
|
||||
"uuid": "^3.3.3",
|
||||
"verror": "^1.10.0"
|
||||
"verror": "^1.10.0",
|
||||
"xregexp": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.8.3",
|
||||
@@ -240,6 +241,7 @@
|
||||
"@types/webpack-bundle-analyzer": "^2.13.1",
|
||||
"@types/webpack-dev-server": "^3.1.5",
|
||||
"@types/ws": "^5.1.2",
|
||||
"@types/xregexp": "^4.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "2.3.3",
|
||||
"@typescript-eslint/eslint-plugin-tslint": "2.3.3",
|
||||
"@typescript-eslint/parser": "2.3.3",
|
||||
|
||||
+8
-4
@@ -12,12 +12,12 @@ $comment-link-active: var(--v2-palette-primary-darkest);
|
||||
color: $comment-content;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
& * bold,
|
||||
& * strong {
|
||||
b,
|
||||
strong {
|
||||
font-weight: var(--v2-font-weight-primary-bold);
|
||||
}
|
||||
& * italic,
|
||||
& * em {
|
||||
i,
|
||||
em {
|
||||
font-style: italic;
|
||||
}
|
||||
blockquote {
|
||||
@@ -50,3 +50,7 @@ $comment-link-active: var(--v2-palette-primary-darkest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.highlight {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
+21
@@ -16,6 +16,7 @@ it("renders correctly", () => {
|
||||
},
|
||||
className: "custom",
|
||||
children: "Hello <b>Bob</b>, you bad guy",
|
||||
highlight: true,
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<CommentContent {...props} />);
|
||||
@@ -33,6 +34,26 @@ it("renders empty words correctly", () => {
|
||||
},
|
||||
className: "custom",
|
||||
children: "Hello <b>Bob</b>, you bad guy",
|
||||
highlight: true,
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<CommentContent {...props} />);
|
||||
expect(renderer.getRenderOutput()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders correctly even if it has consecutive banned words on comments", () => {
|
||||
const props: PropTypesOf<typeof CommentContent> = {
|
||||
phrases: {
|
||||
locale: "en-US",
|
||||
wordList: {
|
||||
suspect: ["worse"],
|
||||
banned: ["bad"],
|
||||
},
|
||||
},
|
||||
className: "custom",
|
||||
children:
|
||||
"This is a very long comment with bad words. Let's try bad and bad. Now bad bad.\nBad BAD bad.\n",
|
||||
highlight: true,
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<CommentContent {...props} />);
|
||||
@@ -0,0 +1,84 @@
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
import striptags from "striptags";
|
||||
|
||||
import {
|
||||
getPhrasesRegExp,
|
||||
GetPhrasesRegExpOptions,
|
||||
markHTMLNode,
|
||||
} from "coral-admin/helpers";
|
||||
import { createPurify } from "coral-common/utils/purify";
|
||||
|
||||
import styles from "./CommentContent.css";
|
||||
|
||||
/**
|
||||
* Create a purify instance that will be used to handle HTML content.
|
||||
*/
|
||||
const purify = createPurify(window, false);
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
children: string | React.ReactElement;
|
||||
phrases: GetPhrasesRegExpOptions;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
const CommentContent: FunctionComponent<Props> = ({
|
||||
phrases,
|
||||
className,
|
||||
children,
|
||||
highlight = false,
|
||||
}) => {
|
||||
// Cache the expression used via memo. This will reduce duplicate renders of
|
||||
// this comment content when the children change but the phrase configuration
|
||||
// does not change. The regExp is already cached on a deeper level
|
||||
// automatically, this is just lessening that impact further.
|
||||
const expression = useMemo(() => {
|
||||
// If we aren't in highlight mode for this comment, don't even attempt to
|
||||
// generate the expression.
|
||||
if (!highlight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getPhrasesRegExp(phrases);
|
||||
}, [phrases, highlight]);
|
||||
|
||||
// Cache the parsed comment node. If the children cannot be parsed, this will
|
||||
// be null.
|
||||
const parsed = useMemo(() => {
|
||||
if (typeof children !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sanitize the input for display.
|
||||
let html = purify.sanitize(children);
|
||||
if (highlight) {
|
||||
html = striptags(html, ["a"]);
|
||||
}
|
||||
|
||||
// We create a Shadow DOM Tree with the HTML body content and use it as a
|
||||
// parser.
|
||||
const node = document.createElement("div");
|
||||
node.innerHTML = html;
|
||||
|
||||
// If the expression is available, then mark the nodes.
|
||||
if (expression) {
|
||||
markHTMLNode(node, expression);
|
||||
}
|
||||
|
||||
return node;
|
||||
}, [children, expression, highlight]);
|
||||
|
||||
if (parsed) {
|
||||
return (
|
||||
<div
|
||||
className={cn(className, styles.root, highlight && styles.highlight)}
|
||||
dangerouslySetInnerHTML={{ __html: parsed.innerHTML }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default CommentContent;
|
||||
@@ -0,0 +1,21 @@
|
||||
$moderateCardUsernameBackgroundHover: var(--v2-colors-grey-200);
|
||||
|
||||
.root {
|
||||
margin-right: var(--mini-unit);
|
||||
padding: var(--spacing-1);
|
||||
margin-left: calc(-1 * var(--v2-spacing-1));
|
||||
|
||||
line-height: var(--v2-line-height-reset);
|
||||
|
||||
&:hover {
|
||||
background-color: $moderateCardUsernameBackgroundHover;
|
||||
border-radius: var(--v2-round-corners);
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: $moderateCardUsernameBackgroundHover;
|
||||
border-radius: var(--v2-round-corners);
|
||||
border-style: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { BaseButton } from "coral-ui/components/v2";
|
||||
import Username from "./Username";
|
||||
|
||||
import styles from "./UsernameButton.css";
|
||||
|
||||
interface Props {
|
||||
username: string;
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const UsernameButton: FunctionComponent<Props> = ({
|
||||
username,
|
||||
onClick,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<BaseButton onClick={onClick} className={cn(styles.root, className)}>
|
||||
<Username>{username}</Username>
|
||||
</BaseButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsernameButton;
|
||||
@@ -0,0 +1,36 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<div
|
||||
className="custom CommentContent-root CommentContent-highlight"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "<span>Hello Bob, you <mark>bad</mark> guy</span>",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`renders correctly even if it has consecutive banned words on comments 1`] = `
|
||||
<div
|
||||
className="custom CommentContent-root CommentContent-highlight"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "<span>This is a very long comment with <mark>bad</mark> words. Let's try <mark>bad</mark> and <mark>bad</mark>. Now <mark>bad</mark> bad.
|
||||
<mark>Bad</mark> BAD <mark>bad</mark>.
|
||||
</span>",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`renders empty words correctly 1`] = `
|
||||
<div
|
||||
className="custom CommentContent-root CommentContent-highlight"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "Hello Bob, you bad guy",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as CommentContent } from "./CommentContent";
|
||||
export { default as InReplyTo } from "./InReplyTo";
|
||||
export { default as Username } from "./Username";
|
||||
export { default as UsernameButton } from "./UsernameButton";
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { Modal } from "coral-ui/components";
|
||||
|
||||
import ConversationModalQuery from "./ConversationModalQuery";
|
||||
|
||||
interface ConversationModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
commentID?: string;
|
||||
onUsernameClicked: (userID: string) => void;
|
||||
}
|
||||
|
||||
const ConversationModal: FunctionComponent<ConversationModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
commentID,
|
||||
onUsernameClicked,
|
||||
}) => {
|
||||
if (!commentID) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
{({ firstFocusableRef, lastFocusableRef }) => (
|
||||
<ConversationModalQuery
|
||||
commentID={commentID}
|
||||
onClose={onClose}
|
||||
firstFocusableRef={firstFocusableRef}
|
||||
lastFocusableRef={lastFocusableRef}
|
||||
onUsernameClicked={onUsernameClicked}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversationModal;
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
$conversationModalHighlightBackground: var(--v2-colors-teal-100);
|
||||
$conversationModalCommentText: var(--v2-colors-mono-500);
|
||||
.root {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.line {
|
||||
border-color: var(--v2-colors-grey-400);
|
||||
}
|
||||
|
||||
.adornments {
|
||||
margin-right: var(--v2-spacing-2);
|
||||
}
|
||||
|
||||
.circle {
|
||||
margin-top: var(--v2-spacing-2);
|
||||
margin-bottom: var(--v2-spacing-2);
|
||||
}
|
||||
|
||||
.highlightedCircle {
|
||||
margin-top: var(--v2-spacing-1);
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
background-color: $conversationModalHighlightBackground;
|
||||
padding: var(--v2-spacing-2);
|
||||
padding-top: var(--v2-spacing-1);
|
||||
margin-bottom: var(--v2-spacing-2);
|
||||
}
|
||||
|
||||
.commentText {
|
||||
color: $conversationModalCommentText;
|
||||
}
|
||||
|
||||
.showReplies {
|
||||
padding-left: var(--v2-spacing-2);
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent, useCallback, useState } from "react";
|
||||
|
||||
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
HorizontalGutter,
|
||||
Timestamp,
|
||||
} from "coral-ui/components/v2";
|
||||
|
||||
import ConversationModalRepliesQuery from "./ConversationModalRepliesQuery";
|
||||
|
||||
import { ConversationModalCommentContainer_comment } from "coral-admin/__generated__/ConversationModalCommentContainer_comment.graphql";
|
||||
import { ConversationModalCommentContainer_settings } from "coral-admin/__generated__/ConversationModalCommentContainer_settings.graphql";
|
||||
|
||||
import { CommentContent, InReplyTo, UsernameButton } from "../Comment";
|
||||
import { Circle, Line } from "../Timeline";
|
||||
|
||||
import styles from "./ConversationModalCommentContainer.css";
|
||||
|
||||
interface Props {
|
||||
comment: ConversationModalCommentContainer_comment;
|
||||
isHighlighted: boolean;
|
||||
onUsernameClick: (id?: string) => void;
|
||||
isParent?: boolean;
|
||||
isReply?: boolean;
|
||||
settings: ConversationModalCommentContainer_settings;
|
||||
}
|
||||
|
||||
const ConversationModalCommentContainer: FunctionComponent<Props> = ({
|
||||
comment,
|
||||
isHighlighted,
|
||||
isParent,
|
||||
isReply,
|
||||
onUsernameClick,
|
||||
settings,
|
||||
}) => {
|
||||
const commentAuthorClick = useCallback(() => {
|
||||
if (comment.author) {
|
||||
onUsernameClick(comment.author.id);
|
||||
}
|
||||
}, [onUsernameClick, comment.author]);
|
||||
const commentParentAuthorClick = useCallback(() => {
|
||||
if (comment.parent && comment.parent.author) {
|
||||
onUsernameClick(comment.parent.author.id);
|
||||
}
|
||||
}, [onUsernameClick, comment.parent]);
|
||||
const [showReplies, setShowReplies] = useState(false);
|
||||
const onShowReplies = useCallback(() => {
|
||||
setShowReplies(true);
|
||||
}, []);
|
||||
return (
|
||||
<HorizontalGutter>
|
||||
<Flex>
|
||||
<Flex
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
className={cn(styles.adornments, {
|
||||
[styles.highlightedCircle]: isHighlighted,
|
||||
})}
|
||||
>
|
||||
{!isReply && <Circle className={styles.circle} />}
|
||||
{(isParent || isReply) && <Line className={styles.line} />}
|
||||
</Flex>
|
||||
<HorizontalGutter
|
||||
spacing={1}
|
||||
className={cn(styles.root, {
|
||||
[styles.highlighted]: isHighlighted,
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<Flex alignItems="center">
|
||||
{comment.author && comment.author.username && (
|
||||
<UsernameButton
|
||||
onClick={commentAuthorClick}
|
||||
username={comment.author.username}
|
||||
/>
|
||||
)}
|
||||
<Timestamp>{comment.createdAt}</Timestamp>
|
||||
</Flex>
|
||||
{comment.parent &&
|
||||
comment.parent.author &&
|
||||
comment.parent.author.username && (
|
||||
<InReplyTo onUsernameClick={commentParentAuthorClick}>
|
||||
{comment.parent.author.username}
|
||||
</InReplyTo>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{comment.body && (
|
||||
<CommentContent className={styles.commentText} phrases={settings}>
|
||||
{comment.body}
|
||||
</CommentContent>
|
||||
)}
|
||||
</div>
|
||||
</HorizontalGutter>
|
||||
</Flex>
|
||||
{isReply && comment.replyCount > 0 && (
|
||||
<div className={styles.showReplies}>
|
||||
{!showReplies && (
|
||||
<Localized id="conversation-modal-reply-show-replies">
|
||||
<Button
|
||||
color="mono"
|
||||
variant="outline"
|
||||
onClick={onShowReplies}
|
||||
fullWidth
|
||||
>
|
||||
Show replies
|
||||
</Button>
|
||||
</Localized>
|
||||
)}
|
||||
{showReplies && (
|
||||
<ConversationModalRepliesQuery
|
||||
onUsernameClicked={onUsernameClick}
|
||||
commentID={comment.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</HorizontalGutter>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
settings: graphql`
|
||||
fragment ConversationModalCommentContainer_settings on Settings {
|
||||
locale
|
||||
wordList {
|
||||
banned
|
||||
suspect
|
||||
}
|
||||
multisite
|
||||
featureFlags
|
||||
...MarkersContainer_settings
|
||||
}
|
||||
`,
|
||||
comment: graphql`
|
||||
fragment ConversationModalCommentContainer_comment on Comment {
|
||||
id
|
||||
body
|
||||
createdAt
|
||||
author {
|
||||
username
|
||||
id
|
||||
}
|
||||
replyCount
|
||||
parent {
|
||||
author {
|
||||
username
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(ConversationModalCommentContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,10 @@
|
||||
.root {
|
||||
}
|
||||
.topCircle {
|
||||
margin-right: var(--v2-spacing-2);
|
||||
}
|
||||
|
||||
.bottomCircleContainer {
|
||||
width: 8px;
|
||||
padding-top: var(--v2-spacing-1);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { graphql, RelayPaginationProp } from "react-relay";
|
||||
|
||||
import {
|
||||
useLoadMore,
|
||||
withPaginationContainer,
|
||||
} from "coral-framework/lib/relay";
|
||||
import {
|
||||
Button,
|
||||
Counter,
|
||||
Flex,
|
||||
HorizontalGutter,
|
||||
} from "coral-ui/components/v2";
|
||||
|
||||
import { ConversationModalContainer_comment } from "coral-admin/__generated__/ConversationModalContainer_comment.graphql";
|
||||
import { ConversationModalContainer_settings } from "coral-admin/__generated__/ConversationModalContainer_settings.graphql";
|
||||
import { ConversationModalContainerPaginationQueryVariables } from "coral-admin/__generated__/ConversationModalContainerPaginationQuery.graphql";
|
||||
|
||||
import { Circle } from "../Timeline";
|
||||
import ConversationModalComment from "./ConversationModalCommentContainer";
|
||||
|
||||
import styles from "./ConversationModalContainer.css";
|
||||
|
||||
interface Props {
|
||||
relay: RelayPaginationProp;
|
||||
comment: ConversationModalContainer_comment;
|
||||
settings: ConversationModalContainer_settings;
|
||||
onClose: () => void;
|
||||
onUsernameClicked: (id?: string) => void;
|
||||
}
|
||||
|
||||
const ConversationModalContainer: FunctionComponent<Props> = ({
|
||||
comment,
|
||||
relay,
|
||||
settings,
|
||||
onUsernameClicked,
|
||||
}) => {
|
||||
const [loadMore] = useLoadMore(relay, 5);
|
||||
const parents = comment.parents.edges.map(edge => edge.node);
|
||||
return (
|
||||
<HorizontalGutter className={styles.root}>
|
||||
{comment.parentCount > parents.length && (
|
||||
<div>
|
||||
<Flex alignItems="center">
|
||||
<Circle hollow={true} className={styles.topCircle} />
|
||||
<Button underline variant="text" onClick={loadMore}>
|
||||
<Localized id="conversation-modal-show-more-parents">
|
||||
<span>Show more of this conversation</span>
|
||||
</Localized>
|
||||
<Counter>{comment.parentCount}</Counter>
|
||||
</Button>
|
||||
</Flex>
|
||||
<Flex
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
className={styles.bottomCircleContainer}
|
||||
>
|
||||
<Circle color="light" size="small" />
|
||||
</Flex>
|
||||
</div>
|
||||
)}
|
||||
{parents.map(parent => (
|
||||
<ConversationModalComment
|
||||
key={parent.id}
|
||||
isParent={true}
|
||||
comment={parent}
|
||||
onUsernameClick={onUsernameClicked}
|
||||
settings={settings}
|
||||
isHighlighted={false}
|
||||
/>
|
||||
))}
|
||||
<ConversationModalComment
|
||||
comment={comment}
|
||||
settings={settings}
|
||||
onUsernameClick={onUsernameClicked}
|
||||
isHighlighted={true}
|
||||
/>
|
||||
</HorizontalGutter>
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: (cvle) If this could be autogenerated.
|
||||
type FragmentVariables = ConversationModalContainerPaginationQueryVariables;
|
||||
|
||||
const enhanced = withPaginationContainer<
|
||||
Props,
|
||||
ConversationModalContainerPaginationQueryVariables,
|
||||
FragmentVariables
|
||||
>(
|
||||
{
|
||||
settings: graphql`
|
||||
fragment ConversationModalContainer_settings on Settings {
|
||||
...ConversationModalCommentContainer_settings
|
||||
}
|
||||
`,
|
||||
comment: graphql`
|
||||
fragment ConversationModalContainer_comment on Comment
|
||||
@argumentDefinitions(
|
||||
count: { type: "Int!", defaultValue: 1 }
|
||||
cursor: { type: "Cursor" }
|
||||
) {
|
||||
id
|
||||
...ConversationModalCommentContainer_comment
|
||||
rootParent {
|
||||
id
|
||||
}
|
||||
parents(last: $count, before: $cursor)
|
||||
@connection(key: "ConversationModal_parents") {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...ConversationModalCommentContainer_comment
|
||||
}
|
||||
}
|
||||
}
|
||||
parentCount
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
direction: "backward",
|
||||
getConnectionFromProps(props) {
|
||||
return props.comment && props.comment.parents;
|
||||
},
|
||||
// This is also the default implementation of `getFragmentVariables` if it isn't provided.
|
||||
getFragmentVariables(prevVars, totalCount) {
|
||||
return {
|
||||
...prevVars,
|
||||
count: totalCount,
|
||||
};
|
||||
},
|
||||
getVariables(props, { count, cursor }) {
|
||||
return {
|
||||
count,
|
||||
cursor,
|
||||
// commentID isn't specified as an @argument for the fragment, but it should be a
|
||||
// variable available for the fragment under the query root.
|
||||
commentID: props.comment.id,
|
||||
};
|
||||
},
|
||||
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 ConversationModalContainerPaginationQuery(
|
||||
$count: Int!
|
||||
$cursor: Cursor
|
||||
$commentID: ID!
|
||||
) {
|
||||
comment(id: $commentID) {
|
||||
...ConversationModalContainer_comment
|
||||
@arguments(count: $count, cursor: $cursor)
|
||||
}
|
||||
}
|
||||
`,
|
||||
}
|
||||
)(ConversationModalContainer);
|
||||
|
||||
export default enhanced;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
$modal-header-text: var(--v2-colors-mono-900);
|
||||
|
||||
.title {
|
||||
font-size: var(--v2-font-size-4);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
font-weight: var(--v2-font-weight-primary-regular);
|
||||
line-height: var(--v2-line-height-title);
|
||||
color: $modal-header-text;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.conversationTitle {
|
||||
font-weight: var(--v2-font-weight-primary-semi-bold);
|
||||
text-transform: uppercase;
|
||||
display: block;
|
||||
margin-bottom: var(--v2-spacing-2);
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import { useRouter } from "found";
|
||||
import React, { FunctionComponent, RefObject, useCallback } from "react";
|
||||
|
||||
import { getModerationLink } from "coral-framework/helpers";
|
||||
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { Button, HorizontalGutter, ModalHeader } from "coral-ui/components/v2";
|
||||
|
||||
import { ConversationModalHeaderContainer_comment } from "coral-admin/__generated__/ConversationModalHeaderContainer_comment.graphql";
|
||||
|
||||
import styles from "./ConversationModalHeaderContainer.css";
|
||||
|
||||
interface Props {
|
||||
comment: ConversationModalHeaderContainer_comment;
|
||||
onClose: () => void;
|
||||
focusableRef: RefObject<any>;
|
||||
}
|
||||
|
||||
const ConversationModalHeaderContainer: FunctionComponent<Props> = ({
|
||||
comment,
|
||||
onClose,
|
||||
focusableRef,
|
||||
}) => {
|
||||
const { router } = useRouter();
|
||||
const onModerate = useCallback(() => {
|
||||
const link = getModerationLink({ storyID: comment.story.id });
|
||||
router.push(link);
|
||||
}, [comment.id]);
|
||||
return (
|
||||
<ModalHeader onClose={onClose} focusableRef={focusableRef}>
|
||||
<HorizontalGutter spacing={3}>
|
||||
<h1 className={styles.title}>
|
||||
<Localized id="conversation-modal-header-title">
|
||||
<span className={styles.conversationTitle}>Conversation on:</span>
|
||||
</Localized>
|
||||
{comment.story.metadata
|
||||
? comment.story.metadata.title
|
||||
: comment.story.url}
|
||||
</h1>
|
||||
<Localized id="conversation-modal-header-moderate-link">
|
||||
<Button variant="outline" color="mono" uppercase onClick={onModerate}>
|
||||
Moderate story
|
||||
</Button>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
</ModalHeader>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
comment: graphql`
|
||||
fragment ConversationModalHeaderContainer_comment on Comment {
|
||||
story {
|
||||
id
|
||||
metadata {
|
||||
title
|
||||
}
|
||||
url
|
||||
}
|
||||
id
|
||||
}
|
||||
`,
|
||||
})(ConversationModalHeaderContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,8 @@
|
||||
.root {
|
||||
padding: 0;
|
||||
width: 635px;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: var(--v2-spacing-4);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import {
|
||||
graphql,
|
||||
QueryRenderData,
|
||||
QueryRenderer,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { CallOut, Card, Spinner } from "coral-ui/components/v2";
|
||||
|
||||
import { ConversationModalQuery as QueryTypes } from "coral-admin/__generated__/ConversationModalQuery.graphql";
|
||||
|
||||
import ConversationModalContainer from "./ConversationModalContainer";
|
||||
import ConversationModalHeaderContainer from "./ConversationModalHeaderContainer";
|
||||
import ConversationModalRepliesContainer from "./ConversationModalRepliesContainer";
|
||||
|
||||
import styles from "./ConversationModalQuery.css";
|
||||
|
||||
interface Props {
|
||||
commentID: string;
|
||||
onClose: () => void;
|
||||
firstFocusableRef: React.RefObject<any>;
|
||||
onUsernameClicked: (userID: string) => void;
|
||||
lastFocusableRef: React.RefObject<any>;
|
||||
}
|
||||
|
||||
const ConversationModalQuery: FunctionComponent<Props> = ({
|
||||
commentID,
|
||||
onClose,
|
||||
lastFocusableRef,
|
||||
onUsernameClicked,
|
||||
}) => {
|
||||
return (
|
||||
<QueryRenderer<QueryTypes>
|
||||
query={graphql`
|
||||
query ConversationModalQuery($commentID: ID!) {
|
||||
settings {
|
||||
...ConversationModalContainer_settings
|
||||
...ConversationModalRepliesContainer_settings
|
||||
}
|
||||
comment(id: $commentID) {
|
||||
...ConversationModalContainer_comment
|
||||
...ConversationModalRepliesContainer_comment
|
||||
...ConversationModalHeaderContainer_comment
|
||||
}
|
||||
}
|
||||
`}
|
||||
variables={{ commentID }}
|
||||
cacheConfig={{ force: true }}
|
||||
render={({ props }: QueryRenderData<QueryTypes>) => {
|
||||
if (!props) {
|
||||
return (
|
||||
<div>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!props.comment) {
|
||||
return (
|
||||
<div>
|
||||
<CallOut>
|
||||
<Localized id="conversation-modal-comment-not-found">
|
||||
Comment not found.
|
||||
</Localized>
|
||||
</CallOut>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={styles.root}>
|
||||
<ConversationModalHeaderContainer
|
||||
onClose={onClose}
|
||||
comment={props.comment}
|
||||
focusableRef={lastFocusableRef}
|
||||
/>
|
||||
<div className={styles.body}>
|
||||
<ConversationModalContainer
|
||||
onClose={onClose}
|
||||
settings={props.settings}
|
||||
comment={props.comment}
|
||||
onUsernameClicked={onUsernameClicked}
|
||||
/>
|
||||
<ConversationModalRepliesContainer
|
||||
onClose={onClose}
|
||||
comment={props.comment}
|
||||
settings={props.settings}
|
||||
onUsernameClicked={onUsernameClicked}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversationModalQuery;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
.comment {
|
||||
padding-left: calc(var(--v2-spacing-4));
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding-left: calc(var(--v2-spacing-4));
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent, useCallback, useState } from "react";
|
||||
import { graphql, RelayPaginationProp } from "react-relay";
|
||||
|
||||
import {
|
||||
useLoadMore,
|
||||
withPaginationContainer,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { Omit } from "coral-framework/types";
|
||||
import { Button, HorizontalGutter } from "coral-ui/components/v2";
|
||||
|
||||
import { ConversationModalRepliesContainer_comment } from "coral-admin/__generated__/ConversationModalRepliesContainer_comment.graphql";
|
||||
import { ConversationModalRepliesContainer_settings } from "coral-admin/__generated__/ConversationModalRepliesContainer_settings.graphql";
|
||||
import { ConversationModalRepliesContainerPaginationQueryVariables } from "coral-admin/__generated__/ConversationModalRepliesContainerPaginationQuery.graphql";
|
||||
import ConversationModalCommentContainer from "./ConversationModalCommentContainer";
|
||||
|
||||
import styles from "./ConversationModalRepliesContainer.css";
|
||||
|
||||
interface Props {
|
||||
relay: RelayPaginationProp;
|
||||
comment: ConversationModalRepliesContainer_comment;
|
||||
settings: ConversationModalRepliesContainer_settings;
|
||||
onClose: () => void;
|
||||
onUsernameClicked: (id?: string) => void;
|
||||
}
|
||||
|
||||
const ConversationModalRepliesContainer: FunctionComponent<Props> = ({
|
||||
comment,
|
||||
relay,
|
||||
settings,
|
||||
onUsernameClicked,
|
||||
}) => {
|
||||
const [loadMore] = useLoadMore(relay, 5);
|
||||
const replies = comment.replies.edges.map(edge => edge.node);
|
||||
const [showReplies, setShowReplies] = useState(false);
|
||||
const onShowReplies = useCallback(() => {
|
||||
setShowReplies(true);
|
||||
}, []);
|
||||
return (
|
||||
<HorizontalGutter>
|
||||
{showReplies &&
|
||||
replies.map(reply => (
|
||||
<div key={reply.id} className={styles.comment}>
|
||||
<ConversationModalCommentContainer
|
||||
key={reply.id}
|
||||
settings={settings}
|
||||
comment={reply}
|
||||
isHighlighted={false}
|
||||
isReply={true}
|
||||
onUsernameClick={onUsernameClicked}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.footer}>
|
||||
{!showReplies && comment.replyCount > 0 && (
|
||||
<Localized id="conversation-modal-replies-show">
|
||||
<Button variant="outline" fullWidth onClick={onShowReplies}>
|
||||
Show replies
|
||||
</Button>
|
||||
</Localized>
|
||||
)}
|
||||
{showReplies &&
|
||||
comment.replyCount > replies.length &&
|
||||
replies.length > 0 && (
|
||||
<Localized id="conversation-modal-replies-show-more">
|
||||
<Button variant="outline" fullWidth onClick={loadMore}>
|
||||
Show more replies
|
||||
</Button>
|
||||
</Localized>
|
||||
)}
|
||||
</div>
|
||||
</HorizontalGutter>
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: (cvle) If this could be autogenerated.
|
||||
type FragmentVariables = Omit<
|
||||
ConversationModalRepliesContainerPaginationQueryVariables,
|
||||
"commentID"
|
||||
>;
|
||||
|
||||
const enhanced = withPaginationContainer<
|
||||
Props,
|
||||
ConversationModalRepliesContainerPaginationQueryVariables,
|
||||
FragmentVariables
|
||||
>(
|
||||
{
|
||||
settings: graphql`
|
||||
fragment ConversationModalRepliesContainer_settings on Settings {
|
||||
...ConversationModalCommentContainer_settings
|
||||
}
|
||||
`,
|
||||
comment: graphql`
|
||||
fragment ConversationModalRepliesContainer_comment on Comment
|
||||
@argumentDefinitions(
|
||||
count: { type: "Int!", defaultValue: 5 }
|
||||
cursor: { type: "Cursor" }
|
||||
orderBy: { type: "COMMENT_SORT!", defaultValue: CREATED_AT_ASC }
|
||||
) {
|
||||
id
|
||||
replies(first: $count, after: $cursor, orderBy: $orderBy)
|
||||
@connection(key: "ConversationModalReplies_replies") {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...ConversationModalCommentContainer_comment
|
||||
}
|
||||
}
|
||||
}
|
||||
replyCount
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
direction: "forward",
|
||||
getConnectionFromProps(props) {
|
||||
return props.comment && props.comment.replies;
|
||||
},
|
||||
// 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,
|
||||
orderBy: fragmentVariables.orderBy,
|
||||
commentID: props.comment.id,
|
||||
};
|
||||
},
|
||||
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 ConversationModalRepliesContainerPaginationQuery(
|
||||
$count: Int!
|
||||
$cursor: Cursor
|
||||
$orderBy: COMMENT_SORT!
|
||||
$commentID: ID!
|
||||
) {
|
||||
comment(id: $commentID) {
|
||||
...ConversationModalRepliesContainer_comment
|
||||
@arguments(count: $count, cursor: $cursor, orderBy: $orderBy)
|
||||
}
|
||||
}
|
||||
`,
|
||||
}
|
||||
)(ConversationModalRepliesContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import {
|
||||
graphql,
|
||||
QueryRenderData,
|
||||
QueryRenderer,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { CallOut, Spinner } from "coral-ui/components/v2";
|
||||
|
||||
import { ConversationModalRepliesQuery as QueryTypes } from "coral-admin/__generated__/ConversationModalRepliesQuery.graphql";
|
||||
import ConversationModalCommentContainer from "./ConversationModalCommentContainer";
|
||||
|
||||
interface Props {
|
||||
commentID: string;
|
||||
onUsernameClicked: (userID: string) => void;
|
||||
}
|
||||
|
||||
const ConversationModalRepliesQuery: FunctionComponent<Props> = ({
|
||||
commentID,
|
||||
onUsernameClicked,
|
||||
}) => {
|
||||
return (
|
||||
<QueryRenderer<QueryTypes>
|
||||
query={graphql`
|
||||
query ConversationModalRepliesQuery($commentID: ID!) {
|
||||
settings {
|
||||
...ConversationModalCommentContainer_settings
|
||||
}
|
||||
comment(id: $commentID) {
|
||||
replies {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...ConversationModalCommentContainer_comment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
variables={{ commentID }}
|
||||
cacheConfig={{ force: true }}
|
||||
render={({ props }: QueryRenderData<QueryTypes>) => {
|
||||
if (!props) {
|
||||
return (
|
||||
<div>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!props.comment) {
|
||||
return (
|
||||
<div>
|
||||
<CallOut>
|
||||
<Localized id="conversation-modal-comment-not-found">
|
||||
Comment not found.
|
||||
</Localized>
|
||||
</CallOut>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const replies = props.comment.replies.edges.map(edge => edge.node);
|
||||
return (
|
||||
<div>
|
||||
{replies.map(reply => (
|
||||
<div key={reply.id}>
|
||||
<ConversationModalCommentContainer
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
settings={props.settings}
|
||||
isHighlighted={false}
|
||||
isReply={true}
|
||||
onUsernameClick={onUsernameClicked}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversationModalRepliesQuery;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./ConversationModal";
|
||||
@@ -13,16 +13,39 @@ interface Props {
|
||||
}
|
||||
|
||||
const CommentAuthorContainer: FunctionComponent<Props> = ({ comment }) => {
|
||||
if (!comment.author || !comment.author.status.ban.active) {
|
||||
if (
|
||||
!comment.author ||
|
||||
!(
|
||||
comment.author.status.ban.active ||
|
||||
comment.author.status.premod.active ||
|
||||
comment.author.status.suspension.active
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className={styles.authorStatus}>
|
||||
<Localized id="commentAuthor-status-banned">
|
||||
<Tag color="error">BANNED</Tag>
|
||||
</Localized>
|
||||
</div>
|
||||
{comment.author.status.ban.active && (
|
||||
<div className={styles.authorStatus}>
|
||||
<Localized id="commentAuthor-status-banned">
|
||||
<Tag color="error">BANNED</Tag>
|
||||
</Localized>
|
||||
</div>
|
||||
)}
|
||||
{comment.author.status.suspension.active && (
|
||||
<div className={styles.authorStatus}>
|
||||
<Localized id="commentAuthor-status-suspended">
|
||||
<Tag color="error">SUSPENDED</Tag>
|
||||
</Localized>
|
||||
</div>
|
||||
)}
|
||||
{comment.author.status.premod.active && (
|
||||
<div className={styles.authorStatus}>
|
||||
<Localized id="commentAuthor-status-premod">
|
||||
<Tag color="grey">PRE-MOD</Tag>
|
||||
</Localized>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -34,6 +57,12 @@ const enhanced = withFragmentContainer<Props>({
|
||||
id
|
||||
username
|
||||
status {
|
||||
suspension {
|
||||
active
|
||||
}
|
||||
premod {
|
||||
active
|
||||
}
|
||||
ban {
|
||||
active
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
|
||||
import { getPhrasesRegExp, GetPhrasesRegExpOptions } from "coral-admin/helpers";
|
||||
import { createPurify } from "coral-common/utils/purify";
|
||||
|
||||
import styles from "./CommentContent.css";
|
||||
|
||||
/**
|
||||
* Create a purify instance that will be used to handle HTML content.
|
||||
*/
|
||||
const purify = createPurify(window, false);
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
children: string | React.ReactElement;
|
||||
phrases: GetPhrasesRegExpOptions;
|
||||
}
|
||||
|
||||
function escapeHTML(unsafe: string) {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// markPhrasesHTML looks for `supsect` and `banned` words inside `text` given
|
||||
// the settings applied for the locale and highlights them by returning an HTML
|
||||
// string.
|
||||
function markPhrasesHTML(text: string, expression: RegExp) {
|
||||
const tokens = text.split(expression);
|
||||
if (tokens.length === 1) {
|
||||
return text;
|
||||
}
|
||||
return tokens
|
||||
.map((token, i) =>
|
||||
// Using our Regexp patterns it returns tokens arranged this way
|
||||
// [STRING_WITH_NO_MATCH, NEW_WORD_DELIMITER, MATCHED_WORD, ...].
|
||||
// This pattern repeats throughout. Next line will mark MATCHED_WORD
|
||||
// and escape all tokens.
|
||||
i % 3 === 2 ? `<mark>${escapeHTML(token)}</mark>` : escapeHTML(token)
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
// markHTMLNode manipulates the node by looking for #text nodes and adding markers
|
||||
// for `supsectWords` and `bannedWords`.
|
||||
function markHTMLNode(parentNode: Node, expression: RegExp) {
|
||||
parentNode.childNodes.forEach(node => {
|
||||
if (node.nodeName === "#text") {
|
||||
const newContent = markPhrasesHTML(node.textContent!, expression);
|
||||
if (newContent !== node.textContent) {
|
||||
const newNode = document.createElement("span");
|
||||
newNode.innerHTML = newContent;
|
||||
parentNode.replaceChild(newNode, node);
|
||||
}
|
||||
} else {
|
||||
markHTMLNode(node, expression);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const CommentContent: FunctionComponent<Props> = ({
|
||||
phrases,
|
||||
className,
|
||||
children,
|
||||
}) => {
|
||||
// Cache the expression used via memo. This will reduce duplicate renders of
|
||||
// this comment content when the children change but the phrase configuration
|
||||
// does not change. The regExp is already cached on a deeper level
|
||||
// automatically, this is just lessening that impact further.
|
||||
const expression = useMemo(() => getPhrasesRegExp(phrases), [phrases]);
|
||||
|
||||
if (typeof children === "string") {
|
||||
// We create a Shadow DOM Tree with the HTML body content and
|
||||
// use it as a parser.
|
||||
const node = document.createElement("div");
|
||||
node.innerHTML = purify.sanitize(children);
|
||||
|
||||
if (expression) {
|
||||
// Then we traverse it recursively and manipulate it to highlight suspect words
|
||||
// and banned words.
|
||||
markHTMLNode(node, expression);
|
||||
}
|
||||
|
||||
// Finally we render the content of the Shadow DOM Tree
|
||||
return (
|
||||
<div
|
||||
className={cn(className, styles.root)}
|
||||
dangerouslySetInnerHTML={{ __html: node.innerHTML }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default CommentContent;
|
||||
@@ -6,7 +6,7 @@ import { HorizontalGutter, Timestamp } from "coral-ui/components/v2";
|
||||
import { CommentRevisionContainer_comment as CommentData } from "coral-admin/__generated__/CommentRevisionContainer_comment.graphql";
|
||||
import { CommentRevisionContainer_settings as SettingsData } from "coral-admin/__generated__/CommentRevisionContainer_settings.graphql";
|
||||
|
||||
import CommentContent from "./CommentContent";
|
||||
import { CommentContent } from "../Comment";
|
||||
|
||||
interface Props {
|
||||
comment: CommentData;
|
||||
|
||||
@@ -9,8 +9,3 @@
|
||||
display: inline-block;
|
||||
padding-right: var(--spacing-2);
|
||||
}
|
||||
|
||||
.button {
|
||||
border-bottom-left-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import CopyToClipboard from "react-copy-to-clipboard";
|
||||
import { getURLWithCommentID } from "coral-framework/helpers";
|
||||
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { getLocationOrigin } from "coral-framework/utils";
|
||||
import { Button, Icon } from "coral-ui/components";
|
||||
import { Button, Icon } from "coral-ui/components/v2";
|
||||
|
||||
import { LinkDetailsContainer_comment } from "coral-admin/__generated__/LinkDetailsContainer_comment.graphql";
|
||||
import { LinkDetailsContainer_settings } from "coral-admin/__generated__/LinkDetailsContainer_settings.graphql";
|
||||
@@ -32,12 +32,7 @@ const LinkDetailsContainer: FunctionComponent<Props> = ({
|
||||
<CopyToClipboard
|
||||
text={getURLWithCommentID(comment.story.url, comment.id)}
|
||||
>
|
||||
<Button
|
||||
color="primary"
|
||||
variant="filled"
|
||||
size="small"
|
||||
className={styles.button}
|
||||
>
|
||||
<Button>
|
||||
<Icon size="md">link</Icon>
|
||||
<Localized id="moderate-in-stream-link-copy">
|
||||
<span>In Stream</span>
|
||||
@@ -49,12 +44,7 @@ const LinkDetailsContainer: FunctionComponent<Props> = ({
|
||||
<CopyToClipboard
|
||||
text={`${getLocationOrigin()}/admin/moderate/comment/${comment.id}`}
|
||||
>
|
||||
<Button
|
||||
color="primary"
|
||||
variant="filled"
|
||||
size="small"
|
||||
className={styles.button}
|
||||
>
|
||||
<Button>
|
||||
<Icon size="md">link</Icon>
|
||||
<Localized id="moderate-in-moderation-link-copy">
|
||||
<span>In Moderation</span>
|
||||
|
||||
@@ -15,26 +15,6 @@ $moderateCardLinkTextColor: var(--v2-colors-teal-700);
|
||||
margin-bottom: var(--v2-spacing-4);
|
||||
}
|
||||
|
||||
.usernameButton {
|
||||
margin-right: var(--mini-unit);
|
||||
padding: var(--spacing-1);
|
||||
margin-left: calc(-1 * var(--v2-spacing-1));
|
||||
|
||||
line-height: var(--v2-line-height-reset);
|
||||
|
||||
&:hover {
|
||||
background-color: $moderateCardUsernameBackgroundHover;
|
||||
border-radius: var(--v2-round-corners);
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: $moderateCardUsernameBackgroundHover;
|
||||
border-radius: var(--v2-round-corners);
|
||||
border-style: none;
|
||||
}
|
||||
}
|
||||
|
||||
.inReplyTo {
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ const baseProps: PropTypesOf<typeof ModerateCardN> = {
|
||||
onBan: noop,
|
||||
onUsernameClick: noop,
|
||||
onFocusOrClick: noop,
|
||||
onConversationClick: noop,
|
||||
showStory: false,
|
||||
moderatedBy: null,
|
||||
};
|
||||
|
||||
@@ -13,7 +13,8 @@ import { HOTKEYS } from "coral-admin/constants";
|
||||
import { GetPhrasesRegExpOptions } from "coral-admin/helpers";
|
||||
import { PropTypesOf } from "coral-framework/types";
|
||||
import {
|
||||
BaseButton,
|
||||
Button,
|
||||
ButtonIcon,
|
||||
Card,
|
||||
Flex,
|
||||
HorizontalGutter,
|
||||
@@ -22,14 +23,12 @@ import {
|
||||
Timestamp,
|
||||
} from "coral-ui/components/v2";
|
||||
|
||||
import { CommentContent, InReplyTo, UsernameButton } from "../Comment";
|
||||
import ApproveButton from "./ApproveButton";
|
||||
import CommentAuthorContainer from "./CommentAuthorContainer";
|
||||
import CommentContent from "./CommentContent";
|
||||
import FeatureButton from "./FeatureButton";
|
||||
import InReplyTo from "./InReplyTo";
|
||||
import MarkersContainer from "./MarkersContainer";
|
||||
import RejectButton from "./RejectButton";
|
||||
import Username from "./Username";
|
||||
|
||||
import styles from "./ModerateCard.css";
|
||||
|
||||
@@ -38,6 +37,7 @@ interface Props {
|
||||
username: string;
|
||||
createdAt: string;
|
||||
body: string;
|
||||
highlight?: boolean;
|
||||
inReplyTo?: {
|
||||
id: string;
|
||||
username: string | null;
|
||||
@@ -59,6 +59,7 @@ interface Props {
|
||||
onReject: () => void;
|
||||
onFeature: () => void;
|
||||
onUsernameClick: (id?: string) => void;
|
||||
onConversationClick: (() => void) | null;
|
||||
onFocusOrClick: () => void;
|
||||
mini?: boolean;
|
||||
hideUsername?: boolean;
|
||||
@@ -82,6 +83,7 @@ const ModerateCard: FunctionComponent<Props> = ({
|
||||
username,
|
||||
createdAt,
|
||||
body,
|
||||
highlight = false,
|
||||
inReplyTo,
|
||||
comment,
|
||||
settings,
|
||||
@@ -102,6 +104,7 @@ const ModerateCard: FunctionComponent<Props> = ({
|
||||
moderatedBy,
|
||||
selected,
|
||||
onFocusOrClick,
|
||||
onConversationClick,
|
||||
mini = false,
|
||||
hideUsername = false,
|
||||
deleted = false,
|
||||
@@ -190,12 +193,10 @@ const ModerateCard: FunctionComponent<Props> = ({
|
||||
<Flex alignItems="center">
|
||||
{!hideUsername && username && (
|
||||
<>
|
||||
<BaseButton
|
||||
<UsernameButton
|
||||
username={username}
|
||||
onClick={commentAuthorClick}
|
||||
className={styles.usernameButton}
|
||||
>
|
||||
<Username>{username}</Username>
|
||||
</BaseButton>
|
||||
/>
|
||||
<CommentAuthorContainer comment={comment} />
|
||||
</>
|
||||
)}
|
||||
@@ -220,20 +221,23 @@ const ModerateCard: FunctionComponent<Props> = ({
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.contentArea}>
|
||||
<CommentContent phrases={phrases} className={styles.content}>
|
||||
<CommentContent
|
||||
highlight={highlight}
|
||||
phrases={phrases}
|
||||
className={styles.content}
|
||||
>
|
||||
{commentBody}
|
||||
</CommentContent>
|
||||
<div className={styles.viewContext}>
|
||||
<Localized id="moderate-comment-viewContext">
|
||||
<TextLink
|
||||
className={styles.link}
|
||||
href={viewContextHref}
|
||||
target="_blank"
|
||||
>
|
||||
View Context
|
||||
</TextLink>
|
||||
</Localized>
|
||||
</div>
|
||||
{onConversationClick && (
|
||||
<div className={styles.viewContext}>
|
||||
<Button iconLeft variant="text" onClick={onConversationClick}>
|
||||
<ButtonIcon>question_answer</ButtonIcon>
|
||||
<Localized id="moderate-comment-viewConversation">
|
||||
<span>View conversation</span>
|
||||
</Localized>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(styles.separator, {
|
||||
[styles.ruledSeparator]: !mini,
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
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";
|
||||
@@ -32,7 +31,6 @@ import UnfeatureCommentMutation from "./UnfeatureCommentMutation";
|
||||
|
||||
interface Props {
|
||||
comment: ModerateCardContainer_comment;
|
||||
viewer: ModerateCardContainer_viewer;
|
||||
settings: ModerateCardContainer_settings;
|
||||
approveComment: MutationProp<typeof ApproveCommentMutation>;
|
||||
rejectComment: MutationProp<typeof RejectCommentMutation>;
|
||||
@@ -46,6 +44,7 @@ interface Props {
|
||||
mini?: boolean;
|
||||
hideUsername?: boolean;
|
||||
onUsernameClicked?: (userID: string) => void;
|
||||
onConversationClicked?: (commentID: string) => void;
|
||||
onSetSelected?: () => void;
|
||||
selected?: boolean;
|
||||
selectPrev?: () => void;
|
||||
@@ -73,7 +72,6 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
|
||||
settings,
|
||||
danglingLogic,
|
||||
showStoryInfo,
|
||||
viewer,
|
||||
match,
|
||||
router,
|
||||
approveComment,
|
||||
@@ -86,6 +84,7 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
|
||||
selectPrev,
|
||||
selectNext,
|
||||
onUsernameClicked: usernameClicked,
|
||||
onConversationClicked: conversationClicked,
|
||||
onSetSelected: setSelected,
|
||||
banUser,
|
||||
loadNext,
|
||||
@@ -160,6 +159,13 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
|
||||
[usernameClicked, comment]
|
||||
);
|
||||
|
||||
const onConversationClicked = useCallback(() => {
|
||||
if (!conversationClicked) {
|
||||
return;
|
||||
}
|
||||
conversationClicked(comment.id);
|
||||
}, [conversationClicked, comment]);
|
||||
|
||||
const handleModerateStory = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
router.push(getModerationLink({ storyID: comment.story.id }));
|
||||
@@ -203,6 +209,16 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
|
||||
},
|
||||
[comment]
|
||||
);
|
||||
|
||||
// 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;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FadeInTransition active={Boolean(comment.enteredLive)}>
|
||||
@@ -215,6 +231,7 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
|
||||
}
|
||||
createdAt={comment.createdAt}
|
||||
body={comment.body!}
|
||||
highlight={highlight}
|
||||
inReplyTo={comment.parent && comment.parent.author}
|
||||
comment={comment}
|
||||
settings={settings}
|
||||
@@ -227,6 +244,9 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
|
||||
onReject={handleReject}
|
||||
onFeature={onFeature}
|
||||
onUsernameClick={onUsernameClicked}
|
||||
onConversationClick={
|
||||
conversationClicked ? onConversationClicked : null
|
||||
}
|
||||
selected={selected}
|
||||
selectPrev={selectPrev}
|
||||
selectNext={selectNext}
|
||||
@@ -235,7 +255,6 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
|
||||
moderatedBy={
|
||||
<ModeratedByContainer
|
||||
onUsernameClicked={onUsernameClicked}
|
||||
viewer={viewer}
|
||||
comment={comment}
|
||||
/>
|
||||
}
|
||||
@@ -283,6 +302,16 @@ const enhanced = withFragmentContainer<Props>({
|
||||
statusLiveUpdated
|
||||
createdAt
|
||||
body
|
||||
revision {
|
||||
actionCounts {
|
||||
flag {
|
||||
reasons {
|
||||
COMMENT_DETECTED_BANNED_WORD
|
||||
COMMENT_DETECTED_SUSPECT_WORD
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tags {
|
||||
code
|
||||
}
|
||||
@@ -332,11 +361,6 @@ const enhanced = withFragmentContainer<Props>({
|
||||
...MarkersContainer_settings
|
||||
}
|
||||
`,
|
||||
viewer: graphql`
|
||||
fragment ModerateCardContainer_viewer on User {
|
||||
...ModeratedByContainer_viewer
|
||||
}
|
||||
`,
|
||||
})(
|
||||
withRouter(
|
||||
withMutation(BanCommentUserMutation)(
|
||||
|
||||
@@ -24,10 +24,6 @@ it("viewer's username shows on moderation cards moderated by viewer", () => {
|
||||
],
|
||||
},
|
||||
},
|
||||
viewer: {
|
||||
id: "viewer",
|
||||
username: "viewer",
|
||||
},
|
||||
onUsernameClicked: (id?: string | null) => {
|
||||
return;
|
||||
},
|
||||
|
||||
@@ -1,55 +1,88 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { useCallback } from "react";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { BaseButton } from "coral-ui/components/v2";
|
||||
|
||||
import { ModeratedByContainer_comment } from "coral-admin/__generated__/ModeratedByContainer_comment.graphql";
|
||||
import { ModeratedByContainer_viewer } from "coral-admin/__generated__/ModeratedByContainer_viewer.graphql";
|
||||
|
||||
import styles from "./ModeratedByContainer.css";
|
||||
|
||||
interface Props {
|
||||
viewer: ModeratedByContainer_viewer;
|
||||
comment: ModeratedByContainer_comment;
|
||||
onUsernameClicked: (id?: string | null) => void;
|
||||
}
|
||||
|
||||
const MODERATION_STATUS = ["APPROVED", "REJECTED"];
|
||||
|
||||
interface ModeratedBy {
|
||||
id?: string;
|
||||
username?: string | null;
|
||||
system?: boolean;
|
||||
}
|
||||
|
||||
const system: ModeratedBy = { system: true };
|
||||
|
||||
const ModeratedByContainer: React.FunctionComponent<Props> = ({
|
||||
comment,
|
||||
viewer,
|
||||
onUsernameClicked,
|
||||
}) => {
|
||||
let moderatedBy: React.ReactElement | null;
|
||||
let id: string | null = null;
|
||||
if (comment.statusHistory.edges.length === 0) {
|
||||
moderatedBy = null;
|
||||
} else if (comment.statusHistory.edges[0].node.moderator === null) {
|
||||
moderatedBy = (
|
||||
<Localized id="moderate-comment-moderatedBySystem">System</Localized>
|
||||
);
|
||||
} else if (viewer.id === comment.statusHistory.edges[0].node.moderator.id) {
|
||||
moderatedBy = viewer.username ? <>{viewer.username}</> : null;
|
||||
} else {
|
||||
moderatedBy = <>{comment.statusHistory.edges[0].node.moderator.username}</>;
|
||||
id = comment.statusHistory.edges[0].node.moderator.id;
|
||||
}
|
||||
const moderatedBy: ModeratedBy | null = useMemo(() => {
|
||||
// If the comment was just moderated by the viewer, don't display anything.
|
||||
// This will update once the mutation returns.
|
||||
if (comment.viewerDidModerate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the comment has not been approved or rejected, don't render anything.
|
||||
if (!MODERATION_STATUS.includes(comment.status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (comment.statusHistory.edges.length === 0) {
|
||||
return system;
|
||||
}
|
||||
|
||||
// Get the node that was the last moderated by element.
|
||||
const {
|
||||
node: { status, moderator },
|
||||
} = comment.statusHistory.edges[0];
|
||||
if (!MODERATION_STATUS.includes(status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!moderator) {
|
||||
return system;
|
||||
}
|
||||
|
||||
return moderator;
|
||||
}, [comment]);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
if (!moderatedBy || !moderatedBy.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
onUsernameClicked(moderatedBy.id);
|
||||
}, [onUsernameClicked, moderatedBy]);
|
||||
|
||||
if (!moderatedBy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
onUsernameClicked(id);
|
||||
}, [onUsernameClicked, comment]);
|
||||
|
||||
return (
|
||||
<BaseButton onClick={onClick}>
|
||||
<Localized id="moderate-comment-moderatedBy">
|
||||
<div className={styles.moderatedBy}>Moderated By</div>
|
||||
</Localized>
|
||||
<div className={styles.moderatedByUsername}>{moderatedBy}</div>
|
||||
<div className={styles.moderatedByUsername}>
|
||||
{moderatedBy.system ? (
|
||||
<Localized id="moderate-comment-moderatedBySystem">System</Localized>
|
||||
) : (
|
||||
moderatedBy.username
|
||||
)}
|
||||
</div>
|
||||
</BaseButton>
|
||||
);
|
||||
};
|
||||
@@ -58,10 +91,12 @@ const enhanced = withFragmentContainer<Props>({
|
||||
comment: graphql`
|
||||
fragment ModeratedByContainer_comment on Comment {
|
||||
id
|
||||
statusLiveUpdated
|
||||
status
|
||||
viewerDidModerate
|
||||
statusHistory(first: 1) {
|
||||
edges {
|
||||
node {
|
||||
status
|
||||
moderator {
|
||||
id
|
||||
username
|
||||
@@ -71,12 +106,6 @@ const enhanced = withFragmentContainer<Props>({
|
||||
}
|
||||
}
|
||||
`,
|
||||
viewer: graphql`
|
||||
fragment ModeratedByContainer_viewer on User {
|
||||
id
|
||||
username
|
||||
}
|
||||
`,
|
||||
})(ModeratedByContainer);
|
||||
|
||||
export default enhanced;
|
||||
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<div
|
||||
className="custom CommentContent-root"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "Hello <b>Bob</b><span>, you <mark>bad</mark> guy</span>",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`renders empty words correctly 1`] = `
|
||||
<div
|
||||
className="custom CommentContent-root"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "Hello <b>Bob</b>, you bad guy",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
+126
-112
@@ -19,14 +19,10 @@ exports[`renders approved correctly 1`] = `
|
||||
alignItems="center"
|
||||
>
|
||||
<React.Fragment>
|
||||
<ForwardRef(forwardRef)
|
||||
className="ModerateCard-usernameButton"
|
||||
<UsernameButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
<Username>
|
||||
Theon
|
||||
</Username>
|
||||
</ForwardRef(forwardRef)>
|
||||
username="Theon"
|
||||
/>
|
||||
<Relay(CommentAuthorContainer)
|
||||
comment={Object {}}
|
||||
/>
|
||||
@@ -48,6 +44,7 @@ exports[`renders approved correctly 1`] = `
|
||||
>
|
||||
<CommentContent
|
||||
className="ModerateCard-content"
|
||||
highlight={false}
|
||||
phrases={
|
||||
Object {
|
||||
"locale": "en-US",
|
||||
@@ -67,17 +64,22 @@ exports[`renders approved correctly 1`] = `
|
||||
<div
|
||||
className="ModerateCard-viewContext"
|
||||
>
|
||||
<Localized
|
||||
id="moderate-comment-viewContext"
|
||||
<ForwardRef(forwardRef)
|
||||
iconLeft={true}
|
||||
onClick={[Function]}
|
||||
variant="text"
|
||||
>
|
||||
<withPropsOnChange(TextLinkProps)
|
||||
className="ModerateCard-link"
|
||||
href="http://localhost/comment"
|
||||
target="_blank"
|
||||
<ForwardRef(forwardRef)>
|
||||
question_answer
|
||||
</ForwardRef(forwardRef)>
|
||||
<Localized
|
||||
id="moderate-comment-viewConversation"
|
||||
>
|
||||
View Context
|
||||
</withPropsOnChange(TextLinkProps)>
|
||||
</Localized>
|
||||
<span>
|
||||
View conversation
|
||||
</span>
|
||||
</Localized>
|
||||
</ForwardRef(forwardRef)>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator ModerateCard-ruledSeparator"
|
||||
@@ -152,14 +154,10 @@ exports[`renders correctly 1`] = `
|
||||
alignItems="center"
|
||||
>
|
||||
<React.Fragment>
|
||||
<ForwardRef(forwardRef)
|
||||
className="ModerateCard-usernameButton"
|
||||
<UsernameButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
<Username>
|
||||
Theon
|
||||
</Username>
|
||||
</ForwardRef(forwardRef)>
|
||||
username="Theon"
|
||||
/>
|
||||
<Relay(CommentAuthorContainer)
|
||||
comment={Object {}}
|
||||
/>
|
||||
@@ -181,6 +179,7 @@ exports[`renders correctly 1`] = `
|
||||
>
|
||||
<CommentContent
|
||||
className="ModerateCard-content"
|
||||
highlight={false}
|
||||
phrases={
|
||||
Object {
|
||||
"locale": "en-US",
|
||||
@@ -200,17 +199,22 @@ exports[`renders correctly 1`] = `
|
||||
<div
|
||||
className="ModerateCard-viewContext"
|
||||
>
|
||||
<Localized
|
||||
id="moderate-comment-viewContext"
|
||||
<ForwardRef(forwardRef)
|
||||
iconLeft={true}
|
||||
onClick={[Function]}
|
||||
variant="text"
|
||||
>
|
||||
<withPropsOnChange(TextLinkProps)
|
||||
className="ModerateCard-link"
|
||||
href="http://localhost/comment"
|
||||
target="_blank"
|
||||
<ForwardRef(forwardRef)>
|
||||
question_answer
|
||||
</ForwardRef(forwardRef)>
|
||||
<Localized
|
||||
id="moderate-comment-viewConversation"
|
||||
>
|
||||
View Context
|
||||
</withPropsOnChange(TextLinkProps)>
|
||||
</Localized>
|
||||
<span>
|
||||
View conversation
|
||||
</span>
|
||||
</Localized>
|
||||
</ForwardRef(forwardRef)>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator ModerateCard-ruledSeparator"
|
||||
@@ -285,14 +289,10 @@ exports[`renders dangling correctly 1`] = `
|
||||
alignItems="center"
|
||||
>
|
||||
<React.Fragment>
|
||||
<ForwardRef(forwardRef)
|
||||
className="ModerateCard-usernameButton"
|
||||
<UsernameButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
<Username>
|
||||
Theon
|
||||
</Username>
|
||||
</ForwardRef(forwardRef)>
|
||||
username="Theon"
|
||||
/>
|
||||
<Relay(CommentAuthorContainer)
|
||||
comment={Object {}}
|
||||
/>
|
||||
@@ -314,6 +314,7 @@ exports[`renders dangling correctly 1`] = `
|
||||
>
|
||||
<CommentContent
|
||||
className="ModerateCard-content"
|
||||
highlight={false}
|
||||
phrases={
|
||||
Object {
|
||||
"locale": "en-US",
|
||||
@@ -333,17 +334,22 @@ exports[`renders dangling correctly 1`] = `
|
||||
<div
|
||||
className="ModerateCard-viewContext"
|
||||
>
|
||||
<Localized
|
||||
id="moderate-comment-viewContext"
|
||||
<ForwardRef(forwardRef)
|
||||
iconLeft={true}
|
||||
onClick={[Function]}
|
||||
variant="text"
|
||||
>
|
||||
<withPropsOnChange(TextLinkProps)
|
||||
className="ModerateCard-link"
|
||||
href="http://localhost/comment"
|
||||
target="_blank"
|
||||
<ForwardRef(forwardRef)>
|
||||
question_answer
|
||||
</ForwardRef(forwardRef)>
|
||||
<Localized
|
||||
id="moderate-comment-viewConversation"
|
||||
>
|
||||
View Context
|
||||
</withPropsOnChange(TextLinkProps)>
|
||||
</Localized>
|
||||
<span>
|
||||
View conversation
|
||||
</span>
|
||||
</Localized>
|
||||
</ForwardRef(forwardRef)>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator ModerateCard-ruledSeparator"
|
||||
@@ -418,14 +424,10 @@ exports[`renders rejected correctly 1`] = `
|
||||
alignItems="center"
|
||||
>
|
||||
<React.Fragment>
|
||||
<ForwardRef(forwardRef)
|
||||
className="ModerateCard-usernameButton"
|
||||
<UsernameButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
<Username>
|
||||
Theon
|
||||
</Username>
|
||||
</ForwardRef(forwardRef)>
|
||||
username="Theon"
|
||||
/>
|
||||
<Relay(CommentAuthorContainer)
|
||||
comment={Object {}}
|
||||
/>
|
||||
@@ -447,6 +449,7 @@ exports[`renders rejected correctly 1`] = `
|
||||
>
|
||||
<CommentContent
|
||||
className="ModerateCard-content"
|
||||
highlight={false}
|
||||
phrases={
|
||||
Object {
|
||||
"locale": "en-US",
|
||||
@@ -466,17 +469,22 @@ exports[`renders rejected correctly 1`] = `
|
||||
<div
|
||||
className="ModerateCard-viewContext"
|
||||
>
|
||||
<Localized
|
||||
id="moderate-comment-viewContext"
|
||||
<ForwardRef(forwardRef)
|
||||
iconLeft={true}
|
||||
onClick={[Function]}
|
||||
variant="text"
|
||||
>
|
||||
<withPropsOnChange(TextLinkProps)
|
||||
className="ModerateCard-link"
|
||||
href="http://localhost/comment"
|
||||
target="_blank"
|
||||
<ForwardRef(forwardRef)>
|
||||
question_answer
|
||||
</ForwardRef(forwardRef)>
|
||||
<Localized
|
||||
id="moderate-comment-viewConversation"
|
||||
>
|
||||
View Context
|
||||
</withPropsOnChange(TextLinkProps)>
|
||||
</Localized>
|
||||
<span>
|
||||
View conversation
|
||||
</span>
|
||||
</Localized>
|
||||
</ForwardRef(forwardRef)>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator ModerateCard-ruledSeparator"
|
||||
@@ -551,14 +559,10 @@ exports[`renders reply correctly 1`] = `
|
||||
alignItems="center"
|
||||
>
|
||||
<React.Fragment>
|
||||
<ForwardRef(forwardRef)
|
||||
className="ModerateCard-usernameButton"
|
||||
<UsernameButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
<Username>
|
||||
Theon
|
||||
</Username>
|
||||
</ForwardRef(forwardRef)>
|
||||
username="Theon"
|
||||
/>
|
||||
<Relay(CommentAuthorContainer)
|
||||
comment={Object {}}
|
||||
/>
|
||||
@@ -589,6 +593,7 @@ exports[`renders reply correctly 1`] = `
|
||||
>
|
||||
<CommentContent
|
||||
className="ModerateCard-content"
|
||||
highlight={false}
|
||||
phrases={
|
||||
Object {
|
||||
"locale": "en-US",
|
||||
@@ -608,17 +613,22 @@ exports[`renders reply correctly 1`] = `
|
||||
<div
|
||||
className="ModerateCard-viewContext"
|
||||
>
|
||||
<Localized
|
||||
id="moderate-comment-viewContext"
|
||||
<ForwardRef(forwardRef)
|
||||
iconLeft={true}
|
||||
onClick={[Function]}
|
||||
variant="text"
|
||||
>
|
||||
<withPropsOnChange(TextLinkProps)
|
||||
className="ModerateCard-link"
|
||||
href="http://localhost/comment"
|
||||
target="_blank"
|
||||
<ForwardRef(forwardRef)>
|
||||
question_answer
|
||||
</ForwardRef(forwardRef)>
|
||||
<Localized
|
||||
id="moderate-comment-viewConversation"
|
||||
>
|
||||
View Context
|
||||
</withPropsOnChange(TextLinkProps)>
|
||||
</Localized>
|
||||
<span>
|
||||
View conversation
|
||||
</span>
|
||||
</Localized>
|
||||
</ForwardRef(forwardRef)>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator ModerateCard-ruledSeparator"
|
||||
@@ -693,14 +703,10 @@ exports[`renders story info 1`] = `
|
||||
alignItems="center"
|
||||
>
|
||||
<React.Fragment>
|
||||
<ForwardRef(forwardRef)
|
||||
className="ModerateCard-usernameButton"
|
||||
<UsernameButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
<Username>
|
||||
Theon
|
||||
</Username>
|
||||
</ForwardRef(forwardRef)>
|
||||
username="Theon"
|
||||
/>
|
||||
<Relay(CommentAuthorContainer)
|
||||
comment={Object {}}
|
||||
/>
|
||||
@@ -722,6 +728,7 @@ exports[`renders story info 1`] = `
|
||||
>
|
||||
<CommentContent
|
||||
className="ModerateCard-content"
|
||||
highlight={false}
|
||||
phrases={
|
||||
Object {
|
||||
"locale": "en-US",
|
||||
@@ -741,17 +748,22 @@ exports[`renders story info 1`] = `
|
||||
<div
|
||||
className="ModerateCard-viewContext"
|
||||
>
|
||||
<Localized
|
||||
id="moderate-comment-viewContext"
|
||||
<ForwardRef(forwardRef)
|
||||
iconLeft={true}
|
||||
onClick={[Function]}
|
||||
variant="text"
|
||||
>
|
||||
<withPropsOnChange(TextLinkProps)
|
||||
className="ModerateCard-link"
|
||||
href="http://localhost/comment"
|
||||
target="_blank"
|
||||
<ForwardRef(forwardRef)>
|
||||
question_answer
|
||||
</ForwardRef(forwardRef)>
|
||||
<Localized
|
||||
id="moderate-comment-viewConversation"
|
||||
>
|
||||
View Context
|
||||
</withPropsOnChange(TextLinkProps)>
|
||||
</Localized>
|
||||
<span>
|
||||
View conversation
|
||||
</span>
|
||||
</Localized>
|
||||
</ForwardRef(forwardRef)>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator ModerateCard-ruledSeparator"
|
||||
@@ -864,14 +876,10 @@ exports[`renders tombstoned when comment is deleted 1`] = `
|
||||
alignItems="center"
|
||||
>
|
||||
<React.Fragment>
|
||||
<ForwardRef(forwardRef)
|
||||
className="ModerateCard-usernameButton"
|
||||
<UsernameButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
<Username>
|
||||
Theon
|
||||
</Username>
|
||||
</ForwardRef(forwardRef)>
|
||||
username="Theon"
|
||||
/>
|
||||
<Relay(CommentAuthorContainer)
|
||||
comment={Object {}}
|
||||
/>
|
||||
@@ -893,6 +901,7 @@ exports[`renders tombstoned when comment is deleted 1`] = `
|
||||
>
|
||||
<CommentContent
|
||||
className="ModerateCard-content"
|
||||
highlight={false}
|
||||
phrases={
|
||||
Object {
|
||||
"locale": "en-US",
|
||||
@@ -920,17 +929,22 @@ exports[`renders tombstoned when comment is deleted 1`] = `
|
||||
<div
|
||||
className="ModerateCard-viewContext"
|
||||
>
|
||||
<Localized
|
||||
id="moderate-comment-viewContext"
|
||||
<ForwardRef(forwardRef)
|
||||
iconLeft={true}
|
||||
onClick={[Function]}
|
||||
variant="text"
|
||||
>
|
||||
<withPropsOnChange(TextLinkProps)
|
||||
className="ModerateCard-link"
|
||||
href="http://localhost/comment"
|
||||
target="_blank"
|
||||
<ForwardRef(forwardRef)>
|
||||
question_answer
|
||||
</ForwardRef(forwardRef)>
|
||||
<Localized
|
||||
id="moderate-comment-viewConversation"
|
||||
>
|
||||
View Context
|
||||
</withPropsOnChange(TextLinkProps)>
|
||||
</Localized>
|
||||
<span>
|
||||
View conversation
|
||||
</span>
|
||||
</Localized>
|
||||
</ForwardRef(forwardRef)>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator ModerateCard-ruledSeparator"
|
||||
|
||||
-6
@@ -19,11 +19,5 @@ exports[`viewer's username shows on moderation cards moderated by viewer 1`] = `
|
||||
}
|
||||
}
|
||||
onUsernameClicked={[Function]}
|
||||
viewer={
|
||||
Object {
|
||||
"id": "viewer",
|
||||
"username": "viewer",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
.sizeReg {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.sizeSmall {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.colorReg {
|
||||
color: var(--v2-colors-grey-500);
|
||||
}
|
||||
|
||||
.colorLight {
|
||||
color: var(--v2-colors-grey-400);
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.circle {
|
||||
fill: currentColor;
|
||||
stroke: currentColor;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import styles from "./Circle.css";
|
||||
|
||||
export interface CircleProps {
|
||||
className?: string;
|
||||
hollow?: boolean;
|
||||
size?: "small" | "regular";
|
||||
color?: "light" | "regular";
|
||||
}
|
||||
|
||||
const Circle: FunctionComponent<CircleProps> = props => {
|
||||
return (
|
||||
<div
|
||||
className={cn(styles.root, props.className, {
|
||||
[styles.sizeReg]: props.size === "regular",
|
||||
[styles.sizeSmall]: props.size === "small",
|
||||
[styles.colorReg]: props.color === "regular",
|
||||
[styles.colorLight]: props.color === "light",
|
||||
})}
|
||||
>
|
||||
<svg
|
||||
className={cn(styles.circle, {})}
|
||||
viewBox="0 0 100 100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{props.hollow && (
|
||||
<circle cx="50%" cy="50%" r="45" strokeWidth="10" fill="none" />
|
||||
)}
|
||||
{!props.hollow && <circle cx="50%" cy="50%" r="50" />}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Circle.defaultProps = {
|
||||
size: "regular",
|
||||
color: "regular",
|
||||
};
|
||||
|
||||
export default Circle;
|
||||
@@ -0,0 +1,9 @@
|
||||
.root {
|
||||
border-left: 1px solid var(--palette-grey-main);
|
||||
border-right: 1px solid var(--palette-grey-main);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dotted {
|
||||
border-left-style: dotted;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import { createRenderer } from "react-test-renderer/shallow";
|
||||
|
||||
import { PropTypesOf } from "coral-framework/types";
|
||||
|
||||
import Line from "./Line";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const props: PropTypesOf<typeof Line> = {
|
||||
className: "root",
|
||||
dotted: true,
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<Line {...props} />);
|
||||
expect(renderer.getRenderOutput()).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import styles from "./Line.css";
|
||||
|
||||
interface LineProps {
|
||||
className?: string;
|
||||
dotted?: boolean;
|
||||
}
|
||||
|
||||
const Line: FunctionComponent<LineProps> = props => {
|
||||
return (
|
||||
<div
|
||||
className={cn(styles.root, props.className, {
|
||||
[styles.dotted]: props.dotted,
|
||||
})}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Line;
|
||||
@@ -0,0 +1,7 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<div
|
||||
className="Line-root root Line-dotted"
|
||||
/>
|
||||
`;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as Circle } from "./Circle";
|
||||
export { default as Line } from "./Line";
|
||||
@@ -11,21 +11,18 @@ 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";
|
||||
|
||||
interface Props {
|
||||
user: UserHistoryDrawerAllComments_user;
|
||||
viewer: UserHistoryDrawerAllComments_viewer;
|
||||
settings: UserHistoryDrawerAllComments_settings;
|
||||
relay: RelayPaginationProp;
|
||||
}
|
||||
|
||||
const UserHistoryDrawerAllComments: FunctionComponent<Props> = ({
|
||||
user,
|
||||
viewer,
|
||||
settings,
|
||||
relay,
|
||||
}) => {
|
||||
@@ -61,7 +58,6 @@ const UserHistoryDrawerAllComments: FunctionComponent<Props> = ({
|
||||
<div key={c.id}>
|
||||
<ModerateCardContainer
|
||||
comment={c}
|
||||
viewer={viewer}
|
||||
settings={settings}
|
||||
danglingLogic={status => false}
|
||||
hideUsername
|
||||
@@ -91,11 +87,6 @@ const enhanced = withPaginationContainer<
|
||||
FragmentVariables
|
||||
>(
|
||||
{
|
||||
viewer: graphql`
|
||||
fragment UserHistoryDrawerAllComments_viewer on User {
|
||||
...ModerateCardContainer_viewer
|
||||
}
|
||||
`,
|
||||
settings: graphql`
|
||||
fragment UserHistoryDrawerAllComments_settings on Settings {
|
||||
...ModerateCardContainer_settings
|
||||
|
||||
-5
@@ -24,9 +24,6 @@ const UserHistoryDrawerAllCommentsQuery: FunctionComponent<Props> = ({
|
||||
user(id: $userID) {
|
||||
...UserHistoryDrawerAllComments_user
|
||||
}
|
||||
viewer {
|
||||
...UserHistoryDrawerAllComments_viewer
|
||||
}
|
||||
settings {
|
||||
...UserHistoryDrawerAllComments_settings
|
||||
}
|
||||
@@ -57,8 +54,6 @@ const UserHistoryDrawerAllCommentsQuery: FunctionComponent<Props> = ({
|
||||
|
||||
return (
|
||||
<UserHistoryDrawerAllComments
|
||||
// We can never get to this part of the UI without being logged in.
|
||||
viewer={props.viewer!}
|
||||
settings={props.settings}
|
||||
user={props.user}
|
||||
/>
|
||||
|
||||
-9
@@ -11,21 +11,18 @@ 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";
|
||||
|
||||
interface Props {
|
||||
user: UserHistoryDrawerRejectedComments_user;
|
||||
viewer: UserHistoryDrawerRejectedComments_viewer;
|
||||
settings: UserHistoryDrawerRejectedComments_settings;
|
||||
relay: RelayPaginationProp;
|
||||
}
|
||||
|
||||
const UserHistoryDrawerRejectedComments: FunctionComponent<Props> = ({
|
||||
user,
|
||||
viewer,
|
||||
settings,
|
||||
relay,
|
||||
}) => {
|
||||
@@ -63,7 +60,6 @@ const UserHistoryDrawerRejectedComments: FunctionComponent<Props> = ({
|
||||
<div key={c.id}>
|
||||
<ModerateCardContainer
|
||||
comment={c}
|
||||
viewer={viewer}
|
||||
settings={settings}
|
||||
danglingLogic={status => false}
|
||||
hideUsername
|
||||
@@ -93,11 +89,6 @@ const enhanced = withPaginationContainer<
|
||||
FragmentVariables
|
||||
>(
|
||||
{
|
||||
viewer: graphql`
|
||||
fragment UserHistoryDrawerRejectedComments_viewer on User {
|
||||
...ModerateCardContainer_viewer
|
||||
}
|
||||
`,
|
||||
settings: graphql`
|
||||
fragment UserHistoryDrawerRejectedComments_settings on Settings {
|
||||
...ModerateCardContainer_settings
|
||||
|
||||
-5
@@ -22,9 +22,6 @@ const UserHistoryDrawerRejectedCommentsQuery: FunctionComponent<Props> = ({
|
||||
user(id: $userID) {
|
||||
...UserHistoryDrawerRejectedComments_user
|
||||
}
|
||||
viewer {
|
||||
...UserHistoryDrawerRejectedComments_viewer
|
||||
}
|
||||
settings {
|
||||
...UserHistoryDrawerRejectedComments_settings
|
||||
}
|
||||
@@ -55,8 +52,6 @@ const UserHistoryDrawerRejectedCommentsQuery: FunctionComponent<Props> = ({
|
||||
|
||||
return (
|
||||
<UserHistoryDrawerRejectedComments
|
||||
// We can never get to this part of the UI without being logged in.
|
||||
viewer={props.viewer!}
|
||||
settings={props.settings}
|
||||
user={props.user}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { lowerCase, uniqBy } from "lodash";
|
||||
|
||||
import { LanguageCode } from "coral-common/helpers";
|
||||
import { createWordListRegExp } from "coral-common/utils";
|
||||
import createWordListRegExp from "coral-common/utils/createWordListRegExp";
|
||||
|
||||
export interface GetPhrasesRegExpOptions {
|
||||
locale: string;
|
||||
@@ -17,20 +19,35 @@ export function getPhrasesRegExp({
|
||||
return null;
|
||||
}
|
||||
|
||||
return createWordListRegExp(locale as LanguageCode, [...banned, ...suspect]);
|
||||
// Because the banned and suspect word lists may sometimes overlap, we should
|
||||
// make this list as short as possible before compiling it into a RegExp.
|
||||
const phrases = uniqBy<string>([...banned, ...suspect], lowerCase);
|
||||
|
||||
// The locale is passed down to us from the Graph, we can cast it to a
|
||||
// LanguageCode.
|
||||
return createWordListRegExp(locale as LanguageCode, phrases);
|
||||
}
|
||||
|
||||
// cache is used as a global validator to the cached RegExp used by the
|
||||
// Cache is used as a global validator to the cached RegExp used by the
|
||||
// application. We expect that generally, there is only ever one word list used
|
||||
// by the client at a time, so this ensures that we only re-create the word list
|
||||
// if we must.
|
||||
const cache = {
|
||||
interface Cache {
|
||||
keys: {
|
||||
locale: string;
|
||||
suspect: ReadonlyArray<string>;
|
||||
banned: ReadonlyArray<string>;
|
||||
};
|
||||
value: RegExp | null;
|
||||
}
|
||||
|
||||
const cache: Cache = {
|
||||
keys: {
|
||||
locale: "",
|
||||
suspect: [] as ReadonlyArray<string>,
|
||||
banned: [] as ReadonlyArray<string>,
|
||||
suspect: [],
|
||||
banned: [],
|
||||
},
|
||||
value: null as RegExp | null,
|
||||
value: null,
|
||||
};
|
||||
|
||||
export default function(options: GetPhrasesRegExpOptions) {
|
||||
@@ -57,7 +74,12 @@ export default function(options: GetPhrasesRegExpOptions) {
|
||||
|
||||
// If the cache is expired, or the value doesn't exist, regenerate it.
|
||||
if (expired) {
|
||||
cache.value = getPhrasesRegExp(options);
|
||||
try {
|
||||
cache.value = getPhrasesRegExp(options);
|
||||
} catch (err) {
|
||||
window.console.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return cache.value;
|
||||
|
||||
@@ -3,3 +3,4 @@ export {
|
||||
default as getPhrasesRegExp,
|
||||
GetPhrasesRegExpOptions,
|
||||
} from "./getPhrasesRegExp";
|
||||
export { default as markHTMLNode } from "./markHTMLNode";
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// markPhrasesHTML looks for `suspect` and `banned` words inside `text` given
|
||||
// the settings applied for the locale and highlights them by returning an HTML
|
||||
// string.
|
||||
function markPhrasesHTML(text: string, expression: RegExp) {
|
||||
const tokens = text.split(expression);
|
||||
|
||||
// If there were less than two matches, then there was no matched word
|
||||
// associated with the passed in text.
|
||||
if (tokens.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tokens
|
||||
.map((token, i) =>
|
||||
// Using our Regexp patterns it returns tokens arranged this way:
|
||||
//
|
||||
// - STRING_WITH_NO_MATCH
|
||||
// - NEW_WORD_DELIMITER
|
||||
// - MATCHED_WORD
|
||||
// - NEW_WORD_DELIMITER
|
||||
// - ...
|
||||
//
|
||||
// This pattern repeats throughout. Next line will mark MATCHED_WORD.
|
||||
i % 4 === 2 ? "<mark>" + token + "</mark>" : token
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
// markHTMLNode manipulates the node by looking for #text nodes and adding
|
||||
// markers.
|
||||
export default function markHTMLNode(parentNode: Node, expression: RegExp) {
|
||||
parentNode.childNodes.forEach(node => {
|
||||
// Anchor links are already marked by default, skip them now.
|
||||
if (node.nodeName === "A") {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the node isn't of text type then we can't mark it directly.
|
||||
if (node.nodeName !== "#text") {
|
||||
return markHTMLNode(node, expression);
|
||||
}
|
||||
|
||||
// If the node doesn't have any text content, then we can't mark it either.
|
||||
if (!node.textContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We've encountered a text node with text content that isn't in an anchor
|
||||
// link. We should try to mark and replace it's content.
|
||||
const replacement = markPhrasesHTML(node.textContent, expression);
|
||||
if (replacement) {
|
||||
// Create the new span node to replace the old node with.
|
||||
const newNode = document.createElement("span");
|
||||
newNode.innerHTML = replacement;
|
||||
parentNode.replaceChild(newNode, node);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,9 @@ extend type Comment {
|
||||
|
||||
# If true then Comment came in live.
|
||||
enteredLive: Boolean
|
||||
|
||||
# If true then the Comment status was updated by the viewer.
|
||||
viewerDidModerate: Boolean
|
||||
}
|
||||
|
||||
extend type CommentsConnection {
|
||||
|
||||
@@ -40,15 +40,7 @@ const ApproveCommentMutation = createMutation(
|
||||
}
|
||||
}
|
||||
}
|
||||
statusHistory(first: 1) {
|
||||
edges {
|
||||
node {
|
||||
moderator {
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
...ModeratedByContainer_comment
|
||||
}
|
||||
moderationQueues(storyID: $storyID) {
|
||||
unmoderated {
|
||||
@@ -74,7 +66,9 @@ const ApproveCommentMutation = createMutation(
|
||||
storyID: input.storyID,
|
||||
},
|
||||
optimisticUpdater: store => {
|
||||
store.get(input.commentID)!.setValue("APPROVED", "status");
|
||||
const proxy = store.get(input.commentID)!;
|
||||
proxy.setValue("APPROVED", "status");
|
||||
proxy.setValue(true, "viewerDidModerate");
|
||||
},
|
||||
updater: store => {
|
||||
const connections = [
|
||||
|
||||
@@ -40,15 +40,7 @@ const RejectCommentMutation = createMutation(
|
||||
}
|
||||
}
|
||||
}
|
||||
statusHistory(first: 1) {
|
||||
edges {
|
||||
node {
|
||||
moderator {
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
...ModeratedByContainer_comment
|
||||
}
|
||||
moderationQueues(storyID: $storyID) {
|
||||
unmoderated {
|
||||
@@ -74,7 +66,9 @@ const RejectCommentMutation = createMutation(
|
||||
storyID: input.storyID,
|
||||
},
|
||||
optimisticUpdater: store => {
|
||||
store.get(input.commentID)!.setValue("REJECTED", "status");
|
||||
const proxy = store.get(input.commentID)!;
|
||||
proxy.setValue("REJECTED", "status");
|
||||
proxy.setValue(true, "viewerDidModerate");
|
||||
},
|
||||
updater: store => {
|
||||
const connections = [
|
||||
|
||||
@@ -2,12 +2,13 @@ import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { PropTypesOf } from "coral-framework/types";
|
||||
import { ExternalLink } from "coral-framework/lib/i18n/components";
|
||||
import { FormFieldDescription } from "coral-ui/components/v2";
|
||||
|
||||
import Header from "../../Header";
|
||||
import ConfigBoxWithToggleField from "./ConfigBoxWithToggleField";
|
||||
import RegistrationField from "./RegistrationField";
|
||||
import SSOKeyFieldContainer from "./SSOKeyFieldContainer";
|
||||
import SSOKeyRotationQuery from "./SSOKeyRotation/SSOKeyRotationQuery";
|
||||
import TargetFilterField from "./TargetFilterField";
|
||||
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
@@ -28,10 +29,9 @@ graphql`
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
sso: PropTypesOf<typeof SSOKeyFieldContainer>["sso"];
|
||||
}
|
||||
|
||||
const SSOConfig: FunctionComponent<Props> = ({ disabled, sso }) => (
|
||||
const SSOConfig: FunctionComponent<Props> = ({ disabled }) => (
|
||||
<ConfigBoxWithToggleField
|
||||
title={
|
||||
<Localized id="configure-auth-sso-loginWith">
|
||||
@@ -44,7 +44,23 @@ const SSOConfig: FunctionComponent<Props> = ({ disabled, sso }) => (
|
||||
>
|
||||
{disabledInside => (
|
||||
<>
|
||||
<SSOKeyFieldContainer sso={sso} disabled={disabledInside} />
|
||||
<Localized
|
||||
id="configure-auth-sso-description"
|
||||
IntroLink={
|
||||
<ExternalLink href="https://jwt.io/introduction/"></ExternalLink>
|
||||
}
|
||||
DocLink={
|
||||
<ExternalLink href="https://docs.coralproject.net/coral/v5/integrating/sso/"></ExternalLink>
|
||||
}
|
||||
>
|
||||
<FormFieldDescription>
|
||||
To enable integration with your existing authentication system, you
|
||||
will need to create a JWT Token to connect. You can learn more about
|
||||
creating a JWT Token with this introduction. See our documentation
|
||||
for additional information on single sign on.
|
||||
</FormFieldDescription>
|
||||
</Localized>
|
||||
<SSOKeyRotationQuery disabled={disabledInside}></SSOKeyRotationQuery>
|
||||
<TargetFilterField
|
||||
label={
|
||||
<Localized id="configure-auth-sso-useLoginOn">
|
||||
|
||||
@@ -16,17 +16,13 @@ const SSOConfigContainer: React.FunctionComponent<Props> = ({
|
||||
disabled,
|
||||
auth,
|
||||
}) => {
|
||||
return <SSOConfig disabled={disabled} sso={auth.integrations.sso} />;
|
||||
return <SSOConfig disabled={disabled} />;
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
auth: graphql`
|
||||
fragment SSOConfigContainer_auth on Auth {
|
||||
integrations {
|
||||
sso {
|
||||
...SSOKeyFieldContainer_sso
|
||||
}
|
||||
}
|
||||
...SSOConfig_formValues
|
||||
}
|
||||
`,
|
||||
})(SSOConfigContainer);
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
.root {
|
||||
padding-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.keyGenerated {
|
||||
composes: button from "coral-ui/shared/typography.css";
|
||||
color: var(--palette-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.warnIcon {
|
||||
color: var(--palette-text-secondary);
|
||||
flex-shrink: 0;
|
||||
padding-top: 3px;
|
||||
padding-right: var(--spacing-1);
|
||||
}
|
||||
|
||||
.warn {
|
||||
color: var(--palette-text-secondary);
|
||||
}
|
||||
|
||||
.warningSection {
|
||||
padding-top: var(--spacing-1);
|
||||
padding-bottom: var(--spacing-1);
|
||||
}
|
||||
|
||||
.regenerateButton {
|
||||
float: right;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
FormField,
|
||||
Icon,
|
||||
Label,
|
||||
PasswordField,
|
||||
} from "coral-ui/components/v2";
|
||||
|
||||
import HelperText from "../../HelperText";
|
||||
|
||||
import styles from "./SSOKeyField.css";
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
generatedKey?: string;
|
||||
keyGeneratedAt?: any;
|
||||
onRegenerate?: () => void;
|
||||
}
|
||||
|
||||
const SSOKeyField: FunctionComponent<Props> = ({
|
||||
generatedKey,
|
||||
keyGeneratedAt,
|
||||
disabled,
|
||||
onRegenerate,
|
||||
}) => (
|
||||
<FormField className={styles.root}>
|
||||
<Localized id="configure-auth-sso-key">
|
||||
<Label htmlFor="configure-auth-sso-key">Key</Label>
|
||||
</Localized>
|
||||
<PasswordField
|
||||
id="configure-auth-sso-key"
|
||||
name="key"
|
||||
value={generatedKey}
|
||||
readOnly
|
||||
// TODO: (wyattjoh) figure out how to add translations to these props
|
||||
hidePasswordTitle="Show SSO Key"
|
||||
showPasswordTitle="Hide SSO Key"
|
||||
fullWidth
|
||||
/>
|
||||
{keyGeneratedAt && (
|
||||
<Localized
|
||||
id="configure-auth-sso-regenerateAt"
|
||||
$date={new Date(keyGeneratedAt)}
|
||||
>
|
||||
<HelperText className={styles.keyGenerated}>
|
||||
KEY GENERATED AT: {keyGeneratedAt}
|
||||
</HelperText>
|
||||
</Localized>
|
||||
)}
|
||||
<div className={styles.warningSection}>
|
||||
<Flex direction="row" itemGutter="half">
|
||||
<Icon className={styles.warnIcon}>warning</Icon>
|
||||
<Localized id="configure-auth-sso-regenerateHonoredWarning">
|
||||
<HelperText>
|
||||
When regenerating a key, tokens signed with the previous key will be
|
||||
honored for 30 days.
|
||||
</HelperText>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<Localized id="configure-auth-sso-regenerate">
|
||||
<Button
|
||||
id="configure-auth-sso-regenerate"
|
||||
disabled={disabled}
|
||||
onClick={onRegenerate}
|
||||
className={styles.regenerateButton}
|
||||
>
|
||||
Regenerate
|
||||
</Button>
|
||||
</Localized>
|
||||
</FormField>
|
||||
);
|
||||
|
||||
export default SSOKeyField;
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import {
|
||||
MutationProp,
|
||||
withFragmentContainer,
|
||||
withMutation,
|
||||
} from "coral-framework/lib/relay";
|
||||
|
||||
import { SSOKeyFieldContainer_sso as SSOData } from "coral-admin/__generated__/SSOKeyFieldContainer_sso.graphql";
|
||||
|
||||
import RegenerateSSOKeyMutation from "./RegenerateSSOKeyMutation";
|
||||
import SSOKeyField from "./SSOKeyField";
|
||||
|
||||
interface Props {
|
||||
sso: SSOData;
|
||||
disabled?: boolean;
|
||||
regenerateSSOKey: MutationProp<typeof RegenerateSSOKeyMutation>;
|
||||
}
|
||||
|
||||
interface State {
|
||||
awaitingResponse: boolean;
|
||||
}
|
||||
|
||||
class SSOKeyFieldContainer extends React.Component<Props, State> {
|
||||
public state = {
|
||||
awaitingResponse: false,
|
||||
};
|
||||
|
||||
private handleRegenerate = async () => {
|
||||
this.setState({ awaitingResponse: true });
|
||||
await this.props.regenerateSSOKey();
|
||||
this.setState({ awaitingResponse: false });
|
||||
};
|
||||
|
||||
public render() {
|
||||
const { disabled } = this.props;
|
||||
return (
|
||||
<SSOKeyField
|
||||
disabled={disabled || this.state.awaitingResponse}
|
||||
generatedKey={this.props.sso.key || undefined}
|
||||
keyGeneratedAt={this.props.sso.keyGeneratedAt || undefined}
|
||||
onRegenerate={this.handleRegenerate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = withMutation(RegenerateSSOKeyMutation)(
|
||||
withFragmentContainer<Props>({
|
||||
sso: graphql`
|
||||
fragment SSOKeyFieldContainer_sso on SSOAuthIntegration {
|
||||
key
|
||||
keyGeneratedAt
|
||||
}
|
||||
`,
|
||||
})(SSOKeyFieldContainer)
|
||||
);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,10 @@
|
||||
.label {
|
||||
padding-bottom: var(--v2-spacing-2);
|
||||
}
|
||||
|
||||
.date {
|
||||
font-family: var(--v2-font-family-primary);
|
||||
font-weight: var(--v2-font-weight-primary-regular);
|
||||
font-size: var(--v2-font-size-2);
|
||||
line-height: var(--v2-line-height-reset);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { Flex, Label } from "coral-ui/components/v2";
|
||||
|
||||
import { SSOKeyStatus } from "./StatusField";
|
||||
|
||||
import styles from "./DateField.css";
|
||||
|
||||
export interface SSOKeyDates {
|
||||
readonly createdAt: string;
|
||||
readonly lastUsedAt: string | null;
|
||||
readonly rotatedAt: string | null;
|
||||
readonly inactiveAt: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
status: SSOKeyStatus;
|
||||
dates: SSOKeyDates;
|
||||
}
|
||||
|
||||
const DateField: FunctionComponent<Props> = ({ status, dates }) => {
|
||||
switch (status) {
|
||||
case SSOKeyStatus.ACTIVE:
|
||||
return (
|
||||
<>
|
||||
<div className={styles.label}>
|
||||
<Localized id="configure-auth-sso-rotate-activeSince">
|
||||
<Label>Active Since</Label>
|
||||
</Localized>
|
||||
</div>
|
||||
<Localized
|
||||
id="configure-auth-sso-rotate-date"
|
||||
$date={new Date(dates.createdAt)}
|
||||
>
|
||||
<span className={styles.date}>{dates.createdAt}</span>
|
||||
</Localized>
|
||||
</>
|
||||
);
|
||||
case SSOKeyStatus.EXPIRING:
|
||||
return (
|
||||
<>
|
||||
<div className={styles.label}>
|
||||
<Localized id="configure-auth-sso-rotate-inactiveAt">
|
||||
<Label>Inactive At</Label>
|
||||
</Localized>
|
||||
</div>
|
||||
<Flex
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
className={styles.date}
|
||||
>
|
||||
<Localized
|
||||
id="configure-auth-sso-rotate-date"
|
||||
$date={
|
||||
dates.inactiveAt
|
||||
? new Date(dates.inactiveAt)
|
||||
: new Date(dates.createdAt)
|
||||
}
|
||||
>
|
||||
{dates.inactiveAt}
|
||||
</Localized>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
case SSOKeyStatus.EXPIRED:
|
||||
return (
|
||||
<>
|
||||
<div className={styles.label}>
|
||||
<Localized id="configure-auth-sso-rotate-inactiveSince">
|
||||
<Label>Inactive Since</Label>
|
||||
</Localized>
|
||||
</div>
|
||||
<Flex
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
className={styles.date}
|
||||
>
|
||||
<Localized
|
||||
id="configure-auth-sso-rotate-date"
|
||||
$date={
|
||||
dates.inactiveAt
|
||||
? new Date(dates.inactiveAt)
|
||||
: new Date(dates.createdAt)
|
||||
}
|
||||
>
|
||||
{dates.inactiveAt}
|
||||
</Localized>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default DateField;
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
MutationInput,
|
||||
} from "coral-framework/lib/relay";
|
||||
|
||||
import { DeactivateSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/DeactivateSSOKeyMutation.graphql";
|
||||
|
||||
const clientMutationId = 0;
|
||||
|
||||
const DeactivateSSOKeyMutation = createMutation(
|
||||
"deactivateSSOKey",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) => {
|
||||
return commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation DeactivateSSOKeyMutation($input: DeactivateSSOKeyInput!) {
|
||||
deactivateSSOKey(input: $input) {
|
||||
settings {
|
||||
auth {
|
||||
integrations {
|
||||
sso {
|
||||
enabled
|
||||
keys {
|
||||
kid
|
||||
secret
|
||||
createdAt
|
||||
lastUsedAt
|
||||
rotatedAt
|
||||
inactiveAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
clientMutationId: clientMutationId.toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default DeactivateSSOKeyMutation;
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
MutationInput,
|
||||
} from "coral-framework/lib/relay";
|
||||
|
||||
import { DeleteSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/DeleteSSOKeyMutation.graphql";
|
||||
|
||||
const clientMutationId = 0;
|
||||
|
||||
const DeleteSSOKeyMutation = createMutation(
|
||||
"deleteSSOKey",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) => {
|
||||
return commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation DeleteSSOKeyMutation($input: DeleteSSOKeyInput!) {
|
||||
deleteSSOKey(input: $input) {
|
||||
settings {
|
||||
auth {
|
||||
integrations {
|
||||
sso {
|
||||
enabled
|
||||
keys {
|
||||
kid
|
||||
secret
|
||||
createdAt
|
||||
lastUsedAt
|
||||
rotatedAt
|
||||
inactiveAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
clientMutationId: clientMutationId.toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default DeleteSSOKeyMutation;
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
MutationInput,
|
||||
} from "coral-framework/lib/relay";
|
||||
|
||||
import { RotateSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/RotateSSOKeyMutation.graphql";
|
||||
|
||||
const clientMutationId = 0;
|
||||
|
||||
const RotateSSOKeyMutation = createMutation(
|
||||
"rotateSSOKey",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) => {
|
||||
return commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation RotateSSOKeyMutation($input: RotateSSOKeyInput!) {
|
||||
rotateSSOKey(input: $input) {
|
||||
settings {
|
||||
auth {
|
||||
integrations {
|
||||
sso {
|
||||
enabled
|
||||
keys {
|
||||
kid
|
||||
secret
|
||||
createdAt
|
||||
lastUsedAt
|
||||
rotatedAt
|
||||
inactiveAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
clientMutationId: clientMutationId.toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default RotateSSOKeyMutation;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
.rotate {
|
||||
margin-right: var(--v2-spacing-1)
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import {
|
||||
Button,
|
||||
ClickOutside,
|
||||
Dropdown,
|
||||
DropdownButton,
|
||||
Icon,
|
||||
Popover,
|
||||
} from "coral-ui/components/v2";
|
||||
|
||||
import RotateOption, { RotateOptions } from "./RotationOption";
|
||||
|
||||
import styles from "./RotationDropdown.css";
|
||||
|
||||
interface Props {
|
||||
onRotateKey: (rotation: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const RotationDropDown: FunctionComponent<Props> = ({
|
||||
onRotateKey,
|
||||
disabled,
|
||||
}) => {
|
||||
return (
|
||||
<Localized
|
||||
id="configure-auth-sso-rotate-dropdown-description"
|
||||
attrs={{ description: true }}
|
||||
>
|
||||
<Popover
|
||||
id="sso-key-rotate"
|
||||
placement="bottom-start"
|
||||
description="A dropdown to rotate the SSO key"
|
||||
body={({ toggleVisibility }) => (
|
||||
<ClickOutside onClickOutside={toggleVisibility}>
|
||||
<Dropdown>
|
||||
{Object.keys(RotateOptions).map((opt: string) => (
|
||||
<DropdownButton
|
||||
key={opt}
|
||||
onClick={() => {
|
||||
onRotateKey(opt);
|
||||
toggleVisibility();
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<RotateOption value={opt}></RotateOption>
|
||||
</DropdownButton>
|
||||
))}
|
||||
</Dropdown>
|
||||
</ClickOutside>
|
||||
)}
|
||||
>
|
||||
{({ toggleVisibility, ref, visible }) => (
|
||||
<Button
|
||||
onClick={toggleVisibility}
|
||||
ref={ref}
|
||||
color="regular"
|
||||
disabled={disabled}
|
||||
>
|
||||
<Localized id="configure-auth-sso-rotate-rotate">
|
||||
<span className={styles.rotate}>Rotate</span>
|
||||
</Localized>
|
||||
<Icon>arrow_drop_down</Icon>
|
||||
</Button>
|
||||
)}
|
||||
</Popover>
|
||||
</Localized>
|
||||
);
|
||||
};
|
||||
|
||||
export default RotationDropDown;
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
export enum RotateOptions {
|
||||
NOW = "NOW",
|
||||
IN1DAY = "IN1DAY",
|
||||
IN1WEEK = "IN1WEEK",
|
||||
IN30DAYS = "IN30DAYS",
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
}
|
||||
|
||||
const RotationOption: FunctionComponent<Props> = ({ value }) => {
|
||||
switch (value) {
|
||||
case RotateOptions.NOW: {
|
||||
return <Localized id="configure-auth-sso-rotate-now">Now</Localized>;
|
||||
}
|
||||
case RotateOptions.IN1DAY: {
|
||||
return (
|
||||
<Localized id="configure-auth-sso-rotate-1day">
|
||||
1 day from now
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
case RotateOptions.IN1WEEK: {
|
||||
return (
|
||||
<Localized id="configure-auth-sso-rotate-1week">
|
||||
1 week from now
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
case RotateOptions.IN30DAYS: {
|
||||
return (
|
||||
<Localized id="configure-auth-sso-rotate-30days">
|
||||
30 days from now
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return <Localized id="configure-auth-sso-rotate-now">Now</Localized>;
|
||||
}
|
||||
};
|
||||
|
||||
export default RotationOption;
|
||||
@@ -0,0 +1,23 @@
|
||||
.label {
|
||||
padding-bottom: var(--v2-spacing-2);
|
||||
}
|
||||
|
||||
.keySection {
|
||||
flex-grow: 1;
|
||||
min-width: 50px;
|
||||
|
||||
padding-right: var(--v2-spacing-3);
|
||||
}
|
||||
|
||||
.statusSection {
|
||||
margin-right: var(--v2-spacing-3);
|
||||
}
|
||||
|
||||
.secretSection {
|
||||
flex-grow: 1;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.action {
|
||||
margin-right: var(--v2-spacing-1)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent, useCallback } from "react";
|
||||
import CopyToClipboard from "react-copy-to-clipboard";
|
||||
|
||||
import { useMutation } from "coral-framework/lib/relay";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
HorizontalGutter,
|
||||
Icon,
|
||||
Label,
|
||||
PasswordField,
|
||||
TextField,
|
||||
} from "coral-ui/components/v2";
|
||||
|
||||
import DateField from "./DateField";
|
||||
import DeactivateSSOKeyMutation from "./DeactivateSSOKeyMutation";
|
||||
import DeleteSSOKeyMutation from "./DeleteSSOKeyMutation";
|
||||
import RotateSSOKeyMutation from "./RotateSSOKeyMutation";
|
||||
import RotationDropDown from "./RotationDropdown";
|
||||
import { RotateOptions } from "./RotationOption";
|
||||
import StatusField, { SSOKeyStatus } from "./StatusField";
|
||||
|
||||
import styles from "./SSOKeyCard.css";
|
||||
|
||||
export interface SSOKeyDates {
|
||||
readonly createdAt: string;
|
||||
readonly lastUsedAt: string | null;
|
||||
readonly rotatedAt: string | null;
|
||||
readonly inactiveAt: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
secret: string;
|
||||
status: SSOKeyStatus;
|
||||
dates: SSOKeyDates;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function createActionButton(
|
||||
status: SSOKeyStatus,
|
||||
onRotateKey: (rotation: string) => void,
|
||||
onDeactivateKey: () => void,
|
||||
onDelete: () => void,
|
||||
disabled?: boolean
|
||||
) {
|
||||
switch (status) {
|
||||
case SSOKeyStatus.ACTIVE:
|
||||
return <RotationDropDown onRotateKey={onRotateKey} disabled={disabled} />;
|
||||
case SSOKeyStatus.EXPIRING:
|
||||
return (
|
||||
<Localized id="configure-auth-sso-rotate-deactivateNow">
|
||||
<Button color="alert" onClick={onDeactivateKey} disabled={disabled}>
|
||||
Deactivate Now
|
||||
</Button>
|
||||
</Localized>
|
||||
);
|
||||
case SSOKeyStatus.EXPIRED:
|
||||
return (
|
||||
<Localized id="configure-auth-sso-rotate-delete">
|
||||
<Button color="alert" onClick={onDelete} disabled={disabled}>
|
||||
Delete
|
||||
</Button>
|
||||
</Localized>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const SSOKeyCard: FunctionComponent<Props> = ({
|
||||
id,
|
||||
secret,
|
||||
status,
|
||||
dates,
|
||||
disabled,
|
||||
}) => {
|
||||
const rotateSSOKey = useMutation(RotateSSOKeyMutation);
|
||||
const deactivateSSOKey = useMutation(DeactivateSSOKeyMutation);
|
||||
const deleteSSOKey = useMutation(DeleteSSOKeyMutation);
|
||||
|
||||
const onRotate = useCallback(
|
||||
(rotation: string) => {
|
||||
switch (rotation) {
|
||||
case RotateOptions.NOW:
|
||||
rotateSSOKey({ inactiveIn: 0 });
|
||||
break;
|
||||
case RotateOptions.IN1DAY:
|
||||
rotateSSOKey({ inactiveIn: 24 * 60 * 60 });
|
||||
break;
|
||||
case RotateOptions.IN1WEEK:
|
||||
rotateSSOKey({ inactiveIn: 7 * 24 * 60 * 60 });
|
||||
break;
|
||||
case RotateOptions.IN30DAYS:
|
||||
rotateSSOKey({ inactiveIn: 30 * 24 * 60 * 60 });
|
||||
break;
|
||||
default:
|
||||
rotateSSOKey({ inactiveIn: 0 });
|
||||
}
|
||||
},
|
||||
[rotateSSOKey]
|
||||
);
|
||||
const onDeactivate = useCallback(() => {
|
||||
deactivateSSOKey({
|
||||
kid: id,
|
||||
});
|
||||
}, [deactivateSSOKey, id]);
|
||||
const onDelete = useCallback(() => {
|
||||
deleteSSOKey({
|
||||
kid: id,
|
||||
});
|
||||
}, [deleteSSOKey, id]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<HorizontalGutter>
|
||||
<Flex alignItems="center" justifyContent="space-between">
|
||||
<div className={styles.keySection}>
|
||||
<div className={styles.label}>
|
||||
<Localized id="configure-auth-sso-rotate-keyID">
|
||||
<Label>Key ID</Label>
|
||||
</Localized>
|
||||
</div>
|
||||
<TextField value={id} readOnly fullWidth data-testid="SSO-Key-ID" />
|
||||
</div>
|
||||
<div className={styles.secretSection}>
|
||||
<div className={styles.label}>
|
||||
<Localized id="configure-auth-sso-rotate-secret">
|
||||
<Label>Secret</Label>
|
||||
</Localized>
|
||||
</div>
|
||||
<Flex alignItems="center" justifyContent="flex-start">
|
||||
<PasswordField
|
||||
id="configure-auth-sso-rotate-secretField"
|
||||
name="key"
|
||||
value={secret}
|
||||
readOnly
|
||||
// TODO: (nick-funk) figure out how to add translations to these props
|
||||
hidePasswordTitle="Show Secret"
|
||||
showPasswordTitle="Hide Secret"
|
||||
fullWidth
|
||||
/>
|
||||
<CopyToClipboard text={secret}>
|
||||
<Button color="mono" variant="flat">
|
||||
<Localized
|
||||
id="configure-auth-sso-rotate-copySecret"
|
||||
attrs={{ "aria-label": true }}
|
||||
>
|
||||
<Icon size="md" aria-label="Copy Secret">
|
||||
content_copy
|
||||
</Icon>
|
||||
</Localized>
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</Flex>
|
||||
</div>
|
||||
</Flex>
|
||||
<Flex alignItems="flex-end" justifyContent="space-between">
|
||||
<Flex alignItems="center" justifyContent="flex-start">
|
||||
<div className={styles.statusSection}>
|
||||
<div className={styles.label}>
|
||||
<Localized id="configure-auth-sso-rotate-status">
|
||||
<Label>Status</Label>
|
||||
</Localized>
|
||||
</div>
|
||||
<StatusField status={status}></StatusField>
|
||||
</div>
|
||||
<div>
|
||||
<DateField status={status} dates={dates} />
|
||||
</div>
|
||||
</Flex>
|
||||
{createActionButton(
|
||||
status,
|
||||
onRotate,
|
||||
onDeactivate,
|
||||
onDelete,
|
||||
disabled
|
||||
)}
|
||||
</Flex>
|
||||
</HorizontalGutter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SSOKeyCard;
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
|
||||
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { Label } from "coral-ui/components/v2";
|
||||
|
||||
import { SSOKeyRotationContainer_settings } from "coral-admin/__generated__/SSOKeyRotationContainer_settings.graphql";
|
||||
|
||||
import SSOKeyCard, { SSOKeyDates } from "./SSOKeyCard";
|
||||
import { SSOKeyStatus } from "./StatusField";
|
||||
|
||||
interface Props {
|
||||
settings: SSOKeyRotationContainer_settings;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface Key {
|
||||
readonly kid: string;
|
||||
readonly secret: string;
|
||||
readonly createdAt: string;
|
||||
readonly lastUsedAt: string | null;
|
||||
readonly rotatedAt: string | null;
|
||||
readonly inactiveAt: string | null;
|
||||
}
|
||||
|
||||
function getStatus(dates: SSOKeyDates) {
|
||||
if (
|
||||
dates.inactiveAt &&
|
||||
dates.rotatedAt &&
|
||||
new Date(dates.inactiveAt) > new Date()
|
||||
) {
|
||||
return SSOKeyStatus.EXPIRING;
|
||||
}
|
||||
|
||||
if (dates.inactiveAt && new Date(dates.inactiveAt) <= new Date()) {
|
||||
return SSOKeyStatus.EXPIRED;
|
||||
}
|
||||
|
||||
return SSOKeyStatus.ACTIVE;
|
||||
}
|
||||
|
||||
const SSOKeyRotationContainer: FunctionComponent<Props> = ({
|
||||
disabled,
|
||||
settings,
|
||||
}) => {
|
||||
const {
|
||||
auth: {
|
||||
integrations: {
|
||||
sso: { keys },
|
||||
},
|
||||
},
|
||||
} = settings;
|
||||
|
||||
const sortedKeys = useMemo(
|
||||
() =>
|
||||
keys
|
||||
// Copy this map because we don't want to modify the underlying copy.
|
||||
.map(key => key)
|
||||
.sort((a: Key, b: Key) => {
|
||||
// Both active, sort on createdAt date.
|
||||
if (!a.inactiveAt && !b.inactiveAt) {
|
||||
return (
|
||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
);
|
||||
}
|
||||
// A is active, B is not, A comes before B.
|
||||
if (!a.inactiveAt && b.inactiveAt) {
|
||||
return -1;
|
||||
}
|
||||
// B is active, A is not, B comes before A.
|
||||
if (a.inactiveAt && !b.inactiveAt) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Sort primarily on inactiveAt, fall back to createdAt if
|
||||
// for some reason it's not available.
|
||||
const aDate = a.inactiveAt
|
||||
? new Date(a.inactiveAt)
|
||||
: new Date(a.createdAt);
|
||||
const bDate = b.inactiveAt
|
||||
? new Date(b.inactiveAt)
|
||||
: new Date(b.createdAt);
|
||||
|
||||
return bDate.getTime() - aDate.getTime();
|
||||
}),
|
||||
[keys]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Localized id="configure-auth-sso-rotate-keys">
|
||||
<Label htmlFor="configure-auth-sso-rotate-keys">Keys</Label>
|
||||
</Localized>
|
||||
{sortedKeys.map(key => (
|
||||
<SSOKeyCard
|
||||
key={key.kid}
|
||||
id={key.kid}
|
||||
secret={key.secret}
|
||||
status={getStatus(key)}
|
||||
dates={key}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
settings: graphql`
|
||||
fragment SSOKeyRotationContainer_settings on Settings {
|
||||
auth {
|
||||
integrations {
|
||||
sso {
|
||||
enabled
|
||||
keys {
|
||||
kid
|
||||
secret
|
||||
createdAt
|
||||
lastUsedAt
|
||||
rotatedAt
|
||||
inactiveAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(SSOKeyRotationContainer);
|
||||
|
||||
export default enhanced;
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import {
|
||||
graphql,
|
||||
QueryRenderData,
|
||||
QueryRenderer,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { CallOut, Spinner } from "coral-ui/components/v2";
|
||||
|
||||
import { SSOKeyRotationQuery as QueryTypes } from "coral-admin/__generated__/SSOKeyRotationQuery.graphql";
|
||||
|
||||
import SSOKeyRotationContainer from "./SSOKeyRotationContainer";
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SSOKeyRotationQuery: FunctionComponent<Props> = ({ disabled }) => {
|
||||
return (
|
||||
<QueryRenderer<QueryTypes>
|
||||
query={graphql`
|
||||
query SSOKeyRotationQuery {
|
||||
settings {
|
||||
...SSOKeyRotationContainer_settings
|
||||
}
|
||||
}
|
||||
`}
|
||||
variables={{}}
|
||||
cacheConfig={{ force: true }}
|
||||
render={({ error, props }: QueryRenderData<QueryTypes>) => {
|
||||
if (error) {
|
||||
return <CallOut>{error.message}</CallOut>;
|
||||
}
|
||||
|
||||
if (!props) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (!props.settings) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SSOKeyRotationContainer
|
||||
settings={props.settings}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SSOKeyRotationQuery;
|
||||
@@ -0,0 +1,35 @@
|
||||
.status {
|
||||
font-family: var(--v2-font-family-primary);
|
||||
font-weight: var(--v2-font-weight-primary-regular);
|
||||
font-size: var(--v2-font-size-2);
|
||||
line-height: var(--v2-line-height-reset);
|
||||
|
||||
border-radius: 2px;
|
||||
padding-left: var(--v2-spacing-1);
|
||||
padding-right: var(--v2-spacing-1);
|
||||
}
|
||||
|
||||
.active {
|
||||
background-color: var(--v2-colors-green-500);
|
||||
color: var(--v2-colors-pure-white);
|
||||
}
|
||||
|
||||
.expiring {
|
||||
background-color: var(--v2-colors-yellow-500);
|
||||
color: var(--v2-colors-mono-500);
|
||||
|
||||
padding-top: var(--v2-spacing-1);
|
||||
padding-bottom: var(--v2-spacing-1);
|
||||
}
|
||||
|
||||
.expired {
|
||||
background-color: var(--v2-colors-red-500);
|
||||
color: var(--v2-colors-pure-white);
|
||||
|
||||
padding-top: var(--v2-spacing-1);
|
||||
padding-bottom: var(--v2-spacing-1);
|
||||
}
|
||||
|
||||
.icon {
|
||||
padding-right: var(--v2-spacing-1);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { Flex, Icon, Tooltip, TooltipButton } from "coral-ui/components/v2";
|
||||
|
||||
import styles from "./StatusField.css";
|
||||
|
||||
export enum SSOKeyStatus {
|
||||
EXPIRED,
|
||||
EXPIRING,
|
||||
ACTIVE,
|
||||
}
|
||||
|
||||
interface Props {
|
||||
status: SSOKeyStatus;
|
||||
}
|
||||
|
||||
const StatusField: FunctionComponent<Props> = ({ status }) => {
|
||||
switch (status) {
|
||||
case SSOKeyStatus.ACTIVE:
|
||||
return (
|
||||
<Localized id="configure-auth-sso-rotate-statusActive">
|
||||
<span
|
||||
className={cn(styles.status, styles.active)}
|
||||
data-testid="SSO-Key-Status"
|
||||
>
|
||||
Active
|
||||
</span>
|
||||
</Localized>
|
||||
);
|
||||
case SSOKeyStatus.EXPIRING:
|
||||
return (
|
||||
<Flex alignItems="center" justifyContent="center">
|
||||
<Flex
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
className={cn(styles.status, styles.expiring)}
|
||||
>
|
||||
<Icon className={styles.icon}>alarm</Icon>
|
||||
<Localized id="configure-auth-sso-rotate-statusExpiring">
|
||||
<span data-testid="SSO-Key-Status">Expiring</span>
|
||||
</Localized>
|
||||
</Flex>
|
||||
<Tooltip
|
||||
id="configure-auth-sso-rotate-expiringTooltip"
|
||||
title=""
|
||||
body={
|
||||
<Localized id="configure-auth-sso-rotate-expiringTooltip">
|
||||
<span>
|
||||
An SSO key is expiring when it is scheduled for rotation.
|
||||
</span>
|
||||
</Localized>
|
||||
}
|
||||
button={({ toggleVisibility, ref, visible }) => (
|
||||
<Localized
|
||||
id="configure-auth-sso-rotate-expiringTooltip-toggleButton"
|
||||
attrs={{ "aria-label": true }}
|
||||
>
|
||||
<TooltipButton
|
||||
active
|
||||
aria-label="Toggle expiring tooltip visibility"
|
||||
toggleVisibility={toggleVisibility}
|
||||
ref={ref}
|
||||
/>
|
||||
</Localized>
|
||||
)}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
case SSOKeyStatus.EXPIRED:
|
||||
return (
|
||||
<Flex alignItems="center" justifyContent="center">
|
||||
<Localized id="configure-auth-sso-rotate-statusExpired">
|
||||
<span
|
||||
className={cn(styles.status, styles.expired)}
|
||||
data-testid="SSO-Key-Status"
|
||||
>
|
||||
Expired
|
||||
</span>
|
||||
</Localized>
|
||||
<Tooltip
|
||||
id="configure-auth-sso-rotate-expiredTooltip"
|
||||
title=""
|
||||
body={
|
||||
<Localized id="configure-auth-sso-rotate-expiredTooltip">
|
||||
<span>
|
||||
An SSO key is expired when it has been rotated out of use.
|
||||
</span>
|
||||
</Localized>
|
||||
}
|
||||
button={({ toggleVisibility, ref, visible }) => (
|
||||
<Localized
|
||||
id="configure-auth-sso-rotate-expiredTooltip-toggleButton"
|
||||
attrs={{ "aria-label": true }}
|
||||
>
|
||||
<TooltipButton
|
||||
active
|
||||
aria-label="Toggle expired tooltip visibility"
|
||||
toggleVisibility={toggleVisibility}
|
||||
ref={ref}
|
||||
/>
|
||||
</Localized>
|
||||
)}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Localized id="configure-auth-sso-rotate-statusUnknown">
|
||||
<span data-testid="SSO-Key-Status">Unknown</span>
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default StatusField;
|
||||
@@ -1,25 +1,31 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { useMemo } from "react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useForm } from "react-final-form";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { useNotification } from "coral-admin/App/GlobalNotification";
|
||||
import { DeepNullable } from "coral-common/types";
|
||||
import {
|
||||
purgeMetadata,
|
||||
useMutation,
|
||||
withFragmentContainer,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { GQLSettings } from "coral-framework/schema";
|
||||
import { AppNotification, Button, CallOut, Flex } from "coral-ui/components/v2";
|
||||
|
||||
import { EmailConfigContainer_email } from "coral-admin/__generated__/EmailConfigContainer_email.graphql";
|
||||
import { EmailConfigContainer_viewer } from "coral-admin/__generated__/EmailConfigContainer_viewer.graphql";
|
||||
|
||||
import Header from "../../Header";
|
||||
import ConfigBoxWithToggleField from "../Auth/ConfigBoxWithToggleField";
|
||||
import From from "./From";
|
||||
import SMTP from "./SMTP";
|
||||
import TestSMTPMutation from "./TestSMTPMutation";
|
||||
|
||||
interface Props {
|
||||
submitting: boolean;
|
||||
email: EmailConfigContainer_email;
|
||||
viewer: EmailConfigContainer_viewer | null;
|
||||
}
|
||||
|
||||
export type FormProps = DeepNullable<Pick<GQLSettings, "email">>;
|
||||
@@ -27,8 +33,34 @@ export type FormProps = DeepNullable<Pick<GQLSettings, "email">>;
|
||||
const EmailConfigContainer: React.FunctionComponent<Props> = ({
|
||||
email,
|
||||
submitting,
|
||||
viewer,
|
||||
}) => {
|
||||
const form = useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<null | string>(null);
|
||||
const { setMessage, clearMessage } = useNotification();
|
||||
const sendTest = useMutation(TestSMTPMutation);
|
||||
const sendTestEmail = useCallback(async () => {
|
||||
if (!viewer) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
await sendTest();
|
||||
setLoading(false);
|
||||
setMessage(
|
||||
<Localized id="configure-smtp-test-success" $email={viewer.email}>
|
||||
<AppNotification icon="check_circle_outline" onClose={clearMessage}>
|
||||
Test email has been sent to {viewer.email}
|
||||
</AppNotification>
|
||||
</Localized>,
|
||||
3000
|
||||
);
|
||||
} catch (error) {
|
||||
setSubmitError(error.message);
|
||||
}
|
||||
}, []);
|
||||
useMemo(() => {
|
||||
let values: any = { email };
|
||||
if (email && email.smtp && email.smtp.authentication === null) {
|
||||
@@ -61,6 +93,21 @@ const EmailConfigContainer: React.FunctionComponent<Props> = ({
|
||||
>
|
||||
{disabledInside => (
|
||||
<>
|
||||
<Flex justifyContent="flex-end">
|
||||
<Localized id="configure-email-send-test">
|
||||
<Button
|
||||
disabled={disabledInside || loading || !email.enabled}
|
||||
onClick={sendTestEmail}
|
||||
>
|
||||
Send test email
|
||||
</Button>
|
||||
</Localized>
|
||||
</Flex>
|
||||
{submitError && (
|
||||
<CallOut fullWidth color="error">
|
||||
{submitError}
|
||||
</CallOut>
|
||||
)}
|
||||
<From disabled={disabledInside} />
|
||||
<SMTP disabled={disabledInside} />
|
||||
</>
|
||||
@@ -70,6 +117,11 @@ const EmailConfigContainer: React.FunctionComponent<Props> = ({
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
viewer: graphql`
|
||||
fragment EmailConfigContainer_viewer on User {
|
||||
email
|
||||
}
|
||||
`,
|
||||
email: graphql`
|
||||
fragment EmailConfigContainer_email on EmailConfiguration {
|
||||
enabled
|
||||
|
||||
@@ -26,6 +26,7 @@ class EmailConfigRoute extends React.Component<Props> {
|
||||
<EmailConfigContainer
|
||||
email={this.props.data.settings.email}
|
||||
submitting={this.props.submitting}
|
||||
viewer={this.props.data.viewer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +35,9 @@ class EmailConfigRoute extends React.Component<Props> {
|
||||
const enhanced = withRouteConfig<Props>({
|
||||
query: graphql`
|
||||
query EmailConfigRouteQuery {
|
||||
viewer {
|
||||
...EmailConfigContainer_viewer
|
||||
}
|
||||
settings {
|
||||
email {
|
||||
...EmailConfigContainer_email
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
MutationInput,
|
||||
} from "coral-framework/lib/relay";
|
||||
|
||||
import { TestSMTPMutation as MutationTypes } from "coral-admin/__generated__/TestSMTPMutation.graphql";
|
||||
|
||||
const clientMutationId = 0;
|
||||
|
||||
const TestSMTPMutation = createMutation(
|
||||
"testSMTP",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) => {
|
||||
return commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation TestSMTPMutation($input: TestSMTPInput!) {
|
||||
testSMTP(input: $input) {
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
clientMutationId: clientMutationId.toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default TestSMTPMutation;
|
||||
@@ -153,6 +153,42 @@ const SlackChannel: FunctionComponent<Props> = ({
|
||||
</div>
|
||||
|
||||
<div className={styles.notificationToggles}>
|
||||
<Field
|
||||
name={`${channel}.triggers.allComments`}
|
||||
type="checkbox"
|
||||
parse={parseBool}
|
||||
>
|
||||
{({ input }) => (
|
||||
<CheckBox
|
||||
id={`configure-slack-channel-triggers-allComments-${input.name}`}
|
||||
disabled={disabled || !channelEnabled}
|
||||
className={styles.trigger}
|
||||
{...input}
|
||||
>
|
||||
<Localized id="configure-slack-channel-triggers-allComments">
|
||||
All Comments
|
||||
</Localized>
|
||||
</CheckBox>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
name={`${channel}.triggers.staffComments`}
|
||||
type="checkbox"
|
||||
parse={parseBool}
|
||||
>
|
||||
{({ input }) => (
|
||||
<CheckBox
|
||||
id={`configure-slack-channel-triggers-staffComments-${input.name}`}
|
||||
disabled={disabled || !channelEnabled}
|
||||
className={styles.trigger}
|
||||
{...input}
|
||||
>
|
||||
<Localized id="configure-slack-channel-triggers-staffComments">
|
||||
Staff Comments
|
||||
</Localized>
|
||||
</CheckBox>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
name={`${channel}.triggers.reportedComments`}
|
||||
type="checkbox"
|
||||
|
||||
@@ -104,7 +104,8 @@ const SlackConfigContainer: FunctionComponent<Props> = ({ form, settings }) => {
|
||||
<FormFieldDescription>
|
||||
Automatically send comments from Coral moderation queues to Slack
|
||||
channels. You will need Slack admin access to set this up. For steps
|
||||
on how to create a Slack App see our documentation.
|
||||
on how to create a Slack App see our documentation. Not recommended
|
||||
for sites with more than 10K comments per month.
|
||||
</FormFieldDescription>
|
||||
</Localized>
|
||||
<Button iconLeft onClick={onAddChannel}>
|
||||
@@ -144,6 +145,8 @@ const enhanced = withFragmentContainer<Props>({
|
||||
reportedComments
|
||||
pendingComments
|
||||
featuredComments
|
||||
allComments
|
||||
staffComments
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ export class ApprovedQueueRoute extends React.Component<
|
||||
return (
|
||||
<IntersectionProvider>
|
||||
<Queue
|
||||
viewer={this.props.query.viewer!}
|
||||
settings={this.props.query.settings}
|
||||
comments={comments}
|
||||
onLoadMore={this.loadMore}
|
||||
@@ -107,9 +106,6 @@ const enhanced = (withPaginationContainer<
|
||||
settings {
|
||||
...ModerateCardContainer_settings
|
||||
}
|
||||
viewer {
|
||||
...ModerateCardContainer_viewer
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
|
||||
@@ -17,7 +17,6 @@ it("renders correctly with load more", () => {
|
||||
hasLoadMore: true,
|
||||
disableLoadMore: false,
|
||||
danglingLogic: () => true,
|
||||
viewer: { id: "me", username: "Mirai" },
|
||||
onViewNew: noop,
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
@@ -33,7 +32,6 @@ it("renders correctly without load more", () => {
|
||||
hasLoadMore: false,
|
||||
disableLoadMore: false,
|
||||
danglingLogic: () => true,
|
||||
viewer: { id: "me", username: "Mirai" },
|
||||
onViewNew: noop,
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent, useCallback, useState } from "react";
|
||||
|
||||
import AutoLoadMore from "coral-admin/components/AutoLoadMore";
|
||||
import ConversationModal from "coral-admin/components/ConversationModal";
|
||||
import ModerateCardContainer from "coral-admin/components/ModerateCard";
|
||||
import UserHistoryDrawer from "coral-admin/components/UserHistoryDrawer";
|
||||
import { HOTKEYS } from "coral-admin/constants";
|
||||
@@ -18,7 +19,6 @@ interface Props {
|
||||
{ id: string } & PropTypesOf<typeof ModerateCardContainer>["comment"]
|
||||
>;
|
||||
settings: PropTypesOf<typeof ModerateCardContainer>["settings"];
|
||||
viewer: PropTypesOf<typeof ModerateCardContainer>["viewer"];
|
||||
onLoadMore: () => void;
|
||||
onViewNew?: () => void;
|
||||
hasLoadMore: boolean;
|
||||
@@ -38,7 +38,6 @@ const Queue: FunctionComponent<Props> = ({
|
||||
danglingLogic,
|
||||
emptyElement,
|
||||
allStories,
|
||||
viewer,
|
||||
viewNewCount,
|
||||
onViewNew,
|
||||
}) => {
|
||||
@@ -46,6 +45,10 @@ const Queue: FunctionComponent<Props> = ({
|
||||
const [userDrawerId, setUserDrawerID] = useState("");
|
||||
const [selectedComment, setSelectedComment] = useState<number | null>(0);
|
||||
const [singleView, setSingleView] = useState(false);
|
||||
const [conversationModalVisible, setConversationModalVisible] = useState(
|
||||
false
|
||||
);
|
||||
const [conversationCommentID, setConversationCommentID] = useState("");
|
||||
|
||||
const toggleView = useCallback(() => {
|
||||
if (!singleView) {
|
||||
@@ -94,6 +97,23 @@ const Queue: FunctionComponent<Props> = ({
|
||||
setUserDrawerID("");
|
||||
}, []);
|
||||
|
||||
const onShowConversationModal = useCallback((commentID: string) => {
|
||||
setConversationCommentID(commentID);
|
||||
setConversationModalVisible(true);
|
||||
}, []);
|
||||
|
||||
const onHideConversationModal = useCallback(() => {
|
||||
setConversationModalVisible(false);
|
||||
setConversationCommentID("");
|
||||
}, []);
|
||||
|
||||
const onUsernameClickedFromModal = useCallback((userID: string) => {
|
||||
setUserDrawerID(userID);
|
||||
setUserDrawerVisible(true);
|
||||
setConversationModalVisible(false);
|
||||
setConversationCommentID("");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<HorizontalGutter className={styles.root} size="double">
|
||||
{Boolean(viewNewCount && viewNewCount > 0) && (
|
||||
@@ -114,11 +134,11 @@ const Queue: FunctionComponent<Props> = ({
|
||||
<ModerateCardContainer
|
||||
key={comment.id}
|
||||
settings={settings}
|
||||
viewer={viewer}
|
||||
comment={comment}
|
||||
danglingLogic={danglingLogic}
|
||||
showStoryInfo={Boolean(allStories)}
|
||||
onUsernameClicked={onShowUserDrawer}
|
||||
onConversationClicked={onShowConversationModal}
|
||||
onSetSelected={() => setSelectedComment(i)}
|
||||
selected={selectedComment === i}
|
||||
selectPrev={selectPrev}
|
||||
@@ -141,6 +161,12 @@ const Queue: FunctionComponent<Props> = ({
|
||||
onClose={onHideUserDrawer}
|
||||
userID={userDrawerId}
|
||||
/>
|
||||
<ConversationModal
|
||||
onUsernameClicked={onUsernameClickedFromModal}
|
||||
open={conversationModalVisible}
|
||||
onClose={onHideConversationModal}
|
||||
commentID={conversationCommentID}
|
||||
/>
|
||||
</HorizontalGutter>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,7 +15,6 @@ 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";
|
||||
@@ -30,7 +29,6 @@ interface Props {
|
||||
queueName: GQLMODERATION_QUEUE;
|
||||
queue: QueueRoute_queue | null;
|
||||
settings: QueueRoute_settings | null;
|
||||
viewer: QueueRoute_viewer | null;
|
||||
relay: RelayPaginationProp;
|
||||
emptyElement: React.ReactElement;
|
||||
storyID?: string;
|
||||
@@ -90,7 +88,6 @@ export const QueueRoute: FunctionComponent<Props> = props => {
|
||||
<IntersectionProvider>
|
||||
<Queue
|
||||
comments={comments}
|
||||
viewer={props.viewer!}
|
||||
settings={props.settings!}
|
||||
onLoadMore={loadMore}
|
||||
hasLoadMore={props.relay.hasMore()}
|
||||
@@ -128,7 +125,6 @@ const createQueueRoute = (
|
||||
queueName={queueName}
|
||||
queue={null}
|
||||
settings={null}
|
||||
viewer={null}
|
||||
emptyElement={emptyElement}
|
||||
storyID={match.params.storyID}
|
||||
siteID={match.params.siteID}
|
||||
@@ -143,7 +139,6 @@ const createQueueRoute = (
|
||||
queueName={queueName}
|
||||
queue={queue}
|
||||
settings={data.settings}
|
||||
viewer={data.viewer}
|
||||
emptyElement={emptyElement}
|
||||
storyID={match.params.storyID}
|
||||
siteID={match.params.siteID}
|
||||
@@ -183,11 +178,6 @@ const createQueueRoute = (
|
||||
...ModerateCardContainer_settings
|
||||
}
|
||||
`,
|
||||
viewer: graphql`
|
||||
fragment QueueRoute_viewer on User {
|
||||
...ModerateCardContainer_viewer
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
direction: "forward",
|
||||
@@ -228,9 +218,6 @@ export const PendingQueueRoute = createQueueRoute(
|
||||
settings {
|
||||
...QueueRoute_settings
|
||||
}
|
||||
viewer {
|
||||
...QueueRoute_viewer
|
||||
}
|
||||
}
|
||||
`,
|
||||
graphql`
|
||||
@@ -269,9 +256,6 @@ export const ReportedQueueRoute = createQueueRoute(
|
||||
settings {
|
||||
...QueueRoute_settings
|
||||
}
|
||||
viewer {
|
||||
...QueueRoute_viewer
|
||||
}
|
||||
}
|
||||
`,
|
||||
graphql`
|
||||
@@ -310,9 +294,6 @@ export const UnmoderatedQueueRoute = createQueueRoute(
|
||||
settings {
|
||||
...QueueRoute_settings
|
||||
}
|
||||
viewer {
|
||||
...QueueRoute_viewer
|
||||
}
|
||||
}
|
||||
`,
|
||||
graphql`
|
||||
|
||||
@@ -37,7 +37,6 @@ export class RejectedQueueRoute extends React.Component<
|
||||
return (
|
||||
<IntersectionProvider>
|
||||
<Queue
|
||||
viewer={this.props.query.viewer!}
|
||||
settings={this.props.query.settings}
|
||||
comments={comments}
|
||||
onLoadMore={this.loadMore}
|
||||
@@ -107,9 +106,6 @@ const enhanced = (withPaginationContainer<
|
||||
settings {
|
||||
...ModerateCardContainer_settings
|
||||
}
|
||||
viewer {
|
||||
...ModerateCardContainer_viewer
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
|
||||
@@ -24,6 +24,12 @@ exports[`renders correctly with load more 1`] = `
|
||||
open={false}
|
||||
userID=""
|
||||
/>
|
||||
<ConversationModal
|
||||
commentID=""
|
||||
onClose={[Function]}
|
||||
onUsernameClicked={[Function]}
|
||||
open={false}
|
||||
/>
|
||||
</ForwardRef(forwardRef)>
|
||||
`;
|
||||
|
||||
@@ -43,5 +49,11 @@ exports[`renders correctly without load more 1`] = `
|
||||
open={false}
|
||||
userID=""
|
||||
/>
|
||||
<ConversationModal
|
||||
commentID=""
|
||||
onClose={[Function]}
|
||||
onUsernameClicked={[Function]}
|
||||
open={false}
|
||||
/>
|
||||
</ForwardRef(forwardRef)>
|
||||
`;
|
||||
|
||||
@@ -38,7 +38,6 @@ const SingleModerateRoute: FunctionComponent<Props> = props => {
|
||||
<Queue
|
||||
comments={[props.comment]}
|
||||
settings={props.settings}
|
||||
viewer={props.viewer!}
|
||||
onLoadMore={noop}
|
||||
hasLoadMore={false}
|
||||
disableLoadMore={false}
|
||||
@@ -58,9 +57,6 @@ const enhanced = withRouteConfig<Props, SingleModerateRouteQueryResponse>({
|
||||
settings {
|
||||
...ModerateCardContainer_settings
|
||||
}
|
||||
viewer {
|
||||
...ModerateCardContainer_viewer
|
||||
}
|
||||
}
|
||||
`,
|
||||
cacheConfig: { force: true },
|
||||
|
||||
@@ -1242,86 +1242,233 @@ integration to register for a new account.
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-4"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root FormField-root SSOKeyField-root HorizontalGutter-spacing-2"
|
||||
<p
|
||||
className="FormFieldDescription-root"
|
||||
>
|
||||
<label
|
||||
className="Label-root"
|
||||
htmlFor="configure-auth-sso-key"
|
||||
To enable integration with your existing authentication system,
|
||||
you will need to create a JWT Token to connect. You can learn
|
||||
more about creating a JWT Token with
|
||||
<a
|
||||
className="ExternalLink-root"
|
||||
href="https://jwt.io/introduction/"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Key
|
||||
</label>
|
||||
this introduction
|
||||
</a>
|
||||
. See our
|
||||
|
||||
<a
|
||||
className="ExternalLink-root"
|
||||
href="https://docs.coralproject.net/coral/v5/integrating/sso/"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
documentation
|
||||
</a>
|
||||
for additional information on single sign on.
|
||||
</p>
|
||||
<label
|
||||
className="Label-root"
|
||||
htmlFor="configure-auth-sso-rotate-keys"
|
||||
>
|
||||
Keys
|
||||
</label>
|
||||
<div
|
||||
className="Card-root"
|
||||
>
|
||||
<div
|
||||
className="PasswordField-fullWidth PasswordField-root"
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
<div
|
||||
className="PasswordField-wrapper"
|
||||
className="Box-root Flex-root Flex-flex Flex-justifySpaceBetween Flex-alignCenter"
|
||||
>
|
||||
<input
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
|
||||
data-testid="password-field"
|
||||
id="configure-auth-sso-key"
|
||||
name="key"
|
||||
placeholder=""
|
||||
readOnly={true}
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
/>
|
||||
<div
|
||||
className="PasswordField-icon"
|
||||
onClick={[Function]}
|
||||
onKeyUp={[Function]}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="Hide SSO Key"
|
||||
className="SSOKeyCard-keySection"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
<div
|
||||
className="SSOKeyCard-label"
|
||||
>
|
||||
visibility
|
||||
</i>
|
||||
<label
|
||||
className="Label-root"
|
||||
>
|
||||
Key ID
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
className="TextField-root TextField-fullWidth"
|
||||
>
|
||||
<input
|
||||
className="TextField-input TextField-colorRegular"
|
||||
data-testid="SSO-Key-ID"
|
||||
placeholder=""
|
||||
readOnly={true}
|
||||
type="text"
|
||||
value="kid-01"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="SSOKeyCard-secretSection"
|
||||
>
|
||||
<div
|
||||
className="SSOKeyCard-label"
|
||||
>
|
||||
<label
|
||||
className="Label-root"
|
||||
>
|
||||
Secret
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-justifyFlexStart Flex-alignCenter"
|
||||
>
|
||||
<div
|
||||
className="PasswordField-fullWidth PasswordField-root"
|
||||
>
|
||||
<div
|
||||
className="PasswordField-wrapper"
|
||||
>
|
||||
<input
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
|
||||
data-testid="password-field"
|
||||
id="configure-auth-sso-rotate-secretField"
|
||||
name="key"
|
||||
placeholder=""
|
||||
readOnly={true}
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
value="secret"
|
||||
/>
|
||||
<div
|
||||
className="PasswordField-icon"
|
||||
onClick={[Function]}
|
||||
onKeyUp={[Function]}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="Hide Secret"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
visibility
|
||||
</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorMono Button-variantFlat Button-uppercase"
|
||||
data-color="mono"
|
||||
data-variant="flat"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
aria-label="Copy Secret"
|
||||
className="Icon-root Icon-md"
|
||||
>
|
||||
content_copy
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-justifySpaceBetween Flex-alignFlexEnd"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-justifyFlexStart Flex-alignCenter"
|
||||
>
|
||||
<div
|
||||
className="SSOKeyCard-statusSection"
|
||||
>
|
||||
<div
|
||||
className="SSOKeyCard-label"
|
||||
>
|
||||
<label
|
||||
className="Label-root"
|
||||
>
|
||||
Status
|
||||
</label>
|
||||
</div>
|
||||
<span
|
||||
className="StatusField-status StatusField-active"
|
||||
data-testid="SSO-Key-Status"
|
||||
>
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className="DateField-label"
|
||||
>
|
||||
<label
|
||||
className="Label-root"
|
||||
>
|
||||
Active Since
|
||||
</label>
|
||||
</div>
|
||||
<span
|
||||
className="DateField-date"
|
||||
>
|
||||
1/1/2020, 1:00 AM
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="Popover-root"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantRegular Button-uppercase Button-disabled"
|
||||
data-color="regular"
|
||||
data-variant="regular"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className="RotationDropdown-rotate"
|
||||
>
|
||||
Rotate
|
||||
</span>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
arrow_drop_down
|
||||
</i>
|
||||
</button>
|
||||
<div
|
||||
aria-hidden={true}
|
||||
aria-labelledby="sso-key-rotate-ariainfo"
|
||||
id="sso-key-rotate"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
className="AriaInfo-root"
|
||||
id="sso-key-rotate-ariainfo"
|
||||
>
|
||||
A dropdown to rotate the SSO key
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="SSOKeyField-warningSection"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-halfItemGutter Flex-directionRow gutter"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm SSOKeyField-warnIcon"
|
||||
>
|
||||
warning
|
||||
</i>
|
||||
<p
|
||||
className="HelperText-root"
|
||||
>
|
||||
When regenerating a key, tokens signed with the previous key will be honored for 30 days.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantRegular Button-uppercase Button-disabled SSOKeyField-regenerateButton"
|
||||
data-color="regular"
|
||||
data-variant="regular"
|
||||
disabled={true}
|
||||
id="configure-auth-sso-regenerate"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Regenerate
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-spacing-2"
|
||||
|
||||
@@ -515,16 +515,15 @@ reactions, be reported, and be shared.
|
||||
This will appear above the comments sitewide.
|
||||
You can format the text using Markdown.
|
||||
More information on how to use Markdown
|
||||
|
||||
here:
|
||||
<a
|
||||
className="ExternalLink-root"
|
||||
href="#"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
here
|
||||
https://www.markdownguide.org/cheat-sheet/
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CreateTestRendererParams,
|
||||
findParentWithType,
|
||||
replaceHistoryLocation,
|
||||
toJSON,
|
||||
wait,
|
||||
waitForElement,
|
||||
within,
|
||||
@@ -57,11 +58,11 @@ it("renders configure auth", async () => {
|
||||
expect(within(configureContainer).toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("regenerate sso key", async () => {
|
||||
it("rotate sso key", async () => {
|
||||
const { testRenderer } = await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
Mutation: {
|
||||
regenerateSSOKey: () => {
|
||||
rotateSSOKey: () => {
|
||||
return {
|
||||
settings: pureMerge<typeof settingsWithEmptyAuth>(
|
||||
settingsWithEmptyAuth,
|
||||
@@ -69,8 +70,22 @@ it("regenerate sso key", async () => {
|
||||
auth: {
|
||||
integrations: {
|
||||
sso: {
|
||||
key: "==GENERATED_KEY==",
|
||||
keyGeneratedAt: "2018-11-12T23:26:06.239Z",
|
||||
enabled: true,
|
||||
keys: [
|
||||
{
|
||||
kid: "kid-01",
|
||||
secret: "secret",
|
||||
createdAt: "2015-01-01T00:00:00.000Z",
|
||||
lastUsedAt: "2016-01-01T01:45:00.000Z",
|
||||
rotatedAt: "2016-01-01T01:45:00.000Z",
|
||||
inactiveAt: "2016-01-01T01:45:00.000Z",
|
||||
},
|
||||
{
|
||||
kid: "kid-02",
|
||||
secret: "new-secret",
|
||||
createdAt: "2019-01-01T01:45:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -90,15 +105,40 @@ it("regenerate sso key", async () => {
|
||||
|
||||
act(() => {
|
||||
within(container)
|
||||
.getByText("Regenerate", { selector: "button" })
|
||||
.getByText("Rotate", { selector: "button" })
|
||||
.props.onClick();
|
||||
});
|
||||
|
||||
await wait(() =>
|
||||
expect(within(container).getByLabelText("Key").props.value).toBe(
|
||||
"==GENERATED_KEY=="
|
||||
)
|
||||
);
|
||||
const rotateNow = await waitForElement(() => {
|
||||
return within(container).getByText("Now", { selector: "button" });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
rotateNow.props.onClick();
|
||||
});
|
||||
|
||||
await wait(() => {
|
||||
// Check that we have two SSO Keys that match
|
||||
// our expected key IDs
|
||||
const keyIDs = within(container).getAllByTestID("SSO-Key-ID");
|
||||
const hasOldKey = keyIDs.some(k => k.props.value === "kid-01");
|
||||
const hasNewKey = keyIDs.some(k => k.props.value === "kid-02");
|
||||
expect(hasNewKey).toBe(true);
|
||||
expect(hasOldKey).toBe(true);
|
||||
|
||||
const statuses = within(container).getAllByTestID("SSO-Key-Status");
|
||||
expect(statuses.length).toBe(2);
|
||||
const firstStatus: any = toJSON(statuses[0]);
|
||||
const firstStatusIsActive = firstStatus.children.some(
|
||||
(s: string) => s === "Active"
|
||||
);
|
||||
expect(firstStatusIsActive).toBe(true);
|
||||
const secondStatus: any = toJSON(statuses[1]);
|
||||
const secondStatusIsActive = secondStatus.children.some(
|
||||
(s: string) => s === "Active"
|
||||
);
|
||||
expect(secondStatusIsActive).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("prevents admin lock out", async () => {
|
||||
|
||||
@@ -114,6 +114,16 @@ export const settings = createFixture<GQLSettings>({
|
||||
admin: true,
|
||||
stream: true,
|
||||
},
|
||||
keys: [
|
||||
{
|
||||
kid: "kid-01",
|
||||
secret: "secret",
|
||||
createdAt: "2020-01-01T01:00:00.000Z",
|
||||
lastUsedAt: undefined,
|
||||
rotatedAt: undefined,
|
||||
inactiveAt: undefined,
|
||||
},
|
||||
],
|
||||
key: "",
|
||||
keyGeneratedAt: null,
|
||||
},
|
||||
@@ -202,6 +212,16 @@ export const settingsWithEmptyAuth = createFixture<GQLSettings>(
|
||||
stream: true,
|
||||
},
|
||||
key: "",
|
||||
keys: [
|
||||
{
|
||||
kid: "kid-01",
|
||||
secret: "secret",
|
||||
createdAt: "2020-01-01T01:00:00.000Z",
|
||||
lastUsedAt: undefined,
|
||||
rotatedAt: undefined,
|
||||
inactiveAt: undefined,
|
||||
},
|
||||
],
|
||||
keyGeneratedAt: null,
|
||||
},
|
||||
google: {
|
||||
|
||||
@@ -34,7 +34,7 @@ exports[`approves comment in reported queue: dangling 1`] = `
|
||||
className="Box-root Flex-root Flex-flex Flex-alignCenter"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root ModerateCard-usernameButton"
|
||||
className="BaseButton-root UsernameButton-root"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
@@ -98,19 +98,28 @@ exports[`approves comment in reported queue: dangling 1`] = `
|
||||
<div
|
||||
className="ModerateCard-viewContext"
|
||||
>
|
||||
<a
|
||||
className="TextLink-root ModerateCard-link"
|
||||
href="http://localhost/comment/0"
|
||||
target="_blank"
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantText Button-uppercase Button-iconLeft"
|
||||
data-color="regular"
|
||||
data-variant="text"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
View Context
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-xs TextLink-icon"
|
||||
className="Icon-root Icon-sm ButtonIcon-root"
|
||||
>
|
||||
open_in_new
|
||||
question_answer
|
||||
</i>
|
||||
</a>
|
||||
<span>
|
||||
View Conversation
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator ModerateCard-ruledSeparator"
|
||||
@@ -291,7 +300,7 @@ exports[`rejects comment in reported queue: dangling 1`] = `
|
||||
className="Box-root Flex-root Flex-flex Flex-alignCenter"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root ModerateCard-usernameButton"
|
||||
className="BaseButton-root UsernameButton-root"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
@@ -355,19 +364,28 @@ exports[`rejects comment in reported queue: dangling 1`] = `
|
||||
<div
|
||||
className="ModerateCard-viewContext"
|
||||
>
|
||||
<a
|
||||
className="TextLink-root ModerateCard-link"
|
||||
href="http://localhost/comment/0"
|
||||
target="_blank"
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantText Button-uppercase Button-iconLeft"
|
||||
data-color="regular"
|
||||
data-variant="text"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
View Context
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-xs TextLink-icon"
|
||||
className="Icon-root Icon-sm ButtonIcon-root"
|
||||
>
|
||||
open_in_new
|
||||
question_answer
|
||||
</i>
|
||||
</a>
|
||||
<span>
|
||||
View Conversation
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator ModerateCard-ruledSeparator"
|
||||
@@ -545,7 +563,7 @@ exports[`renders reported queue with comments 1`] = `
|
||||
className="Box-root Flex-root Flex-flex Flex-alignCenter"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root ModerateCard-usernameButton"
|
||||
className="BaseButton-root UsernameButton-root"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
@@ -609,19 +627,28 @@ exports[`renders reported queue with comments 1`] = `
|
||||
<div
|
||||
className="ModerateCard-viewContext"
|
||||
>
|
||||
<a
|
||||
className="TextLink-root ModerateCard-link"
|
||||
href="http://localhost/comment/0"
|
||||
target="_blank"
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantText Button-uppercase Button-iconLeft"
|
||||
data-color="regular"
|
||||
data-variant="text"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
View Context
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-xs TextLink-icon"
|
||||
className="Icon-root Icon-sm ButtonIcon-root"
|
||||
>
|
||||
open_in_new
|
||||
question_answer
|
||||
</i>
|
||||
</a>
|
||||
<span>
|
||||
View Conversation
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator ModerateCard-ruledSeparator"
|
||||
@@ -786,7 +813,7 @@ exports[`renders reported queue with comments 1`] = `
|
||||
className="Box-root Flex-root Flex-flex Flex-alignCenter"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root ModerateCard-usernameButton"
|
||||
className="BaseButton-root UsernameButton-root"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
@@ -850,19 +877,28 @@ exports[`renders reported queue with comments 1`] = `
|
||||
<div
|
||||
className="ModerateCard-viewContext"
|
||||
>
|
||||
<a
|
||||
className="TextLink-root ModerateCard-link"
|
||||
href="http://localhost/comment/1"
|
||||
target="_blank"
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantText Button-uppercase Button-iconLeft"
|
||||
data-color="regular"
|
||||
data-variant="text"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
View Context
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-xs TextLink-icon"
|
||||
className="Icon-root Icon-sm ButtonIcon-root"
|
||||
>
|
||||
open_in_new
|
||||
question_answer
|
||||
</i>
|
||||
</a>
|
||||
<span>
|
||||
View Conversation
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator ModerateCard-ruledSeparator"
|
||||
@@ -1043,7 +1079,7 @@ exports[`renders reported queue with comments 2`] = `
|
||||
className="Box-root Flex-root Flex-flex Flex-alignCenter"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root ModerateCard-usernameButton"
|
||||
className="BaseButton-root UsernameButton-root"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
@@ -1107,19 +1143,28 @@ exports[`renders reported queue with comments 2`] = `
|
||||
<div
|
||||
className="ModerateCard-viewContext"
|
||||
>
|
||||
<a
|
||||
className="TextLink-root ModerateCard-link"
|
||||
href="http://localhost/comment/0"
|
||||
target="_blank"
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantText Button-uppercase Button-iconLeft"
|
||||
data-color="regular"
|
||||
data-variant="text"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
View Context
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-xs TextLink-icon"
|
||||
className="Icon-root Icon-sm ButtonIcon-root"
|
||||
>
|
||||
open_in_new
|
||||
question_answer
|
||||
</i>
|
||||
</a>
|
||||
<span>
|
||||
View Conversation
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator ModerateCard-ruledSeparator"
|
||||
@@ -1284,7 +1329,7 @@ exports[`renders reported queue with comments 2`] = `
|
||||
className="Box-root Flex-root Flex-flex Flex-alignCenter"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root ModerateCard-usernameButton"
|
||||
className="BaseButton-root UsernameButton-root"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
@@ -1348,19 +1393,28 @@ exports[`renders reported queue with comments 2`] = `
|
||||
<div
|
||||
className="ModerateCard-viewContext"
|
||||
>
|
||||
<a
|
||||
className="TextLink-root ModerateCard-link"
|
||||
href="http://localhost/comment/1"
|
||||
target="_blank"
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantText Button-uppercase Button-iconLeft"
|
||||
data-color="regular"
|
||||
data-variant="text"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
View Context
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-xs TextLink-icon"
|
||||
className="Icon-root Icon-sm ButtonIcon-root"
|
||||
>
|
||||
open_in_new
|
||||
question_answer
|
||||
</i>
|
||||
</a>
|
||||
<span>
|
||||
View Conversation
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator ModerateCard-ruledSeparator"
|
||||
@@ -1531,7 +1585,7 @@ exports[`renders reported queue with comments and load more 1`] = `
|
||||
className="Box-root Flex-root Flex-flex Flex-alignCenter"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root ModerateCard-usernameButton"
|
||||
className="BaseButton-root UsernameButton-root"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
@@ -1595,19 +1649,28 @@ exports[`renders reported queue with comments and load more 1`] = `
|
||||
<div
|
||||
className="ModerateCard-viewContext"
|
||||
>
|
||||
<a
|
||||
className="TextLink-root ModerateCard-link"
|
||||
href="http://localhost/comment/2"
|
||||
target="_blank"
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantText Button-uppercase Button-iconLeft"
|
||||
data-color="regular"
|
||||
data-variant="text"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
View Context
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-xs TextLink-icon"
|
||||
className="Icon-root Icon-sm ButtonIcon-root"
|
||||
>
|
||||
open_in_new
|
||||
question_answer
|
||||
</i>
|
||||
</a>
|
||||
<span>
|
||||
View Conversation
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator ModerateCard-ruledSeparator"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user