onDemandBudgetEdit.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. import {Component, Fragment} from 'react';
  2. import {css} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {Tag} from 'sentry/components/core/badge/tag';
  5. import {Input} from 'sentry/components/core/input';
  6. import {Radio} from 'sentry/components/core/radio';
  7. import PanelBody from 'sentry/components/panels/panelBody';
  8. import PanelItem from 'sentry/components/panels/panelItem';
  9. import {Tooltip} from 'sentry/components/tooltip';
  10. import {DATA_CATEGORY_INFO} from 'sentry/constants';
  11. import {t} from 'sentry/locale';
  12. import {space} from 'sentry/styles/space';
  13. import {DataCategoryExact} from 'sentry/types/core';
  14. import type {Organization} from 'sentry/types/organization';
  15. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  16. import {CronsOnDemandStepWarning} from 'getsentry/components/cronsOnDemandStepWarning';
  17. import type {OnDemandBudgets, Plan, Subscription} from 'getsentry/types';
  18. import {OnDemandBudgetMode, PlanTier} from 'getsentry/types';
  19. import {getPlanCategoryName, listDisplayNames} from 'getsentry/utils/dataCategory';
  20. function coerceValue(value: number): number {
  21. return value / 100;
  22. }
  23. function parseInputValue(e: React.ChangeEvent<HTMLInputElement>) {
  24. let value = parseInt(e.target.value, 10) || 0;
  25. value = Math.max(value, 0);
  26. const cents = value * 100;
  27. return cents;
  28. }
  29. type Props = {
  30. activePlan: Plan;
  31. currentBudgetMode: OnDemandBudgetMode;
  32. onDemandBudget: OnDemandBudgets;
  33. onDemandEnabled: boolean;
  34. onDemandSupported: boolean;
  35. organization: Organization;
  36. setBudgetMode: (nextMode: OnDemandBudgetMode) => void;
  37. setOnDemandBudget: (onDemandBudget: OnDemandBudgets) => void;
  38. subscription: Subscription;
  39. };
  40. class OnDemandBudgetEdit extends Component<Props> {
  41. onDemandUnsupportedCopy = () => {
  42. const {subscription} = this.props;
  43. return t(
  44. '%s is not supported for your account.',
  45. subscription.planTier === PlanTier.AM3 ? 'Pay-as-you-go' : 'On-demand'
  46. );
  47. };
  48. renderInputFields = (displayBudgetMode: OnDemandBudgetMode) => {
  49. const {
  50. onDemandBudget,
  51. setOnDemandBudget,
  52. onDemandSupported,
  53. activePlan,
  54. organization,
  55. subscription,
  56. } = this.props;
  57. const cronCategoryName = DATA_CATEGORY_INFO[DataCategoryExact.MONITOR_SEAT].plural;
  58. if (
  59. onDemandBudget.budgetMode === OnDemandBudgetMode.SHARED &&
  60. displayBudgetMode === OnDemandBudgetMode.SHARED
  61. ) {
  62. return (
  63. <InputFields style={{alignSelf: 'center'}}>
  64. <Tooltip disabled={onDemandSupported} title={this.onDemandUnsupportedCopy()}>
  65. <InputDiv>
  66. <div>
  67. <Description>{t('Monthly Budget')}</Description>
  68. {subscription.planTier !== PlanTier.AM3 && (
  69. <MediumTitle>{t('All Usage')}</MediumTitle>
  70. )}
  71. </div>
  72. <Currency>
  73. <OnDemandInput
  74. disabled={!onDemandSupported}
  75. aria-label={
  76. subscription.planTier === PlanTier.AM3
  77. ? t('Pay-as-you-go max budget')
  78. : t('Shared max budget')
  79. }
  80. name="sharedMaxBudget"
  81. type="text"
  82. inputMode="numeric"
  83. pattern="[0-9]*"
  84. maxLength={7}
  85. placeholder="e.g. 50"
  86. value={coerceValue(onDemandBudget.sharedMaxBudget)}
  87. onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
  88. setOnDemandBudget({
  89. ...onDemandBudget,
  90. sharedMaxBudget: parseInputValue(e),
  91. });
  92. }}
  93. />
  94. </Currency>
  95. </InputDiv>
  96. </Tooltip>
  97. <CronsOnDemandStepWarning
  98. currentOnDemand={onDemandBudget.sharedMaxBudget ?? 0}
  99. activePlan={activePlan}
  100. organization={organization}
  101. subscription={subscription}
  102. />
  103. </InputFields>
  104. );
  105. }
  106. if (
  107. onDemandBudget.budgetMode === OnDemandBudgetMode.PER_CATEGORY &&
  108. displayBudgetMode === OnDemandBudgetMode.PER_CATEGORY
  109. ) {
  110. return (
  111. <InputFields>
  112. {activePlan.onDemandCategories.map(category => {
  113. const categoryBudgetKey = `${category}Budget`;
  114. const displayName = getPlanCategoryName({plan: activePlan, category});
  115. return (
  116. <Fragment key={category}>
  117. <Tooltip
  118. disabled={onDemandSupported}
  119. title={this.onDemandUnsupportedCopy()}
  120. >
  121. <InputDiv>
  122. <div>
  123. <MediumTitle>{displayName}</MediumTitle>
  124. <Description>{t('Monthly Budget')}</Description>
  125. </div>
  126. <Currency>
  127. <OnDemandInput
  128. disabled={!onDemandSupported}
  129. aria-label={`${displayName} budget`}
  130. name={categoryBudgetKey}
  131. type="text"
  132. inputMode="numeric"
  133. pattern="[0-9]*"
  134. maxLength={7}
  135. placeholder="e.g. 50"
  136. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  137. value={coerceValue(onDemandBudget.budgets[category] ?? 0)}
  138. onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
  139. const inputValue = parseInputValue(e);
  140. const updatedBudgets = {
  141. ...onDemandBudget.budgets,
  142. [category]: inputValue,
  143. };
  144. setOnDemandBudget({
  145. ...onDemandBudget,
  146. ...{[categoryBudgetKey]: inputValue},
  147. budgets: updatedBudgets,
  148. });
  149. }}
  150. />
  151. </Currency>
  152. </InputDiv>
  153. </Tooltip>
  154. </Fragment>
  155. );
  156. })}
  157. <CronsOnDemandStepWarning
  158. currentOnDemand={onDemandBudget.budgets[cronCategoryName] ?? 0}
  159. activePlan={activePlan}
  160. organization={organization}
  161. subscription={subscription}
  162. />
  163. </InputFields>
  164. );
  165. }
  166. return null;
  167. };
  168. render() {
  169. const {
  170. onDemandBudget,
  171. onDemandEnabled,
  172. onDemandSupported,
  173. currentBudgetMode,
  174. setBudgetMode,
  175. activePlan,
  176. subscription,
  177. } = this.props;
  178. const selectedBudgetMode = onDemandBudget.budgetMode;
  179. const oxfordCategories = listDisplayNames({
  180. plan: activePlan,
  181. categories: activePlan.onDemandCategories,
  182. });
  183. if (subscription.planTier === PlanTier.AM3) {
  184. return (
  185. <PaygBody>
  186. <BudgetDetails>
  187. <Description>
  188. {t(
  189. "This budget ensures continued monitoring after you've used up your reserved event volume. We'll only charge you for actual usage, so this is your maximum charge for overage.%s",
  190. subscription.isSelfServePartner
  191. ? ` This will be part of your ${subscription.partner?.partnership.displayName} bill.`
  192. : ''
  193. )}
  194. </Description>
  195. {this.renderInputFields(OnDemandBudgetMode.SHARED)}
  196. </BudgetDetails>
  197. </PaygBody>
  198. );
  199. }
  200. return (
  201. <PanelBody>
  202. <BudgetModeOption isSelected={selectedBudgetMode === OnDemandBudgetMode.SHARED}>
  203. <Label aria-label={t('Shared')}>
  204. <div>
  205. <BudgetContainer>
  206. <StyledRadio
  207. readOnly
  208. id="shared"
  209. value="shared"
  210. data-test-id="shared-budget-radio"
  211. checked={selectedBudgetMode === OnDemandBudgetMode.SHARED}
  212. disabled={!onDemandSupported}
  213. onClick={() => {
  214. setBudgetMode(OnDemandBudgetMode.SHARED);
  215. }}
  216. />
  217. <BudgetDetails>
  218. <Title>
  219. <OnDemandType>{t('Shared')}</OnDemandType>
  220. {onDemandEnabled &&
  221. currentBudgetMode === OnDemandBudgetMode.SHARED && (
  222. <Tag>{t('Current Budget')}</Tag>
  223. )}
  224. </Title>
  225. <Description>
  226. {t(
  227. 'The on-demand budget is shared among all categories on a first come, first serve basis. There are no restrictions for any single category consuming the entire budget.'
  228. )}
  229. </Description>
  230. {this.renderInputFields(OnDemandBudgetMode.SHARED)}
  231. </BudgetDetails>
  232. </BudgetContainer>
  233. </div>
  234. </Label>
  235. </BudgetModeOption>
  236. <BudgetModeOption
  237. isSelected={selectedBudgetMode === OnDemandBudgetMode.PER_CATEGORY}
  238. >
  239. <Label aria-label={t('Per-Category')}>
  240. <div>
  241. <BudgetContainer>
  242. <StyledRadio
  243. readOnly
  244. id="per_category"
  245. value="per_category"
  246. data-test-id="per-category-budget-radio"
  247. checked={selectedBudgetMode === OnDemandBudgetMode.PER_CATEGORY}
  248. disabled={!onDemandSupported}
  249. onClick={() => {
  250. setBudgetMode(OnDemandBudgetMode.PER_CATEGORY);
  251. }}
  252. />
  253. <BudgetDetails>
  254. <Title>
  255. <OnDemandType>{t('Per-Category')}</OnDemandType>
  256. {onDemandEnabled &&
  257. currentBudgetMode === OnDemandBudgetMode.PER_CATEGORY && (
  258. <Tag>{t('Current Budget')}</Tag>
  259. )}
  260. </Title>
  261. <Description>
  262. {t(
  263. 'Dedicated on-demand budget for %s. Any overages in one category will not consume the budget of another category.',
  264. oxfordCategories
  265. )}
  266. </Description>
  267. {this.renderInputFields(OnDemandBudgetMode.PER_CATEGORY)}
  268. </BudgetDetails>
  269. </BudgetContainer>
  270. </div>
  271. </Label>
  272. </BudgetModeOption>
  273. </PanelBody>
  274. );
  275. }
  276. }
  277. const BudgetModeOption = styled(PanelItem)<{isSelected?: boolean}>`
  278. padding: 0;
  279. border-bottom: 1px solid ${p => p.theme.innerBorder};
  280. ${p =>
  281. p.isSelected &&
  282. css`
  283. background: ${p.theme.backgroundSecondary};
  284. color: ${p.theme.textColor};
  285. `}
  286. `;
  287. const Label = styled('label')`
  288. padding: ${space(2)} ${space(4)};
  289. font-weight: normal;
  290. width: 100%;
  291. margin: 0;
  292. `;
  293. const PaygBody = styled('div')`
  294. padding: ${space(2)} ${space(4)};
  295. font-weight: normal;
  296. `;
  297. const BudgetContainer = styled('div')`
  298. display: grid;
  299. grid-template-columns: max-content auto;
  300. gap: ${space(1.5)};
  301. `;
  302. const InputFields = styled('div')`
  303. color: ${p => p.theme.gray400};
  304. font-size: ${p => p.theme.fontSizeExtraLarge};
  305. margin-bottom: 1px;
  306. `;
  307. const StyledRadio = styled(Radio)`
  308. background: ${p => p.theme.background};
  309. `;
  310. const BudgetDetails = styled('div')`
  311. display: inline-grid;
  312. gap: ${space(0.75)};
  313. font-size: ${p => p.theme.fontSizeExtraLarge};
  314. color: ${p => p.theme.textColor};
  315. `;
  316. const Title = styled('div')`
  317. display: flex;
  318. flex-direction: row;
  319. gap: 1rem;
  320. flex-wrap: nowrap;
  321. `;
  322. const Description = styled(TextBlock)`
  323. font-size: ${p => p.theme.fontSizeMedium};
  324. color: ${p => p.theme.gray300};
  325. margin: 0;
  326. `;
  327. const Currency = styled('div')`
  328. &::before {
  329. position: absolute;
  330. padding: 9px ${space(1.5)};
  331. content: '$';
  332. color: ${p => p.theme.subText};
  333. font-weight: bold;
  334. font-size: ${p => p.theme.fontSizeMedium};
  335. }
  336. `;
  337. const OnDemandInput = styled(Input)`
  338. padding-left: ${space(4)};
  339. color: ${p => p.theme.textColor};
  340. max-width: 140px;
  341. height: 36px;
  342. `;
  343. const OnDemandType = styled('div')`
  344. font-weight: 600;
  345. `;
  346. const MediumTitle = styled('div')`
  347. font-size: ${p => p.theme.fontSizeMedium};
  348. `;
  349. const InputDiv = styled('div')`
  350. display: grid;
  351. grid-template-columns: repeat(2, auto);
  352. justify-content: space-between;
  353. gap: ${space(0.5)};
  354. align-items: center;
  355. padding: ${space(1)} 0;
  356. `;
  357. export default OnDemandBudgetEdit;