notifications.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. import {Fragment, useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import isEqual from 'lodash/isEqual';
  4. import {
  5. addErrorMessage,
  6. addLoadingMessage,
  7. addSuccessMessage,
  8. } from 'sentry/actionCreators/indicator';
  9. import {Button} from 'sentry/components/button';
  10. import {CompactSelect} from 'sentry/components/compactSelect';
  11. import {AlertLink} from 'sentry/components/core/alert/alertLink';
  12. import FieldGroup from 'sentry/components/forms/fieldGroup';
  13. import LoadingError from 'sentry/components/loadingError';
  14. import LoadingIndicator from 'sentry/components/loadingIndicator';
  15. import Panel from 'sentry/components/panels/panel';
  16. import PanelBody from 'sentry/components/panels/panelBody';
  17. import PanelFooter from 'sentry/components/panels/panelFooter';
  18. import PanelHeader from 'sentry/components/panels/panelHeader';
  19. import {IconAdd, IconDelete, IconInfo} from 'sentry/icons';
  20. import {t} from 'sentry/locale';
  21. import {space} from 'sentry/styles/space';
  22. import type {RouteComponentProps} from 'sentry/types/legacyReactRouter';
  23. import type {Organization} from 'sentry/types/organization';
  24. import {useApiQuery} from 'sentry/utils/queryClient';
  25. import useApi from 'sentry/utils/useApi';
  26. import withOrganization from 'sentry/utils/withOrganization';
  27. import withSubscription from 'getsentry/components/withSubscription';
  28. import {PlanTier, type Subscription} from 'getsentry/types';
  29. import ContactBillingMembers from 'getsentry/views/contactBillingMembers';
  30. import SubscriptionHeader from './subscriptionHeader';
  31. import {trackSubscriptionView} from './utils';
  32. interface SubscriptionNotificationsProps extends RouteComponentProps<unknown, unknown> {
  33. organization: Organization;
  34. subscription: Subscription;
  35. }
  36. type ThresholdsType = {
  37. perProductOndemandPercent: number[];
  38. reservedPercent: number[];
  39. };
  40. const OPTIONS = [
  41. {label: '90%', value: 90},
  42. {label: '80%', value: 80},
  43. {label: '70%', value: 70},
  44. {label: '60%', value: 60},
  45. {label: '50%', value: 50},
  46. {label: '40%', value: 40},
  47. {label: '30%', value: 30},
  48. {label: '20%', value: 20},
  49. {label: '10%', value: 10},
  50. ];
  51. const MAX_THRESHOLDS = OPTIONS.length;
  52. function isThresholdsEqual(value: ThresholdsType, other: ThresholdsType): boolean {
  53. return isEqual(value, other);
  54. }
  55. function SubscriptionNotifications({
  56. organization,
  57. subscription,
  58. }: SubscriptionNotificationsProps) {
  59. const api = useApi();
  60. useEffect(() => {
  61. trackSubscriptionView(organization, subscription, 'notifications');
  62. }, [organization, subscription]);
  63. const {
  64. data: backendThresholds,
  65. isPending,
  66. refetch,
  67. isError,
  68. } = useApiQuery<ThresholdsType>(
  69. [`/customers/${organization.slug}/spend-notifications/`],
  70. {
  71. staleTime: 0,
  72. gcTime: 0,
  73. }
  74. );
  75. const [notificationThresholds, setNotificationThresholds] = useState<
  76. ThresholdsType | undefined
  77. >(undefined);
  78. useEffect(() => {
  79. if (!isPending && backendThresholds && !notificationThresholds) {
  80. setNotificationThresholds(backendThresholds);
  81. }
  82. }, [backendThresholds, isPending, notificationThresholds]);
  83. const hasBillingPerms = organization.access?.includes('org:billing');
  84. if (!hasBillingPerms) {
  85. return <ContactBillingMembers />;
  86. }
  87. if (isPending || !backendThresholds || !notificationThresholds) {
  88. return (
  89. <Fragment>
  90. <SubscriptionHeader subscription={subscription} organization={organization} />
  91. <LoadingIndicator />
  92. </Fragment>
  93. );
  94. }
  95. if (isError) {
  96. return <LoadingError onRetry={refetch} />;
  97. }
  98. const onDemandEnabled = subscription.planDetails.allowOnDemand;
  99. return (
  100. <Fragment>
  101. <SubscriptionHeader organization={organization} subscription={subscription} />
  102. <PageDescription>
  103. {t("Configure the thresholds for your organization's spend notifications.")}
  104. </PageDescription>
  105. <Panel>
  106. <PanelHeader>{t('Notification thresholds')}</PanelHeader>
  107. <PanelBody>
  108. <GenericConsumptionGroup
  109. label={t('Subscription Consumption')}
  110. help={t(
  111. "Receive notifications when your organization's usage exceeds a threshold (% of monthly subscription)"
  112. )}
  113. thresholds={notificationThresholds.reservedPercent}
  114. removeThreshold={indexToRemove => {
  115. setNotificationThresholds({
  116. ...notificationThresholds,
  117. reservedPercent: notificationThresholds.reservedPercent.filter(
  118. (_, index) => index !== indexToRemove
  119. ),
  120. });
  121. }}
  122. updateThreshold={(indexToUpdate, value) => {
  123. setNotificationThresholds({
  124. ...notificationThresholds,
  125. reservedPercent: notificationThresholds.reservedPercent.map(
  126. (threshold, index) => (index === indexToUpdate ? value : threshold)
  127. ),
  128. });
  129. }}
  130. addThreshold={value => {
  131. setNotificationThresholds({
  132. ...notificationThresholds,
  133. reservedPercent: [...notificationThresholds.reservedPercent, value],
  134. });
  135. }}
  136. />
  137. {onDemandEnabled && (
  138. <GenericConsumptionGroup
  139. label={
  140. subscription.planTier === PlanTier.AM3
  141. ? t('Pay-as-you-go Consumption')
  142. : t('On-Demand Consumption')
  143. }
  144. help={t(
  145. "Receive notifications when your organization's usage exceeds a threshold (%% of monthly %s budget)",
  146. subscription.planTier === PlanTier.AM3
  147. ? t('Pay-as-you-go')
  148. : t('On-Demand')
  149. )}
  150. thresholds={notificationThresholds.perProductOndemandPercent}
  151. removeThreshold={indexToRemove => {
  152. setNotificationThresholds({
  153. ...notificationThresholds,
  154. perProductOndemandPercent:
  155. notificationThresholds.perProductOndemandPercent.filter(
  156. (_, index) => index !== indexToRemove
  157. ),
  158. });
  159. }}
  160. updateThreshold={(indexToUpdate, value) => {
  161. setNotificationThresholds({
  162. ...notificationThresholds,
  163. perProductOndemandPercent:
  164. notificationThresholds.perProductOndemandPercent.map(
  165. (threshold, index) => (index === indexToUpdate ? value : threshold)
  166. ),
  167. });
  168. }}
  169. addThreshold={value => {
  170. setNotificationThresholds({
  171. ...notificationThresholds,
  172. perProductOndemandPercent: [
  173. ...notificationThresholds.perProductOndemandPercent,
  174. value,
  175. ],
  176. });
  177. }}
  178. />
  179. )}
  180. </PanelBody>
  181. <NotificationsFooter>
  182. <Button
  183. disabled={isThresholdsEqual(backendThresholds, notificationThresholds)}
  184. onClick={() => {
  185. setNotificationThresholds(backendThresholds);
  186. }}
  187. >
  188. {t('Reset')}
  189. </Button>
  190. <Button
  191. priority="primary"
  192. disabled={isThresholdsEqual(backendThresholds, notificationThresholds)}
  193. onClick={() => {
  194. addLoadingMessage(t('Saving threshold notifications\u2026'));
  195. api
  196. .requestPromise(`/customers/${organization.slug}/spend-notifications/`, {
  197. method: 'POST',
  198. data: notificationThresholds,
  199. })
  200. .then(response => {
  201. addSuccessMessage(t('Threshold notifications saved successfully.'));
  202. setNotificationThresholds(response);
  203. refetch();
  204. })
  205. .catch(() => {
  206. addErrorMessage(t('Unable to save threshold notifications.'));
  207. });
  208. }}
  209. >
  210. {t('Save Changes')}
  211. </Button>
  212. </NotificationsFooter>
  213. </Panel>
  214. <AlertLink
  215. to="/settings/account/notifications/quota/"
  216. type="info"
  217. trailingItems={<IconInfo />}
  218. >
  219. {t(
  220. 'To adjust your personal billing notification settings, please go to Fine Tune Alerts in your account settings.'
  221. )}
  222. </AlertLink>
  223. </Fragment>
  224. );
  225. }
  226. type GenericConsumptionGroupProps = {
  227. addThreshold: (value: number) => void;
  228. help: string;
  229. label: string;
  230. removeThreshold: (index: number) => void;
  231. thresholds: number[];
  232. updateThreshold: (index: number, value: number) => void;
  233. };
  234. function GenericConsumptionGroup(props: GenericConsumptionGroupProps) {
  235. const {thresholds, removeThreshold, updateThreshold, addThreshold, label, help} = props;
  236. const [newThresholdValue, setNewThresholdValue] = useState<number | undefined>(
  237. undefined
  238. );
  239. const availableOptions = OPTIONS.filter(option => !thresholds.includes(option.value));
  240. const availableThresholdValues = availableOptions.map(option => option.value);
  241. const disableRemoveButton = thresholds.length <= 1;
  242. const hideAddButton = thresholds.length >= MAX_THRESHOLDS;
  243. useEffect(() => {
  244. if (
  245. newThresholdValue !== undefined &&
  246. !availableThresholdValues.includes(newThresholdValue)
  247. ) {
  248. setNewThresholdValue(undefined);
  249. }
  250. }, [newThresholdValue, availableThresholdValues]);
  251. return (
  252. <ConsumptionGroup label={label} help={help}>
  253. <SelectGroup>
  254. {thresholds.map((threshold, index) => {
  255. return (
  256. <SelectGroupRow key={index}>
  257. <StyledCompactSelect
  258. triggerLabel={`${threshold}%`}
  259. triggerProps={{style: {width: '100%', fontWeight: 'normal'}}}
  260. value={undefined}
  261. options={availableOptions}
  262. onChange={value => {
  263. updateThreshold(index, value.value as number);
  264. }}
  265. />
  266. <Button
  267. priority="default"
  268. onClick={() => {
  269. removeThreshold(index);
  270. }}
  271. icon={<IconDelete />}
  272. disabled={disableRemoveButton}
  273. aria-label={t('Remove notification threshold')}
  274. />
  275. </SelectGroupRow>
  276. );
  277. })}
  278. {hideAddButton ? null : (
  279. <SelectGroupRow>
  280. <StyledCompactSelect
  281. triggerLabel={
  282. newThresholdValue !== undefined
  283. ? `${newThresholdValue}%`
  284. : t('Add threshold')
  285. }
  286. triggerProps={{style: {width: '100%', fontWeight: 'normal'}}}
  287. value={undefined}
  288. options={availableOptions}
  289. onChange={value => {
  290. setNewThresholdValue(value.value as number);
  291. }}
  292. />
  293. <Button
  294. priority="primary"
  295. onClick={() => {
  296. if (newThresholdValue !== undefined) {
  297. setNewThresholdValue(undefined);
  298. addThreshold(newThresholdValue);
  299. }
  300. }}
  301. icon={<IconAdd />}
  302. disabled={newThresholdValue === undefined}
  303. aria-label={t('Add notification threshold')}
  304. />
  305. </SelectGroupRow>
  306. )}
  307. </SelectGroup>
  308. </ConsumptionGroup>
  309. );
  310. }
  311. export default withOrganization(withSubscription(SubscriptionNotifications));
  312. const PageDescription = styled('p')`
  313. font-size: ${p => p.theme.fontSizeMedium};
  314. margin-bottom: ${space(2)};
  315. `;
  316. const NotificationsFooter = styled(PanelFooter)`
  317. padding: ${space(2)};
  318. display: flex;
  319. justify-content: flex-end;
  320. gap: ${space(1)};
  321. align-items: center;
  322. `;
  323. const ConsumptionGroup = styled(FieldGroup)`
  324. align-items: flex-start;
  325. `;
  326. const StyledCompactSelect = styled(CompactSelect)`
  327. width: 100%;
  328. `;
  329. const SelectGroup = styled('div')`
  330. display: flex;
  331. flex-direction: column;
  332. gap: ${space(1)};
  333. `;
  334. const SelectGroupRow = styled('div')`
  335. display: flex;
  336. gap: ${space(1)};
  337. `;