Skip to content

Commit 7c6a809

Browse files
committed
feat(project-creation): Add collapsible feature selection via ScmCollapsibleSection
Introduce ScmCollapsibleSection, a reusable collapsible section for the SCM project-creation flow, and use it to fold the "What do you want to instrument?" feature cards. Enabled only in project creation (via `collapsible={!isOnboarding}` from ScmPlatformFeaturesCore); the onboarding flow keeps the cards always expanded with a plain heading. ScmCollapsibleSection puts the chevron and title in one transparent toggle button (mirroring the core Disclosure look) with an optional trailing slot pinned right, and animates the body's own height (auto<->0) so sibling layout="position" cards below follow via normal document flow. initial=false keeps it from animating on mount. The body is indented to align with the title copy. It is a local variant of the core Disclosure rather than a consumer: Disclosure.Content hides with display:none (can't tween, won't reflow the sibling cards), and Disclosure.Title's full-width stretched button can't express a content-hugging toggle without forking the shared component.
1 parent 30f6dc3 commit 7c6a809

5 files changed

Lines changed: 282 additions & 35 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
2+
3+
import {ScmCollapsibleSection} from './scmCollapsibleSection';
4+
5+
describe('ScmCollapsibleSection', () => {
6+
it('renders the title and content expanded by default', () => {
7+
render(
8+
<ScmCollapsibleSection title="Section title">
9+
<div>Body content</div>
10+
</ScmCollapsibleSection>
11+
);
12+
13+
expect(screen.getByRole('button', {name: 'Section title'})).toBeInTheDocument();
14+
expect(screen.getByText('Body content')).toBeInTheDocument();
15+
});
16+
17+
it('starts collapsed when defaultExpanded is false', () => {
18+
render(
19+
<ScmCollapsibleSection title="Section title" defaultExpanded={false}>
20+
<div>Body content</div>
21+
</ScmCollapsibleSection>
22+
);
23+
24+
expect(screen.queryByText('Body content')).not.toBeInTheDocument();
25+
});
26+
27+
it('toggles the content when the title is clicked', async () => {
28+
render(
29+
<ScmCollapsibleSection title="Section title">
30+
<div>Body content</div>
31+
</ScmCollapsibleSection>
32+
);
33+
34+
const toggle = screen.getByRole('button', {name: 'Section title'});
35+
expect(toggle).toHaveAttribute('aria-expanded', 'true');
36+
37+
await userEvent.click(toggle);
38+
expect(toggle).toHaveAttribute('aria-expanded', 'false');
39+
expect(screen.queryByText('Body content')).not.toBeInTheDocument();
40+
41+
await userEvent.click(toggle);
42+
expect(toggle).toHaveAttribute('aria-expanded', 'true');
43+
expect(screen.getByText('Body content')).toBeInTheDocument();
44+
});
45+
46+
it('renders trailing content in the header', () => {
47+
render(
48+
<ScmCollapsibleSection title="Section title" trailing={<span>Trailing</span>}>
49+
<div>Body content</div>
50+
</ScmCollapsibleSection>
51+
);
52+
53+
expect(screen.getByText('Trailing')).toBeInTheDocument();
54+
});
55+
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import {useId, useState} from 'react';
2+
import styled from '@emotion/styled';
3+
import {AnimatePresence, motion} from 'framer-motion';
4+
5+
import {Button} from '@sentry/scraps/button';
6+
import {Flex, Stack} from '@sentry/scraps/layout';
7+
import {Text} from '@sentry/scraps/text';
8+
9+
import {IconChevron} from 'sentry/icons';
10+
11+
interface ScmCollapsibleSectionProps {
12+
children: React.ReactNode;
13+
title: React.ReactNode;
14+
/**
15+
* Whether the section starts expanded. Defaults to true.
16+
*/
17+
defaultExpanded?: boolean;
18+
/**
19+
* Rendered at the far right of the title row (e.g. a helper label). Stays in
20+
* the header whether or not the section is expanded.
21+
*/
22+
trailing?: React.ReactNode;
23+
}
24+
25+
/**
26+
* A collapsible section for the SCM project-creation flow: a chevron and the
27+
* title share one transparent toggle button (mirroring the core Disclosure
28+
* look) with an optional trailing slot pinned right, and the body animates its
29+
* own height so sibling cards in a framer-motion `layout="position"` group
30+
* follow via normal document flow. `initial={false}` keeps it from animating on
31+
* mount, so it renders in its `defaultExpanded` state.
32+
*
33+
* This is a local variant of the core Disclosure rather than a consumer of it:
34+
* Disclosure.Content hides with `display: none`, which can't tween and won't
35+
* reflow sibling cards, and Disclosure.Title's full-width stretched button
36+
* can't express a content-hugging toggle without forking the shared component.
37+
*/
38+
export function ScmCollapsibleSection({
39+
title,
40+
trailing,
41+
defaultExpanded = true,
42+
children,
43+
}: ScmCollapsibleSectionProps) {
44+
const [expanded, setExpanded] = useState(defaultExpanded);
45+
const contentId = useId();
46+
47+
return (
48+
<Stack gap="0" width="100%">
49+
<Flex justify="between" align="center" width="100%">
50+
<ToggleButton
51+
variant="transparent"
52+
size="md"
53+
icon={<IconChevron direction={expanded ? 'down' : 'right'} />}
54+
aria-expanded={expanded}
55+
aria-controls={contentId}
56+
onClick={() => setExpanded(value => !value)}
57+
>
58+
<Text as="span" bold>
59+
{title}
60+
</Text>
61+
</ToggleButton>
62+
{trailing}
63+
</Flex>
64+
<AnimatePresence initial={false}>
65+
{expanded && (
66+
<motion.div
67+
id={contentId}
68+
key="content"
69+
initial={{height: 0, opacity: 0}}
70+
animate={{height: 'auto', opacity: 1}}
71+
exit={{height: 0, opacity: 0}}
72+
transition={{duration: 0.2, ease: 'easeOut'}}
73+
style={{overflow: 'hidden', width: '100%'}}
74+
>
75+
<Content width="100%">{children}</Content>
76+
</motion.div>
77+
)}
78+
</AnimatePresence>
79+
</Stack>
80+
);
81+
}
82+
83+
// Mirrors core Disclosure's StretchedButton: a transparent toggle holding the
84+
// chevron + title that hugs its content, with the left padding pulled in so the
85+
// chevron sits near-flush with the section edge.
86+
const ToggleButton = styled(Button)`
87+
padding-left: ${p => p.theme.space.xs};
88+
`;
89+
90+
// Indents the body so its left edge lines up with the title copy inside
91+
// ToggleButton: button padding-left (xs) + chevron width (md button -> sm icon,
92+
// 14px) + the button's icon gap (md). Matches core Disclosure's 26px inset.
93+
// padding-top lives here (not as a Stack gap) so the spacing collapses with the
94+
// height animation instead of leaving a gap behind the title.
95+
const Content = styled(Stack)`
96+
padding-top: ${p => p.theme.space.md};
97+
padding-left: calc(${p => p.theme.space.xs} + 14px + ${p => p.theme.space.md});
98+
`;

static/app/views/onboarding/components/scmFeatureSelectionCards.spec.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,4 +169,63 @@ describe('ScmFeatureSelectionCards', () => {
169169
ALL_FEATURES.length
170170
);
171171
});
172+
173+
it('is not collapsible by default', () => {
174+
render(
175+
<ScmFeatureSelectionCards
176+
availableFeatures={ALL_FEATURES}
177+
selectedFeatures={[ProductSolution.ERROR_MONITORING]}
178+
disabledProducts={NO_DISABLED}
179+
onToggleFeature={jest.fn()}
180+
featureMeta={FALLBACK_FEATURE_META}
181+
/>
182+
);
183+
184+
expect(
185+
screen.queryByRole('button', {name: 'What do you want to instrument?'})
186+
).not.toBeInTheDocument();
187+
expect(
188+
screen.getByRole('heading', {name: 'What do you want to instrument?'})
189+
).toBeInTheDocument();
190+
});
191+
192+
it('renders a toggle and starts expanded when collapsible', () => {
193+
render(
194+
<ScmFeatureSelectionCards
195+
collapsible
196+
availableFeatures={ALL_FEATURES}
197+
selectedFeatures={[ProductSolution.ERROR_MONITORING]}
198+
disabledProducts={NO_DISABLED}
199+
onToggleFeature={jest.fn()}
200+
featureMeta={FALLBACK_FEATURE_META}
201+
/>
202+
);
203+
204+
expect(
205+
screen.getByRole('button', {name: 'What do you want to instrument?'})
206+
).toBeInTheDocument();
207+
// Expanded by default: cards are visible
208+
expect(screen.getByRole('checkbox', {name: /Error monitoring/})).toBeInTheDocument();
209+
});
210+
211+
it('hides the cards when collapsed', async () => {
212+
render(
213+
<ScmFeatureSelectionCards
214+
collapsible
215+
availableFeatures={ALL_FEATURES}
216+
selectedFeatures={[ProductSolution.ERROR_MONITORING]}
217+
disabledProducts={NO_DISABLED}
218+
onToggleFeature={jest.fn()}
219+
featureMeta={FALLBACK_FEATURE_META}
220+
/>
221+
);
222+
223+
await userEvent.click(
224+
screen.getByRole('button', {name: 'What do you want to instrument?'})
225+
);
226+
227+
expect(
228+
screen.queryByRole('checkbox', {name: /Error monitoring/})
229+
).not.toBeInTheDocument();
230+
});
172231
});

