diff --git a/static/app/components/onboarding/scm/scmFeatureSelectionPanel.spec.tsx b/static/app/components/onboarding/scm/scmFeatureSelectionPanel.spec.tsx index 527bfba408ee..fcb4bf087ba3 100644 --- a/static/app/components/onboarding/scm/scmFeatureSelectionPanel.spec.tsx +++ b/static/app/components/onboarding/scm/scmFeatureSelectionPanel.spec.tsx @@ -16,6 +16,17 @@ const pythonPlatform: OnboardingSelectedSDK = { category: 'popular', }; +// In neither platformProductAvailability nor PLATFORM_PRODUCT_INFO, so the +// section has nothing to configure. +const platformWithoutProducts: OnboardingSelectedSDK = { + key: 'other', + name: 'Other', + language: 'other', + type: 'language', + link: 'https://docs.sentry.io/platforms/', + category: 'popular', +}; + function defaultProps(overrides: Partial> = {}) { return { analyticsFlow: 'onboarding' as const, @@ -61,4 +72,67 @@ describe('ScmFeatureSelectionPanel', () => { expect(screen.queryByText(/unlimited volume for 14 days/)).not.toBeInTheDocument(); expect(screen.queryByText('5,000 errors / mo')).not.toBeInTheDocument(); }); + + it('prompts for a platform without showing cards in project creation', async () => { + render( + , + {organization} + ); + + expect(await screen.findByText('Products')).toBeInTheDocument(); + expect( + screen.getByText('Select a platform to configure products') + ).toBeInTheDocument(); + expect(screen.queryByRole('checkbox', {name: /Tracing/})).not.toBeInTheDocument(); + }); + + it('reveals the cards once a platform with products is chosen', async () => { + const {rerender} = render( + , + {organization} + ); + + expect(await screen.findByText('Products')).toBeInTheDocument(); + expect(screen.queryByRole('checkbox', {name: /Tracing/})).not.toBeInTheDocument(); + + rerender( + + ); + + expect(await screen.findByRole('checkbox', {name: /Tracing/})).toBeInTheDocument(); + expect( + screen.queryByText('Select a platform to configure products') + ).not.toBeInTheDocument(); + }); + + it('drops the section and its trailing divider for a platform with no products', () => { + render( + Trailing divider, + })} + />, + {organization} + ); + + expect(screen.queryByText('Products')).not.toBeInTheDocument(); + expect(screen.queryByText('Trailing divider')).not.toBeInTheDocument(); + }); }); diff --git a/static/app/components/onboarding/scm/scmFeatureSelectionPanel.tsx b/static/app/components/onboarding/scm/scmFeatureSelectionPanel.tsx index 56b18ae3f722..620e842f4965 100644 --- a/static/app/components/onboarding/scm/scmFeatureSelectionPanel.tsx +++ b/static/app/components/onboarding/scm/scmFeatureSelectionPanel.tsx @@ -1,8 +1,8 @@ -import {Fragment, type ReactNode, useCallback, useMemo} from 'react'; +import {type ReactNode, useCallback, useMemo} from 'react'; import {motion} from 'framer-motion'; import {Tag} from '@sentry/scraps/badge'; -import {Flex, Stack} from '@sentry/scraps/layout'; +import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {Heading, Text} from '@sentry/scraps/text'; import {ProductSolution} from 'sentry/components/onboarding/gettingStartedDoc/types'; @@ -19,6 +19,7 @@ import {trackAnalytics} from 'sentry/utils/analytics'; import {useOrganization} from 'sentry/utils/useOrganization'; import {type ScmAnalyticsFlow, scmFlowVariantParams} from './scmAnalyticsFlow'; +import {ScmCollapsibleReveal} from './scmCollapsibleReveal'; import {ScmFeatureInfoCards} from './scmFeatureInfoCards'; import {ScmFeatureSelectionCards} from './scmFeatureSelectionCards'; import { @@ -40,9 +41,10 @@ interface ScmFeatureSelectionPanelProps { selectedFeatures: ProductSolution[] | undefined; selectedPlatform: OnboardingSelectedSDK | undefined; selectedRepository: Repository | undefined; - // Optional element rendered as a sibling after the panel content (e.g. a - // divider from the host). Dropped together with the panel when there is - // nothing to show, so the host never strands an orphaned divider. + // Optional element rendered after the panel content (e.g. a divider from the + // host). Lives inside the panel's reveal, so it is dropped — and tweens away + // — together with the panel when there is nothing to show, and the host never + // strands an orphaned divider. trailing?: ReactNode; } @@ -168,81 +170,99 @@ export function ScmFeatureSelectionPanel({ ] ); - // Hide the whole section when a resolved platform has no configurable + const hasFeatureCards = featureMode !== 'none'; + + // Show the whole section unless a resolved platform has no configurable // products. Before a platform is chosen (no resolved key), keep it visible in // project creation for the select-a-platform prompt; onboarding hides both. - if (featureMode === 'none' && (isOnboarding || !!currentPlatformKey)) { - return null; - } + // Expressed as a flag rather than an early `return null` so the reveal below + // stays mounted and can tween the section in and out. AnimatePresence's + // `initial={false}` still renders the settled state on first mount, so a page + // load with a platform already resolved does not animate. + const showSection = hasFeatureCards || (!isOnboarding && !currentPlatformKey); return ( - - - - {isOnboarding ? ( - - - - {tct( - 'You’ve got [bold:unlimited volume for 14 days] to try out everything. After that, free plan volumes apply ⋅ No credit card required', - { - bold: ( - - {null} - - ), - } + + {/* No gap: the section claims no spacing of its own, so everything that + tweens away on collapse is inside this box. Hosts own the rhythm — + the section above supplies the space before it, and `trailing` + carries the space around itself. */} + + + {/* gap="0" because the spacing above the cards has to tween with + them: a flex gap would snap in at full size while the revealed + height is still 0, jumping the layout by that much. The cards own + it as padding inside the reveal instead. */} + + {isOnboarding ? ( + + + + {tct( + 'You’ve got [bold:unlimited volume for 14 days] to try out everything. After that, free plan volumes apply ⋅ No credit card required', + { + bold: ( + + {null} + + ), + } + )} + + + ) : null} + + {isOnboarding ? null : ( + + {t('Products')} + {currentPlatformKey ? null : ( + } style={{minWidth: 0}}> + + {t('Select a platform to configure products')} + + )} - - - ) : null} - - {isOnboarding ? null : ( - - {t('Products')} - {currentPlatformKey ? null : ( - } style={{minWidth: 0}}> - - {t('Select a platform to configure products')} - - - )} - - )} - - {featureMode === 'toggleable' ? ( - - ) : featureMode === 'informational' ? ( - - ) : null} - - - {trailing} - + + )} + + {/* The cards mount at their full height, so without a clipped + height tween they paint over the sections below while + framer-motion is still animating those into place. */} + + + {featureMode === 'toggleable' ? ( + + ) : featureMode === 'informational' ? ( + + ) : null} + + + + + {trailing} + + ); } diff --git a/static/app/views/projectInstall/scmCreateProject.tsx b/static/app/views/projectInstall/scmCreateProject.tsx index e41771b6eb79..3f2622502bbf 100644 --- a/static/app/views/projectInstall/scmCreateProject.tsx +++ b/static/app/views/projectInstall/scmCreateProject.tsx @@ -3,7 +3,7 @@ import {LayoutGroup, motion} from 'framer-motion'; import {Tag} from '@sentry/scraps/badge'; import {Button} from '@sentry/scraps/button'; -import {Flex, Stack} from '@sentry/scraps/layout'; +import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {ExternalLink} from '@sentry/scraps/link'; import {Separator} from '@sentry/scraps/separator'; import {Heading, Text} from '@sentry/scraps/text'; @@ -236,9 +236,15 @@ function ScmCreateProjectWizard({initialState}: {initialState: WizardState}) { + {/* Section rhythm is each section's own paddingBottom rather than a + flex gap on this Stack. ScmFeatureSelectionPanel collapses to + nothing when its platform has no products, and a flex gap sits + outside the box it tweens: it would survive the whole collapse and + then vanish in one step on unmount. As padding it lives inside the + animated box and tweens away with the content. */} {t('Create a new project')} - + {t('Create a project')} {tct( @@ -262,7 +268,7 @@ function ScmCreateProjectWizard({initialState}: {initialState: WizardState}) { - + {t('Repository')} @@ -287,7 +293,7 @@ function ScmCreateProjectWizard({initialState}: {initialState: WizardState}) { /> - + - + - + - + + {/* The divider below the section is passed in so it collapses + with it. It carries the surrounding rhythm as padding for the + same reason the Stack above uses none. */} + - + } /> - + - + - + - + - + {/* Last section, so no trailing padding. */} + - + {/* Page-level CTA: disabled until a platform and project details are ready. */} @@ -370,3 +380,4 @@ function ScmCreateProjectWizard({initialState}: {initialState: WizardState}) { } const MotionStack = motion.create(Stack); +const MotionContainer = motion.create(Container);