productSelection.tsx 17 KB

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