profilingOnboardingModal.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700
  1. import {Fragment, useCallback, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import * as Sentry from '@sentry/react';
  4. import {PlatformIcon} from 'platformicons';
  5. import {ModalRenderProps} from 'sentry/actionCreators/modal';
  6. import Button, {ButtonPropsWithoutAriaLabel} from 'sentry/components/button';
  7. import {CodeSnippet} from 'sentry/components/codeSnippet';
  8. import {SelectField} from 'sentry/components/forms';
  9. import {SelectFieldProps} from 'sentry/components/forms/fields/selectField';
  10. import ExternalLink from 'sentry/components/links/externalLink';
  11. import Link from 'sentry/components/links/link';
  12. import List from 'sentry/components/list';
  13. import LoadingIndicator from 'sentry/components/loadingIndicator';
  14. import Tag from 'sentry/components/tag';
  15. import {IconOpen} from 'sentry/icons';
  16. import {t, tct} from 'sentry/locale';
  17. import space from 'sentry/styles/space';
  18. import {Organization, UpdateSdkSuggestion} from 'sentry/types';
  19. import {RequestState} from 'sentry/types/core';
  20. import {Project, ProjectSdkUpdates} from 'sentry/types/project';
  21. import {semverCompare} from 'sentry/utils/profiling/units/versions';
  22. import {useProjectKeys} from 'sentry/utils/useProjectKeys';
  23. import useProjects from 'sentry/utils/useProjects';
  24. import {useProjectSdkUpdates} from 'sentry/utils/useProjectSdkUpdates';
  25. // This is just a doubly linked list of steps
  26. interface OnboardingStep {
  27. current: React.ComponentType<OnboardingStepProps>;
  28. next: OnboardingStep | null;
  29. previous: OnboardingStep | null;
  30. }
  31. type OnboardingRouterState = [OnboardingStep, (step: OnboardingStep | null) => void];
  32. function useOnboardingRouter(initialStep: OnboardingStep): OnboardingRouterState {
  33. const [state, setState] = useState(initialStep);
  34. const toStep = useCallback((nextStep: OnboardingStep | null) => {
  35. // For ergonomics, else we need to move everything to consts so that typescript can infer non nullable types
  36. if (nextStep === null) {
  37. return;
  38. }
  39. setState(current => {
  40. const next = {...nextStep, next: null, previous: current};
  41. // Add the edges between the old and the new step
  42. current.next = next;
  43. next.previous = current;
  44. // Return the next step
  45. return next;
  46. });
  47. }, []);
  48. return [state, toStep];
  49. }
  50. // The wrapper component for all of the onboarding steps. Keeps track of the current step
  51. // and all state. This ensures that moving from step to step does not require users to redo their actions
  52. // and each step can just re-initialize with the values that the user has already selected.
  53. interface ProfilingOnboardingModalProps extends ModalRenderProps {
  54. organization: Organization;
  55. }
  56. export function ProfilingOnboardingModal(props: ProfilingOnboardingModalProps) {
  57. const [state, toStep] = useOnboardingRouter({
  58. previous: null,
  59. current: SelectProjectStep,
  60. next: null,
  61. });
  62. const [project, setProject] = useState<Project | null>(null);
  63. return (
  64. <state.current
  65. {...props}
  66. toStep={toStep}
  67. step={state}
  68. project={project}
  69. setProject={setProject}
  70. />
  71. );
  72. }
  73. // Generate an option for the select field from project
  74. function asSelectOption(
  75. project: Project,
  76. options: {disabled: boolean}
  77. ): SelectFieldProps<Project>['options'][0]['options'] {
  78. return {
  79. label: project.slug,
  80. value: project,
  81. disabled: options.disabled,
  82. leadingItems: project.platform ? <PlatformIcon platform={project.platform} /> : null,
  83. };
  84. }
  85. const platformToInstructionsMapping: Record<
  86. string,
  87. React.ComponentType<OnboardingStepProps>
  88. > = {
  89. android: AndroidSendDebugFilesInstruction,
  90. 'apple-ios': IOSSendDebugFilesInstruction,
  91. };
  92. // Splits a list of projects into supported and unsuported list
  93. function splitProjectsByProfilingSupport(projects: Project[]): {
  94. supported: Project[];
  95. unsupported: Project[];
  96. } {
  97. const supported: Project[] = [];
  98. const unsupported: Project[] = [];
  99. for (const project of projects) {
  100. if (project.platform && platformToInstructionsMapping[project.platform]) {
  101. supported.push(project);
  102. } else {
  103. unsupported.push(project);
  104. }
  105. }
  106. return {supported, unsupported};
  107. }
  108. // Individual modal steps are defined here.
  109. // We proxy the modal props to each individaul modal component
  110. // so that each can build their own modal and they can remain independent.
  111. interface OnboardingStepProps extends ModalRenderProps {
  112. organization: Organization;
  113. project: Project | null;
  114. setProject: React.Dispatch<React.SetStateAction<Project | null>>;
  115. step: OnboardingStep;
  116. toStep: OnboardingRouterState[1];
  117. }
  118. function SelectProjectStep({
  119. Body: ModalBody,
  120. Header: ModalHeader,
  121. Footer: ModalFooter,
  122. closeModal,
  123. toStep,
  124. step,
  125. project,
  126. setProject,
  127. organization,
  128. }: OnboardingStepProps) {
  129. const {projects} = useProjects();
  130. const onFormSubmit = useCallback(
  131. (evt: React.FormEvent) => {
  132. evt.preventDefault();
  133. if (!project?.platform) {
  134. return;
  135. }
  136. const nextStep = platformToInstructionsMapping[project.platform];
  137. if (nextStep === undefined) {
  138. throw new TypeError(
  139. "Platform doesn't have a onboarding step, user should not be able to select it"
  140. );
  141. }
  142. toStep({
  143. previous: step,
  144. current: nextStep,
  145. next: null,
  146. });
  147. },
  148. [project, step, toStep]
  149. );
  150. const projectSelectOptions = useMemo((): SelectFieldProps<Project>['options'] => {
  151. const {supported: supportedProjects, unsupported: unsupporedProjects} =
  152. splitProjectsByProfilingSupport(projects);
  153. return [
  154. {
  155. label: t('Supported'),
  156. options: supportedProjects.map(p => asSelectOption(p, {disabled: false})),
  157. },
  158. {
  159. label: t('Unsupported'),
  160. options: unsupporedProjects.map(p => asSelectOption(p, {disabled: true})),
  161. },
  162. ];
  163. }, [projects]);
  164. const sdkUpdates = useProjectSdkUpdates({organization, projectId: project?.id ?? null});
  165. const publicDSN = usePublicDSN({organization, project});
  166. return (
  167. <ModalBody>
  168. <ModalHeader>
  169. <h3>{t('Set Up Profiling')}</h3>
  170. </ModalHeader>
  171. <form onSubmit={onFormSubmit}>
  172. <StyledList symbol="colored-numeric">
  173. <li>
  174. <StepTitle>
  175. <label htmlFor="project-select">{t('Select a project')}</label>
  176. </StepTitle>
  177. <div>
  178. <StyledSelectField
  179. id="project-select"
  180. name="select"
  181. options={projectSelectOptions}
  182. onChange={setProject}
  183. />
  184. </div>
  185. </li>
  186. {project?.platform === 'android' ? (
  187. <AndroidInstallSteps
  188. sdkUpdates={sdkUpdates}
  189. project={project}
  190. organization={organization}
  191. publicDSN={publicDSN}
  192. />
  193. ) : null}
  194. {project?.platform === 'apple-ios' ? (
  195. <IOSInstallSteps
  196. sdkUpdates={sdkUpdates}
  197. project={project}
  198. organization={organization}
  199. publicDSN={publicDSN}
  200. />
  201. ) : null}
  202. </StyledList>
  203. <ModalFooter>
  204. <ModalActions>
  205. <DocsLink />
  206. <div>
  207. <StepIndicator>{t('Step 1 of 2')}</StepIndicator>
  208. <PreviousStepButton type="button" onClick={closeModal} />
  209. <NextStepButton
  210. disabled={
  211. !(
  212. project?.platform && platformToInstructionsMapping[project.platform]
  213. ) || publicDSN.type === 'loading'
  214. }
  215. type="submit"
  216. />
  217. </div>
  218. </ModalActions>
  219. </ModalFooter>
  220. </form>
  221. </ModalBody>
  222. );
  223. }
  224. function usePublicDSN({
  225. organization,
  226. project,
  227. }: {
  228. organization: Organization | null;
  229. project: Project | null;
  230. }) {
  231. const response = useProjectKeys({organization, project});
  232. if (response.type !== 'resolved') {
  233. return response;
  234. }
  235. const dsn = response.data?.[0]?.dsn.public;
  236. if (!dsn) {
  237. Sentry.captureException(
  238. new Error(`public dsn not found for ${organization?.slug}/${project?.slug}`)
  239. );
  240. }
  241. return {
  242. ...response,
  243. data: dsn,
  244. };
  245. }
  246. function SetupPerformanceMonitoringStep({href}: {href: string}) {
  247. return (
  248. <Fragment>
  249. <StepTitle>{t('Setup Performance Monitoring')}</StepTitle>
  250. {tct(
  251. `For Profiling to function, it's required to set up Performance monitoring. Follow the [setupDocs]`,
  252. {
  253. setupDocs: (
  254. <ExternalLink openInNewTab href={href}>
  255. {t('step-by-step instructions here.')}
  256. </ExternalLink>
  257. ),
  258. }
  259. )}
  260. </Fragment>
  261. );
  262. }
  263. interface ProjectSdkUpdateProps {
  264. minSdkVersion: string;
  265. organization: Organization;
  266. project: Project;
  267. sdkUpdates: ProjectSdkUpdates;
  268. }
  269. function ProjectSdkUpdate({
  270. sdkUpdates,
  271. organization,
  272. project,
  273. minSdkVersion,
  274. }: ProjectSdkUpdateProps) {
  275. const updateSdkSuggestion = sdkUpdates.suggestions.find(v => v.type === 'updateSdk') as
  276. | UpdateSdkSuggestion
  277. | undefined;
  278. return (
  279. <Fragment>
  280. <SDKUpdatesContainer>
  281. <SdkUpdatesPlatformIcon platform={project.platform ?? 'unknown'} />
  282. <Link
  283. to={`/organizations/${organization.slug}/projects/${project.slug}/?project=${project.id}`}
  284. >
  285. {project.slug}
  286. </Link>
  287. </SDKUpdatesContainer>
  288. <SdkUpdatesText>
  289. {t('This project is on %s@%s', sdkUpdates.sdkName, sdkUpdates.sdkVersion)}
  290. <br />
  291. {updateSdkSuggestion ? (
  292. <Link to={updateSdkSuggestion.sdkUrl ?? ''}>
  293. {t(
  294. 'Update to %s@%s',
  295. updateSdkSuggestion.sdkName,
  296. updateSdkSuggestion.newSdkVersion
  297. )}
  298. </Link>
  299. ) : (
  300. t('Update to %s or higher', minSdkVersion)
  301. )}
  302. </SdkUpdatesText>
  303. </Fragment>
  304. );
  305. }
  306. const SdkUpdatesText = styled('p')`
  307. margin-top: ${space(0.75)};
  308. padding-left: ${space(4)};
  309. `;
  310. // doesnt use space because it's just off by 2px all the time
  311. const SdkUpdatesPlatformIcon = styled(PlatformIcon)`
  312. margin-right: 10px;
  313. `;
  314. const SDKUpdatesContainer = styled('div')`
  315. display: flex;
  316. align-items: center;
  317. margin-top: ${space(1.5)};
  318. font-size: ${p => p.theme.fontSizeLarge};
  319. `;
  320. interface InstallStepsProps {
  321. organization: Organization;
  322. project: Project;
  323. publicDSN: RequestState<string | null>;
  324. sdkUpdates: RequestState<ProjectSdkUpdates | null>;
  325. }
  326. function AndroidInstallSteps({
  327. project,
  328. sdkUpdates,
  329. organization,
  330. publicDSN,
  331. }: InstallStepsProps) {
  332. const hasSdkUpdates = sdkUpdates.type === 'resolved' && sdkUpdates.data !== null;
  333. const requiresSdkUpdates =
  334. hasSdkUpdates &&
  335. sdkUpdates.data?.sdkVersion &&
  336. // ensure we only prompt an upgrade when the sdk is one of the following
  337. ['sentry.java.android', 'sentry.java.android.timber'].includes(
  338. sdkUpdates.data.sdkName
  339. )
  340. ? semverCompare(sdkUpdates.data.sdkVersion, '6.0.0') < 0
  341. : false;
  342. const dsn =
  343. publicDSN.type === 'resolved' && publicDSN.data !== null ? publicDSN.data : '...';
  344. return (
  345. <Fragment>
  346. {hasSdkUpdates && requiresSdkUpdates && (
  347. <li>
  348. <StepTitle>{t('Update your projects SDK version')}</StepTitle>
  349. <ProjectSdkUpdate
  350. minSdkVersion="6.0.0 (sentry.android)"
  351. project={project}
  352. sdkUpdates={sdkUpdates.data!}
  353. organization={organization}
  354. />
  355. </li>
  356. )}
  357. <li>
  358. <SetupPerformanceMonitoringStep href="https://docs.sentry.io/platforms/android/performance/" />
  359. </li>
  360. <li>
  361. <StepTitle>{t('Set Up Profiling')}</StepTitle>
  362. {publicDSN.type === 'loading' ? (
  363. <LoadingIndicator />
  364. ) : (
  365. <CodeSnippet language="xml" filename="AndroidManifest.xml">
  366. {`<application>
  367. <meta-data android:name="io.sentry.dsn" android:value="${dsn}" />
  368. <meta-data android:name="io.sentry.traces.sample-rate" android:value="1.0" />
  369. <meta-data android:name="io.sentry.traces.profiling.sample-rate" android:value="1.0" />
  370. </application>`}
  371. </CodeSnippet>
  372. )}
  373. </li>
  374. </Fragment>
  375. );
  376. }
  377. function IOSInstallSteps({
  378. project,
  379. sdkUpdates,
  380. organization,
  381. publicDSN,
  382. }: InstallStepsProps) {
  383. const hasSdkUpdates = sdkUpdates.type === 'resolved' && sdkUpdates.data !== null;
  384. const requiresSdkUpdates =
  385. hasSdkUpdates &&
  386. sdkUpdates.data?.sdkVersion &&
  387. sdkUpdates.data?.sdkName === 'sentry.cocoa'
  388. ? semverCompare(sdkUpdates.data.sdkVersion, '7.23.0') < 0
  389. : false;
  390. const dsn = publicDSN.type === 'resolved' && publicDSN.data ? publicDSN.data : '...';
  391. return (
  392. <Fragment>
  393. {hasSdkUpdates && requiresSdkUpdates && (
  394. <li>
  395. <StepTitle>{t('Update your projects SDK version')}</StepTitle>
  396. <ProjectSdkUpdate
  397. minSdkVersion="7.23.0 (sentry.cocoa)"
  398. project={project}
  399. sdkUpdates={sdkUpdates.data!}
  400. organization={organization}
  401. />
  402. </li>
  403. )}
  404. <li>
  405. <SetupPerformanceMonitoringStep href="https://docs.sentry.io/platforms/apple/guides/ios/performance/" />
  406. </li>
  407. <li>
  408. <StepTitle>
  409. {t('Enable profiling in your app by configuring the SDKs like below:')}
  410. </StepTitle>
  411. {publicDSN.type === 'loading' ? (
  412. <LoadingIndicator />
  413. ) : (
  414. <CodeSnippet language="swift">
  415. {`SentrySDK.start { options in
  416. options.dsn = "${dsn}"
  417. options.tracesSampleRate = 1.0 // Make sure transactions are enabled
  418. options.profilesSampleRate = 1.0
  419. }`}
  420. </CodeSnippet>
  421. )}
  422. </li>
  423. </Fragment>
  424. );
  425. }
  426. const StyledList = styled(List)`
  427. position: relative;
  428. li {
  429. margin-bottom: ${space(3)};
  430. }
  431. `;
  432. const StyledSelectField = styled(SelectField)`
  433. padding: 0;
  434. border-bottom: 0;
  435. > div {
  436. width: 100%;
  437. padding-left: 0;
  438. }
  439. `;
  440. function AndroidSendDebugFilesInstruction({
  441. Body: ModalBody,
  442. Header: ModalHeader,
  443. Footer: ModalFooter,
  444. closeModal,
  445. toStep,
  446. step,
  447. }: OnboardingStepProps) {
  448. return (
  449. <ModalBody>
  450. <ModalHeader>
  451. <h3>{t('Set Up Profiling')}</h3>
  452. </ModalHeader>
  453. <p>
  454. {t(
  455. `If you want to see de-obfuscated stack traces, you'll need to use ProGuard with Sentry. To do so, upload the ProGuard mapping files by either the recommended method of using our Gradle integration or manually by using sentry-cli.`
  456. )}{' '}
  457. <ExternalLink href="https://docs.sentry.io/product/cli/dif/">
  458. {t('Learn more about Debug Information Files.')}
  459. </ExternalLink>
  460. </p>
  461. <OptionsContainer>
  462. <OptionTitleContainer>
  463. <OptionTitle>{t('Option 1')}</OptionTitle> <Tag>{t('Recommended')}</Tag>
  464. </OptionTitleContainer>
  465. <OptionTitleContainer>
  466. <OptionTitle>{t('Option 2')}</OptionTitle>
  467. </OptionTitleContainer>
  468. </OptionsContainer>
  469. <OptionsContainer>
  470. <Option>
  471. <ExternalOptionTitle href="https://docs.sentry.io/platforms/android/proguard/">
  472. {t('Proguard and DexGuard')}
  473. <IconOpen />
  474. </ExternalOptionTitle>
  475. <p>{t('Upload ProGuard files using our Gradle plugin.')}</p>
  476. </Option>
  477. <Option>
  478. <ExternalOptionTitle href="https://docs.sentry.io/product/cli/dif/#uploading-files">
  479. {t('Sentry-cli')}
  480. <IconOpen />
  481. </ExternalOptionTitle>
  482. <p>{t('Validate and upload debug files using our cli tool.')}</p>
  483. </Option>
  484. </OptionsContainer>
  485. <ModalFooter>
  486. <ModalActions>
  487. <DocsLink />
  488. <div>
  489. <StepIndicator>{t('Step 2 of 2')}</StepIndicator>
  490. {step.previous ? (
  491. <PreviousStepButton onClick={() => toStep(step.previous)} />
  492. ) : null}
  493. <Button priority="primary" onClick={closeModal}>
  494. {t('Done')}
  495. </Button>
  496. </div>
  497. </ModalActions>
  498. </ModalFooter>
  499. </ModalBody>
  500. );
  501. }
  502. function IOSSendDebugFilesInstruction({
  503. Body: ModalBody,
  504. Header: ModalHeader,
  505. Footer: ModalFooter,
  506. closeModal,
  507. toStep,
  508. step,
  509. }: OnboardingStepProps) {
  510. return (
  511. <ModalBody>
  512. <ModalHeader>
  513. <h3>{t('Set Up Profiling')}</h3>
  514. </ModalHeader>
  515. <p>
  516. {t(`The most straightforward way to provide Sentry with debug information files is to
  517. upload them using sentry-cli. Depending on your workflow, you may want to upload
  518. as part of your build pipeline or when deploying and publishing your application.`)}{' '}
  519. <ExternalLink href="https://docs.sentry.io/product/cli/dif/">
  520. {t('Learn more about Debug Information Files.')}
  521. </ExternalLink>
  522. </p>
  523. <OptionsContainer>
  524. <OptionTitleContainer>
  525. <OptionTitle>{t('Option 1')}</OptionTitle> <Tag>{t('Recommended')}</Tag>
  526. </OptionTitleContainer>
  527. <OptionTitleContainer>
  528. <OptionTitle>{t('Option 2')}</OptionTitle>
  529. </OptionTitleContainer>
  530. </OptionsContainer>
  531. <OptionsContainer>
  532. <Option>
  533. <ExternalOptionTitle href="https://docs.sentry.io/product/cli/dif/#uploading-files">
  534. {t('Sentry-cli')}
  535. <IconOpen />
  536. </ExternalOptionTitle>
  537. <p>{t('Validate and upload debug files using our cli tool.')}</p>
  538. </Option>
  539. <Option>
  540. <ExternalOptionTitle href="https://docs.sentry.io/platforms/apple/dsym/">
  541. {t('Symbol servers')}
  542. <IconOpen />
  543. </ExternalOptionTitle>
  544. <p>
  545. {t('Sentry downloads debug information files from external repositories.')}
  546. </p>
  547. </Option>
  548. </OptionsContainer>
  549. <ModalFooter>
  550. <ModalActions>
  551. <DocsLink />
  552. <div>
  553. <StepIndicator>{t('Step 2 of 2')}</StepIndicator>
  554. {step.previous !== null ? (
  555. <PreviousStepButton onClick={() => toStep(step.previous)} />
  556. ) : null}
  557. <Button priority="primary" onClick={closeModal}>
  558. {t('Next')}
  559. </Button>
  560. </div>
  561. </ModalActions>
  562. </ModalFooter>
  563. </ModalBody>
  564. );
  565. }
  566. type StepButtonProps = Omit<ButtonPropsWithoutAriaLabel, 'children'>;
  567. // A few common component definitions that are used in each step
  568. function NextStepButton(props: StepButtonProps) {
  569. return (
  570. <Button priority="primary" {...props}>
  571. {t('Next')}
  572. </Button>
  573. );
  574. }
  575. function PreviousStepButton(props: StepButtonProps) {
  576. return <Button {...props}>{t('Back')}</Button>;
  577. }
  578. function DocsLink() {
  579. return (
  580. <Button external href="https://docs.sentry.io/product/profiling/">
  581. {t('Read Docs')}
  582. </Button>
  583. );
  584. }
  585. interface ModalActionsProps {
  586. children: React.ReactNode;
  587. }
  588. function ModalActions({children}: ModalActionsProps) {
  589. return <ModalActionsContainer>{children}</ModalActionsContainer>;
  590. }
  591. const OptionTitleContainer = styled('div')`
  592. margin-bottom: ${space(0.5)};
  593. `;
  594. const OptionTitle = styled('span')`
  595. font-weight: bold;
  596. `;
  597. const ExternalOptionTitle = styled(ExternalLink)`
  598. font-weight: bold;
  599. font-size: ${p => p.theme.fontSizeLarge};
  600. display: flex;
  601. align-items: center;
  602. margin-bottom: ${space(0.5)};
  603. svg {
  604. margin-left: ${space(0.5)};
  605. }
  606. `;
  607. const Option = styled('div')`
  608. border-radius: ${p => p.theme.borderRadius};
  609. border: 1px solid ${p => p.theme.border};
  610. padding: ${space(2)};
  611. margin-top: ${space(1)};
  612. `;
  613. const OptionsContainer = styled('div')`
  614. display: grid;
  615. grid-template-columns: 1fr 1fr;
  616. gap: ${space(2)};
  617. > p {
  618. margin: 0;
  619. }
  620. `;
  621. const ModalActionsContainer = styled('div')`
  622. display: flex;
  623. justify-content: space-between;
  624. align-items: center;
  625. flex: 1 1 100%;
  626. button:not(:last-child) {
  627. margin-right: ${space(1)};
  628. }
  629. `;
  630. const StepTitle = styled('div')`
  631. margin-bottom: ${space(1)};
  632. font-weight: bold;
  633. `;
  634. const StepIndicator = styled('span')`
  635. color: ${p => p.theme.subText};
  636. margin-right: ${space(2)};
  637. `;