From 21093c2646b72ff96849e9fbb4520b543eecabcd Mon Sep 17 00:00:00 2001 From: Jay Goss Date: Fri, 2 Oct 2026 14:56:27 -0500 Subject: [PATCH] ref(onboarding): Remove components orphaned by legacy project creation Delete the legacy IssueAlertOptions, IssueAlertNotificationOptions and MessagingIntegrationAlertRule components and the PlatformPicker, which only the removed CreateProject flow rendered, along with the unused growth.platformpicker_category event and filterAliases. Move the MessagingIntegrationAlertRule component tests to ScmMessagingIntegrationAlertRule, which renders the same controls through the shared useMessagingIntegrationAlertRule hook. Refs VDY-235 --- .../scmMessagingIntegrationAlertRule.spec.tsx | 345 +++++++++++++++- static/app/components/platformPicker.spec.tsx | 149 ------- static/app/components/platformPicker.tsx | 378 ------------------ static/app/data/platformPickerCategories.tsx | 9 +- static/app/types/onboarding.tsx | 2 +- .../utils/analytics/growthAnalyticsEvents.tsx | 9 - static/app/utils/platform.tsx | 4 +- .../issueAlertNotificationOptions.spec.tsx | 79 ---- .../issueAlertNotificationOptions.tsx | 77 +--- .../projectInstall/issueAlertOptions.spec.tsx | 88 ---- .../projectInstall/issueAlertOptions.tsx | 133 +----- .../messagingIntegrationAlertRule.spec.tsx | 355 +--------------- .../messagingIntegrationAlertRule.tsx | 83 ---- 13 files changed, 344 insertions(+), 1367 deletions(-) delete mode 100644 static/app/components/platformPicker.spec.tsx delete mode 100644 static/app/components/platformPicker.tsx delete mode 100644 static/app/views/projectInstall/issueAlertOptions.spec.tsx diff --git a/static/app/components/onboarding/scm/scmMessagingIntegrationAlertRule.spec.tsx b/static/app/components/onboarding/scm/scmMessagingIntegrationAlertRule.spec.tsx index b274a990f7ee..403071bceae8 100644 --- a/static/app/components/onboarding/scm/scmMessagingIntegrationAlertRule.spec.tsx +++ b/static/app/components/onboarding/scm/scmMessagingIntegrationAlertRule.spec.tsx @@ -1,7 +1,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; import {OrganizationIntegrationsFixture} from 'sentry-fixture/organizationIntegrations'; -import {render, screen} from 'sentry-test/reactTestingLibrary'; +import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; import {selectEvent} from 'sentry-test/selectEvent'; import * as analytics from 'sentry/utils/analytics'; @@ -12,34 +12,58 @@ import {ScmMessagingIntegrationAlertRule} from './scmMessagingIntegrationAlertRu describe('ScmMessagingIntegrationAlertRule', () => { const organization = OrganizationFixture(); const slackIntegrations = [ - OrganizationIntegrationsFixture({name: "Moo Deng's Workspace"}), + OrganizationIntegrationsFixture({ + name: "Moo Deng's Workspace", + }), + OrganizationIntegrationsFixture({ + name: "Moo Waan's Workspace", + }), ]; const discordIntegrations = [ - OrganizationIntegrationsFixture({name: "Moo Deng's Server"}), + OrganizationIntegrationsFixture({ + name: "Moo Deng's Server", + }), ]; + const msteamsIntegrations = [ + OrganizationIntegrationsFixture({ + name: "Moo Deng's Team", + }), + ]; + + const providersToIntegrations = { + slack: slackIntegrations, + discord: discordIntegrations, + msteams: msteamsIntegrations, + }; + + const mockSetChannel = jest.fn(); + const mockSetIntegration = jest.fn(); + const mockSetProvider = jest.fn(); const notificationProps: IssueAlertNotificationProps = { actions: [], - channel: {label: 'channel', value: 'channel'}, + channel: { + label: 'channel', + value: 'channel', + }, integration: slackIntegrations[0], provider: 'slack', - providersToIntegrations: { - slack: slackIntegrations, - discord: discordIntegrations, - }, + providersToIntegrations, queryError: false, querySuccess: true, shouldRenderSetupButton: false, setActions: jest.fn(), - setChannel: jest.fn(), - setIntegration: jest.fn(), - setProvider: jest.fn(), + setChannel: mockSetChannel, + setIntegration: mockSetIntegration, + setProvider: mockSetProvider, }; beforeEach(() => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/integrations/${slackIntegrations[0]!.id}/channels/`, - body: {results: []}, + body: { + results: [], + }, }); }); @@ -48,6 +72,14 @@ describe('ScmMessagingIntegrationAlertRule', () => { jest.restoreAllMocks(); }); + const getComponent = () => ( + + ); + const getChannelSelect = () => screen.getByRole('textbox', {name: 'channel'}); + it('renders the reused provider sentence with its three controls', () => { render( { } } ); + + it('clears the channel select when channel prop becomes undefined', () => { + const {rerender} = render(getComponent(), {organization}); + + // The initial channel value label is visible. + expect(screen.getByText('channel')).toBeInTheDocument(); + + // Parent state clears channel (e.g. after provider or integration change). + rerender( + + ); + + // The stale channel label must no longer be shown; the select is empty. + expect(screen.queryByText('channel')).not.toBeInTheDocument(); + }); + + it('calls setter when new integration is selected', async () => { + render(getComponent()); + await selectEvent.select( + screen.getByText("Moo Deng's Workspace"), + "Moo Waan's Workspace" + ); + expect(mockSetIntegration).toHaveBeenCalled(); + }); + + it('calls setters when new provider is selected', async () => { + render(getComponent()); + await selectEvent.select(screen.getByText('Slack'), 'Discord'); + expect(mockSetProvider).toHaveBeenCalled(); + expect(mockSetIntegration).toHaveBeenCalled(); + expect(mockSetChannel).toHaveBeenCalled(); + }); + + it('disables provider select when there is only one provider option', () => { + render( + + ); + expect(screen.getByLabelText('provider')).toBeDisabled(); + }); + + it('disables integration select when there is only one integration option', () => { + render( + + ); + expect(screen.getByLabelText('integration')).toBeDisabled(); + }); + + it('loads channels', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/${discordIntegrations[0]!.id}/channels/`, + body: { + nextCursor: null, + results: [ + {id: '1', name: 'general', display: '#general', type: 'text'}, + {id: '2', name: 'alerts', display: '#alerts', type: 'text'}, + ], + }, + }); + render( + + ); + await selectEvent.openMenu(getChannelSelect()); + expect(await screen.findByText('#general (1)')).toBeInTheDocument(); + expect(screen.getByText('#alerts (2)')).toBeInTheDocument(); + await selectEvent.select(getChannelSelect(), /#alerts/); + expect(mockSetChannel).toHaveBeenCalledWith({ + label: '#alerts (2)', + value: '2', + new: false, + channelId: '2', + channelName: '#alerts', + }); + }); + + it('shows the selected channel when no channels are returned', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/${discordIntegrations[0]!.id}/channels/`, + body: { + nextCursor: null, + results: [], + }, + }); + + render( + + ); + + await selectEvent.openMenu(getChannelSelect()); + expect( + await screen.findByRole('menuitemradio', {name: 'channel'}) + ).toBeInTheDocument(); + expect(mockSetChannel).not.toHaveBeenCalled(); + }); + + it('set custom channel as "new" when created', async () => { + render(getComponent()); + + await selectEvent.create(getChannelSelect(), '#custom-channel', { + waitForElement: false, + createOptionText: '#custom-channel', + }); + + expect(mockSetChannel).toHaveBeenCalledWith({ + label: '#custom-channel', + value: '#custom-channel', + new: true, + }); + }); + + it('validates custom channel when created', async () => { + const validationRequest = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/${slackIntegrations[0]!.id}/channel-validate/`, + body: {valid: true}, + }); + + render( + + ); + + await waitFor(() => { + expect(validationRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + query: {channel: '#custom-channel'}, + }) + ); + }); + }); + + it('displays validation error when channel is invalid', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/${slackIntegrations[0]!.id}/channel-validate/`, + body: {valid: false, detail: 'Channel not found'}, + }); + + render( + + ); + + expect(await screen.findByText('Channel not found')).toBeInTheDocument(); + }); + + it('displays default error message when validation fails without detail', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/${slackIntegrations[0]!.id}/channel-validate/`, + body: {valid: false}, + }); + + render( + + ); + + expect( + await screen.findByText('Channel not found or restricted') + ).toBeInTheDocument(); + }); + + it('displays error when validation request fails', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/${slackIntegrations[0]!.id}/channel-validate/`, + statusCode: 500, + body: {detail: 'Internal Error'}, + }); + + render( + + ); + + expect( + await screen.findByText('Unexpected integration channel validation error') + ).toBeInTheDocument(); + }); + + it('clears the selected channel', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/${slackIntegrations[0]!.id}/channel-validate/`, + body: {valid: false, detail: 'Channel not found'}, + }); + + render( + + ); + + expect(await screen.findByText('Channel not found')).toBeInTheDocument(); + await userEvent.click(screen.getByLabelText('Clear choices')); + expect(mockSetChannel).toHaveBeenCalledWith(undefined); + }); + + it('changes provider after a validation error', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/${slackIntegrations[0]!.id}/channel-validate/`, + body: {valid: false, detail: 'Channel not found'}, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/${discordIntegrations[0]!.id}/channels/`, + body: {results: []}, + }); + + render( + + ); + + expect(await screen.findByText('Channel not found')).toBeInTheDocument(); + + await selectEvent.select(screen.getByText('Slack'), 'Discord'); + expect(mockSetProvider).toHaveBeenCalledWith('discord'); + }); + + it('changes integration after a validation error', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/${slackIntegrations[0]!.id}/channel-validate/`, + body: {valid: false, detail: 'Channel not found'}, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/integrations/${slackIntegrations[1]!.id}/channels/`, + body: {results: []}, + }); + + render( + + ); + + expect(await screen.findByText('Channel not found')).toBeInTheDocument(); + + await selectEvent.select( + screen.getByText("Moo Deng's Workspace"), + "Moo Waan's Workspace" + ); + expect(mockSetIntegration).toHaveBeenCalledWith(slackIntegrations[1]); + }); }); diff --git a/static/app/components/platformPicker.spec.tsx b/static/app/components/platformPicker.spec.tsx deleted file mode 100644 index d921f060ec20..000000000000 --- a/static/app/components/platformPicker.spec.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; - -import {PlatformPicker} from 'sentry/components/platformPicker'; -import {gaming} from 'sentry/data/platformCategories'; -import {trackAnalytics} from 'sentry/utils/analytics'; - -jest.mock('sentry/utils/analytics'); - -describe('PlatformPicker', () => { - const baseProps = { - platform: '', - setPlatform: () => {}, - location: {query: {}}, - }; - - it('should only render Mobile platforms under Mobile tab', () => { - render(); - - expect(screen.queryByTestId('platform-java')).not.toBeInTheDocument(); - expect(screen.getByTestId('platform-apple-ios')).toBeInTheDocument(); - expect(screen.getByTestId('platform-react-native')).toBeInTheDocument(); - }); - - it('should render renderPlatformList with Python when filtered with py', () => { - render(); - - expect(screen.queryByTestId('platform-java')).not.toBeInTheDocument(); - expect(screen.getByTestId('platform-python-flask')).toBeInTheDocument(); - }); - - it('should render renderPlatformList with Native when filtered with c++ alias', () => { - render(); - - expect(screen.getByTestId('platform-native')).toBeInTheDocument(); - }); - - it('should render renderPlatformList with community SDKs message if platform not found', async () => { - render(); - - await userEvent.type(screen.getByPlaceholderText('Filter Platforms'), 'aaaaaa'); - - expect(screen.getByText("We don't have an SDK for that yet!")).toBeInTheDocument(); - }); - - it('should update State.tab onClick when particular tab is clicked', async () => { - render(); - - expect(screen.getByText('Popular')).toBeInTheDocument(); - - await userEvent.click(screen.getByText('All')); - expect(trackAnalytics).toHaveBeenCalledWith( - 'growth.platformpicker_category', - expect.objectContaining({ - category: 'all', - }) - ); - }); - - it('records picker selections as manual', async () => { - render(); - - await userEvent.click(screen.getByTestId('platform-python')); - - expect(trackAnalytics).toHaveBeenCalledWith( - 'growth.select_platform', - expect.objectContaining({ - platform_id: 'python', - selection_source: 'manual', - }) - ); - }); - - it('should clear the platform when clear is clicked', async () => { - const props = { - ...baseProps, - platform: 'javascript-react', - setPlatform: jest.fn(), - }; - - render(); - - await userEvent.click(screen.getByRole('button', {name: 'Clear'})); - expect(props.setPlatform).toHaveBeenCalledWith(null); - }); - - it('platforms shall be sorted alphabetically', () => { - render(); - - const alphabeticallyOrderedPlatformNames = [ - 'Angular', - 'Astro', - 'Browser JavaScript', - 'Dart', - 'Ember', - 'Flutter', - 'Gatsby', - 'Next.js', - 'Nuxt', - 'React', - 'React Native', - 'React Router Framework', - 'Remix', - 'Solid', - 'SolidStart', - 'Svelte', - 'SvelteKit', - 'TanStack Start React', - 'Unity', - 'Vue', - ]; - - const platformNames = screen.getAllByRole('heading', {level: 3}); - - platformNames.forEach((platform, index) => { - expect(platform).toHaveTextContent(alphabeticallyOrderedPlatformNames[index]!); - }); - }); - - it('"other" platform shall be rendered if filter contains it', async () => { - render(); - - expect(screen.queryByTestId('platform-other')).not.toBeInTheDocument(); - - await userEvent.type(screen.getByRole('textbox'), 'Oth'); - - expect(screen.queryByTestId('platform-other')).not.toBeInTheDocument(); - - // complete the word 'other' - await userEvent.type(screen.getByRole('textbox'), 'er'); - - expect(screen.getByTestId('platform-other')).toBeInTheDocument(); - }); - - it('shows gaming tab and consoles', async () => { - render(); - - await userEvent.click(screen.getByRole('tab', {name: 'Gaming'})); - - for (const platform of gaming) { - expect(screen.getByTestId(`platform-${platform}`)).toBeInTheDocument(); - } - - await userEvent.click(screen.getByRole('tab', {name: 'Browser'})); - - await userEvent.type(screen.getByPlaceholderText('Filter Platforms'), 'play'); - - expect(screen.getByTestId('platform-playstation')).toBeInTheDocument(); - }); -}); diff --git a/static/app/components/platformPicker.tsx b/static/app/components/platformPicker.tsx deleted file mode 100644 index 146a8fe82940..000000000000 --- a/static/app/components/platformPicker.tsx +++ /dev/null @@ -1,378 +0,0 @@ -import {Fragment, useEffect, useMemo, useRef, useState} from 'react'; -import {css} from '@emotion/react'; -import styled from '@emotion/styled'; -import debounce from 'lodash/debounce'; -import {PlatformIcon} from 'platformicons'; - -import {Button} from '@sentry/scraps/button'; -import {Container, Grid} from '@sentry/scraps/layout'; -import {TabList, Tabs} from '@sentry/scraps/tabs'; - -import {EmptyMessage} from 'sentry/components/emptyMessage'; -import {LoadingMask} from 'sentry/components/loadingMask'; -import {SearchBar} from 'sentry/components/searchBar'; -import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants'; -import {consoles, gaming} from 'sentry/data/platformCategories'; -import { - categoryList, - createablePlatforms, - filterAliases, -} from 'sentry/data/platformPickerCategories'; -import {otherPlatform, allPlatforms as platforms} from 'sentry/data/platforms'; -import {IconClose, IconProject} from 'sentry/icons'; -import {t, tct} from 'sentry/locale'; -import {ConfigStore} from 'sentry/stores/configStore'; -import {useLegacyStore} from 'sentry/stores/useLegacyStore'; -import type {Organization} from 'sentry/types/organization'; -import type {PlatformIntegration} from 'sentry/types/project'; -import {trackAnalytics} from 'sentry/utils/analytics'; -import {comparePlatformNames} from 'sentry/utils/platform'; - -const PlatformList = styled('div')` - display: grid; - gap: ${p => p.theme.space.md}; - grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); - margin-bottom: ${p => p.theme.space.xl}; - - &.centered { - justify-content: center; - } -`; - -const selectablePlatforms = platforms.filter(platform => - createablePlatforms.has(platform.id) -); - -export type Category = (typeof categoryList)[number]['id']; - -export type Platform = PlatformIntegration & { - category: Category; -}; - -interface PlatformPickerProps { - setPlatform: (props: Platform | null) => void; - defaultCategory?: Category; - loading?: boolean; - noAutoFilter?: boolean; - organization?: Organization; - platform?: string | null; - source?: string; - /** - * For project-creation picker events, `source` identifies the flow and `variant` - * identifies the SCM or legacy experience. - */ - variant?: 'scm' | 'legacy'; - /** - * When `false`, hides the close button and does not display a custom background color. - */ - visibleSelection?: boolean; -} - -export function PlatformPicker({ - defaultCategory, - noAutoFilter, - platform, - setPlatform, - organization, - source, - variant, - visibleSelection = true, - loading = false, -}: PlatformPickerProps) { - const {isSelfHosted} = useLegacyStore(ConfigStore); - - const [category, setCategory] = useState(defaultCategory ?? categoryList[0]!.id); - const [filter, setFilter] = useState( - noAutoFilter ? '' : (platform || '').split('-')[0]! - ); - - const availablePlatforms = useMemo(() => { - const gamingPlatforms = platforms.filter(p => { - if (!gaming.includes(p.id) || createablePlatforms.has(p.id)) { - return false; - } - if (isSelfHosted) { - return !consoles.includes(p.id); - } - - return true; - }); - - return [...selectablePlatforms, ...gamingPlatforms]; - }, [isSelfHosted]); - - const platformList = useMemo(() => { - const currentCategory = categoryList.find(({id}) => id === category); - - const subsetMatch = (platformIntegration: PlatformIntegration) => - platformIntegration.id.includes(filter.toLowerCase()) || - platformIntegration.name.toLowerCase().includes(filter.toLowerCase()) || - filterAliases[platformIntegration.id]?.some(alias => - alias.includes(filter.toLowerCase()) - ); - - const categoryMatch = (platformIntegration: PlatformIntegration) => { - return currentCategory?.platforms?.has(platformIntegration.id); - }; - - // 'other' is not part of the createablePlatforms list, therefore it won't be included in the filtered list - const filtered = availablePlatforms.filter(filter ? subsetMatch : categoryMatch); - - if (filter.toLowerCase() === 'other') { - // We only show 'Other' if users click on the 'Other' suggestion rendered in the not found state or type this word in the search bar - return [otherPlatform]; - } - - if (category === 'popular') { - const popularPlatformList = Array.from(currentCategory?.platforms ?? []); - // We keep the order of the platforms defined in the set - return filtered.sort( - (a, b) => popularPlatformList.indexOf(a.id) - popularPlatformList.indexOf(b.id) - ); - } - - // We only want to sort the platforms alphabetically if users are not viewing the 'popular' tab category - return filtered.sort((a, b) => comparePlatformNames(a.name, b.name)); - }, [filter, category, availablePlatforms]); - - const latestValuesRef = useRef({ - filter, - platformList, - source, - variant, - organization, - category, - }); - - useEffect(() => { - latestValuesRef.current = { - filter, - platformList, - source, - variant, - organization, - category, - }; - }); - - const debounceSearch = useRef( - // oxlint-disable-next-line react/refs - debounce(() => { - const { - filter: currentFilter, - platformList: currentPlatformList, - source: currentSource, - variant: currentVariant, - organization: currentOrganization, - category: currentCategory, - } = latestValuesRef.current; - - if (!currentFilter) { - return; - } - trackAnalytics('growth.platformpicker_search', { - search: currentFilter.toLowerCase(), - num_results: currentPlatformList.length, - source: currentSource, - variant: currentVariant, - organization: currentOrganization ?? null, - }); - - if (!visibleSelection) { - return; - } - - const fullPlatformMatch = currentPlatformList.find( - platformItem => platformItem.name.toLowerCase() === currentFilter.toLowerCase() - ); - - if (!fullPlatformMatch) { - return; - } - - setPlatform({...fullPlatformMatch, category: currentCategory}); - }, DEFAULT_DEBOUNCE_DURATION) - ).current; - - return ( - - - - { - trackAnalytics('growth.platformpicker_category', { - category: val, - source, - variant, - organization: organization ?? null, - }); - setCategory(val); - setFilter(''); - }} - > - - {categoryList.map(({id, name}) => ( - {name} - ))} - - - - - { - setFilter(val); - debounceSearch(); - }} - /> - - - - {platformList.map(item => { - return ( -
- - { - setPlatform(null); - e.stopPropagation(); - }} - onClick={() => { - trackAnalytics('growth.select_platform', { - platform_id: item.id, - selection_source: 'manual', - source, - variant, - organization: organization ?? null, - }); - - setPlatform({...item, category}); - }} - /> -
- ); - })} -
- {platformList.length === 0 && ( - } - title={t("We don't have an SDK for that yet!")} - > - {tct( - "Sure you haven't misspelled? If you're using a lesser-known platform, consider choosing a more generic SDK like Browser JavaScript, Python, Node, .NET & Java or create a generic project, by selecting [linkOther:“Other”].", - { - linkOther: ( -