From 8e24ca51ad233a627e0a0218c469ebcd7bb6828c Mon Sep 17 00:00:00 2001 From: Jono M Date: Tue, 22 Aug 2023 12:15:38 +0100 Subject: [PATCH] Wired up Slack integration in AdminX (#17781) refs https://github.com/TryGhost/Product/issues/3729 This pull request improves the Slack integration settings by allowing users to test the webhook URL and save the settings from a modal. It also refactors the `SlackModal` component and adds a new API function `useTestSlack` to handle the test message. --- apps/admin-x-settings/src/api/slack.ts | 6 ++ .../advanced/integrations/SlackModal.tsx | 67 ++++++++++++++++-- .../src/hooks/useSettingGroup.tsx | 15 ++-- .../e2e/advanced/integrations/slack.test.ts | 68 +++++++++++++++++++ 4 files changed, 146 insertions(+), 10 deletions(-) create mode 100644 apps/admin-x-settings/src/api/slack.ts create mode 100644 apps/admin-x-settings/test/e2e/advanced/integrations/slack.test.ts diff --git a/apps/admin-x-settings/src/api/slack.ts b/apps/admin-x-settings/src/api/slack.ts new file mode 100644 index 0000000000..8056de41f2 --- /dev/null +++ b/apps/admin-x-settings/src/api/slack.ts @@ -0,0 +1,6 @@ +import {createMutation} from '../utils/apiRequests'; + +export const useTestSlack = createMutation({ + method: 'POST', + path: () => '/slack/test/' +}); diff --git a/apps/admin-x-settings/src/components/settings/advanced/integrations/SlackModal.tsx b/apps/admin-x-settings/src/components/settings/advanced/integrations/SlackModal.tsx index 63258fa3be..4e7901d662 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/integrations/SlackModal.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/integrations/SlackModal.tsx @@ -4,23 +4,71 @@ import IntegrationHeader from './IntegrationHeader'; import Modal from '../../../../admin-x-ds/global/modal/Modal'; import NiceModal from '@ebay/nice-modal-react'; import TextField from '../../../../admin-x-ds/global/form/TextField'; +import toast from 'react-hot-toast'; import useRouting from '../../../../hooks/useRouting'; +import useSettingGroup from '../../../../hooks/useSettingGroup'; +import validator from 'validator'; import {ReactComponent as Icon} from '../../../../assets/icons/slack.svg'; +import {getSettingValues} from '../../../../api/settings'; +import {showToast} from '../../../../admin-x-ds/global/Toast'; +import {useTestSlack} from '../../../../api/slack'; const SlackModal = NiceModal.create(() => { const {updateRoute} = useRouting(); const modal = NiceModal.useModal(); + const {localSettings, updateSetting, handleSave, validate, errors, clearError} = useSettingGroup({ + onValidate: () => { + const newErrors: Record = {}; + + if (slackUrl && !validator.isURL(slackUrl, {require_protocol: true})) { + newErrors.slackUrl = 'The URL must be in a format like https://hooks.slack.com/services/'; + } + + return newErrors; + } + }); + const [slackUrl, slackUsername] = getSettingValues(localSettings, ['slack_url', 'slack_username']); + + const {mutateAsync: testSlack} = useTestSlack(); + + const handleTestClick = async () => { + toast.remove(); + if (await handleSave()) { + await testSlack(null); + showToast({ + message: 'Check your Slack channel for the test message', + type: 'neutral' + }); + } else { + showToast({ + type: 'pageError', + message: 'Can\'t save Slack settings! One or more fields have errors, please doublecheck you filled all mandatory fields' + }); + } + }; + return ( { updateRoute('integrations'); }} + dirty={localSettings.some(setting => setting.dirty)} okColor='black' okLabel='Save & close' + testId='slack-modal' title='' - onOk={() => { - modal.remove(); + onOk={async () => { + toast.remove(); + if (await handleSave()) { + modal.remove(); + updateRoute('integrations'); + } else { + showToast({ + type: 'pageError', + message: 'Can\'t save Slack settings! One or more fields have errors, please doublecheck you filled all mandatory fields' + }); + } }} > {
- Automatically send newly published posts to a channel in Slack or any Slack-compatible service like Discord or Mattermost. Set up a new incoming webhook here [← link to be set], and grab the URL. + error={Boolean(errors.slackUrl)} + hint={errors.slackUrl || <> + Automatically send newly published posts to a channel in Slack or any Slack-compatible service like Discord or Mattermost. Set up a new incoming webhook here, and grab the URL. } placeholder='https://hooks.slack.com/services/...' title='Webhook URL' + value={slackUrl} + onBlur={validate} + onChange={e => updateSetting('slack_url', e.target.value)} + onKeyDown={() => clearError('slackUrl')} />
updateSetting('slack_username', e.target.value)} /> -
@@ -51,4 +106,4 @@ const SlackModal = NiceModal.create(() => { ); }); -export default SlackModal; \ No newline at end of file +export default SlackModal; diff --git a/apps/admin-x-settings/src/hooks/useSettingGroup.tsx b/apps/admin-x-settings/src/hooks/useSettingGroup.tsx index 58b313950f..a6da0f33e1 100644 --- a/apps/admin-x-settings/src/hooks/useSettingGroup.tsx +++ b/apps/admin-x-settings/src/hooks/useSettingGroup.tsx @@ -19,9 +19,12 @@ export interface SettingGroupHook { handleCancel: () => void; updateSetting: (key: string, value: SettingValue) => void; handleEditingChange: (newState: boolean) => void; + validate: () => boolean; + errors: Record; + clearError: (key: string) => void; } -const useSettingGroup = (): SettingGroupHook => { +const useSettingGroup = ({onValidate}: {onValidate?: () => Record} = {}): SettingGroupHook => { // create a ref to focus the input field const focusRef = useRef(null); @@ -30,11 +33,12 @@ const useSettingGroup = (): SettingGroupHook => { const [isEditing, setEditing] = useState(false); - const {formState: localSettings, saveState, handleSave, updateForm, reset} = useForm({ + const {formState: localSettings, saveState, handleSave, updateForm, reset, validate, errors, clearError} = useForm({ initialState: settings || [], onSave: async () => { await editSettings?.(changedSettings()); - } + }, + onValidate }); const {setGlobalDirtyState} = useGlobalDirtyState(); @@ -98,7 +102,10 @@ const useSettingGroup = (): SettingGroupHook => { }, handleCancel, updateSetting, - handleEditingChange + handleEditingChange, + validate, + errors, + clearError }; }; diff --git a/apps/admin-x-settings/test/e2e/advanced/integrations/slack.test.ts b/apps/admin-x-settings/test/e2e/advanced/integrations/slack.test.ts new file mode 100644 index 0000000000..12ad6ffc7f --- /dev/null +++ b/apps/admin-x-settings/test/e2e/advanced/integrations/slack.test.ts @@ -0,0 +1,68 @@ +import {expect, test} from '@playwright/test'; +import {globalDataRequests, mockApi, updatedSettingsResponse} from '../../../utils/e2e'; + +test.describe('Slack integration', async () => { + test('Supports updating Slack settings', async ({page}) => { + const {lastApiRequests} = await mockApi({page, requests: { + ...globalDataRequests, + editSettings: {method: 'PUT', path: '/settings/', response: updatedSettingsResponse([ + {key: 'slack_url', value: 'https://hooks.slack.com/services/123456789/123456789/123456789'}, + {key: 'slack_username', value: 'My site'} + ])} + }}); + + await page.goto('/'); + const section = page.getByTestId('integrations'); + const slackElement = section.getByText('Slack').last(); + await slackElement.hover(); + await section.getByRole('button', {name: 'Configure'}).click(); + + const slackModal = page.getByTestId('slack-modal'); + + await slackModal.getByLabel('Webhook URL').fill('https://hooks.slack.com/services/123456789/123456789/123456789'); + await slackModal.getByLabel('Username').fill('My site'); + await slackModal.getByRole('button', {name: 'Save & close'}).click(); + + await expect(slackModal).toHaveCount(0); + + expect(lastApiRequests.editSettings?.body).toEqual({ + settings: [ + {key: 'slack_url', value: 'https://hooks.slack.com/services/123456789/123456789/123456789'}, + {key: 'slack_username', value: 'My site'} + ] + }); + }); + + test('Supports testing Slack messages', async ({page}) => { + const {lastApiRequests} = await mockApi({page, requests: { + ...globalDataRequests, + editSettings: {method: 'PUT', path: '/settings/', response: updatedSettingsResponse([ + {key: 'slack_url', value: 'https://hooks.slack.com/services/123456789/123456789/123456789'}, + {key: 'slack_username', value: 'My site'} + ])}, + testSlack: {method: 'POST', path: '/slack/test/', responseStatus: 204, response: ''} + }}); + + await page.goto('/'); + const section = page.getByTestId('integrations'); + const slackElement = section.getByText('Slack').last(); + await slackElement.hover(); + await section.getByRole('button', {name: 'Configure'}).click(); + + const slackModal = page.getByTestId('slack-modal'); + + await slackModal.getByLabel('Webhook URL').fill('https://hooks.slack.com/services/123456789/123456789/123456789'); + await slackModal.getByLabel('Username').fill('My site'); + await slackModal.getByRole('button', {name: 'Send test notification'}).click(); + + await expect(page.getByTestId('toast')).toHaveText(/Check your Slack channel for the test message/); + + expect(lastApiRequests.editSettings?.body).toEqual({ + settings: [ + {key: 'slack_url', value: 'https://hooks.slack.com/services/123456789/123456789/123456789'}, + {key: 'slack_username', value: 'My site'} + ] + }); + expect(lastApiRequests.testSlack).toBeTruthy(); + }); +});