sidebarNavigationItem.tsx 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. import {Fragment} from 'react';
  2. import {ClassNames} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {IconBusiness} from 'sentry/icons';
  5. import {space} from 'sentry/styles/space';
  6. import type {Organization} from 'sentry/types/organization';
  7. import withOrganization from 'sentry/utils/withOrganization';
  8. import PowerFeatureHovercard from 'getsentry/components/powerFeatureHovercard';
  9. import withSubscription from 'getsentry/components/withSubscription';
  10. import {useBillingConfig} from 'getsentry/hooks/useBillingConfig';
  11. import type {Subscription} from 'getsentry/types';
  12. interface ChildRenderProps {
  13. Wrapper: React.FunctionComponent<{children: React.ReactElement}>;
  14. additionalContent: React.ReactElement | null;
  15. disabled: boolean;
  16. }
  17. interface Props {
  18. children: (opts: ChildRenderProps) => React.ReactElement;
  19. id: string;
  20. organization: Organization;
  21. subscription: Subscription;
  22. }
  23. /** @internal exported for tests only */
  24. export function SidebarNavigationItem({id, organization, subscription, children}: Props) {
  25. const {data: billingConfig} = useBillingConfig({organization, subscription});
  26. const subscriptionPlan = subscription.planDetails;
  27. const subscriptionPlanFeatures = subscriptionPlan?.features ?? [];
  28. const trialPlan = subscription.trialPlan
  29. ? billingConfig?.planList?.find(plan => plan.id === subscription.trialPlan)
  30. : undefined;
  31. const trialPlanFeatures = trialPlan?.features ?? [];
  32. const planFeatures = [...new Set([...subscriptionPlanFeatures, ...trialPlanFeatures])];
  33. const rule = NavigationItemAccessRule.forId(id, organization, planFeatures);
  34. return children(rule.props);
  35. }
  36. interface CheckableForAccess {
  37. get props(): ChildRenderProps;
  38. }
  39. class NavigationItemAccessRule implements CheckableForAccess {
  40. id: string;
  41. organization: Organization;
  42. planFeatures: string[];
  43. constructor(id: string, organization: Organization, planFeatures: string[]) {
  44. this.id = id;
  45. this.organization = organization;
  46. this.planFeatures = planFeatures;
  47. }
  48. get props(): ChildRenderProps {
  49. return {
  50. disabled: false,
  51. additionalContent: null,
  52. Wrapper: Fragment,
  53. };
  54. }
  55. static forId(
  56. id: string,
  57. organization: Organization,
  58. planFeatures: string[]
  59. ): CheckableForAccess {
  60. let cls: any;
  61. if (id === 'sidebar-accordion-insights-item') {
  62. cls = InsightsAccordionAccessRule;
  63. } else if (Object.keys(INSIGHTS_LINK_ID_FEATURE_REQUIREMENTS).includes(id)) {
  64. cls = InsightsItemAccessRule;
  65. } else {
  66. cls = NavigationItemAccessRule;
  67. }
  68. return new cls(id, organization, planFeatures);
  69. }
  70. }
  71. export class InsightsItemAccessRule extends NavigationItemAccessRule {
  72. get doesOrganizationHaveAnyInsightsAccess() {
  73. return (
  74. this.organization?.features?.includes('insights-initial-modules') ||
  75. this.organization?.features?.includes('insights-addon-modules')
  76. );
  77. }
  78. get hasRequiredFeatures(): boolean {
  79. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  80. const requiredFeatures = INSIGHTS_LINK_ID_FEATURE_REQUIREMENTS[this.id] ?? [];
  81. const enabledFeatures = [...this.planFeatures, ...this.organization.features];
  82. return requiredFeatures.every((feature: any) => enabledFeatures.includes(feature));
  83. }
  84. get props() {
  85. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  86. const requiredFeatures = INSIGHTS_LINK_ID_FEATURE_REQUIREMENTS[this.id] ?? [];
  87. const hasRequiredFeatures = this.hasRequiredFeatures;
  88. // Show the Turbo link if the organization doesn't have access to that link, but hide it if they don't have access to _any_ Insight modules, since in that case there'd be a Turbo icon next to each item
  89. const hasTurboIcon =
  90. !hasRequiredFeatures && this.doesOrganizationHaveAnyInsightsAccess;
  91. return {
  92. disabled: !hasRequiredFeatures,
  93. additionalContent: hasTurboIcon ? <CenteredIcon data-test-id="power-icon" /> : null,
  94. Wrapper: hasRequiredFeatures
  95. ? Fragment
  96. : makeUpsellWrapper(this.id, requiredFeatures ?? []),
  97. };
  98. }
  99. }
  100. class InsightsAccordionAccessRule extends InsightsItemAccessRule {
  101. get props() {
  102. // If the organization has access to _some_ modules, leave the Insights link alone. If it doesn't have any access to Insights modules, show an upsell around the "Insights" link.
  103. return this.doesOrganizationHaveAnyInsightsAccess
  104. ? {
  105. disabled: false,
  106. additionalContent: null,
  107. Wrapper: Fragment,
  108. }
  109. : {
  110. disabled: true,
  111. additionalContent: <CenteredIcon data-test-id="power-icon" />,
  112. Wrapper: makeUpsellWrapper(this.id, ['insights-initial-modules']),
  113. };
  114. }
  115. }
  116. const CenteredIcon = styled(IconBusiness)`
  117. display: inline-flex;
  118. flex-shrink: 0;
  119. margin-left: ${space(1)};
  120. `;
  121. function makeUpsellWrapper(
  122. id: string,
  123. requiredFeatures: string[]
  124. ): React.FunctionComponent<{children: React.ReactElement}> {
  125. // @ts-expect-error TS(7031): Binding element 'upsellWrapperChildren' implicitly... Remove this comment to see the full error message
  126. function UpsellWrapper({children: upsellWrapperChildren}) {
  127. return (
  128. <ClassNames>
  129. {({css}) => (
  130. <PowerFeatureHovercard
  131. id={id}
  132. partial={false}
  133. features={requiredFeatures}
  134. containerDisplayMode="inline-block"
  135. containerClassName={css`
  136. width: 100%;
  137. `}
  138. >
  139. {upsellWrapperChildren}
  140. </PowerFeatureHovercard>
  141. )}
  142. </ClassNames>
  143. );
  144. }
  145. return UpsellWrapper;
  146. }
  147. // Each key is an `id` prop of `SidebarItem` components. Each value is a list of plan feature strings that id needs
  148. const INSIGHTS_LINK_ID_FEATURE_REQUIREMENTS = {
  149. 'performance-database': ['insights-initial-modules'],
  150. 'performance-http': ['insights-initial-modules'],
  151. 'performance-webvitals': ['insights-initial-modules'],
  152. 'performance-mobile-screens': ['insights-initial-modules'],
  153. 'performance-mobile-app-startup': ['insights-initial-modules'],
  154. 'performance-browser-resources': ['insights-initial-modules'],
  155. 'performance-cache': ['insights-addon-modules'],
  156. 'performance-queues': ['insights-addon-modules'],
  157. 'performance-mobile-ui': ['insights-addon-modules'],
  158. 'llm-monitoring': ['insights-addon-modules'],
  159. 'performance-screen-rendering': ['insights-addon-modules'],
  160. };
  161. export type InsightSidebarId = keyof typeof INSIGHTS_LINK_ID_FEATURE_REQUIREMENTS;
  162. export default withOrganization(
  163. withSubscription(SidebarNavigationItem, {noLoader: true})
  164. );