Skip to content

Commit 7ccde16

Browse files
authored
feat(releases): Allow Tabs in FoldSection to expand FoldSection (#89159)
Refactors `<FoldSection>` to allow for it to be a controlled component so that we can expand it when a Tab is clicked.
1 parent 049dde1 commit 7ccde16

File tree

3 files changed

+248
-13
lines changed

3 files changed

+248
-13
lines changed

static/app/views/releases/drawer/commitsFilesSection.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {useCallback, useState} from 'react';
12
import styled from '@emotion/styled';
23

34
import {Badge} from 'sentry/components/core/badge';
@@ -48,17 +49,26 @@ export function CommitsFilesSection({
4849
enabled: !!projectSlug,
4950
},
5051
});
52+
const [isCollapsed, setCollapsed] = useState(false);
5153
const isError = repositoriesQuery.isError || releaseReposQuery.isError;
5254
const isLoading = repositoriesQuery.isPending || releaseReposQuery.isPending;
5355
const releaseRepos = releaseReposQuery.data;
5456
const repositories = repositoriesQuery.data;
5557
const noReleaseReposFound = !releaseRepos?.length;
5658
const noRepositoryOrgRelatedFound = !repositories?.length;
59+
const handleChange = useCallback(() => {
60+
setCollapsed(false);
61+
}, []);
62+
const handleFoldChange = useCallback((collapsed: boolean) => {
63+
setCollapsed(collapsed);
64+
}, []);
5765

