[CORL-645] Add Slack support for v5 (#2713)

* Create preliminary schema for setting Slack channels

CORL-645

* Implement preliminary slack notification using tenant slack channels

CORL-645

* Very preliminarily get data loading with FieldArray's for slack channels

CORL-645

* Update settings input on schema to allow saving Slack settings to tenant

CORL-645

* Filter off UNMODERATED queue events from slack channels

We don't send these to slack through our filters, we only care
about pending, reported, and featured

CORL-645

* Include a moderation link in comments pushed to Slack

CORL-645

* Hook up proper callback functions for adding/removing slack channels

CORL-645

* Add missing translation for Slack navigation item

CORL-645

* Update snapshots for preliminary Slack configuration changes

CORL-645

* Add some FormField wrappers around slack config elements

Makes the UI appear a little nicer

CORL-645

* Set up slack config to only provide one slack channel

We need to do this until we can get ArrayField's working
in final-form.

CORL-645

* Disable the other trigger checkboxes when "All Comments" is checked

CORL-645

* Clean up the formatting of Slack messages

CORL-645

* Add error handling around sending comments to Slack

CORL-645

* Add links to external Slack setup documentation

CORL-645

* Replace form state with wrapped field element

CORL-645

* Clean up fetch request sending Slack notifications

CORL-645

* Prefer global string replacement over RegEx

CORL-645

* Use URL class to construct comment URL's

CORL-645

* Require slack configuration in schema

CORL-645

* Initialize Slack in fixtures

Also fix up a flaky test that wasn't waiting
on form submission and on-change events.

CORL-645

* Preliminarily fix up styles to match other config pages

CORL-645

* Create placeholder add/remove buttons

* Convert SlackConfigContainer to FunctionalComponent

CORL-645

* Add name field to slack channels

CORL-645

* Disable inner fields on Slack channel when not enabled

CORL-645

* Improve the delete channel button

CORL-645

* Use pureMerge to extract slack channel settings

CORL-645

* Do a bit of cleanup on the add channel button

CORL-645
This commit is contained in:
Nick Funk
2019-12-03 16:06:38 -05:00
committed by Kim Gardner
parent c109188efe
commit 54296fa484
34 changed files with 1057 additions and 19 deletions
+2
View File
@@ -15,6 +15,7 @@ import {
GeneralConfigRoute,
ModerationConfigRoute,
OrganizationConfigRoute,
SlackConfigRoute,
WordListConfigRoute,
} from "./routes/Configure/sections";
import ForgotPasswordRoute from "./routes/ForgotPassword";
@@ -75,6 +76,7 @@ export default makeRouteConfig(
<Route path="auth" {...AuthConfigRoute.routeConfig} />
<Route path="advanced" {...AdvancedConfigRoute.routeConfig} />
<Route path="email" {...EmailConfigRoute.routeConfig} />
<Route path="slack" {...SlackConfigRoute.routeConfig} />
</Route>
</Route>
</Route>
@@ -1,4 +1,5 @@
import { FormApi, FormState } from "final-form";
import arrayMutators from "final-form-arrays";
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import { Form, FormSpy } from "react-final-form";
@@ -24,7 +25,7 @@ const Configure: FunctionComponent<Props> = ({
children,
}) => (
<MainLayout data-testid="configure-container">
<Form onSubmit={onSubmit}>
<Form onSubmit={onSubmit} mutators={{ ...arrayMutators }}>
{({ handleSubmit, submitting, form, pristine, submitError }) => (
<form autoComplete="off" onSubmit={handleSubmit} id="configure-form">
<FormSpy onChange={onChange} />
@@ -52,6 +53,9 @@ const Configure: FunctionComponent<Props> = ({
<Localized id="configure-sideBarNavigation-email">
<Link to="/admin/configure/email">Email</Link>
</Localized>
<Localized id="configure-sideBarNavigation-slack">
<Link to="/admin/configure/slack">Slack</Link>
</Localized>
<Localized id="configure-sideBarNavigation-advanced">
<Link to="/admin/configure/advanced">Advanced</Link>
</Localized>
@@ -30,6 +30,7 @@ const UpdateSettingsMutation = createMutation(
...OrganizationConfigContainer_settings
...WordListConfigContainer_settings
...AdvancedConfigContainer_settings
...SlackConfigContainer_settings
}
clientMutationId
}
@@ -5,6 +5,19 @@ exports[`renders correctly 1`] = `
data-testid="configure-container"
>
<ReactFinalForm
mutators={
Object {
"insert": [Function],
"move": [Function],
"pop": [Function],
"push": [Function],
"remove": [Function],
"shift": [Function],
"swap": [Function],
"unshift": [Function],
"update": [Function],
}
}
onSubmit={[Function]}
>
[Function]
@@ -0,0 +1,28 @@
.header {
flex: 1;
}
.description {
padding-bottom: var(--spacing-1);
}
.channelName {
margin-right: var(--spacing-2);
}
.trigger {
padding-left: var(--spacing-1);
}
.removeButton {
padding-top: 0px;
padding-bottom: 0px;
margin-bottom: 2px;
float: right;
}
.buttonIcon {
padding-right: var(--spacing-1);
}
@@ -0,0 +1,230 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { Field } from "react-final-form";
import { parseBool } from "coral-framework/lib/form";
import { ExternalLink } from "coral-framework/lib/i18n/components";
import { InputDescription, InputLabel } from "coral-ui/components";
import {
Button,
ButtonIcon,
CheckBox,
Flex,
FormField,
TextField,
} from "coral-ui/components/v2";
import Header from "../../Header";
import ConfigBoxWithToggleField from "../Auth/ConfigBoxWithToggleField";
import styles from "./SlackChannel.css";
interface Props {
channel: any;
disabled: boolean;
index: number;
onRemoveClicked: (index: number) => void;
}
const SlackChannel: FunctionComponent<Props> = ({
channel,
disabled,
index,
onRemoveClicked,
}) => {
const onRemove = useCallback(() => {
onRemoveClicked(index);
}, [index, onRemoveClicked]);
return (
<ConfigBoxWithToggleField
title={
<Flex
className={styles.header}
justifyContent="space-between"
alignItems="center"
>
<div>
<Field name={`${channel}.name`}>
{({ input }) => (
<Header className={styles.channelName}>{input.value}</Header>
)}
</Field>
</div>
<div>
<Button
size="small"
variant="filled"
color="alert"
onClick={onRemove}
className={styles.removeButton}
>
<ButtonIcon size="md" className={styles.buttonIcon}>
delete_forever
</ButtonIcon>
<Localized id="configure-slack-channel-remove">Remove</Localized>
</Button>
</div>
</Flex>
}
name={`${channel}.enabled`}
disabled={disabled}
>
{(disabledInside: boolean) => (
<>
<FormField>
<Field name={`${channel}.name`}>
{({ input, meta }) => (
<>
<Localized id="configure-slack-channel-name-label">
<InputLabel container="legend">Name</InputLabel>
</Localized>
<Localized id="configure-slack-channel-name-description">
<InputDescription className={styles.description}>
This is only for your information, to easily identify each
Slack connection. Slack does not tell us the name of the
channel/s you're connecting to Coral.
</InputDescription>
</Localized>
<TextField
id={`configure-slack-channel-name-${input.name}`}
disabled={disabled || disabledInside}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
fullWidth
{...input}
/>
</>
)}
</Field>
</FormField>
<FormField>
<Field name={`${channel}.hookURL`}>
{({ input, meta }) => (
<>
<Localized id="configure-slack-channel-hookURL-label">
<InputLabel container="legend">Webhook URL</InputLabel>
</Localized>
<Localized
id="configure-slack-channel-hookURL-description"
externalLink={
<ExternalLink href="https://docs.coralproject.net/coral/v5/integrating/slack/#i-need-to-find-the-webhook-url-again-where-is-it" />
}
>
<InputDescription className={styles.description}>
Slack provides a channel-specific URL to activate webhook
connections. To find the URL for one of your Slack
channels, follow the instructions here.
</InputDescription>
</Localized>
<TextField
id={`configure-slack-channel-hookURL-${input.name}`}
disabled={disabled || disabledInside}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
fullWidth
{...input}
/>
</>
)}
</Field>
</FormField>
<FormField>
<Localized id="configure-slack-channel-triggers-label">
<InputLabel container="legend">
Receive notifications in this Slack channel for
</InputLabel>
</Localized>
<Field
name={`${channel}.triggers.allComments`}
subscription={{ value: true }}
>
{({ input: { value } }) => (
<>
<Field
name={`${channel}.triggers.allComments`}
type="checkbox"
parse={parseBool}
>
{({ input }) => (
<CheckBox
id={`configure-slack-channel-triggers-allComments-${input.name}`}
disabled={disabled || disabledInside}
className={styles.trigger}
{...input}
>
<Localized id="configure-slack-channel-triggers-allComments">
All Comments
</Localized>
</CheckBox>
)}
</Field>
<Field
name={`${channel}.triggers.reportedComments`}
type="checkbox"
parse={parseBool}
>
{({ input }) => (
<CheckBox
id={`configure-slack-channel-triggers-reportedComments-${input.name}`}
disabled={disabled || value || disabledInside}
className={styles.trigger}
{...input}
>
<Localized id="configure-slack-channel-triggers-reportedComments">
Reported Comments
</Localized>
</CheckBox>
)}
</Field>
<Field
name={`${channel}.triggers.pendingComments`}
type="checkbox"
parse={parseBool}
>
{({ input }) => (
<CheckBox
id={`configure-slack-channel-triggers-pendingComments-${input.name}`}
disabled={disabled || value || disabledInside}
className={styles.trigger}
{...input}
>
<Localized id="configure-slack-channel-triggers-pendingComments">
Pending Comments
</Localized>
</CheckBox>
)}
</Field>
<Field
name={`${channel}.triggers.featuredComments`}
type="checkbox"
parse={parseBool}
>
{({ input }) => (
<CheckBox
id={`configure-slack-channel-triggers-featuredComments-${input.name}`}
disabled={disabled || value || disabledInside}
className={styles.trigger}
{...input}
>
<Localized id="configure-slack-channel-triggers-featuredComments">
Featured Comments
</Localized>
</CheckBox>
)}
</Field>
</>
)}
</Field>
</FormField>
</>
)}
</ConfigBoxWithToggleField>
);
};
export default SlackChannel;
@@ -0,0 +1,3 @@
.icon {
padding-right: var(--spacing-2);
}
@@ -0,0 +1,162 @@
import { FormApi } from "final-form";
import { Localized } from "fluent-react/compat";
import React, {
FunctionComponent,
useCallback,
useMemo,
useState,
} from "react";
import { FieldArray } from "react-final-form-arrays";
import { pureMerge } from "coral-common/utils";
import { ExternalLink } from "coral-framework/lib/i18n/components";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import {
Button,
ButtonIcon,
FormFieldDescription,
HorizontalGutter,
} from "coral-ui/components/v2";
import { SlackConfigContainer_settings } from "coral-admin/__generated__/SlackConfigContainer_settings.graphql";
import ConfigBox from "../../ConfigBox";
import Header from "../../Header";
import SlackChannel from "./SlackChannel";
import styles from "./SlackConfigContainer.css";
interface Props {
form: FormApi;
submitting: boolean;
settings: SlackConfigContainer_settings;
}
const SlackConfigContainer: FunctionComponent<Props> = ({ form, settings }) => {
const [defaultValues] = useState({
slack: {
channels: [
{
enabled: false,
name: "",
hookURL: "",
triggers: {
allComments: false,
reportedComments: false,
pendingComments: false,
featuredComments: false,
},
},
],
},
});
const onAddChannel = useCallback(() => {
const mutators = form.mutators;
mutators.insert("slack.channels", 0, {
enabled: true,
hookURL: "",
triggers: {
allComments: false,
reportedComments: false,
pendingComments: false,
featuredComments: false,
},
});
}, [form]);
const onRemoveChannel = useCallback(
(index: number) => {
const mutators = form.mutators;
mutators.remove("slack.channels", index);
},
[form]
);
useMemo(() => {
if (
!settings ||
!settings.slack ||
!settings.slack.channels ||
settings.slack.channels.length === 0
) {
form.initialize(defaultValues);
} else {
const settingsValues = pureMerge(defaultValues, settings);
form.initialize(settingsValues);
}
}, [settings, defaultValues]);
return (
<HorizontalGutter size="double">
<ConfigBox
title={
<Localized id="configure-slack-header-title">
<Header htmlFor="configure-slack-header.title">
Slack Integrations
</Header>
</Localized>
}
>
<Localized
id="configure-slack-description"
externalLink={
<ExternalLink href="https://docs.coralproject.net/coral/v5/integrating/slack/" />
}
>
<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.
</FormFieldDescription>
</Localized>
<Button
size="medium"
variant="filled"
color="emphasis"
onClick={onAddChannel}
>
<ButtonIcon size="md" className={styles.icon}>
add
</ButtonIcon>
<Localized id="configure-slack-addChannel">Add</Localized>
</Button>
<FieldArray name="slack.channels">
{({ fields }) =>
fields.map((channel: any, index: number) => (
<div key={index}>
<SlackChannel
channel={channel}
disabled={false}
index={index}
onRemoveClicked={onRemoveChannel}
/>
</div>
))
}
</FieldArray>
</ConfigBox>
</HorizontalGutter>
);
};
const enhanced = withFragmentContainer<Props>({
settings: graphql`
fragment SlackConfigContainer_settings on Settings {
slack {
channels {
enabled
name
hookURL
triggers {
allComments
reportedComments
pendingComments
featuredComments
}
}
}
}
`,
})(SlackConfigContainer);
export default enhanced;
@@ -0,0 +1,50 @@
import { FormApi } from "final-form";
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import { SlackConfigRouteQueryResponse } from "coral-admin/__generated__/SlackConfigRouteQuery.graphql";
import { withRouteConfig } from "coral-framework/lib/router";
import { Delay, Spinner } from "coral-ui/components";
import SlackConfigContainer from "./SlackConfigContainer";
interface Props {
data: SlackConfigRouteQueryResponse | null;
form: FormApi;
submitting: boolean;
}
const SlackConfigRoute: FunctionComponent<Props> = ({
data,
form,
submitting,
}) => {
if (!data) {
return (
<Delay>
<Spinner />
</Delay>
);
}
return (
<SlackConfigContainer
settings={data.settings}
form={form}
submitting={submitting}
/>
);
};
const enhanced = withRouteConfig<Props>({
query: graphql`
query SlackConfigRouteQuery {
settings {
...SlackConfigContainer_settings
}
}
`,
cacheConfig: { force: true },
})(SlackConfigRoute);
export default enhanced;
@@ -0,0 +1 @@
export { default, default as SlackConfigRoute } from "./SlackConfigRoute";
@@ -5,3 +5,4 @@ export { GeneralConfigRoute } from "./General";
export { ModerationConfigRoute } from "./Moderation";
export { OrganizationConfigRoute } from "./Organization";
export { WordListConfigRoute } from "./WordList";
export { SlackConfigRoute } from "./Slack";
@@ -79,6 +79,15 @@ exports[`renders configure advanced 1`] = `
Email
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/slack"
onClick={[Function]}
>
Slack
</a>
</li>
<li>
<a
className="Link-link Link-linkActive"
@@ -79,6 +79,15 @@ exports[`renders configure auth 1`] = `
Email
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/slack"
onClick={[Function]}
>
Slack
</a>
</li>
<li>
<a
className="Link-link"
@@ -79,6 +79,15 @@ exports[`renders configure general 1`] = `
Email
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/slack"
onClick={[Function]}
>
Slack
</a>
</li>
<li>
<a
className="Link-link"
@@ -79,6 +79,15 @@ exports[`renders configure moderation 1`] = `
Email
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/slack"
onClick={[Function]}
>
Slack
</a>
</li>
<li>
<a
className="Link-link"
@@ -79,6 +79,15 @@ exports[`renders configure organization 1`] = `
Email
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/slack"
onClick={[Function]}
>
Slack
</a>
</li>
<li>
<a
className="Link-link"
@@ -79,6 +79,15 @@ exports[`renders configure wordList 1`] = `
Email
</a>
</li>
<li>
<a
className="Link-link"
href="/admin/configure/slack"
onClick={[Function]}
>
Slack
</a>
</li>
<li>
<a
className="Link-link"
@@ -90,14 +90,16 @@ it("change language", async () => {
act(() => languageField.props.onChange("es"));
// Send form
act(() => {
within(configureContainer)
await act(async () => {
await within(configureContainer)
.getByType("form")
.props.onSubmit();
});
// Submit button and text field should be disabled.
expect(saveChangesButton.props.disabled).toBe(true);
await wait(() => {
expect(saveChangesButton.props.disabled).toBe(true);
});
// Wait for submission to be finished
await act(async () => {
+3
View File
@@ -161,6 +161,9 @@ export const settings = createFixture<GQLSettings>({
changeUsername: true,
deleteAccount: true,
},
slack: {
channels: [],
},
});
export const settingsWithEmptyAuth = createFixture<GQLSettings>(