productSelection.tsx 14 KB

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