From 6dbfe2aab9488d5b35a16b45f873c814d97768f5 Mon Sep 17 00:00:00 2001 From: Elizabeth Thompson Date: Wed, 17 Jul 2024 15:14:32 -0700 Subject: [PATCH] feat: add slackv2 notification (#29264) --- UPDATING.md | 1 + .../src/utils/featureFlags.ts | 1 + .../src/components/Select/Select.test.tsx | 14 + .../src/components/Select/Select.tsx | 12 +- .../features/alerts/AlertReportModal.test.tsx | 6 +- .../src/features/alerts/AlertReportModal.tsx | 24 +- .../components/NotificationMethod.test.tsx | 183 +++++++++ .../alerts/components/NotificationMethod.tsx | 275 +++++++++++-- .../alerts/components/RecipientIcon.test.tsx | 50 +++ .../alerts/components/RecipientIcon.tsx | 14 +- .../src/features/alerts/types.ts | 14 +- superset/commands/report/execute.py | 56 ++- superset/config.py | 1 + superset/constants.py | 1 + superset/reports/api.py | 71 +++- superset/reports/models.py | 1 + superset/reports/notifications/__init__.py | 1 + superset/reports/notifications/email.py | 3 +- superset/reports/notifications/exceptions.py | 11 + superset/reports/notifications/slack.py | 218 ++--------- superset/reports/notifications/slack_mixin.py | 124 ++++++ superset/reports/notifications/slackv2.py | 130 +++++++ superset/reports/schemas.py | 11 + superset/tasks/scheduler.py | 2 +- superset/utils/slack.py | 89 +++++ superset/views/base.py | 1 + .../fixtures/world_bank_dashboard.py | 15 +- .../reports/commands_tests.py | 237 ++---------- tests/unit_tests/notifications/__init__.py | 16 - tests/unit_tests/notifications/slack_tests.py | 88 ----- .../notifications/email_tests.py | 0 .../reports/notifications/slack_tests.py | 360 +++++++++++++++++- tests/unit_tests/utils/slack_test.py | 193 ++++++++++ 33 files changed, 1667 insertions(+), 556 deletions(-) create mode 100644 superset-frontend/src/features/alerts/components/NotificationMethod.test.tsx create mode 100644 superset-frontend/src/features/alerts/components/RecipientIcon.test.tsx create mode 100644 superset/reports/notifications/slack_mixin.py create mode 100644 superset/reports/notifications/slackv2.py delete mode 100644 tests/unit_tests/notifications/__init__.py delete mode 100644 tests/unit_tests/notifications/slack_tests.py rename tests/unit_tests/{ => reports}/notifications/email_tests.py (100%) create mode 100644 tests/unit_tests/utils/slack_test.py diff --git a/UPDATING.md b/UPDATING.md index 7ecc1a298..f1ccbbdc0 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -57,6 +57,7 @@ assists people when migrating to a new version. translations inside the python package. This includes the .mo files needed by pybabel on the backend, as well as the .json files used by the frontend. If you were doing anything before as part of your bundling to expose translation packages, it's probably not needed anymore. +- [29264](https://github.com/apache/superset/pull/29264) Slack has updated its file upload api, and we are now supporting this new api in Superset, although the Slack api is not backward compatible. The original Slack integration is deprecated and we will require a new Slack scope `channels:read` to be added to Slack workspaces in order to use this new api. In an upcoming release, we will make this new Slack scope mandatory and remove the old Slack functionality. ### Potential Downtime diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts index fcc38dc2e..67f3785ab 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts @@ -25,6 +25,7 @@ export enum FeatureFlag { AlertsAttachReports = 'ALERTS_ATTACH_REPORTS', AlertReports = 'ALERT_REPORTS', AlertReportTabs = 'ALERT_REPORT_TABS', + AlertReportSlackV2 = 'ALERT_REPORT_SLACK_V2', AllowFullCsvExport = 'ALLOW_FULL_CSV_EXPORT', AvoidColorsCollision = 'AVOID_COLORS_COLLISION', ChartPluginsExperimental = 'CHART_PLUGINS_EXPERIMENTAL', diff --git a/superset-frontend/src/components/Select/Select.test.tsx b/superset-frontend/src/components/Select/Select.test.tsx index e7fde6cc2..593a51c97 100644 --- a/superset-frontend/src/components/Select/Select.test.tsx +++ b/superset-frontend/src/components/Select/Select.test.tsx @@ -188,6 +188,20 @@ test('does not add a new option if the value is already in the options', async ( expect(options).toHaveLength(1); }); +test('does not add new options when the value is in a nested/grouped option', async () => { + const options = [ + { + label: 'Group', + options: [OPTIONS[0]], + }, + ]; + render(); await open(); diff --git a/superset-frontend/src/components/Select/Select.tsx b/superset-frontend/src/components/Select/Select.tsx index 9356880ba..bebb78887 100644 --- a/superset-frontend/src/components/Select/Select.tsx +++ b/superset-frontend/src/components/Select/Select.tsx @@ -182,8 +182,18 @@ const Select = forwardRef( // add selected values to options list if they are not in it const fullSelectOptions = useMemo(() => { + // check to see if selectOptions are grouped + let groupedOptions: SelectOptionsType; + if (selectOptions.some(opt => opt.options)) { + groupedOptions = selectOptions.reduce( + (acc, group) => [...acc, ...group.options], + [] as SelectOptionsType, + ); + } const missingValues: SelectOptionsType = ensureIsArray(selectValue) - .filter(opt => !hasOption(getValue(opt), selectOptions)) + .filter( + opt => !hasOption(getValue(opt), groupedOptions || selectOptions), + ) .map(opt => isLabeledValue(opt) ? opt : { value: opt, label: String(opt) }, ); diff --git a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx index e32d13ab6..6d8c1df65 100644 --- a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx +++ b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx @@ -21,7 +21,7 @@ import fetchMock from 'fetch-mock'; import { render, screen, waitFor, within } from 'spec/helpers/testing-library'; import { buildErrorTooltipMessage } from './buildErrorTooltipMessage'; import AlertReportModal, { AlertReportModalProps } from './AlertReportModal'; -import { AlertObject } from './types'; +import { AlertObject, NotificationMethodOption } from './types'; jest.mock('@superset-ui/core', () => ({ ...jest.requireActual('@superset-ui/core'), @@ -30,7 +30,7 @@ jest.mock('@superset-ui/core', () => ({ jest.mock('src/features/databases/state.ts', () => ({ useCommonConf: () => ({ - ALERT_REPORTS_NOTIFICATION_METHODS: ['Email', 'Slack'], + ALERT_REPORTS_NOTIFICATION_METHODS: ['Email', 'Slack', 'SlackV2'], }), })); @@ -135,7 +135,7 @@ const validAlert: AlertObject = { ], recipients: [ { - type: 'Email', + type: NotificationMethodOption.Email, recipient_config_json: { target: 'test@user.com' }, }, ], diff --git a/superset-frontend/src/features/alerts/AlertReportModal.tsx b/superset-frontend/src/features/alerts/AlertReportModal.tsx index 633138e57..8c71a7f6a 100644 --- a/superset-frontend/src/features/alerts/AlertReportModal.tsx +++ b/superset-frontend/src/features/alerts/AlertReportModal.tsx @@ -99,7 +99,9 @@ const DEFAULT_WORKING_TIMEOUT = 3600; const DEFAULT_CRON_VALUE = '0 0 * * *'; // every day const DEFAULT_RETENTION = 90; -const DEFAULT_NOTIFICATION_METHODS: NotificationMethodOption[] = ['Email']; +const DEFAULT_NOTIFICATION_METHODS: NotificationMethodOption[] = [ + NotificationMethodOption.Email, +]; const DEFAULT_NOTIFICATION_FORMAT = 'PNG'; const CONDITIONS = [ { @@ -517,7 +519,7 @@ const AlertReportModal: FunctionComponent = ({ ]); setNotificationAddState( - notificationSettings.length === allowedNotificationMethods.length + notificationSettings.length === allowedNotificationMethodsCount ? 'hidden' : 'disabled', ); @@ -1131,7 +1133,7 @@ const AlertReportModal: FunctionComponent = ({ { recipients: '', options: allowedNotificationMethods, - method: 'Email', + method: NotificationMethodOption.Email, }, ]); setNotificationAddState('active'); @@ -1235,6 +1237,20 @@ const AlertReportModal: FunctionComponent = ({ enforceValidation(); }, [validationStatus]); + const allowedNotificationMethodsCount = useMemo( + () => + allowedNotificationMethods.reduce((accum: string[], setting: string) => { + if ( + accum.some(nm => nm.includes('slack')) && + setting.toLowerCase().includes('slack') + ) { + return accum; + } + return [...accum, setting.toLowerCase()]; + }, []).length, + [allowedNotificationMethods], + ); + // Show/hide if (isHidden && show) { setIsHidden(false); @@ -1743,7 +1759,7 @@ const AlertReportModal: FunctionComponent = ({ ))} { // Prohibit 'add notification method' button if only one present - allowedNotificationMethods.length > notificationSettings.length && ( + allowedNotificationMethodsCount > notificationSettings.length && ( { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render the component', () => { + render( + , + ); + + expect(screen.getByText('Notification Method')).toBeInTheDocument(); + expect( + screen.getByText('Email subject name (optional)'), + ).toBeInTheDocument(); + expect(screen.getByText('Email recipients')).toBeInTheDocument(); + }); + + it('should call onRemove when the delete button is clicked', () => { + render( + , + ); + + const deleteButton = screen.getByRole('button'); + userEvent.click(deleteButton); + + expect(mockOnRemove).toHaveBeenCalledWith(1); + }); + + it('should update recipient value when input changes', () => { + render( + , + ); + + const recipientsInput = screen.getByTestId('recipients'); + fireEvent.change(recipientsInput, { + target: { value: 'test1@example.com' }, + }); + + expect(mockOnUpdate).toHaveBeenCalledWith(0, { + ...mockSetting, + recipients: 'test1@example.com', + }); + }); + + it('should call onRecipientsChange when the recipients value is changed', () => { + render( + , + ); + + const recipientsInput = screen.getByTestId('recipients'); + fireEvent.change(recipientsInput, { + target: { value: 'test1@example.com' }, + }); + + expect(mockOnUpdate).toHaveBeenCalledWith(0, { + ...mockSetting, + recipients: 'test1@example.com', + }); + }); + + it('should correctly map recipients when method is SlackV2', () => { + const method = 'SlackV2'; + const recipientValue = 'user1,user2'; + const slackOptions: { label: string; value: string }[] = [ + { label: 'User One', value: 'user1' }, + { label: 'User Two', value: 'user2' }, + ]; + + const result = mapSlackValues({ method, recipientValue, slackOptions }); + + expect(result).toEqual([ + { label: 'User One', value: 'user1' }, + { label: 'User Two', value: 'user2' }, + ]); + }); + + it('should return an empty array when recipientValue is an empty string', () => { + const method = 'SlackV2'; + const recipientValue = ''; + const slackOptions: { label: string; value: string }[] = [ + { label: 'User One', value: 'user1' }, + { label: 'User Two', value: 'user2' }, + ]; + + const result = mapSlackValues({ method, recipientValue, slackOptions }); + + expect(result).toEqual([]); + }); + + it('should correctly map recipients when method is Slack with updated recipient values', () => { + const method = 'Slack'; + const recipientValue = 'User One,User Two'; + const slackOptions: { label: string; value: string }[] = [ + { label: 'User One', value: 'user1' }, + { label: 'User Two', value: 'user2' }, + ]; + + const result = mapSlackValues({ method, recipientValue, slackOptions }); + + expect(result).toEqual([ + { label: 'User One', value: 'user1' }, + { label: 'User Two', value: 'user2' }, + ]); + }); +}); diff --git a/superset-frontend/src/features/alerts/components/NotificationMethod.tsx b/superset-frontend/src/features/alerts/components/NotificationMethod.tsx index b2d780423..85e26f777 100644 --- a/superset-frontend/src/features/alerts/components/NotificationMethod.tsx +++ b/superset-frontend/src/features/alerts/components/NotificationMethod.tsx @@ -16,12 +16,31 @@ * specific language governing permissions and limitations * under the License. */ -import { FunctionComponent, useState, ChangeEvent } from 'react'; +import { + FunctionComponent, + useState, + ChangeEvent, + useEffect, + useMemo, +} from 'react'; +import rison from 'rison'; -import { styled, t, useTheme } from '@superset-ui/core'; +import { + FeatureFlag, + JsonResponse, + SupersetClient, + isFeatureEnabled, + styled, + t, + useTheme, +} from '@superset-ui/core'; import { Select } from 'src/components'; import Icons from 'src/components/Icons'; -import { NotificationMethodOption, NotificationSetting } from '../types'; +import { + NotificationMethodOption, + NotificationSetting, + SlackChannel, +} from '../types'; import { StyledInputContainer } from '../AlertReportModal'; const StyledNotificationMethod = styled.div` @@ -73,6 +92,68 @@ const TRANSLATIONS = { ), }; +export const mapSlackValues = ({ + method, + recipientValue, + slackOptions, +}: { + method: string; + recipientValue: string; + slackOptions: { label: string; value: string }[]; +}) => { + const prop = method === NotificationMethodOption.SlackV2 ? 'value' : 'label'; + return recipientValue + .split(',') + .map(recipient => + slackOptions.find( + option => + option[prop].trim().toLowerCase() === recipient.trim().toLowerCase(), + ), + ) + .filter(val => !!val) as { label: string; value: string }[]; +}; + +export const mapChannelsToOptions = (result: SlackChannel[]) => { + const publicChannels: SlackChannel[] = []; + const privateChannels: SlackChannel[] = []; + + result.forEach(channel => { + if (channel.is_private) { + privateChannels.push(channel); + } else { + publicChannels.push(channel); + } + }); + + return [ + { + label: 'Public Channels', + options: publicChannels.map((channel: SlackChannel) => ({ + label: `${channel.name} ${ + channel.is_member ? '' : t('(Bot not in channel)') + }`, + value: channel.id, + key: channel.id, + })), + key: 'public', + }, + { + label: t('Private Channels (Bot in channel)'), + options: privateChannels.map((channel: SlackChannel) => ({ + label: channel.name, + value: channel.id, + key: channel.id, + })), + key: 'private', + }, + ]; +}; + +type SlackOptionsType = { + label: string; + options: { label: string; value: string }[]; +}[]; + export const NotificationMethod: FunctionComponent = ({ setting = null, index, @@ -87,20 +168,30 @@ export const NotificationMethod: FunctionComponent = ({ const [recipientValue, setRecipientValue] = useState( recipients || '', ); + const [slackRecipients, setSlackRecipients] = useState< + { label: string; value: string }[] + >([]); const [error, setError] = useState(false); const theme = useTheme(); + const [slackOptions, setSlackOptions] = useState([ + { + label: '', + options: [], + }, + ]); - if (!setting) { - return null; - } + const [useSlackV1, setUseSlackV1] = useState(false); - const onMethodChange = (method: NotificationMethodOption) => { + const onMethodChange = (selected: { + label: string; + value: NotificationMethodOption; + }) => { // Since we're swapping the method, reset the recipients setRecipientValue(''); - if (onUpdate) { + if (onUpdate && setting) { const updatedSetting = { ...setting, - method, + method: selected.value, recipients: '', }; @@ -108,6 +199,94 @@ export const NotificationMethod: FunctionComponent = ({ } }; + const fetchSlackChannels = async ({ + searchString = '', + types = [], + exactMatch = false, + }: { + searchString?: string | undefined; + types?: string[]; + exactMatch?: boolean | undefined; + } = {}): Promise => { + const queryString = rison.encode({ searchString, types, exactMatch }); + const endpoint = `/api/v1/report/slack_channels/?q=${queryString}`; + return SupersetClient.get({ endpoint }); + }; + + useEffect(() => { + if ( + method && + [ + NotificationMethodOption.Slack, + NotificationMethodOption.SlackV2, + ].includes(method) && + !slackOptions[0]?.options.length + ) { + fetchSlackChannels({ types: ['public_channel', 'private_channel'] }) + .then(({ json }) => { + const { result } = json; + + const options: SlackOptionsType = mapChannelsToOptions(result); + + setSlackOptions(options); + + if (isFeatureEnabled(FeatureFlag.AlertReportSlackV2)) { + // map existing ids to names for display + // or names to ids if slack v1 + const [publicOptions, privateOptions] = options; + + setSlackRecipients( + mapSlackValues({ + method, + recipientValue, + slackOptions: [ + ...publicOptions.options, + ...privateOptions.options, + ], + }), + ); + if (method === NotificationMethodOption.Slack) { + onMethodChange({ + label: NotificationMethodOption.Slack, + value: NotificationMethodOption.SlackV2, + }); + } + } + }) + .catch(() => { + // Fallback to slack v1 if slack v2 is not compatible + setUseSlackV1(true); + }); + } + }, [method]); + + const methodOptions = useMemo( + () => + (options || []) + .filter( + method => + (isFeatureEnabled(FeatureFlag.AlertReportSlackV2) && + !useSlackV1 && + method === NotificationMethodOption.SlackV2) || + ((!isFeatureEnabled(FeatureFlag.AlertReportSlackV2) || + useSlackV1) && + method === NotificationMethodOption.Slack) || + method === NotificationMethodOption.Email, + ) + .map(method => ({ + label: + method === NotificationMethodOption.SlackV2 + ? NotificationMethodOption.Slack + : method, + value: method, + })), + [options], + ); + + if (!setting) { + return null; + } + const onRecipientsChange = (event: ChangeEvent) => { const { target } = event; @@ -123,6 +302,21 @@ export const NotificationMethod: FunctionComponent = ({ } }; + const onSlackRecipientsChange = ( + recipients: { label: string; value: string }[], + ) => { + setSlackRecipients(recipients); + + if (onUpdate) { + const updatedSetting = { + ...setting, + recipients: recipients?.map(obj => obj.value).join(','), + }; + + onUpdate(index, updatedSetting); + } + }; + const onSubjectChange = ( event: ChangeEvent, ) => { @@ -153,15 +347,12 @@ export const NotificationMethod: FunctionComponent = ({