Skip to content

Commit 7800bb5

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 0b7e908 commit 7800bb5

4 files changed

Lines changed: 358 additions & 1 deletion

File tree

static/app/components/connectRepository/pathMapping.tsx

Lines changed: 10 additions & 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;
@@ -64,6 +64,15 @@ const schema = z.object({
6464
branch: z.string(),
6565
});
6666

67+
/**
68+
* Canonical form of a mapping used to compare two mappings for equality. The
69+
* branch is resolved to its effective value (an empty branch becomes `main`),
70+
* so mappings that differ only by that defaulting parse to the same values.
71+
*/
72+
export const normalizedPathMappingSchema = schema.extend({
73+
branch: z.string().transform(resolveBranch),
74+
});
75+
6776
/**
6877
* Sample stack frame path used to illustrate how the stack trace root is
6978
* rewritten to the source code root in the preview.
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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+
it('falls back to a fresh open row when the last mapping is deleted', async () => {
99+
const onChange = jest.fn();
100+
renderPathMappingList({pathMappings: [MAPPINGS[0]!], onChange});
101+
102+
await userEvent.click(screen.getByRole('button', {name: 'Delete path mapping'}));
103+
104+
expect(screen.getByText(/Paths \(1\)/)).toBeInTheDocument();
105+
expect(screen.getByRole('textbox', {name: 'Stack trace root'})).toHaveValue('');
106+
expect(onChange).toHaveBeenLastCalledWith([]);
107+
});
108+
});
109+
110+
describe('duplicate mappings', () => {
111+
it('blocks adding another path until the duplicate is resolved', () => {
112+
const duplicates: PathMappingValue[] = [
113+
{stackRoot: 'app/', sourceRoot: 'static/app/', branch: 'main'},
114+
{stackRoot: 'app/', sourceRoot: 'static/app/', branch: 'main'},
115+
];
116+
renderPathMappingList({pathMappings: duplicates});
117+
118+
expect(screen.getByRole('button', {name: 'Add another path'})).toBeDisabled();
119+
});
120+
121+
it('treats an empty branch as a duplicate of the default branch', () => {
122+
const duplicates: PathMappingValue[] = [
123+
{stackRoot: 'app/', sourceRoot: 'static/app/', branch: ''},
124+
{stackRoot: 'app/', sourceRoot: 'static/app/', branch: 'main'},
125+
];
126+
renderPathMappingList({pathMappings: duplicates});
127+
128+
expect(screen.getByRole('button', {name: 'Add another path'})).toBeDisabled();
129+
});
130+
});
131+
132+
describe('add another path', () => {
133+
it('reopens a trailing empty row instead of stacking a new one', async () => {
134+
renderPathMappingList({pathMappings: [MAPPINGS[0]!]});
135+
136+
// Add a new empty row, then collapse it by expanding the existing one.
137+
await userEvent.click(screen.getByRole('button', {name: 'Add another path'}));
138+
expect(screen.getByText(/Paths \(2\)/)).toBeInTheDocument();
139+
140+
await userEvent.click(screen.getByRole('button', {name: 'Expand path mapping'}));
141+
expect(screen.getByRole('textbox', {name: 'Stack trace root'})).toHaveValue('app/');
142+
143+
// The trailing empty row is now collapsed, so adding reopens it rather
144+
// than appending a third row.
145+
await userEvent.click(screen.getByRole('button', {name: 'Add another path'}));
146+
147+
expect(screen.getByText(/Paths \(2\)/)).toBeInTheDocument();
148+
expect(screen.getByRole('textbox', {name: 'Stack trace root'})).toHaveValue('');
149+
});
150+
});
151+
});
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: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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 {
11+
normalizedPathMappingSchema,
12+
PathMapping,
13+
type PathMappingValue,
14+
} from './pathMapping';
15+
16+
interface PathMappingListProps {
17+
/**
18+
* Called whenever the set of mappings changes. Receives the list of mappings
19+
* that have content (empty new rows are omitted).
20+
*/
21+
onChange: (pathMappings: PathMappingValue[]) => void;
22+
/**
23+
* The persisted mappings to seed the list with. When empty, the list starts
24+
* with a single new mapping ready to fill out.
25+
*/
26+
pathMappings?: PathMappingValue[];
27+
}
28+
29+
interface Entry {
30+
id: number;
31+
/**
32+
* New (not-yet-persisted) rows render the editable form without a summary
33+
* while expanded. Existing rows keep their summary pinned above the form.
34+
*/
35+
isNew: boolean;
36+
value: PathMappingValue;
37+
}
38+
39+
const EMPTY_MAPPING: PathMappingValue = {stackRoot: '', sourceRoot: '', branch: ''};
40+
41+
const hasContent = (value: PathMappingValue) =>
42+
value.stackRoot.trim() !== '' || value.sourceRoot.trim() !== '';
43+
44+
const mappingKey = (value: PathMappingValue) => {
45+
const {stackRoot, sourceRoot, branch} = normalizedPathMappingSchema.parse(value);
46+
return `${stackRoot}\0${sourceRoot}\0${branch}`;
47+
};
48+
49+
/**
50+
* Whether two or more filled mappings are identical. Used to block adding
51+
* another path until the duplicate is resolved.
52+
*/
53+
const hasDuplicateMappings = (entries: Entry[]) => {
54+
const keys = entries
55+
.filter(entry => hasContent(entry.value))
56+
.map(entry => mappingKey(entry.value));
57+
58+
return new Set(keys).size !== keys.length;
59+
};
60+
61+
export function PathMappingList({pathMappings, onChange}: PathMappingListProps) {
62+
const idRef = useRef(0);
63+
const nextId = () => idRef.current++;
64+
65+
const [entries, setEntries] = useState<Entry[]>(() => {
66+
const seeded = (pathMappings ?? []).map(value => ({
67+
id: nextId(),
68+
isNew: false,
69+
value,
70+
}));
71+
72+
return seeded.length > 0
73+
? seeded
74+
: [{id: nextId(), isNew: true, value: EMPTY_MAPPING}];
75+
});
76+
77+
// Only a single mapping can be expanded at a time. Start with the initial new
78+
// mapping open; otherwise everything is collapsed.
79+
const [openId, setOpenId] = useState<number | null>(() =>
80+
entries.length === 1 && entries[0]!.isNew ? entries[0]!.id : null
81+
);
82+
83+
const commit = (next: Entry[]) => {
84+
setEntries(next);
85+
onChange(next.map(entry => entry.value).filter(hasContent));
86+
};
87+
88+
const handleChange = (id: number, value: PathMappingValue) => {
89+
commit(entries.map(entry => (entry.id === id ? {...entry, value} : entry)));
90+
};
91+
92+
const handleDelete = (id: number) => {
93+
const remaining = entries.filter(entry => entry.id !== id);
94+
95+
// Deleting the last mapping would leave nothing to fill out, so fall back
96+
// to a fresh open row — the same state the list seeds itself with when
97+
// there are no mappings to begin with.
98+
if (remaining.length === 0) {
99+
const newId = nextId();
100+
commit([{id: newId, isNew: true, value: EMPTY_MAPPING}]);
101+
setOpenId(newId);
102+
return;
103+
}
104+
105+
commit(remaining);
106+
setOpenId(open => (open === id ? null : open));
107+
};
108+
109+
const handleAddAnother = () => {
110+
// If the last row is still empty, just reopen it rather than stacking
111+
// another blank row on top of it.
112+
const last = entries.at(-1);
113+
if (last && !hasContent(last.value)) {
114+
setOpenId(last.id);
115+
return;
116+
}
117+
118+
const id = nextId();
119+
setEntries([...entries, {id, isNew: true, value: EMPTY_MAPPING}]);
120+
setOpenId(id);
121+
};
122+
123+
const toggle = (id: number) => setOpenId(open => (open === id ? null : id));
124+
125+
const duplicate = hasDuplicateMappings(entries);
126+
const addDisabledReason = duplicate
127+
? t('Resolve the duplicate path mapping first')
128+
: undefined;
129+
130+
// Nothing to add while the trailing empty row is already open for editing —
131+
// "Add another path" only reopens it when it's collapsed.
132+
const last = entries.at(-1);
133+
const editingEmptyRow =
134+
last !== undefined && !hasContent(last.value) && openId === last.id;
135+
136+
return (
137+
<Stack gap="lg">
138+
<Stack gap="xs">
139+
<Text bold>{tct('Paths ([count])', {count: entries.length})}</Text>
140+
<Text size="sm" variant="muted">
141+
{t(
142+
'Tell Sentry how to translate file paths, so errors open the right line of code.'
143+
)}
144+
</Text>
145+
</Stack>
146+
147+
<Stack gap="md">
148+
{entries.map(entry => (
149+
<PathMapping
150+
key={entry.id}
151+
{...entry.value}
152+
editing={openId === entry.id}
153+
isNew={entry.isNew}
154+
onChange={value => handleChange(entry.id, value)}
155+
onDelete={() => handleDelete(entry.id)}
156+
onExpandToggle={() => toggle(entry.id)}
157+
/>
158+
))}
159+
</Stack>
160+
161+
<Flex justify="end">
162+
<Button
163+
size="xs"
164+
variant="transparent"
165+
icon={<IconAdd />}
166+
disabled={Boolean(addDisabledReason) || editingEmptyRow}
167+
tooltipProps={{title: addDisabledReason}}
168+
onClick={handleAddAnother}
169+
>
170+
{t('Add another path')}
171+
</Button>
172+
</Flex>
173+
</Stack>
174+
);
175+
}

0 commit comments

Comments
 (0)