productSelection.tsx 14 KB

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