mirror of
https://github.com/wassname/talk.git
synced 2026-07-06 04:44:57 +08:00
[CORL-276] Sitewide Pre-Moderation (#2392)
* feat: initial implementation * chore: docs update * fix: lint * fix: naming
This commit is contained in:
@@ -299,7 +299,7 @@ To test out the email sending functionality, you can run [inbucket](https://www.
|
||||
which provides a test SMTP server that can visualize emails in the browser:
|
||||
|
||||
```bash
|
||||
docker run -d --name inbucket -p 2500:2500 -p 9000:9000 inbucket/inbucket
|
||||
docker run -d --name inbucket --restart always -p 2500:2500 -p 9000:9000 inbucket/inbucket
|
||||
```
|
||||
|
||||
You can then configure the email server on Coral by updating the Tenant with:
|
||||
|
||||
@@ -13,6 +13,8 @@ interface Props {
|
||||
invert?: boolean;
|
||||
onLabel?: React.ReactNode;
|
||||
offLabel?: React.ReactNode;
|
||||
format?: ((value: any, name: string) => any) | null;
|
||||
parse?: ((value: any, name: string) => any) | null;
|
||||
}
|
||||
|
||||
const OnOffField: FunctionComponent<Props> = ({
|
||||
@@ -21,9 +23,17 @@ const OnOffField: FunctionComponent<Props> = ({
|
||||
onLabel,
|
||||
offLabel,
|
||||
invert = false,
|
||||
parse = parseStringBool,
|
||||
format,
|
||||
}) => (
|
||||
<div>
|
||||
<Field name={name} type="radio" parse={parseStringBool} value={!invert}>
|
||||
<Field
|
||||
name={name}
|
||||
type="radio"
|
||||
value={!invert}
|
||||
parse={parse}
|
||||
format={format}
|
||||
>
|
||||
{({ input }) => (
|
||||
<RadioButton
|
||||
id={`${input.name}-true`}
|
||||
@@ -43,7 +53,13 @@ const OnOffField: FunctionComponent<Props> = ({
|
||||
</RadioButton>
|
||||
)}
|
||||
</Field>
|
||||
<Field name={name} type="radio" parse={parseStringBool} value={invert}>
|
||||
<Field
|
||||
name={name}
|
||||
type="radio"
|
||||
parse={parse}
|
||||
format={format}
|
||||
value={invert}
|
||||
>
|
||||
{({ input }) => (
|
||||
<RadioButton
|
||||
id={`${input.name}-false`}
|
||||
|
||||
@@ -5,11 +5,13 @@ import { HorizontalGutter } from "coral-ui/components";
|
||||
|
||||
import AkismetConfigContainer from "./AkismetConfigContainer";
|
||||
import PerspectiveConfigContainer from "./PerspectiveConfigContainer";
|
||||
import PreModerationConfigContainer from "./PreModerationConfigContainer";
|
||||
|
||||
interface Props {
|
||||
disabled: boolean;
|
||||
settings: PropTypesOf<typeof AkismetConfigContainer>["settings"] &
|
||||
PropTypesOf<typeof PerspectiveConfigContainer>["settings"];
|
||||
PropTypesOf<typeof PerspectiveConfigContainer>["settings"] &
|
||||
PropTypesOf<typeof PreModerationConfigContainer>["settings"];
|
||||
onInitValues: (values: any) => void;
|
||||
}
|
||||
|
||||
@@ -19,6 +21,11 @@ const ModerationConfig: FunctionComponent<Props> = ({
|
||||
onInitValues,
|
||||
}) => (
|
||||
<HorizontalGutter size="double" data-testid="configure-moderationContainer">
|
||||
<PreModerationConfigContainer
|
||||
disabled={disabled}
|
||||
settings={settings}
|
||||
onInitValues={onInitValues}
|
||||
/>
|
||||
<PerspectiveConfigContainer
|
||||
disabled={disabled}
|
||||
settings={settings}
|
||||
|
||||
+1
@@ -47,6 +47,7 @@ const enhanced = withFragmentContainer<Props>({
|
||||
fragment ModerationConfigContainer_settings on Settings {
|
||||
...AkismetConfigContainer_settings
|
||||
...PerspectiveConfigContainer_settings
|
||||
...PreModerationConfigContainer_settings
|
||||
}
|
||||
`,
|
||||
})(ModerationConfigContainer);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { parseStringBool } from "coral-framework/lib/form";
|
||||
import {
|
||||
FieldSet,
|
||||
FormField,
|
||||
HorizontalGutter,
|
||||
InputLabel,
|
||||
Typography,
|
||||
} from "coral-ui/components";
|
||||
|
||||
import Header from "../../Header";
|
||||
import OnOffField from "../../OnOffField";
|
||||
|
||||
interface Props {
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const parse = (v: string) => {
|
||||
return parseStringBool(v) ? "PRE" : "POST";
|
||||
};
|
||||
|
||||
const format = (v: "PRE" | "POST") => {
|
||||
return v === "PRE";
|
||||
};
|
||||
|
||||
const PreModerationConfig: FunctionComponent<Props> = ({ disabled }) => {
|
||||
return (
|
||||
<HorizontalGutter size="oneAndAHalf" container={<FieldSet />}>
|
||||
<Localized id="configure-moderation-preModeration-title">
|
||||
<Header container="legend">Pre-moderation</Header>
|
||||
</Localized>
|
||||
<Localized id="configure-moderation-preModeration-explanation">
|
||||
<Typography variant="detail">
|
||||
When pre-moderation is turned on, comments will not be published
|
||||
unless approved by a moderator.
|
||||
</Typography>
|
||||
</Localized>
|
||||
<FormField container={<FieldSet />}>
|
||||
<Localized id="configure-moderation-preModeration-moderation">
|
||||
<InputLabel container="legend">
|
||||
Pre-moderate all comments sitewide
|
||||
</InputLabel>
|
||||
</Localized>
|
||||
<OnOffField
|
||||
name="moderation"
|
||||
disabled={disabled}
|
||||
parse={parse}
|
||||
format={format}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField container={<FieldSet />}>
|
||||
<Localized id="configure-moderation-preModeration-premodLinksEnable">
|
||||
<InputLabel container="legend">
|
||||
Pre-moderate comments containing links sitewide
|
||||
</InputLabel>
|
||||
</Localized>
|
||||
<OnOffField name="premodLinksEnable" disabled={disabled} />
|
||||
</FormField>
|
||||
</HorizontalGutter>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreModerationConfig;
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { PreModerationConfigContainer_settings as SettingsData } from "coral-admin/__generated__/PreModerationConfigContainer_settings.graphql";
|
||||
import { withFragmentContainer } from "coral-framework/lib/relay";
|
||||
|
||||
import PreModerationConfig from "./PreModerationConfig";
|
||||
|
||||
interface Props {
|
||||
settings: SettingsData;
|
||||
onInitValues: (values: SettingsData) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
class PreModerationConfigContainer extends React.Component<Props> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
props.onInitValues(props.settings);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { disabled } = this.props;
|
||||
return <PreModerationConfig disabled={disabled} />;
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
settings: graphql`
|
||||
fragment PreModerationConfigContainer_settings on Settings {
|
||||
moderation
|
||||
premodLinksEnable
|
||||
}
|
||||
`,
|
||||
})(PreModerationConfigContainer);
|
||||
|
||||
export default enhanced;
|
||||
+5
@@ -5,6 +5,11 @@ exports[`renders correctly 1`] = `
|
||||
data-testid="configure-moderationContainer"
|
||||
size="double"
|
||||
>
|
||||
<Relay(PreModerationConfigContainer)
|
||||
disabled={false}
|
||||
onInitValues={[Function]}
|
||||
settings={Object {}}
|
||||
/>
|
||||
<Relay(PerspectiveConfigContainer)
|
||||
disabled={false}
|
||||
onInitValues={[Function]}
|
||||
|
||||
@@ -107,6 +107,139 @@ exports[`renders configure moderation 1`] = `
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
data-testid="configure-moderationContainer"
|
||||
>
|
||||
<fieldset
|
||||
className="FieldSet-root Box-root HorizontalGutter-root HorizontalGutter-oneAndAHalf"
|
||||
>
|
||||
<legend
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary Header-root"
|
||||
>
|
||||
Pre-moderation
|
||||
</legend>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-detail Typography-colorTextPrimary"
|
||||
>
|
||||
When pre-moderation is turned on, comments will not be published unless
|
||||
approved by a moderator.
|
||||
</p>
|
||||
<fieldset
|
||||
className="FieldSet-root Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
|
||||
>
|
||||
<legend
|
||||
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
|
||||
>
|
||||
Pre-moderate all comments sitewide
|
||||
</legend>
|
||||
<div>
|
||||
<div
|
||||
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
|
||||
>
|
||||
<input
|
||||
checked={false}
|
||||
className="RadioButton-input"
|
||||
disabled={false}
|
||||
id="moderation-true"
|
||||
name="moderation"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
type="radio"
|
||||
value={true}
|
||||
/>
|
||||
<label
|
||||
className="RadioButton-label"
|
||||
htmlFor="moderation-true"
|
||||
>
|
||||
<span>
|
||||
On
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
|
||||
>
|
||||
<input
|
||||
checked={true}
|
||||
className="RadioButton-input"
|
||||
disabled={false}
|
||||
id="moderation-false"
|
||||
name="moderation"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
type="radio"
|
||||
value={false}
|
||||
/>
|
||||
<label
|
||||
className="RadioButton-label"
|
||||
htmlFor="moderation-false"
|
||||
>
|
||||
<span>
|
||||
Off
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset
|
||||
className="FieldSet-root Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
|
||||
>
|
||||
<legend
|
||||
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
|
||||
>
|
||||
Pre-moderate comments containing links sitewide
|
||||
</legend>
|
||||
<div>
|
||||
<div
|
||||
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
|
||||
>
|
||||
<input
|
||||
checked={false}
|
||||
className="RadioButton-input"
|
||||
disabled={false}
|
||||
id="premodLinksEnable-true"
|
||||
name="premodLinksEnable"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
type="radio"
|
||||
value={true}
|
||||
/>
|
||||
<label
|
||||
className="RadioButton-label"
|
||||
htmlFor="premodLinksEnable-true"
|
||||
>
|
||||
<span>
|
||||
On
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
|
||||
>
|
||||
<input
|
||||
checked={true}
|
||||
className="RadioButton-input"
|
||||
disabled={false}
|
||||
id="premodLinksEnable-false"
|
||||
name="premodLinksEnable"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
type="radio"
|
||||
value={false}
|
||||
/>
|
||||
<label
|
||||
className="RadioButton-label"
|
||||
htmlFor="premodLinksEnable-false"
|
||||
>
|
||||
<span>
|
||||
Off
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</fieldset>
|
||||
<fieldset
|
||||
className="FieldSet-root Box-root HorizontalGutter-root HorizontalGutter-oneAndAHalf"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import { GQLResolver } from "coral-framework/schema";
|
||||
import { GQLMODERATION_MODE, GQLResolver } from "coral-framework/schema";
|
||||
import {
|
||||
createResolversStub,
|
||||
CreateTestRendererParams,
|
||||
@@ -60,6 +60,102 @@ it("renders configure moderation", async () => {
|
||||
expect(within(configureContainer).toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("change site wide pre-moderation", async () => {
|
||||
const resolvers = createResolversStub<GQLResolver>({
|
||||
Mutation: {
|
||||
updateSettings: ({ variables }) => {
|
||||
expectAndFail(variables.settings.moderation).toEqual(
|
||||
GQLMODERATION_MODE.PRE
|
||||
);
|
||||
return {
|
||||
settings: pureMerge(settings, variables.settings),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const {
|
||||
configureContainer,
|
||||
moderationContainer,
|
||||
saveChangesButton,
|
||||
} = await createTestRenderer({
|
||||
resolvers,
|
||||
});
|
||||
|
||||
const preModerationContainer = within(moderationContainer).getAllByText(
|
||||
"Pre-moderate all comments sitewide",
|
||||
{ selector: "fieldset" }
|
||||
)[0];
|
||||
|
||||
const onField = within(preModerationContainer).getByLabelText("On");
|
||||
|
||||
// Let's enable it.
|
||||
onField.props.onChange(onField.props.value.toString());
|
||||
|
||||
// Send form
|
||||
within(configureContainer)
|
||||
.getByType("form")
|
||||
.props.onSubmit();
|
||||
|
||||
// Submit button and text field should be disabled.
|
||||
expect(saveChangesButton.props.disabled).toBe(true);
|
||||
expect(onField.props.disabled).toBe(true);
|
||||
|
||||
// Wait for submission to be finished
|
||||
await wait(() => {
|
||||
expect(onField.props.disabled).toBe(false);
|
||||
});
|
||||
|
||||
// Should have successfully sent with server.
|
||||
expect(resolvers.Mutation!.updateSettings!.called).toBe(true);
|
||||
});
|
||||
|
||||
it("change site wide link pre-moderation", async () => {
|
||||
const resolvers = createResolversStub<GQLResolver>({
|
||||
Mutation: {
|
||||
updateSettings: ({ variables }) => {
|
||||
expectAndFail(variables.settings.premodLinksEnable).toEqual(true);
|
||||
return {
|
||||
settings: pureMerge(settings, variables.settings),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const {
|
||||
configureContainer,
|
||||
moderationContainer,
|
||||
saveChangesButton,
|
||||
} = await createTestRenderer({
|
||||
resolvers,
|
||||
});
|
||||
|
||||
const preModerationContainer = within(moderationContainer).getAllByText(
|
||||
"Pre-moderate comments containing links sitewide",
|
||||
{ selector: "fieldset" }
|
||||
)[0];
|
||||
|
||||
const onField = within(preModerationContainer).getByLabelText("On");
|
||||
|
||||
// Let's enable it.
|
||||
onField.props.onChange(onField.props.value.toString());
|
||||
|
||||
// Send form
|
||||
within(configureContainer)
|
||||
.getByType("form")
|
||||
.props.onSubmit();
|
||||
|
||||
// Submit button and text field should be disabled.
|
||||
expect(saveChangesButton.props.disabled).toBe(true);
|
||||
expect(onField.props.disabled).toBe(true);
|
||||
|
||||
// Wait for submission to be finished
|
||||
await wait(() => {
|
||||
expect(onField.props.disabled).toBe(false);
|
||||
});
|
||||
|
||||
// Should have successfully sent with server.
|
||||
expect(resolvers.Mutation!.updateSettings!.called).toBe(true);
|
||||
});
|
||||
|
||||
it("change akismet settings", async () => {
|
||||
const resolvers = createResolversStub<GQLResolver>({
|
||||
Mutation: {
|
||||
|
||||
@@ -215,6 +215,16 @@ configure-auth-oidc-jwksURI = JWKS URI
|
||||
configure-auth-oidc-useLoginOn = Use OpenID Connect login on
|
||||
|
||||
### Moderation
|
||||
#### Pre-Moderation
|
||||
configure-moderation-preModeration-title = Pre-moderation
|
||||
configure-moderation-preModeration-explanation =
|
||||
When pre-moderation is turned on, comments will not be published unless
|
||||
approved by a moderator.
|
||||
configure-moderation-preModeration-moderation =
|
||||
Pre-moderate all comments sitewide
|
||||
configure-moderation-preModeration-premodLinksEnable =
|
||||
Pre-moderate comments containing links sitewide
|
||||
|
||||
configure-moderation-apiKey = API Key
|
||||
|
||||
configure-moderation-akismet-title = Akismet Spam Detection Filter
|
||||
|
||||
Reference in New Issue
Block a user