From 94bb143d29fa1a3682bcef3622fdaedc53a794ad Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Mon, 13 Apr 2026 16:49:53 -0700 Subject: [PATCH] feat(aci): Add created_by filter to automations search Add frontend support for the created_by search filter in the automations list, following the same pattern as the detector search assignee filter. Refactor filter key definitions into a useAutomationFilterKeys hook that provides dynamic member values for the created_by dropdown. Refs #112873 --- .../components/automationListTable/search.tsx | 46 +------- static/app/views/automations/constants.tsx | 26 ----- static/app/views/automations/list.spec.tsx | 23 ++++ .../utils/useAutomationFilterKeys.tsx | 108 ++++++++++++++++++ .../views/detectors/detectorViewContainer.tsx | 11 ++ 5 files changed, 147 insertions(+), 67 deletions(-) create mode 100644 static/app/views/automations/utils/useAutomationFilterKeys.tsx diff --git a/static/app/views/automations/components/automationListTable/search.tsx b/static/app/views/automations/components/automationListTable/search.tsx index 9137a35ebe3331..11a56145ab80da 100644 --- a/static/app/views/automations/components/automationListTable/search.tsx +++ b/static/app/views/automations/components/automationListTable/search.tsx @@ -1,60 +1,24 @@ import {SearchQueryBuilder} from 'sentry/components/searchQueryBuilder'; import {t} from 'sentry/locale'; -import type {TagCollection} from 'sentry/types/group'; -import type {FieldDefinition} from 'sentry/utils/fields'; -import {FieldKind} from 'sentry/utils/fields'; -import {AUTOMATION_FILTER_KEYS} from 'sentry/views/automations/constants'; +import {useAutomationFilterKeys} from 'sentry/views/automations/utils/useAutomationFilterKeys'; type AutomationSearchProps = { initialQuery: string; onSearch: (query: string) => void; }; -function getAutomationFilterKeyDefinition(filterKey: string): FieldDefinition | null { - if ( - AUTOMATION_FILTER_KEYS.hasOwnProperty(filterKey) && - AUTOMATION_FILTER_KEYS[filterKey] - ) { - const {description, valueType, keywords, values} = AUTOMATION_FILTER_KEYS[filterKey]; - - return { - kind: FieldKind.FIELD, - desc: description, - valueType, - keywords, - values, - }; - } - - return null; -} - -const FILTER_KEYS: TagCollection = Object.fromEntries( - Object.keys(AUTOMATION_FILTER_KEYS).map(key => { - const {values} = AUTOMATION_FILTER_KEYS[key] ?? {}; - - return [ - key, - { - key, - name: key, - predefined: values !== undefined, - values, - }, - ]; - }) -); - export function AutomationSearch({initialQuery, onSearch}: AutomationSearchProps) { + const {filterKeys, getFieldDefinition} = useAutomationFilterKeys(); + return ( Promise.resolve([])} searchSource="automations-list" - fieldDefinitionGetter={getAutomationFilterKeyDefinition} + fieldDefinitionGetter={getFieldDefinition} disallowUnsupportedFilters disallowWildcard disallowLogicalOperators diff --git a/static/app/views/automations/constants.tsx b/static/app/views/automations/constants.tsx index 5569238c83d98a..2b9a04be291c2e 100644 --- a/static/app/views/automations/constants.tsx +++ b/static/app/views/automations/constants.tsx @@ -1,27 +1 @@ -import {FieldValueType} from 'sentry/utils/fields'; -import {ActionType} from 'sentry/views/alerts/rules/metric/types'; - export const AUTOMATION_LIST_PAGE_LIMIT = 20; - -const ACTION_TYPE_VALUES = Object.values(ActionType).sort(); - -export const AUTOMATION_FILTER_KEYS: Record< - string, - { - description: string; - valueType: FieldValueType; - keywords?: string[]; - values?: string[]; - } -> = { - name: { - description: 'Name of the automation (exact match).', - valueType: FieldValueType.STRING, - keywords: ['name'], - }, - action: { - description: 'Action triggered by the automation.', - valueType: FieldValueType.STRING, - values: ACTION_TYPE_VALUES, - }, -}; diff --git a/static/app/views/automations/list.spec.tsx b/static/app/views/automations/list.spec.tsx index 3910f298f71031..f4c6a925be895b 100644 --- a/static/app/views/automations/list.spec.tsx +++ b/static/app/views/automations/list.spec.tsx @@ -44,6 +44,10 @@ describe('AutomationsList', () => { url: '/organizations/org-slug/prompts-activity/', body: {}, }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/members/', + body: [], + }); MockApiClient.addMockResponse({ url: '/organizations/org-slug/workflows/', body: [AutomationFixture({name: 'Automation 1'})], @@ -189,6 +193,25 @@ describe('AutomationsList', () => { await screen.findByText('Slack Automation'); expect(mockAutomationActionSlack).toHaveBeenCalled(); }); + + it('can filter by created_by', async () => { + const mockAutomationCreatedBy = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/workflows/', + body: [AutomationFixture({name: 'My Automation'})], + match: [MockApiClient.matchQuery({query: 'created_by:me'})], + }); + + render(, {organization}); + await screen.findByText('Automation 1'); + + // Click through menus to select created_by:me + await userEvent.click(screen.getByRole('combobox', {name: 'Add a search term'})); + await userEvent.click(await screen.findByRole('option', {name: 'created_by'})); + await userEvent.click(await screen.findByRole('option', {name: 'me'})); + + await screen.findByText('My Automation'); + expect(mockAutomationCreatedBy).toHaveBeenCalled(); + }); }); describe('bulk actions', () => { diff --git a/static/app/views/automations/utils/useAutomationFilterKeys.tsx b/static/app/views/automations/utils/useAutomationFilterKeys.tsx new file mode 100644 index 00000000000000..9608e7cc2e2019 --- /dev/null +++ b/static/app/views/automations/utils/useAutomationFilterKeys.tsx @@ -0,0 +1,108 @@ +import {useCallback, useMemo} from 'react'; + +import {ItemType, type SearchGroup} from 'sentry/components/searchBar/types'; +import {escapeTagValue} from 'sentry/components/searchBar/utils'; +import type {FieldDefinitionGetter} from 'sentry/components/searchQueryBuilder/types'; +import {IconStar, IconUser} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import type {TagCollection} from 'sentry/types/group'; +import {FieldKind, FieldValueType, type FieldDefinition} from 'sentry/utils/fields'; +import {getUsername} from 'sentry/utils/membersAndTeams/userUtils'; +import {useMembers} from 'sentry/utils/useMembers'; +import {ActionType} from 'sentry/views/alerts/rules/metric/types'; + +const ACTION_TYPE_VALUES = Object.values(ActionType).sort(); + +const AUTOMATION_FILTER_KEYS: Record< + string, + { + fieldDefinition: FieldDefinition; + predefined?: boolean; + } +> = { + name: { + fieldDefinition: { + desc: 'Name of the Alert.', + kind: FieldKind.FIELD, + valueType: FieldValueType.STRING, + keywords: ['name'], + }, + }, + action: { + predefined: true, + fieldDefinition: { + desc: 'Action triggered by the Alert.', + kind: FieldKind.FIELD, + valueType: FieldValueType.STRING, + values: ACTION_TYPE_VALUES, + }, + }, + created_by: { + predefined: true, + fieldDefinition: { + desc: 'User who created the Alert.', + kind: FieldKind.FIELD, + valueType: FieldValueType.STRING, + allowWildcard: false, + keywords: ['creator', 'author'], + }, + }, +}; + +const convertToSearchItem = (value: string) => { + const escapedValue = escapeTagValue(value); + return { + value: escapedValue, + desc: value, + type: ItemType.TAG_VALUE, + }; +}; + +export function useAutomationFilterKeys(): { + filterKeys: TagCollection; + getFieldDefinition: FieldDefinitionGetter; +} { + const {members} = useMembers(); + + const createdByValues: SearchGroup[] = useMemo(() => { + const usernames = members.map(getUsername); + + return [ + { + title: t('Suggested Values'), + type: 'header', + icon: , + children: [convertToSearchItem('me')], + }, + { + title: t('All Values'), + type: 'header', + icon: , + children: usernames.map(convertToSearchItem), + }, + ]; + }, [members]); + + const filterKeys = useMemo(() => { + const entries = Object.entries(AUTOMATION_FILTER_KEYS).map(([key, config]) => [ + key, + { + key, + name: key, + predefined: config.predefined, + values: key === 'created_by' ? createdByValues : undefined, + }, + ]); + + return Object.fromEntries(entries); + }, [createdByValues]); + + const getFieldDefinition = useCallback((key: string) => { + return AUTOMATION_FILTER_KEYS[key]?.fieldDefinition ?? null; + }, []); + + return { + filterKeys, + getFieldDefinition, + }; +} diff --git a/static/app/views/detectors/detectorViewContainer.tsx b/static/app/views/detectors/detectorViewContainer.tsx index 9d9825c87ecc14..ae34f207afcfdc 100644 --- a/static/app/views/detectors/detectorViewContainer.tsx +++ b/static/app/views/detectors/detectorViewContainer.tsx @@ -1,11 +1,22 @@ +import {useEffect} from 'react'; import {Outlet} from 'react-router-dom'; +import {fetchOrgMembers} from 'sentry/actionCreators/members'; import {PageFiltersContainer} from 'sentry/components/pageFilters/container'; import {useWorkflowEngineFeatureGate} from 'sentry/components/workflowEngine/useWorkflowEngineFeatureGate'; +import {useApi} from 'sentry/utils/useApi'; +import {useOrganization} from 'sentry/utils/useOrganization'; export default function DetectorViewContainer() { useWorkflowEngineFeatureGate({redirect: true}); + const api = useApi(); + const organization = useOrganization(); + + useEffect(() => { + fetchOrgMembers(api, organization.slug); + }, [api, organization.slug]); + return (