integrationFeatures.tsx 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import groupBy from 'lodash/groupBy';
  4. import partition from 'lodash/partition';
  5. import {IconCheckmark} from 'sentry/icons';
  6. import {t, tct} from 'sentry/locale';
  7. import {space} from 'sentry/styles/space';
  8. import type {Hooks} from 'sentry/types/hooks';
  9. import type {IntegrationProvider} from 'sentry/types/integrations';
  10. import type {Organization} from 'sentry/types/organization';
  11. import {getIntegrationType} from 'sentry/utils/integrationUtil';
  12. import UpsellButton from 'getsentry/components/upsellButton';
  13. import withSubscription from 'getsentry/components/withSubscription';
  14. import {useBillingConfig} from 'getsentry/hooks/useBillingConfig';
  15. import type {BillingConfig, Plan, Subscription} from 'getsentry/types';
  16. import {displayPlanName} from 'getsentry/utils/billing';
  17. type IntegrationFeature = {
  18. description: React.ReactNode;
  19. featureGate: string;
  20. };
  21. type GatedFeatureGroup = {
  22. features: IntegrationFeature[];
  23. hasFeatures: boolean;
  24. plan?: Plan;
  25. };
  26. type MapFeatureGroupsOpts = {
  27. billingConfig: BillingConfig;
  28. features: IntegrationFeature[];
  29. organization: Organization;
  30. subscription: Subscription;
  31. };
  32. /**
  33. * Given a users subscription, billing config, and organization, determine from
  34. * a set of features three things:
  35. *
  36. * - What features are free ungated features.
  37. *
  38. * - Group together features that *are* gated by plan type, and indicate if the
  39. * current plan type supports that set of features
  40. *
  41. * - Does the user current plan support *any* of the features, or are they
  42. * required to upgrade to receive any features.
  43. */
  44. function mapFeatureGroups({
  45. features,
  46. organization,
  47. subscription,
  48. billingConfig,
  49. }: MapFeatureGroupsOpts) {
  50. if (billingConfig === null || subscription === null) {
  51. return {
  52. disabled: false,
  53. disabledReason: null,
  54. ungatedFeatures: [],
  55. gatedFeatureGroups: [],
  56. };
  57. }
  58. // Group integration by if their features are part of a paid subscription.
  59. const [ungatedFeatures, premiumFeatures] = partition(
  60. features,
  61. (feature: IntegrationFeature) =>
  62. billingConfig.featureList[feature.featureGate] === undefined
  63. );
  64. // TODO: use sortPlansForUpgrade here
  65. // Filter plans down to just user selectable plans types of the orgs current
  66. // contract interval. Sorted by price as features will become progressively
  67. // more available.
  68. let plans = billingConfig.planList
  69. .sort((a, b) => a.price - b.price)
  70. .filter(p => p.userSelectable && p.billingInterval === subscription.billingInterval);
  71. // If we're dealing with plans that are *not part of a tier* Then we can
  72. // assume special case that there is only one plan.
  73. if (billingConfig.id === null && plans.length === 0) {
  74. plans = billingConfig.planList;
  75. }
  76. // Group premium features by the plans they belong to
  77. const groupedPlanFeatures = groupBy(
  78. premiumFeatures,
  79. feature => plans.find(p => p.features.includes(feature.featureGate))?.id
  80. );
  81. // Transform our grouped plan features into a list of feature groups
  82. // including the plan. For each feature group it is determined if all
  83. // features also have associated organization feature flags, indicating that
  84. // the features are enabled.
  85. const gatedFeatureGroups = plans
  86. .filter(plan => groupedPlanFeatures[plan.id] !== undefined)
  87. .map<GatedFeatureGroup>(plan => ({
  88. plan,
  89. features: groupedPlanFeatures[plan.id]!,
  90. hasFeatures:
  91. groupedPlanFeatures[plan.id]!.map(f => f.featureGate)
  92. .map(f => organization.features.includes(f))
  93. .filter(v => v !== true).length === 0,
  94. }));
  95. // Are any features available for the current users plan?
  96. const disabled =
  97. ungatedFeatures.length === 0 &&
  98. gatedFeatureGroups.filter(group => group.hasFeatures).length === 0;
  99. // Checks if 'disabled' and if there are any gatedFeatureGroups with plans,
  100. // then takes the cheapest tiered plan and generates the first error message.
  101. // If gatedFeatureGroups do not exist and is disabled, then give generic error message.
  102. // There are some deprecated plugins that require this logic that some customers may see.
  103. const disabledReason =
  104. disabled && gatedFeatureGroups.length && gatedFeatureGroups[0]!.plan
  105. ? tct('Requires [planName] Plan or above', {
  106. planName: displayPlanName(gatedFeatureGroups[0]!.plan),
  107. })
  108. : disabled
  109. ? t('Integration unavailable on your billing plan.')
  110. : null;
  111. return {ungatedFeatures, gatedFeatureGroups, disabled, disabledReason};
  112. }
  113. type RenderProps = {
  114. /**
  115. * Boolean false if the integration may be installed on the current users
  116. * plan, or a string describing why it cannot be installed.
  117. */
  118. disabled: boolean;
  119. /**
  120. * The text (translated) reason the integration cannot be installed.
  121. */
  122. disabledReason: React.ReactNode;
  123. /**
  124. * Features grouped by what plan they belong to.
  125. */
  126. gatedFeatureGroups: GatedFeatureGroup[];
  127. /**
  128. * A list of features that are available for free.
  129. */
  130. ungatedFeatures: IntegrationFeature[];
  131. };
  132. type IntegrationFeaturesProps = {
  133. children: (props: RenderProps) => React.ReactElement;
  134. features: IntegrationFeature[];
  135. organization: Organization;
  136. subscription: Subscription;
  137. };
  138. function IntegrationFeaturesBase({
  139. features,
  140. organization,
  141. subscription,
  142. children,
  143. }: IntegrationFeaturesProps) {
  144. const {data: billingConfig} = useBillingConfig({organization, subscription});
  145. if (!billingConfig) {
  146. return null;
  147. }
  148. const opts = mapFeatureGroups({
  149. features,
  150. organization,
  151. subscription,
  152. billingConfig,
  153. });
  154. return children(opts);
  155. }
  156. const IntegrationFeatures = withSubscription(IntegrationFeaturesBase);
  157. type FeatureListProps = Omit<IntegrationFeaturesProps, 'children'> & {
  158. provider: Pick<IntegrationProvider, 'key'>;
  159. };
  160. function FeatureListBase(props: FeatureListProps) {
  161. const {provider, subscription, organization} = props;
  162. return (
  163. <IntegrationFeatures {...props}>
  164. {({ungatedFeatures, gatedFeatureGroups}) => (
  165. <Fragment>
  166. <IntegrationFeatureGroup
  167. message={tct('For [plans:All billing plans]', {plans: <strong />})}
  168. features={ungatedFeatures}
  169. hasFeatures
  170. />
  171. {gatedFeatureGroups.map(({plan, features, hasFeatures}) => {
  172. const planText = tct('[planName] billing plans', {
  173. planName: displayPlanName(plan),
  174. });
  175. const action = (
  176. <UpsellButton
  177. source="integration-features"
  178. size="xs"
  179. subscription={subscription}
  180. organization={organization}
  181. priority="primary"
  182. extraAnalyticsParams={{
  183. integration: provider.key,
  184. integration_type: getIntegrationType(provider as IntegrationProvider),
  185. integration_tab: 'overview',
  186. plan: plan?.name,
  187. }}
  188. />
  189. );
  190. const message = (
  191. <Fragment>
  192. {tct('For [plan] and above', {plan: <strong>{planText}</strong>})}
  193. </Fragment>
  194. );
  195. return (
  196. <IntegrationFeatureGroup
  197. key={plan?.id}
  198. message={message}
  199. features={features}
  200. hasFeatures={hasFeatures}
  201. action={!hasFeatures && action}
  202. />
  203. );
  204. })}
  205. </Fragment>
  206. )}
  207. </IntegrationFeatures>
  208. );
  209. }
  210. const FeatureList = withSubscription(FeatureListBase);
  211. const HasFeatureIndicator = styled((p: any) => (
  212. <div {...p}>
  213. Enabled
  214. <IconCheckmark isCircled />
  215. </div>
  216. ))`
  217. display: grid;
  218. grid-auto-flow: column;
  219. gap: ${space(1)};
  220. align-items: center;
  221. color: ${p => p.theme.green300};
  222. font-weight: bold;
  223. text-transform: uppercase;
  224. font-size: 0.8em;
  225. margin-right: 4px;
  226. `;
  227. type GroupProps = {
  228. features: IntegrationFeature[];
  229. hasFeatures: boolean;
  230. message: React.ReactNode;
  231. action?: React.ReactNode;
  232. className?: string;
  233. };
  234. const IntegrationFeatureGroup = styled((p: GroupProps) => {
  235. if (p.features.length === 0) {
  236. return null;
  237. }
  238. return (
  239. <div className={p.className}>
  240. <FeatureGroupHeading>
  241. <div>{p.message}</div>
  242. {p.action && p.action}
  243. {p.hasFeatures && <HasFeatureIndicator />}
  244. </FeatureGroupHeading>
  245. <GroupFeatureList features={p.features} />
  246. </div>
  247. );
  248. })`
  249. overflow: hidden;
  250. border-radius: 4px;
  251. border: 1px solid ${p => p.theme.gray200};
  252. margin-bottom: ${space(2)};
  253. `;
  254. const FeatureGroupHeading = styled('div')`
  255. display: flex;
  256. align-items: center;
  257. justify-content: space-between;
  258. border-bottom: 1px solid ${p => p.theme.gray200};
  259. background: ${p => p.theme.backgroundSecondary};
  260. font-size: 0.9em;
  261. padding: 8px 8px 8px 12px;
  262. `;
  263. type GroupListProps = Pick<GroupProps, 'features' | 'className'>;
  264. const GroupFeatureList = styled(({features, className}: GroupListProps) => (
  265. <ul className={className}>
  266. {features.map((feature, i) => (
  267. <FeatureDescription key={i}>{feature.description}</FeatureDescription>
  268. ))}
  269. </ul>
  270. ))`
  271. padding: 0;
  272. margin: 0;
  273. list-style: none;
  274. background-color: ${p => p.theme.background};
  275. `;
  276. const FeatureDescription = styled('li')`
  277. padding: 8px 12px;
  278. &:not(:last-child) {
  279. border-bottom: 1px solid ${p => p.theme.gray200};
  280. }
  281. `;
  282. /**
  283. * This hook provides integration feature components used to determine what
  284. * features an organization currently has access too.
  285. *
  286. * All components exported through this hook require the organization and
  287. * integration features list to be passed
  288. *
  289. * Provides two components:
  290. *
  291. * - IntegrationFeatures
  292. * This is a render-prop style component that given a set of integration
  293. * features will call children as a render-prop. See the proptypes
  294. * descriptions above.
  295. *
  296. * - FeatureList
  297. * Renders a list of integration features grouped by plan.
  298. */
  299. const hookIntegrationFeatures = () => ({
  300. IntegrationFeatures,
  301. FeatureList,
  302. });
  303. export default hookIntegrationFeatures as Hooks['integrations:feature-gates'];