productSelection.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. import type {ReactNode} from 'react';
  2. import {Fragment, useCallback, useEffect, useMemo} from 'react';
  3. import {css} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import {openModal} from 'sentry/actionCreators/modal';
  6. import {FeatureDisabledModal} from 'sentry/components/acl/featureDisabledModal';
  7. import {Alert} from 'sentry/components/alert';
  8. import {Button} from 'sentry/components/button';
  9. import Checkbox from 'sentry/components/checkbox';
  10. import ExternalLink from 'sentry/components/links/externalLink';
  11. import {Tooltip} from 'sentry/components/tooltip';
  12. import {IconQuestion} from 'sentry/icons';
  13. import {t, tct} from 'sentry/locale';
  14. import HookStore from 'sentry/stores/hookStore';
  15. import {space} from 'sentry/styles/space';
  16. import type {Organization, PlatformKey} from 'sentry/types';
  17. import {decodeList} from 'sentry/utils/queryString';
  18. import useRouter from 'sentry/utils/useRouter';
  19. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  20. // TODO(aknaus): move to types
  21. export enum ProductSolution {
  22. ERROR_MONITORING = 'error-monitoring',
  23. PERFORMANCE_MONITORING = 'performance-monitoring',
  24. SESSION_REPLAY = 'session-replay',
  25. PROFILING = 'profiling',
  26. }
  27. interface DisabledProduct {
  28. reason: ReactNode;
  29. onClick?: () => void;
  30. }
  31. export type DisabledProducts = Partial<Record<ProductSolution, DisabledProduct>>;
  32. function getDisabledProducts(organization: Organization): DisabledProducts {
  33. const disabledProducts: DisabledProducts = {};
  34. const hasSessionReplay = organization.features.includes('session-replay');
  35. const hasPerformance = organization.features.includes('performance-view');
  36. const hasProfiling = organization.features.includes('profiling-view');
  37. const reason = t('This feature is not enabled on your Sentry installation.');
  38. const createClickHandler = (feature: string, featureName: string) => () => {
  39. openModal(deps => (
  40. <FeatureDisabledModal {...deps} features={[feature]} featureName={featureName} />
  41. ));
  42. };
  43. if (!hasSessionReplay) {
  44. disabledProducts[ProductSolution.SESSION_REPLAY] = {
  45. reason,
  46. onClick: createClickHandler('organizations:session-replay', 'Session Replay'),
  47. };
  48. }
  49. if (!hasPerformance) {
  50. disabledProducts[ProductSolution.PERFORMANCE_MONITORING] = {
  51. reason,
  52. onClick: createClickHandler(
  53. 'organizations:performance-view',
  54. 'Performance Monitoring'
  55. ),
  56. };
  57. }
  58. if (!hasProfiling) {
  59. disabledProducts[ProductSolution.PROFILING] = {
  60. reason,
  61. onClick: createClickHandler('organizations:profiling-view', 'Profiling'),
  62. };
  63. }
  64. return disabledProducts;
  65. }
  66. // This is the list of products that are available for each platform
  67. // Since the ProductSelection component is rendered in the onboarding/project creation flow only, it is ok to have this list here
  68. // NOTE: Please keep the prefix in alphabetical order
  69. export const platformProductAvailability = {
  70. android: [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  71. bun: [ProductSolution.PERFORMANCE_MONITORING],
  72. flutter: [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  73. kotlin: [ProductSolution.PERFORMANCE_MONITORING],
  74. go: [ProductSolution.PERFORMANCE_MONITORING],
  75. 'go-echo': [ProductSolution.PERFORMANCE_MONITORING],
  76. 'go-fasthttp': [ProductSolution.PERFORMANCE_MONITORING],
  77. 'go-gin': [ProductSolution.PERFORMANCE_MONITORING],
  78. 'go-http': [ProductSolution.PERFORMANCE_MONITORING],
  79. 'go-iris': [ProductSolution.PERFORMANCE_MONITORING],
  80. 'go-martini': [ProductSolution.PERFORMANCE_MONITORING],
  81. 'go-negroni': [ProductSolution.PERFORMANCE_MONITORING],
  82. java: [ProductSolution.PERFORMANCE_MONITORING],
  83. 'java-spring-boot': [ProductSolution.PERFORMANCE_MONITORING],
  84. javascript: [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.SESSION_REPLAY],
  85. 'javascript-react': [
  86. ProductSolution.PERFORMANCE_MONITORING,
  87. ProductSolution.SESSION_REPLAY,
  88. ],
  89. 'javascript-vue': [
  90. ProductSolution.PERFORMANCE_MONITORING,
  91. ProductSolution.SESSION_REPLAY,
  92. ],
  93. 'javascript-angular': [
  94. ProductSolution.PERFORMANCE_MONITORING,
  95. ProductSolution.SESSION_REPLAY,
  96. ],
  97. capacitor: [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.SESSION_REPLAY],
  98. 'javascript-ember': [
  99. ProductSolution.PERFORMANCE_MONITORING,
  100. ProductSolution.SESSION_REPLAY,
  101. ],
  102. 'javascript-gatsby': [
  103. ProductSolution.PERFORMANCE_MONITORING,
  104. ProductSolution.SESSION_REPLAY,
  105. ],
  106. 'javascript-svelte': [
  107. ProductSolution.PERFORMANCE_MONITORING,
  108. ProductSolution.SESSION_REPLAY,
  109. ],
  110. 'javascript-astro': [
  111. ProductSolution.PERFORMANCE_MONITORING,
  112. ProductSolution.SESSION_REPLAY,
  113. ],
  114. node: [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  115. 'node-azurefunctions': [
  116. ProductSolution.PERFORMANCE_MONITORING,
  117. ProductSolution.PROFILING,
  118. ],
  119. 'node-awslambda': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  120. 'node-connect': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  121. 'node-express': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  122. 'node-gcpfunctions': [
  123. ProductSolution.PERFORMANCE_MONITORING,
  124. ProductSolution.PROFILING,
  125. ],
  126. 'node-koa': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  127. php: [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  128. 'php-laravel': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  129. ['php-symfony']: [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  130. python: [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  131. 'python-aiohttp': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  132. 'python-asgi': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  133. 'python-awslambda': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  134. 'python-bottle': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  135. 'python-celery': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  136. 'python-chalice': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  137. 'python-django': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  138. 'python-falcon': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  139. 'python-fastapi': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  140. 'python-flask': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  141. 'python-gcpfunctions': [
  142. ProductSolution.PERFORMANCE_MONITORING,
  143. ProductSolution.PROFILING,
  144. ],
  145. 'python-quart': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  146. 'python-rq': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  147. 'python-serverless': [
  148. ProductSolution.PERFORMANCE_MONITORING,
  149. ProductSolution.PROFILING,
  150. ],
  151. 'python-tornado': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  152. 'python-starlette': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  153. 'python-wsgi': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  154. 'react-native': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  155. ruby: [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  156. 'ruby-rack': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  157. 'ruby-rails': [ProductSolution.PERFORMANCE_MONITORING, ProductSolution.PROFILING],
  158. } as Record<PlatformKey, ProductSolution[]>;
  159. type ProductProps = {
  160. /**
  161. * If the product is checked. This information is grabbed from the URL.
  162. */
  163. checked: boolean;
  164. /**
  165. * The name of the product
  166. */
  167. label: string;
  168. /**
  169. * Brief product description
  170. */
  171. description?: ReactNode;
  172. /**
  173. * If the product is disabled. It contains a reason and an optional onClick handler
  174. */
  175. disabled?: DisabledProduct;
  176. /**
  177. * Link of the product documentation. Rendered if there is also a description.
  178. */
  179. docLink?: string;
  180. /**
  181. * Click handler. If the product is enabled, by clicking on the button, the product is added or removed from the URL.
  182. */
  183. onClick?: () => void;
  184. /**
  185. * A permanent disabled product is always disabled and cannot be enabled.
  186. */
  187. permanentDisabled?: boolean;
  188. };
  189. function Product({
  190. disabled,
  191. permanentDisabled,
  192. checked,
  193. label,
  194. onClick,
  195. docLink,
  196. description,
  197. }: ProductProps) {
  198. const ProductWrapper = permanentDisabled
  199. ? PermanentDisabledProductWrapper
  200. : disabled
  201. ? DisabledProductWrapper
  202. : ProductButtonWrapper;
  203. return (
  204. <Tooltip
  205. title={
  206. disabled?.reason ??
  207. (description && (
  208. <TooltipDescription>
  209. {description}
  210. {docLink && <ExternalLink href={docLink}>{t('Read the Docs')}</ExternalLink>}
  211. </TooltipDescription>
  212. ))
  213. }
  214. delay={500}
  215. isHoverable
  216. >
  217. <ProductWrapper
  218. onClick={disabled?.onClick ?? onClick}
  219. disabled={disabled?.onClick ?? permanentDisabled ? false : !!disabled}
  220. priority={permanentDisabled || checked ? 'primary' : 'default'}
  221. aria-label={label}
  222. >
  223. <ProductButtonInner>
  224. <Checkbox
  225. checked={checked}
  226. disabled={permanentDisabled ? false : !!disabled}
  227. aria-label={label}
  228. size="xs"
  229. readOnly
  230. />
  231. <span>{label}</span>
  232. <IconQuestion size="xs" color="subText" />
  233. </ProductButtonInner>
  234. </ProductWrapper>
  235. </Tooltip>
  236. );
  237. }
  238. export type ProductSelectionProps = {
  239. /**
  240. * The current organization
  241. */
  242. organization: Organization;
  243. /**
  244. * List of products that are disabled. All of them have to contain a reason by default and optionally an onClick handler.
  245. */
  246. disabledProducts?: DisabledProducts;
  247. /**
  248. * If true, the loader script is used instead of the npm/yarn guide.
  249. */
  250. lazyLoader?: boolean;
  251. /**
  252. * The platform key of the project (e.g. javascript-react, python-django, etc.)
  253. */
  254. platform?: PlatformKey;
  255. /**
  256. * A custom list of products per platform. If not provided, the default list is used.
  257. */
  258. productsPerPlatform?: Record<PlatformKey, ProductSolution[]>;
  259. skipLazyLoader?: () => void;
  260. /**
  261. * If true, the component has a bottom margin of 20px
  262. */
  263. withBottomMargin?: boolean;
  264. };
  265. export function ProductSelection({
  266. disabledProducts: disabledProductsProp,
  267. lazyLoader,
  268. organization,
  269. platform,
  270. productsPerPlatform = platformProductAvailability,
  271. skipLazyLoader,
  272. }: ProductSelectionProps) {
  273. const router = useRouter();
  274. const urlProducts = decodeList(router.location.query.product);
  275. const products: ProductSolution[] | undefined = platform
  276. ? productsPerPlatform[platform]
  277. : undefined;
  278. const disabledProducts = useMemo(
  279. () => disabledProductsProp ?? getDisabledProducts(organization),
  280. [organization, disabledProductsProp]
  281. );
  282. const defaultProducts = useMemo(() => {
  283. return products?.filter(product => !(product in disabledProducts)) ?? [];
  284. }, [products, disabledProducts]);
  285. useEffect(() => {
  286. router.replace({
  287. pathname: router.location.pathname,
  288. query: {
  289. ...router.location.query,
  290. product: defaultProducts,
  291. },
  292. });
  293. // Adding defaultProducts to the dependency array causes an max-depth error
  294. // eslint-disable-next-line react-hooks/exhaustive-deps
  295. }, [router]);
  296. const handleClickProduct = useCallback(
  297. (product: ProductSolution) => {
  298. const newProduct = new Set(
  299. urlProducts.includes(product)
  300. ? urlProducts.filter(p => p !== product)
  301. : [...urlProducts, product]
  302. );
  303. if (defaultProducts?.includes(ProductSolution.PROFILING)) {
  304. // Ensure that if profiling is enabled, performance monitoring is also enabled
  305. if (
  306. product === ProductSolution.PROFILING &&
  307. newProduct.has(ProductSolution.PROFILING)
  308. ) {
  309. newProduct.add(ProductSolution.PERFORMANCE_MONITORING);
  310. } else if (
  311. product === ProductSolution.PERFORMANCE_MONITORING &&
  312. !newProduct.has(ProductSolution.PERFORMANCE_MONITORING)
  313. ) {
  314. newProduct.delete(ProductSolution.PROFILING);
  315. }
  316. }
  317. const selectedProducts = [...newProduct] as ProductSolution[];
  318. router.replace({
  319. pathname: router.location.pathname,
  320. query: {
  321. ...router.location.query,
  322. product: selectedProducts,
  323. },
  324. });
  325. if (organization.features.includes('project-create-replay-feedback')) {
  326. HookStore.get('callback:on-create-project-product-selection').map(cb =>
  327. cb({defaultProducts, organization, selectedProducts})
  328. );
  329. }
  330. },
  331. [defaultProducts, organization, router, urlProducts]
  332. );
  333. if (!products) {
  334. // if the platform does not support any product, we don't render anything
  335. return null;
  336. }
  337. // TODO(aknaus): clean up
  338. // The package manager info is only shown for javascript platforms
  339. // until we improve multi snippet suppport
  340. const showPackageManagerInfo =
  341. (platform?.indexOf('javascript') === 0 || platform?.indexOf('node') === 0) &&
  342. platform !== 'javascript-astro';
  343. const showAstroInfo = platform === 'javascript-astro';
  344. return (
  345. <Fragment>
  346. {showPackageManagerInfo && (
  347. <TextBlock noMargin>
  348. {lazyLoader
  349. ? tct('In this quick guide you’ll use our [loaderScript] to set up:', {
  350. loaderScript: <strong>Loader Script</strong>,
  351. })
  352. : tct('In this quick guide you’ll use [npm] or [yarn] to set up:', {
  353. npm: <strong>npm</strong>,
  354. yarn: <strong>yarn</strong>,
  355. })}
  356. </TextBlock>
  357. )}
  358. {showAstroInfo && (
  359. <TextBlock noMargin>
  360. {tct("In this quick guide you'll use the [astrocli:astro] CLI to set up:", {
  361. astrocli: <strong />,
  362. })}
  363. </TextBlock>
  364. )}
  365. <Products>
  366. <Product
  367. label={t('Error Monitoring')}
  368. disabled={{reason: t("Let's admit it, we all have errors.")}}
  369. checked
  370. permanentDisabled
  371. />
  372. {products.includes(ProductSolution.PERFORMANCE_MONITORING) && (
  373. <Product
  374. label={t('Performance Monitoring')}
  375. description={t(
  376. 'Automatic performance issue detection across services and context on who is impacted, outliers, regressions, and the root cause of your slowdown.'
  377. )}
  378. docLink="https://docs.sentry.io/platforms/javascript/guides/react/performance/"
  379. onClick={() => handleClickProduct(ProductSolution.PERFORMANCE_MONITORING)}
  380. disabled={disabledProducts[ProductSolution.PERFORMANCE_MONITORING]}
  381. checked={urlProducts.includes(ProductSolution.PERFORMANCE_MONITORING)}
  382. />
  383. )}
  384. {products.includes(ProductSolution.SESSION_REPLAY) && (
  385. <Product
  386. label={t('Session Replay')}
  387. description={t(
  388. 'Video-like reproductions of user sessions with debugging context to help you confirm issue impact and troubleshoot faster.'
  389. )}
  390. docLink="https://docs.sentry.io/platforms/javascript/guides/react/session-replay/"
  391. onClick={() => handleClickProduct(ProductSolution.SESSION_REPLAY)}
  392. disabled={disabledProducts[ProductSolution.SESSION_REPLAY]}
  393. checked={urlProducts.includes(ProductSolution.SESSION_REPLAY)}
  394. />
  395. )}
  396. {products.includes(ProductSolution.PROFILING) && (
  397. <Product
  398. label={t('Profiling')}
  399. description={tct(
  400. '[strong:Requires Performance Monitoring]\nSee the exact lines of code causing your performance bottlenecks, for faster troubleshooting and resource optimization.',
  401. {
  402. strong: <strong />,
  403. }
  404. )}
  405. docLink="https://docs.sentry.io/platforms/python/profiling/"
  406. onClick={() => handleClickProduct(ProductSolution.PROFILING)}
  407. disabled={disabledProducts[ProductSolution.PROFILING]}
  408. checked={urlProducts.includes(ProductSolution.PROFILING)}
  409. />
  410. )}
  411. </Products>
  412. {showPackageManagerInfo && lazyLoader && (
  413. <AlternativeInstallationAlert type="info" showIcon>
  414. {tct('Prefer to set up Sentry using [npm:npm] or [yarn:yarn]? [goHere].', {
  415. npm: <strong />,
  416. yarn: <strong />,
  417. goHere: (
  418. <Button onClick={skipLazyLoader} priority="link">
  419. {t('Go here')}
  420. </Button>
  421. ),
  422. })}
  423. </AlternativeInstallationAlert>
  424. )}
  425. </Fragment>
  426. );
  427. }
  428. const Products = styled('div')`
  429. display: flex;
  430. flex-wrap: wrap;
  431. gap: ${space(1)};
  432. `;
  433. const ProductButtonWrapper = styled(Button)`
  434. ${p =>
  435. p.priority === 'primary' &&
  436. css`
  437. &,
  438. :hover {
  439. background: ${p.theme.purple100};
  440. color: ${p.theme.purple300};
  441. }
  442. `}
  443. `;
  444. const DisabledProductWrapper = styled(Button)`
  445. && {
  446. cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')};
  447. input {
  448. cursor: ${p =>
  449. p.disabled || p.priority === 'default' ? 'not-allowed' : 'pointer'};
  450. }
  451. }
  452. `;
  453. const PermanentDisabledProductWrapper = styled(Button)`
  454. && {
  455. &,
  456. :hover {
  457. background: ${p => p.theme.purple100};
  458. color: ${p => p.theme.purple300};
  459. opacity: 0.5;
  460. cursor: not-allowed;
  461. input {
  462. cursor: not-allowed;
  463. }
  464. }
  465. }
  466. `;
  467. const ProductButtonInner = styled('div')`
  468. display: grid;
  469. grid-template-columns: repeat(3, max-content);
  470. gap: ${space(1)};
  471. align-items: center;
  472. `;
  473. const TooltipDescription = styled('div')`
  474. display: flex;
  475. flex-direction: column;
  476. gap: ${space(0.5)};
  477. justify-content: flex-start;
  478. `;
  479. const AlternativeInstallationAlert = styled(Alert)`
  480. margin-bottom: 0px;
  481. `;