diff --git a/static/app/views/settings/organizationIntegrations/integrationListDirectory.spec.tsx b/static/app/views/settings/organizationIntegrations/integrationListDirectory.spec.tsx
index e3ac16b3c371..18f15c13d6f4 100644
--- a/static/app/views/settings/organizationIntegrations/integrationListDirectory.spec.tsx
+++ b/static/app/views/settings/organizationIntegrations/integrationListDirectory.spec.tsx
@@ -10,8 +10,11 @@ import {OrganizationFixture} from 'sentry-fixture/organization';
import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+import {trackAnalytics} from 'sentry/utils/analytics';
import IntegrationListDirectory from 'sentry/views/settings/organizationIntegrations/integrationListDirectory';
+jest.mock('sentry/utils/analytics');
+
const mockResponse = (mocks: Array<[string, unknown]>) => {
mocks.forEach(([url, body]) => MockApiClient.addMockResponse({url, body}));
};
@@ -70,6 +73,39 @@ describe('IntegrationListDirectory', () => {
expect(screen.getByText('Bitbucket')).toBeInTheDocument();
expect(screen.getByText('La Croix Monitor')).toBeInTheDocument();
});
+
+ it('tracks searches with the number of results shown', async () => {
+ const {router} = render(, {organization});
+ expect(await screen.findByRole('textbox', {name: 'Filter'})).toBeInTheDocument();
+
+ await userEvent.type(screen.getByRole('textbox', {name: 'Filter'}), 'it');
+ await userEvent.keyboard('{enter}');
+
+ expect(trackAnalytics).toHaveBeenLastCalledWith(
+ 'integrations.directory_item_searched',
+ expect.objectContaining({search_term: 'it', num_results: 2})
+ );
+
+ router.navigate('/mock-pathname/?category=unpublished');
+ await userEvent.type(screen.getByRole('textbox', {name: 'Filter'}), 'it');
+ await userEvent.keyboard('{enter}');
+
+ expect(trackAnalytics).toHaveBeenLastCalledWith(
+ 'integrations.directory_item_searched',
+ expect.objectContaining({search_term: 'it', num_results: 1})
+ );
+
+ // The legacy webhook row renders as a result, so it counts as one
+ router.navigate('/mock-pathname/');
+ await userEvent.type(screen.getByRole('textbox', {name: 'Filter'}), 'legacy');
+ await userEvent.keyboard('{enter}');
+
+ expect(screen.getByText('Webhooks (Legacy)')).toBeInTheDocument();
+ expect(trackAnalytics).toHaveBeenLastCalledWith(
+ 'integrations.directory_item_searched',
+ expect.objectContaining({search_term: 'legacy', num_results: 1})
+ );
+ });
});
describe('Legacy webhook entry', () => {
diff --git a/static/app/views/settings/organizationIntegrations/integrationListDirectory.tsx b/static/app/views/settings/organizationIntegrations/integrationListDirectory.tsx
index fa683598fba2..fec3ef088699 100644
--- a/static/app/views/settings/organizationIntegrations/integrationListDirectory.tsx
+++ b/static/app/views/settings/organizationIntegrations/integrationListDirectory.tsx
@@ -2,7 +2,6 @@ import {Fragment, useCallback, useEffect, useMemo} from 'react';
import {useSearchParams} from 'react-router-dom';
import styled from '@emotion/styled';
import {useQuery} from '@tanstack/react-query';
-import debounce from 'lodash/debounce';
import startCase from 'lodash/startCase';
import {DocIntegrationAvatar, SentryAppAvatar} from '@sentry/scraps/avatar';
@@ -31,7 +30,6 @@ import type {
SentryApp,
SentryAppInstallation,
} from 'sentry/types/integrations';
-import type {Organization} from 'sentry/types/organization';
import {getApiUrl} from 'sentry/utils/api/getApiUrl';
import {uniq} from 'sentry/utils/array/uniq';
import {
@@ -62,20 +60,35 @@ const FirstPartyIntegrationAlert = OverrideOrDefault({
defaultComponent: () => null,
});
+const WEBHOOK_ROW_CATEGORIES = ['notification action'];
+
/**
- * Debounce the tracking of integration search events to avoid spamming the
- * analytics endpoint.
+ * Everything the directory renders for a search: the matching integrations
+ * plus the synthetic legacy webhook row. Search analytics report numResults
+ * so the count must stay in sync with what is shown.
*/
-const TEXT_SEARCH_ANALYTICS_DEBOUNCE_IN_MS = 1000;
-const debouncedTrackIntegrationSearch = debounce(
- (props: {num_results: number; organization: Organization; search_term: string}) => {
- trackIntegrationAnalytics('integrations.directory_item_searched', {
- view: 'integrations_directory',
- ...props,
- });
- },
- TEXT_SEARCH_ANALYTICS_DEBOUNCE_IN_MS
-);
+function getDisplayedResults(
+ list: AppOrProviderOrPlugin[],
+ search: string,
+ category: string,
+ hasLegacyWebhooks: boolean
+) {
+ const term = search.toLowerCase();
+ const matches = list.filter(
+ integration =>
+ integration.name.toLowerCase().includes(term) &&
+ (!category || getCategoriesForIntegration(integration).includes(category))
+ );
+ const showLegacyWebhookRow =
+ hasLegacyWebhooks &&
+ t('Webhooks (Legacy)').toLowerCase().includes(term) &&
+ (!category || WEBHOOK_ROW_CATEGORIES.includes(category));
+ return {
+ matches,
+ showLegacyWebhookRow,
+ numResults: matches.length + (showLegacyWebhookRow ? 1 : 0),
+ };
+}
function useIntegrationList() {
const queryOptions = {staleTime: 0};
@@ -221,34 +234,19 @@ export default function IntegrationListDirectory() {
const category = decodeScalar(location.query.category) ?? '';
const search = decodeScalar(location.query.search) ?? '';
- const webhookName = t('Webhooks (Legacy)');
- const webhookCategories = ['notification action'];
- const showLegacyWebhookRow =
- !!legacyWebhooks &&
- (!search || webhookName.toLowerCase().includes(search.toLowerCase())) &&
- (!category || webhookCategories.includes(category));
-
- const displayList = useMemo(() => {
- let listToDisplay = [...list];
+ const legacyWebhookProjectCount = legacyWebhooks?.projects?.length ?? 0;
- if (search) {
- listToDisplay = list.filter(integration =>
- integration.name.toLowerCase().includes(search.toLowerCase())
- );
- }
-
- if (category) {
- listToDisplay = listToDisplay.filter(integration =>
- getCategoriesForIntegration(integration).includes(category)
- );
- }
-
- return sortIntegrations({
- list: listToDisplay,
- sentryAppInstalls: appInstalls,
- integrationInstalls: integrations,
- });
- }, [list, appInstalls, integrations, category, search]);
+ const {displayList, showLegacyWebhookRow} = useMemo(() => {
+ const results = getDisplayedResults(list, search, category, !!legacyWebhooks);
+ return {
+ displayList: sortIntegrations({
+ list: results.matches,
+ sentryAppInstalls: appInstalls,
+ integrationInstalls: integrations,
+ }),
+ showLegacyWebhookRow: results.showLegacyWebhookRow,
+ };
+ }, [list, search, category, legacyWebhooks, appInstalls, integrations]);
const getAppInstall = useCallback(
(app: SentryApp) => appInstalls.find(i => i.app.slug === app.slug),
@@ -283,14 +281,16 @@ export default function IntegrationListDirectory() {
{replace: true}
);
if (newSearch) {
- debouncedTrackIntegrationSearch({
+ trackIntegrationAnalytics('integrations.directory_item_searched', {
+ view: 'integrations_directory',
search_term: newSearch,
- num_results: list.length,
+ num_results: getDisplayedResults(list, newSearch, category, !!legacyWebhooks)
+ .numResults,
organization,
});
}
},
- [location, navigate, organization, list.length]
+ [location, navigate, organization, list, category, legacyWebhooks]
);
/**
@@ -456,12 +456,10 @@ export default function IntegrationListDirectory() {
type="firstParty"
slug="legacy-webhooks"
displayName={t('Webhooks (Legacy)')}
- status={
- legacyWebhooks.projects?.length ? 'Installed' : 'Not Installed'
- }
+ status={legacyWebhookProjectCount ? 'Installed' : 'Not Installed'}
publishStatus="published"
- configurations={legacyWebhooks.projects?.length ?? 0}
- categories={webhookCategories}
+ configurations={legacyWebhookProjectCount}
+ categories={WEBHOOK_ROW_CATEGORIES}
customIcon={}
/>
)}