5866
return (
59-
<Tabs disabled={isError}>
67+
<Tabs disabled={isError} onChange={handleChange}>
6068
<FoldSection
69+
isCollapsed={isCollapsed}
6170
sectionKey="commits"
71+
onChange={handleFoldChange}
6272
title={
6373
<TabList hideBorder>
6474
<TabList.Item key="commits">
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
2+
3+
import {FoldSection} from './foldSection';
4+
5+
describe('FoldSection', function () {
6+
it('renders basic section with title and content', function () {
7+
render(
8+
<FoldSection title="Test Section" sectionKey="test-section">
9+
<div>Test Content</div>
10+
</FoldSection>
11+
);
12+
13+
expect(
14+
screen.getByRole('button', {name: /Collapse Test Section Section/})
15+
).toBeInTheDocument();
16+
expect(screen.getByText('Test Content')).toBeInTheDocument();
17+
});
18+
19+
it('can toggle section collapse state', async function () {
20+
render(
21+
<FoldSection title="Test Section" sectionKey="test-section">
22+
<div>Test Content</div>
23+
</FoldSection>
24+
);
25+
26+
// Initially expanded
27+
expect(screen.getByText('Test Content')).toBeInTheDocument();
28+
29+
// Click to collapse
30+
await userEvent.click(
31+
screen.getByRole('button', {name: /Collapse Test Section Section/})
32+
);
33+
expect(screen.queryByText('Test Content')).not.toBeInTheDocument();
34+
35+
// Click to expand
36+
await userEvent.click(
37+
screen.getByRole('button', {name: /View Test Section Section/})
38+
);
39+
expect(screen.getByText('Test Content')).toBeInTheDocument();
40+
});
41+
42+
it('respects initialCollapse prop', function () {
43+
render(
44+
<FoldSection title="Test Section" sectionKey="test-section" initialCollapse>
45+
<div>Test Content</div>
46+
</FoldSection>
47+
);
48+
49+
expect(screen.queryByText('Test Content')).not.toBeInTheDocument();
50+
});
51+
52+
it('prevents collapsing when preventCollapse is true', async function () {
53+
render(
54+
<FoldSection title="Test Section" sectionKey="test-section" preventCollapse>
55+
<div>Test Content</div>
56+
</FoldSection>
57+
);
58+
59+
const button = screen.getByRole('button', {name: /Test Section Section/});
60+
await userEvent.click(button);
61+
62+
// Content should still be visible
63+
expect(screen.getByText('Test Content')).toBeInTheDocument();
64+
});
65+
66+
it('renders actions when provided and section is expanded', async function () {
67+
const actions = <button>Action Button</button>;
68+
69+
render(
70+
<FoldSection title="Test Section" sectionKey="test-section" actions={actions}>
71+
<div>Test Content</div>
72+
</FoldSection>
73+
);
74+
75+
// Actions should be visible when expanded
76+
expect(screen.getByRole('button', {name: 'Action Button'})).toBeInTheDocument();
77+
78+
// Click to collapse
79+
await userEvent.click(
80+
screen.getByRole('button', {name: /Collapse Test Section Section/})
81+
);
82+
83+
// Actions should not be visible when collapsed
84+
expect(screen.queryByRole('button', {name: 'Action Button'})).not.toBeInTheDocument();
85+
});
86+
87+
it('calls onChange when toggling collapse state', async function () {
88+
const handleChange = jest.fn();
89+
90+
render(
91+
<FoldSection
92+
title="Test Section"
93+
sectionKey="test-section"
94+
isCollapsed={false}
95+
onChange={handleChange}
96+
>
97+
<div>Test Content</div>
98+
</FoldSection>
99+
);
100+
101+
// Click to collapse
102+
await userEvent.click(
103+
screen.getByRole('button', {name: /Collapse Test Section Section/})
104+
);
105+
expect(handleChange).toHaveBeenCalledWith(true);
106+
107+
// Since it's controlled, nothing actually happens
108+
expect(screen.getByText('Test Content')).toBeInTheDocument();
109+
110+
render(
111+
<FoldSection
112+
title="Test Section"
113+
sectionKey="test-section"
114+
isCollapsed
115+
onChange={handleChange}
116+
>
117+
<div>Test Content</div>
118+
</FoldSection>
119+
);
120+
// Click to expand
121+
await userEvent.click(
122+
screen.getByRole('button', {name: /View Test Section Section/})
123+
);
124+
expect(handleChange).toHaveBeenCalledWith(false);
125+
126+
// Since it's controlled, nothing actually happens
127+
expect(screen.getByText('Test Content')).toBeInTheDocument();
128+
});
129+
130+
it('scrolls to section when hash matches sectionKey', async function () {
131+
// Mock window.location.hash
132+
const originalHash = window.location.hash;
133+
window.location.hash = '#test-section';
134+
135+
const scrollIntoViewMock = jest.fn();
136+
Element.prototype.scrollIntoView = scrollIntoViewMock;
137+
138+
render(
139+
<FoldSection title="Test Section" initialCollapse sectionKey="test-section">
140+
<div>Test Content</div>
141+
</FoldSection>
142+
);
143+
144+
expect(screen.getByText('Test Content')).toBeInTheDocument();
145+
146+
// Wait for the setTimeout in the component
147+
await new Promise(resolve => setTimeout(resolve, 100));
148+
149+
expect(scrollIntoViewMock).toHaveBeenCalled();
150+
151+
// Cleanup
152+
window.location.hash = originalHash;
153+
});
154+
});

static/app/views/releases/drawer/foldSection.tsx

Lines changed: 83 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {IconChevron} from 'sentry/icons';
88
import {t} from 'sentry/locale';
99
import {space} from 'sentry/styles/space';
1010

11-
export interface FoldSectionProps {
11+
interface FoldSectionStatelessProps {
1212
children: React.ReactNode;
1313
sectionKey: string;
1414
/**
@@ -21,13 +21,26 @@ export interface FoldSectionProps {
2121
actions?: React.ReactNode;
2222
className?: string;
2323
/**
24-
* Should this section be initially open, gets overridden by user preferences
24+
* If this is defined on the initial render, then the component will be
25+
* treated as a controlled component, meaning the parent component will need
26+
* to handle this component's state. You will likely need to use `onChange`
27+
* callback.
2528
*/
26-
initialCollapse?: boolean;
29+
isCollapsed?: boolean;
2730
/**
2831
* Additional margin required to ensure that scrolling to `sectionKey` is correct.
2932
*/
3033
navScrollMargin?: number;
34+
/**
35+
* Callback when the collapsed state changes
36+
*/
37+
onChange?: (collapsed: boolean) => void;
38+
/**
39+
* Callback when scrolled to a collapsed section. If this is a controlled
40+
* component, you'll likely want to update state to have "is collapsed" =
41+
* false so that the section is expanded when you scroll to it.
42+
*/
43+
onScrollToCollapsedSection?: (element?: HTMLElement) => void;
3144
/**
3245
* Disable the ability for the user to collapse the section
3346
*/
@@ -36,24 +49,83 @@ export interface FoldSectionProps {
3649
style?: CSSProperties;
3750
}
3851

52+
interface FoldSectionProps extends FoldSectionStatelessProps {
53+
/**
54+
* Should this section be initially open, gets overridden by user preferences
55+
*/
56+
initialCollapse?: boolean;
57+
}
58+
59+
export function FoldSection({
60+
isCollapsed: isCollapsedProp,
61+
onChange,
62+
onScrollToCollapsedSection,
63+
initialCollapse = false,
64+
...props
65+
}: FoldSectionProps) {
66+
//
67+
const isControlled = useRef(isCollapsedProp !== undefined);
68+
const [isCollapsedState, setIsCollapsed] = useState(initialCollapse);
69+
70+
const handleScrollToCollapsedSection = useCallback(
71+
(element: HTMLElement | undefined) => {
72+
if (isControlled.current) {
73+
onScrollToCollapsedSection?.(element);
74+
} else {
75+
setIsCollapsed(false);
76+
}
77+
},
78+
[onScrollToCollapsedSection, setIsCollapsed]
79+
);
80+
81+
const handleChange = useCallback(
82+
(nextCollapsed: boolean) => {
83+
if (isControlled.current) {
84+
if (typeof onChange !== 'function') {
85+
// eslint-disable-next-line no-console
86+
console.warn(
87+
new Error(
88+
'Controlled prop `isCollapsed` used without on `onChange` prop. You likely need an `onChange` so parent component can handle the collapsed state.'
89+
)
90+
);
91+
}
92+
onChange?.(nextCollapsed);
93+
} else {
94+
setIsCollapsed(nextCollapsed);
95+
}
96+
},
97+
[onChange, setIsCollapsed]
98+
);
99+
100+
return (
101+
<FoldSectionStateless
102+
{...props}
103+
isCollapsed={isCollapsedProp ?? isCollapsedState}
104+
onChange={handleChange}
105+
onScrollToCollapsedSection={handleScrollToCollapsedSection}
106+
/>
107+
);
108+
}
109+
39110
/**
40111
* This is a near-duplicate of the component in the
41112
* steamlined issues view, without localStorage syncing and
42113
* analytics.
43114
*/
44-
export function FoldSection({
115+
function FoldSectionStateless({
45116
ref,
46117
children,
47118
title,
48119
sectionKey,
49120
actions,
50121
className,
122+
isCollapsed,
123+
onChange,
124+
onScrollToCollapsedSection,
51125
navScrollMargin = 0,
52-
initialCollapse = false,
53126
preventCollapse = false,
54-
}: FoldSectionProps) {
127+
}: FoldSectionStatelessProps) {
55128
const hasAttemptedScroll = useRef(false);
56-
const [isCollapsed, setIsCollapsed] = useState(initialCollapse);
57129

58130
const scrollToSection = useCallback(
59131
(element: HTMLElement | null) => {
@@ -68,15 +140,15 @@ export function FoldSection({
68140
const [, hash] = window.location.hash.split('#');
69141
if (hash === sectionKey) {
70142
if (isCollapsed) {
71-
setIsCollapsed(false);
143+
onScrollToCollapsedSection?.(element);
72144
}
73145

74146
// Delay scrollIntoView to allow for layout changes to take place
75147
setTimeout(() => element?.scrollIntoView(), 100);
76148
}
77149
}
78150
},
79-
[sectionKey, navScrollMargin, isCollapsed, setIsCollapsed]
151+
[sectionKey, navScrollMargin, isCollapsed, onScrollToCollapsedSection]
80152
);
81153

82154
// This controls disabling the InteractionStateLayer when hovering over action items. We don't
@@ -87,10 +159,9 @@ export function FoldSection({
87159
(e: React.MouseEvent) => {
88160
e.preventDefault(); // Prevent browser summary/details behaviour
89161
window.getSelection()?.removeAllRanges(); // Prevent text selection on expand
90-
setIsCollapsed(!isCollapsed);
91-
// onToggleCollapse
162+
onChange?.(!isCollapsed);
92163
},
93-
[isCollapsed, setIsCollapsed]
164+
[isCollapsed, onChange]
94165
);
95166
const labelPrefix = isCollapsed ? t('View') : t('Collapse');
96167
const labelSuffix = typeof title === 'string' ? title + t(' Section') : t('Section');

0 commit comments

Comments
 (0)