diff --git a/static/app/components/backendJsonFormAdapter/choiceMapperAdapter.spec.tsx b/static/app/components/backendJsonFormAdapter/choiceMapperAdapter.spec.tsx new file mode 100644 index 00000000000000..fcf575fa72e503 --- /dev/null +++ b/static/app/components/backendJsonFormAdapter/choiceMapperAdapter.spec.tsx @@ -0,0 +1,511 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {BackendJsonFormAdapter} from './'; + +const org = OrganizationFixture(); +const mutationOptions = { + mutationFn: jest.fn().mockResolvedValue({}), +}; + +describe('ChoiceMapperAdapter', () => { + it('renders choice_mapper with empty value showing only Add button', async () => { + render( + , + {organization: org} + ); + + expect(screen.getByText('Status Mapping')).toBeInTheDocument(); + expect(await screen.findByRole('button', {name: /Add Repo/i})).toBeInTheDocument(); + // No table headers when empty + expect(screen.queryByText('Repository')).not.toBeInTheDocument(); + }); + + it('renders choice_mapper table with existing values', async () => { + render( + , + {organization: org} + ); + + // Wait for component to settle (CompactSelect popper setup) + expect(await screen.findByRole('button', {name: /Add Repo/i})).toBeInTheDocument(); + // Column headers + expect(screen.getByText('Repository')).toBeInTheDocument(); + expect(screen.getByText('When Resolved')).toBeInTheDocument(); + expect(screen.getByText('When Unresolved')).toBeInTheDocument(); + // Item label from valueMap + expect(screen.getByText('my-org/repo1')).toBeInTheDocument(); + // Current values rendered in selects + expect(screen.getByText('Closed')).toBeInTheDocument(); + expect(screen.getByText('Open')).toBeInTheDocument(); + // Delete button + expect(screen.getByRole('button', {name: 'Delete'})).toBeInTheDocument(); + }); + + it('choice_mapper add row does not immediately submit', async () => { + render( + , + {organization: org} + ); + + await userEvent.click(screen.getByText('Add Repo')); + await userEvent.click(await screen.findByRole('option', {name: 'my-org/repo1'})); + + // Row should appear in the UI + expect(await screen.findByText('my-org/repo1')).toBeInTheDocument(); + // But mutation should NOT fire — the user hasn't filled in the select yet + expect(mutationOptions.mutationFn).not.toHaveBeenCalled(); + }); + + it('choice_mapper add row then fill select triggers mutation', async () => { + render( + , + {organization: org} + ); + + // Add a row + await userEvent.click(screen.getByText('Add Repo')); + await userEvent.click(await screen.findByRole('option', {name: 'my-org/repo1'})); + expect(await screen.findByText('my-org/repo1')).toBeInTheDocument(); + + // Now fill in the select — this should trigger mutation + await userEvent.click(screen.getByText('Select...')); + await userEvent.click(await screen.findByText('Closed')); + + await waitFor(() => { + expect(mutationOptions.mutationFn).toHaveBeenCalledWith({ + status_mapping: {repo1: {on_resolve: 'closed'}}, + }); + }); + }); + + it('choice_mapper does not submit until all columns in every row are filled', async () => { + render( + , + {organization: org} + ); + + // Add a row + await userEvent.click(screen.getByText('Add Repo')); + await userEvent.click(await screen.findByRole('option', {name: 'my-org/repo1'})); + expect(await screen.findByText('my-org/repo1')).toBeInTheDocument(); + + // Fill only the first column — should NOT submit + const selects = screen.getAllByText('Select...'); + await userEvent.click(selects[0]!); + await userEvent.click(await screen.findByText('Closed')); + + expect(mutationOptions.mutationFn).not.toHaveBeenCalled(); + + // Fill the second column — NOW it should submit + await userEvent.click(screen.getByText('Select...')); + await userEvent.click(await screen.findByText('Reopened')); + + await waitFor(() => { + expect(mutationOptions.mutationFn).toHaveBeenCalledWith({ + status_mapping: {repo1: {on_resolve: 'closed', on_unresolve: 'reopened'}}, + }); + }); + }); + + it('choice_mapper remove row triggers mutation', async () => { + render( + , + {organization: org} + ); + + await userEvent.click(screen.getByRole('button', {name: 'Delete'})); + + await waitFor(() => { + expect(mutationOptions.mutationFn).toHaveBeenCalledWith({ + status_mapping: {}, + }); + }); + }); + + it('choice_mapper update cell value triggers mutation', async () => { + render( + , + {organization: org} + ); + + // Click the current value to open the select menu + await userEvent.click(screen.getByText('Closed')); + // Select the new value from the dropdown menu + await userEvent.click(await screen.findByText('Open')); + + await waitFor(() => { + expect(mutationOptions.mutationFn).toHaveBeenCalledWith({ + status_mapping: {repo1: {on_resolve: 'open'}}, + }); + }); + }); + + it('choice_mapper async search fetches results and adds row', async () => { + const searchUrl = '/extensions/github/search/my-org/123/'; + + MockApiClient.addMockResponse({ + url: searchUrl, + body: [ + {value: 'my-org/cool-repo', label: 'my-org/cool-repo'}, + {value: 'my-org/other-repo', label: 'my-org/other-repo'}, + ], + }); + + render( + , + {organization: org} + ); + + // Open the dropdown + await userEvent.click( + await screen.findByRole('button', {name: /Add GitHub Project/i}) + ); + + // Before typing, should show "Type to search" + expect(screen.getByText('Type to search')).toBeInTheDocument(); + + // Type a search query into the search input + await userEvent.type(screen.getByRole('textbox'), 'cool'); + + // Wait for search results to appear + expect( + await screen.findByRole('option', {name: 'my-org/cool-repo'}) + ).toBeInTheDocument(); + + // Select a result + await userEvent.click(screen.getByRole('option', {name: 'my-org/cool-repo'})); + + // Row should appear with the label + expect(await screen.findByText('my-org/cool-repo')).toBeInTheDocument(); + }); + + it('choice_mapper async search displays item value as row label', async () => { + const searchUrl = '/extensions/github/search/my-org/123/'; + + MockApiClient.addMockResponse({ + url: searchUrl, + body: [{value: 'my-org/cool-repo', label: 'Cool Repo (friendly name)'}], + }); + + render( + , + {organization: org} + ); + + // Search and add a row + await userEvent.click( + await screen.findByRole('button', {name: /Add GitHub Project/i}) + ); + await userEvent.type(screen.getByRole('textbox'), 'cool'); + await userEvent.click( + await screen.findByRole('option', {name: 'Cool Repo (friendly name)'}) + ); + + // Row label should display the item value (key), not the friendly label, + // because saved entries from the server only have the value as the key + expect(await screen.findByText('my-org/cool-repo')).toBeInTheDocument(); + expect(screen.queryByText('Cool Repo (friendly name)')).not.toBeInTheDocument(); + }); + + it('choice_mapper disables controls while mutation is in flight', async () => { + let resolveMutation!: () => void; + const pendingMutationOptions = { + mutationFn: jest.fn( + () => new Promise(resolve => (resolveMutation = resolve)) + ), + }; + + render( + , + {organization: org} + ); + + // Verify controls are initially enabled + expect(await screen.findByRole('button', {name: 'Delete'})).toBeEnabled(); + + // Change a value to trigger mutation + await userEvent.click(screen.getByText('Closed')); + await userEvent.click(await screen.findByText('Open')); + + // Mutation should be called but not resolved + await waitFor(() => { + expect(pendingMutationOptions.mutationFn).toHaveBeenCalled(); + }); + + // Controls should be disabled while mutation is pending + expect(screen.getByRole('button', {name: 'Delete'})).toBeDisabled(); + expect(screen.getByRole('button', {name: /Add Repo/i})).toBeDisabled(); + + // Resolve the mutation + resolveMutation(); + + // Controls should be re-enabled + await waitFor(() => { + expect(screen.getByRole('button', {name: 'Delete'})).toBeEnabled(); + }); + expect(screen.getByRole('button', {name: /Add Repo/i})).toBeEnabled(); + }); +}); diff --git a/static/app/components/backendJsonFormAdapter/choiceMapperAdapter.tsx b/static/app/components/backendJsonFormAdapter/choiceMapperAdapter.tsx new file mode 100644 index 00000000000000..55ea3298391ab8 --- /dev/null +++ b/static/app/components/backendJsonFormAdapter/choiceMapperAdapter.tsx @@ -0,0 +1,325 @@ +import {useState, type ReactNode} from 'react'; +import {useQuery} from '@tanstack/react-query'; +import type {DistributedPick} from 'type-fest'; + +import {Button} from '@sentry/scraps/button'; +import { + CompactSelect, + type SelectOption, + type SelectProps, +} from '@sentry/scraps/compactSelect'; +import {Flex, Stack} from '@sentry/scraps/layout'; +import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; +import {Select} from '@sentry/scraps/select'; +import {Text} from '@sentry/scraps/text'; + +import {Client} from 'sentry/api'; +import {IconAdd, IconDelete} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {useApi} from 'sentry/utils/useApi'; +import {useDebouncedValue} from 'sentry/utils/useDebouncedValue'; + +import type {JsonFormAdapterFieldConfig} from './types'; + +type ChoiceMapperConfig = Extract; + +interface ChoiceMapperDropdownProps { + config: ChoiceMapperConfig; + onChange: (value: Record>) => void; + onLabelAdd: (value: string, label: ReactNode) => void; + value: Record>; + disabled?: boolean; + indicator?: React.ReactNode; +} + +interface ChoiceMapperTableProps { + config: ChoiceMapperConfig; + labels: Record; + onSave: (value: Record>) => void; + onUpdate: (value: Record>) => void; + value: Record>; + disabled?: boolean; +} + +/** + * Async search dropdown that fetches options from a URL as the user types. + */ +function AsyncSearchCompactSelect({ + url, + searchField, + defaultOptions, + noResultsMessage, + disabled, + onChange, + ...triggerProps +}: { + defaultOptions: Array>; + onChange: (option: SelectOption) => void; + url: string; + disabled?: boolean; + noResultsMessage?: string; + searchField?: string; +} & DistributedPick, 'trigger'>) { + const [search, setSearch] = useState(''); + const debouncedSearch = useDebouncedValue(search, 200); + const [apiClient] = useState(() => new Client({baseUrl: ''})); + const api = useApi({api: apiClient}); + + const {data: options = defaultOptions, isFetching} = useQuery({ + queryKey: [url, {field: searchField, query: debouncedSearch}], + queryFn: (): Promise>> => + api.requestPromise(url, { + query: {field: searchField, query: debouncedSearch}, + }), + enabled: !!debouncedSearch, + staleTime: 30_000, + placeholderData: previousData => (debouncedSearch ? previousData : undefined), + select: data => data.map(item => ({value: item.value, label: item.label})), + }); + + return ( + { + setSearch(''); + onChange(option); + }} + onOpenChange={(isOpen: boolean) => { + if (!isOpen) { + setSearch(''); + } + }} + loading={isFetching} + emptyMessage={ + isFetching + ? t('Searching\u2026') + : debouncedSearch + ? (noResultsMessage ?? t('No results found')) + : t('Type to search') + } + size="xs" + menuWidth={250} + /> + ); +} + +/** + * Renders just the "Add" dropdown button. + * Placed inside Layout.Row alongside the label. + */ +export function ChoiceMapperDropdown({ + config, + value, + onChange, + onLabelAdd, + disabled, + indicator, +}: ChoiceMapperDropdownProps) { + const {columnLabels = {}} = config; + + const asyncUrl = config.addDropdown?.url; + const selectableValues = + config.addDropdown?.items?.filter(i => !value.hasOwnProperty(i.value)) ?? []; + + const addRow = (item: SelectOption) => { + const emptyValue = Object.keys(columnLabels).reduce>( + (acc, key) => { + acc[key] = null; + return acc; + }, + {} + ); + + if (asyncUrl) { + // using item.value as label because it's what we display for saved values too + onLabelAdd(item.value, item.value); + } + onChange({...value, [item.value]: emptyValue}); + }; + + if (asyncUrl) { + return ( + + ({value: i.value, label: i.label}))} + noResultsMessage={config.addDropdown?.noResultsMessage ?? t('No results found')} + disabled={disabled} + onChange={addRow} + trigger={triggerProps => ( + + + {config.addButtonText ?? t('Add Item')} + + + )} + /> + {indicator} + + ); + } + + return ( + + ({value: i.value, label: i.label}))} + menuWidth={250} + onChange={addRow} + trigger={triggerProps => ( + + + {config.addButtonText ?? t('Add Item')} + + + )} + /> + {indicator} + + ); +} + +/** + * Renders the mapping table rows (header + data rows). + * Placed below the Layout.Row. + */ +export function ChoiceMapperTable({ + config, + labels, + value, + onUpdate, + onSave, + disabled, +}: ChoiceMapperTableProps) { + const {addDropdown, columnLabels = {}, mappedColumnLabel} = config; + + const mappedKeys = Object.keys(columnLabels); + + const getSelector = (itemKey: string, fieldKey: string) => { + if (config.perItemMapping) { + return config.mappedSelectors?.[itemKey]?.[fieldKey]; + } + return config.mappedSelectors?.[fieldKey]; + }; + + const labelMap = + addDropdown?.items?.reduce( + (map, item) => { + map[item.value] = item.label; + return map; + }, + {...labels} + ) ?? {}; + + const allColumnsFilled = (val: Record>) => + Object.values(val).every(row => + mappedKeys.every(key => row[key] !== null && row[key] !== undefined) + ); + + const updateAndSaveIfComplete = (newValue: Record>) => { + onUpdate(newValue); + if (allColumnsFilled(newValue)) { + onSave(newValue); + } + }; + + const removeRow = (itemKey: string) => { + const newValue = Object.fromEntries( + Object.entries(value).filter(([key]) => key !== itemKey) + ); + updateAndSaveIfComplete(newValue); + }; + + const setValue = ( + itemKey: string, + fieldKey: string, + cellValue: string | number | null + ) => { + const newValue = {...value, [itemKey]: {...value[itemKey], [fieldKey]: cellValue}}; + updateAndSaveIfComplete(newValue); + }; + + const hasValues = Object.keys(value).length > 0; + + if (!hasValues) { + return null; + } + + return ( + + + + + {mappedColumnLabel} + + + {mappedKeys.map(fieldKey => ( + + + {columnLabels[fieldKey]} + + + ))} + + {Object.keys(value).map(itemKey => ( + + {labelMap[itemKey]} + {mappedKeys.map((fieldKey, i) => ( + + + + setSelectedMappedValue(option ? option.value : null) + } + value={selectedMappedValue} + disabled={disabled} + /> + + + + handleCellChange(rowIndex, key, e.currentTarget.value)} + onBlur={handleCellBlur} + onKeyDown={e => { + if (e.key === 'Enter') { + handleCellBlur(); + } + }} + disabled={disabled} + /> + + ))} + handleDelete(rowIndex)} + message={ + + + + } + > +