static/app/views/onboarding/components/scmFeatureSelectionCards.tsx

Lines changed: 69 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {ProductSolution} from 'sentry/components/onboarding/gettingStartedD
55
import type {DisabledProducts} from 'sentry/components/onboarding/productSelection';
66
import {t} from 'sentry/locale';
77

8+
import {ScmCollapsibleSection} from './scmCollapsibleSection';
89
import {ScmFeatureCard} from './scmFeatureCard';
910
import type {FeatureMeta} from './useScmFeatureMeta';
1011

@@ -14,54 +15,87 @@ interface ScmFeatureSelectionCardsProps {
1415
featureMeta: Record<ProductSolution, FeatureMeta>;
1516
onToggleFeature: (feature: ProductSolution) => void;
1617
selectedFeatures: ProductSolution[];
18+
/**
19+
* When true, the feature cards collapse behind a disclosure toggle and start
20+
* expanded. Used in the project-creation flow, where this section is one of
21+
* several stacked config cards the user may want to fold away. The onboarding
22+
* flow leaves it always expanded with no toggle.
23+
*/
24+
collapsible?: boolean;
1725
isVolumeLoading?: boolean;
1826
showVolume?: boolean;
1927
}
2028

29+
const HEADING = t('What do you want to instrument?');
30+
2131
export function ScmFeatureSelectionCards({
32+
collapsible = false,
33+
...props
34+
}: ScmFeatureSelectionCardsProps) {
35+
const helperText =
36+
props.availableFeatures.length > 1 ? (
37+
<Text size="sm" variant="secondary">
38+
{t('Choose one or more')}
39+
</Text>
40+
) : null;
41+
42+
// In the project-creation flow the section folds away behind a toggle; the
43+
// onboarding flow keeps it always expanded with a plain heading.
44+
if (collapsible) {
45+
return (
46+
<ScmCollapsibleSection title={HEADING} trailing={helperText}>
47+
<FeatureCardList {...props} />
48+
</ScmCollapsibleSection>
49+
);
50+
}
51+
52+
return (
53+
<Stack gap="xl" width="100%" justify="center">
54+
<Flex justify="between" align="center">
55+
<Heading as="h3">{HEADING}</Heading>
56+
{helperText}
57+
</Flex>
58+
<FeatureCardList {...props} />
59+
</Stack>
60+
);
61+
}
62+
63+
type FeatureCardListProps = Omit<ScmFeatureSelectionCardsProps, 'collapsible'>;
64+
65+
function FeatureCardList({
2266
availableFeatures,
2367
selectedFeatures,
2468
disabledProducts,
2569
onToggleFeature,
2670
featureMeta,
2771
isVolumeLoading,
2872
showVolume = true,
29-
}: ScmFeatureSelectionCardsProps) {
73+
}: FeatureCardListProps) {
3074
return (
31-
<Stack gap="xl" width="100%" justify="center">
32-
<Flex justify="between" align="center">
33-
<Heading as="h3">{t('What do you want to instrument?')}</Heading>
34-
{availableFeatures.length > 1 ? (
35-
<Text size="sm" variant="secondary">
36-
{t('Choose one or more')}
37-
</Text>
38-
) : null}
39-
</Flex>
40-
<Stack gap="md">
41-
{availableFeatures.map(feature => {
42-
const meta = featureMeta[feature];
43-
const disabledProduct = disabledProducts[feature];
44-
const disabledReason = meta.alwaysEnabled
45-
? t('Error monitoring is always enabled')
46-
: disabledProduct?.reason;
47-
return (
48-
<ScmFeatureCard
49-
key={feature}
50-
icon={meta.icon}
51-
label={meta.label}
52-
description={meta.description}
53-
isSelected={selectedFeatures.includes(feature) || !!meta.alwaysEnabled}
54-
disabled={!!meta.alwaysEnabled || !!disabledProduct}
55-
disabledReason={disabledReason}
56-
onClick={() => onToggleFeature(feature)}
57-
volume={meta.volume}
58-
volumeTooltip={meta.volumeTooltip}
59-
isVolumeLoading={isVolumeLoading}
60-
showVolume={showVolume}
61-
/>
62-
);
63-
})}
64-
</Stack>
75+
<Stack gap="md" width="100%">
76+
{availableFeatures.map(feature => {
77+
const meta = featureMeta[feature];
78+
const disabledProduct = disabledProducts[feature];
79+
const disabledReason = meta.alwaysEnabled
80+
? t('Error monitoring is always enabled')
81+
: disabledProduct?.reason;
82+
return (
83+
<ScmFeatureCard
84+
key={feature}
85+
icon={meta.icon}
86+
label={meta.label}
87+
description={meta.description}
88+
isSelected={selectedFeatures.includes(feature) || !!meta.alwaysEnabled}
89+
disabled={!!meta.alwaysEnabled || !!disabledProduct}
90+
disabledReason={disabledReason}
91+
onClick={() => onToggleFeature(feature)}
92+
volume={meta.volume}
93+
volumeTooltip={meta.volumeTooltip}
94+
isVolumeLoading={isVolumeLoading}
95+
showVolume={showVolume}
96+
/>
97+
);
98+
})}
6599
</Stack>
66100
);
67101
}

static/app/views/onboarding/components/scmPlatformFeaturesCore.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,7 @@ export function ScmPlatformFeaturesCore({
547547
featureMeta={featureMeta}
548548
isVolumeLoading={isFeatureMetaLoading}
549549
showVolume={isOnboarding}
550+
collapsible={!isOnboarding}
550551
/>
551552
) : (
552553
<ScmFeatureInfoCards

0 commit comments

Comments
 (0)