Skip to content

Commit 9cdf52f

Browse files
committed
feat(integrations): Add PathMappingList component for repo code mappings
Wraps PathMapping with the add/remove/expand orchestration: a single row can be expanded at a time, empty rows are omitted from the reported value, and adding another path is blocked while a duplicate mapping is unresolved.
1 parent ece66a6 commit 9cdf52f

4 files changed

Lines changed: 310 additions & 1 deletion

File tree

static/app/components/connectRepository/pathMapping.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {Tooltip} from '@sentry/scraps/tooltip';
1111
import {IconArrow, IconBranch, IconChevron, IconDelete} from 'sentry/icons';
1212
import {t} from 'sentry/locale';
1313

14-
interface PathMappingValue {
14+
export interface PathMappingValue {
1515
branch: string;
1616
sourceRoot: string;
1717
stackRoot: string;
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
2+
3+
import type {PathMappingValue} from 'sentry/components/connectRepository/pathMapping';
4+
import {PathMappingList} from 'sentry/components/connectRepository/pathMappingList';
5+
6+
function renderPathMappingList(
7+
props: Partial<Parameters<typeof PathMappingList>[0]> = {}
8+
) {
9+
return render(<PathMappingList onChange={() => {}} {...props} />);
10+
}
11+
12+
const MAPPINGS: PathMappingValue[] = [
13+
{stackRoot: 'app/', sourceRoot: 'static/app/', branch: 'main'},
14+
{stackRoot: 'src/', sourceRoot: 'src/app/', branch: 'frontend'},
15+
];
16+
17+
describe('PathMappingList', () => {
18+
describe('empty', () => {
19+
it('starts with a single new mapping open for editing', () => {
20+
renderPathMappingList();
21+
22+
expect(screen.getByText(/Paths \(1\)/)).toBeInTheDocument();
23+
expect(screen.getByRole('textbox', {name: 'Stack trace root'})).toBeInTheDocument();
24+
});
25+
26+
it('disables "Add another path" while the empty row is being edited', () => {
27+
renderPathMappingList();
28+
29+
expect(screen.getByRole('button', {name: 'Add another path'})).toBeDisabled();
30+
});
31+
32+
it('reports filled values through onChange', async () => {
33+
const onChange = jest.fn();
34+
renderPathMappingList({onChange});
35+
36+
await userEvent.type(
37+
screen.getByRole('textbox', {name: 'Stack trace root'}),
38+
'lib/'
39+
);
40+
41+
expect(onChange).toHaveBeenLastCalledWith([
42+
expect.objectContaining({stackRoot: 'lib/', branch: 'main'}),
43+
]);
44+
});
45+
});
46+
47+
describe('with existing mappings', () => {
48+
it('renders each mapping as a collapsed summary', () => {
49+
renderPathMappingList({pathMappings: MAPPINGS});
50+
51+
expect(screen.getByText(/Paths \(2\)/)).toBeInTheDocument();
52+
expect(screen.getByText('app/')).toBeInTheDocument();
53+
expect(screen.getByText('src/')).toBeInTheDocument();
54+
expect(
55+
screen.queryByRole('textbox', {name: 'Stack trace root'})
56+
).not.toBeInTheDocument();
57+
});
58+
59+
it('expands a single mapping at a time', async () => {
60+
renderPathMappingList({pathMappings: MAPPINGS});
61+
62+
const [first, second] = screen.getAllByRole('button', {
63+
name: 'Expand path mapping',
64+
});
65+
66+
await userEvent.click(first!);
67+
expect(screen.getByRole('textbox', {name: 'Stack trace root'})).toHaveValue('app/');
68+
69+
await userEvent.click(second!);
70+
expect(screen.getByRole('textbox', {name: 'Stack trace root'})).toHaveValue('src/');
71+
expect(screen.getAllByRole('textbox', {name: 'Stack trace root'})).toHaveLength(1);
72+
});
73+
74+
it('adds a new mapping when "Add another path" is clicked', async () => {
75+
renderPathMappingList({pathMappings: MAPPINGS});
76+
77+
await userEvent.click(screen.getByRole('button', {name: 'Add another path'}));
78+
79+
expect(screen.getByText(/Paths \(3\)/)).toBeInTheDocument();
80+
expect(screen.getByRole('textbox', {name: 'Stack trace root'})).toHaveValue('');
81+
});
82+
83+
it('removes a mapping and reports the change', async () => {
84+
const onChange = jest.fn();
85+
renderPathMappingList({pathMappings: MAPPINGS, onChange});
86+
87+
const [firstDelete] = screen.getAllByRole('button', {
88+
name: 'Delete path mapping',
89+
});
90+
await userEvent.click(firstDelete!);
91+
92+
expect(screen.getByText(/Paths \(1\)/)).toBeInTheDocument();
93+
expect(onChange).toHaveBeenLastCalledWith([
94+
expect.objectContaining({stackRoot: 'src/'}),
95+
]);
96+
});
97+
});
98+
99+
describe('duplicate mappings', () => {
100+
it('blocks adding another path until the duplicate is resolved', () => {
101+
const duplicates: PathMappingValue[] = [
102+
{stackRoot: 'app/', sourceRoot: 'static/app/', branch: 'main'},
103+
{stackRoot: 'app/', sourceRoot: 'static/app/', branch: 'main'},
104+
];
105+
renderPathMappingList({pathMappings: duplicates});
106+
107+
expect(screen.getByRole('button', {name: 'Add another path'})).toBeDisabled();
108+
});
109+
});
110+
111+
describe('add another path', () => {
112+
it('reopens a trailing empty row instead of stacking a new one', async () => {
113+
renderPathMappingList({pathMappings: [MAPPINGS[0]!]});
114+
115+
// Add a new empty row, then collapse it by expanding the existing one.
116+
await userEvent.click(screen.getByRole('button', {name: 'Add another path'}));
117+
expect(screen.getByText(/Paths \(2\)/)).toBeInTheDocument();
118+
119+
await userEvent.click(screen.getByRole('button', {name: 'Expand path mapping'}));
120+
expect(screen.getByRole('textbox', {name: 'Stack trace root'})).toHaveValue('app/');
121+
122+
// The trailing empty row is now collapsed, so adding reopens it rather
123+
// than appending a third row.
124+
await userEvent.click(screen.getByRole('button', {name: 'Add another path'}));
125+
126+
expect(screen.getByText(/Paths \(2\)/)).toBeInTheDocument();
127+
expect(screen.getByRole('textbox', {name: 'Stack trace root'})).toHaveValue('');
128+
});
129+
});
130+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {useState} from 'react';
2+
3+
import type {PathMappingValue} from 'sentry/components/connectRepository/pathMapping';
4+
import {PathMappingList} from 'sentry/components/connectRepository/pathMappingList';
5+
import * as Storybook from 'sentry/stories';
6+
7+
export default Storybook.story('PathMappingList', story => {
8+
story('Empty (starts with a new path)', () => {
9+
const [pathMappings, setPathMappings] = useState<PathMappingValue[]>([]);
10+
11+
return <PathMappingList pathMappings={pathMappings} onChange={setPathMappings} />;
12+
});
13+
14+
story('With existing paths', () => {
15+
const [pathMappings, setPathMappings] = useState([
16+
{stackRoot: 'app/', sourceRoot: 'static/app/', branch: 'main'},
17+
{stackRoot: 'src/', sourceRoot: 'src/app/', branch: 'frontend'},
18+
]);
19+
20+
return <PathMappingList pathMappings={pathMappings} onChange={setPathMappings} />;
21+
});
22+
});
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import {useRef, useState} from 'react';
2+
3+
import {Button} from '@sentry/scraps/button';
4+
import {Flex, Stack} from '@sentry/scraps/layout';
5+
import {Text} from '@sentry/scraps/text';
6+
7+
import {IconAdd} from 'sentry/icons';
8+
import {t, tct} from 'sentry/locale';
9+
10+
import {PathMapping, type PathMappingValue} from './pathMapping';
11+
12+
interface PathMappingListProps {
13+
/**
14+
* Called whenever the set of mappings changes. Receives the list of mappings
15+
* that have content (empty new rows are omitted).
16+
*/
17+
onChange: (pathMappings: PathMappingValue[]) => void;
18+
/**
19+
* The persisted mappings to seed the list with. When empty, the list starts
20+
* with a single new mapping ready to fill out.
21+
*/
22+
pathMappings?: PathMappingValue[];
23+
}
24+
25+
interface Entry {
26+
id: number;
27+
/**
28+
* New (not-yet-persisted) rows render the editable form without a summary
29+
* while expanded. Existing rows keep their summary pinned above the form.
30+
*/
31+
isNew: boolean;
32+
value: PathMappingValue;
33+
}
34+
35+
const EMPTY_MAPPING: PathMappingValue = {stackRoot: '', sourceRoot: '', branch: ''};
36+
37+
const hasContent = (value: PathMappingValue) =>
38+
value.stackRoot.trim() !== '' || value.sourceRoot.trim() !== '';
39+
40+
const mappingKey = (value: PathMappingValue) =>
41+
`${value.stackRoot}\0${value.sourceRoot}\0${value.branch}`;
42+
43+
/**
44+
* Whether two or more filled mappings are identical. Used to block adding
45+
* another path until the duplicate is resolved.
46+
*/
47+
const hasDuplicateMappings = (entries: Entry[]) => {
48+
const keys = entries
49+
.filter(entry => hasContent(entry.value))
50+
.map(entry => mappingKey(entry.value));
51+
52+
return new Set(keys).size !== keys.length;
53+
};
54+
55+
export function PathMappingList({pathMappings, onChange}: PathMappingListProps) {
56+
const idRef = useRef(0);
57+
const nextId = () => idRef.current++;
58+
59+
const [entries, setEntries] = useState<Entry[]>(() => {
60+
const seeded = (pathMappings ?? []).map(value => ({
61+
id: nextId(),
62+
isNew: false,
63+
value,
64+
}));
65+
66+
return seeded.length > 0
67+
? seeded
68+
: [{id: nextId(), isNew: true, value: EMPTY_MAPPING}];
69+
});
70+
71+
// Only a single mapping can be expanded at a time. Start with the initial new
72+
// mapping open; otherwise everything is collapsed.
73+
const [openId, setOpenId] = useState<number | null>(() =>
74+
entries.length === 1 && entries[0]!.isNew ? entries[0]!.id : null
75+
);
76+
77+
const commit = (next: Entry[]) => {
78+
setEntries(next);
79+
onChange(next.map(entry => entry.value).filter(hasContent));
80+
};
81+
82+
const handleChange = (id: number, value: PathMappingValue) => {
83+
commit(entries.map(entry => (entry.id === id ? {...entry, value} : entry)));
84+
};
85+
86+
const handleDelete = (id: number) => {
87+
commit(entries.filter(entry => entry.id !== id));
88+
setOpenId(open => (open === id ? null : open));
89+
};
90+
91+
const handleAddAnother = () => {
92+
// If the last row is still empty, just reopen it rather than stacking
93+
// another blank row on top of it.
94+
const last = entries.at(-1);
95+
if (last && !hasContent(last.value)) {
96+
setOpenId(last.id);
97+
return;
98+
}
99+
100+
const id = nextId();
101+
setEntries([...entries, {id, isNew: true, value: EMPTY_MAPPING}]);
102+
setOpenId(id);
103+
};
104+
105+
const toggle = (id: number) => setOpenId(open => (open === id ? null : id));
106+
107+
const duplicate = hasDuplicateMappings(entries);
108+
const addDisabledReason = duplicate
109+
? t('Resolve the duplicate path mapping first')
110+
: undefined;
111+
112+
// Nothing to add while the trailing empty row is already open for editing —
113+
// "Add another path" only reopens it when it's collapsed.
114+
const last = entries.at(-1);
115+
const editingEmptyRow =
116+
last !== undefined && !hasContent(last.value) && openId === last.id;
117+
118+
return (
119+
<Stack gap="lg">
120+
<Stack gap="xs">
121+
<Text bold>{tct('Paths ([count])', {count: entries.length})}</Text>
122+
<Text size="sm" variant="muted">
123+
{t(
124+
'Tell Sentry how to translate file paths, so errors open the right line of code.'
125+
)}
126+
</Text>
127+
</Stack>
128+
129+
<Stack gap="md">
130+
{entries.map(entry => (
131+
<PathMapping
132+
key={entry.id}
133+
{...entry.value}
134+
editing={openId === entry.id}
135+
isNew={entry.isNew}
136+
onChange={value => handleChange(entry.id, value)}
137+
onDelete={() => handleDelete(entry.id)}
138+
onExpandToggle={() => toggle(entry.id)}
139+
/>
140+
))}
141+
</Stack>
142+
143+
<Flex justify="end">
144+
<Button
145+
size="xs"
146+
variant="transparent"
147+
icon={<IconAdd />}
148+
disabled={Boolean(addDisabledReason) || editingEmptyRow}
149+
title={addDisabledReason}
150+
onClick={handleAddAnother}
151+
>
152+
{t('Add another path')}
153+
</Button>
154+
</Flex>
155+
</Stack>
156+
);
157+
}

0 commit comments

Comments
 (0)