utils.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. import set from 'lodash/set';
  2. import {FieldObject} from 'sentry/components/forms/types';
  3. import {t} from 'sentry/locale';
  4. import {OrganizationSummary, Project} from 'sentry/types';
  5. import {
  6. ALL_PROVIDERS,
  7. MIN_PROJECTS_FOR_CONFIRMATION,
  8. NOTIFICATION_SETTINGS_PATHNAMES,
  9. NotificationSettingsByProviderObject,
  10. NotificationSettingsObject,
  11. VALUE_MAPPING,
  12. } from 'sentry/views/settings/account/notifications/constants';
  13. import {NOTIFICATION_SETTING_FIELDS} from 'sentry/views/settings/account/notifications/fields2';
  14. import ParentLabel from 'sentry/views/settings/account/notifications/parentLabel';
  15. /**
  16. * Which fine-tuning parts are grouped by project
  17. */
  18. const notificationsByProject = ['alerts', 'email', 'workflow', 'spikeProtection'];
  19. export const isGroupedByProject = (notificationType: string): boolean =>
  20. notificationsByProject.includes(notificationType);
  21. export const getParentKey = (notificationType: string): string => {
  22. return isGroupedByProject(notificationType) ? 'project' : 'organization';
  23. };
  24. export const groupByOrganization = (
  25. projects: Project[]
  26. ): Record<string, {organization: OrganizationSummary; projects: Project[]}> => {
  27. return projects.reduce<
  28. Record<string, {organization: OrganizationSummary; projects: Project[]}>
  29. >((acc, project) => {
  30. const orgSlug = project.organization.slug;
  31. if (acc.hasOwnProperty(orgSlug)) {
  32. acc[orgSlug].projects.push(project);
  33. } else {
  34. acc[orgSlug] = {
  35. organization: project.organization,
  36. projects: [project],
  37. };
  38. }
  39. return acc;
  40. }, {});
  41. };
  42. export const getFallBackValue = (notificationType: string): string => {
  43. switch (notificationType) {
  44. case 'alerts':
  45. return 'always';
  46. case 'deploy':
  47. return 'committed_only';
  48. case 'workflow':
  49. return 'subscribe_only';
  50. case 'approval':
  51. return 'always';
  52. case 'quota':
  53. return 'always';
  54. case 'spikeProtection':
  55. return 'always';
  56. case 'reports':
  57. return 'always';
  58. default:
  59. // These are the expected potential settings with fallback of ''
  60. // issue, quotaErrors, quotaTransactions, quotaAttachments,
  61. // quotaReplays, quotaWarnings, quotaSpendAllocations
  62. return '';
  63. }
  64. };
  65. export const providerListToString = (providers: string[]): string => {
  66. return providers.sort().join('+');
  67. };
  68. export const getChoiceString = (choices: string[][], key: string): string => {
  69. if (!choices) {
  70. return 'default';
  71. }
  72. const found = choices.find(row => row[0] === key);
  73. if (!found) {
  74. throw new Error(`Could not find ${key}`);
  75. }
  76. return found[1];
  77. };
  78. const isDataAllNever = (data: {[key: string]: string}): boolean =>
  79. !!Object.keys(data).length && Object.values(data).every(value => value === 'never');
  80. const getNonNeverValue = (data: {[key: string]: string}): string | null =>
  81. Object.values(data).reduce(
  82. (previousValue: string | null, currentValue) =>
  83. currentValue === 'never' ? previousValue : currentValue,
  84. null
  85. );
  86. /**
  87. * Transform `data`, a mapping of providers to values, so that all providers in
  88. * `providerList` are "on" in the resulting object. The "on" value is
  89. * determined by checking `data` for non-"never" values and falling back to the
  90. * value `fallbackValue`. The "off" value is either "default" or "never"
  91. * depending on whether `scopeType` is "parent" or "user" respectively.
  92. */
  93. export const backfillMissingProvidersWithFallback = (
  94. data: {[key: string]: string},
  95. providerList: string[],
  96. fallbackValue: string,
  97. scopeType: string
  98. ): NotificationSettingsByProviderObject => {
  99. // First pass: What was this scope's previous value?
  100. let existingValue;
  101. if (scopeType === 'user') {
  102. existingValue = isDataAllNever(data)
  103. ? fallbackValue
  104. : getNonNeverValue(data) || fallbackValue;
  105. } else {
  106. existingValue = isDataAllNever(data) ? 'never' : getNonNeverValue(data) || 'default';
  107. }
  108. // Second pass: Fill in values for every provider.
  109. return Object.fromEntries(
  110. Object.keys(ALL_PROVIDERS).map(provider => [
  111. provider,
  112. providerList.includes(provider) ? existingValue : 'never',
  113. ])
  114. );
  115. };
  116. /**
  117. * Deeply merge N notification settings objects (usually just 2).
  118. */
  119. export const mergeNotificationSettings = (
  120. ...objects: NotificationSettingsObject[]
  121. ): NotificationSettingsObject => {
  122. const output: NotificationSettingsObject = {};
  123. objects.forEach(settingsByType =>
  124. Object.entries(settingsByType).forEach(([type, settingsByScopeType]) =>
  125. Object.entries(settingsByScopeType).forEach(([scopeType, settingsByScopeId]) =>
  126. Object.entries(settingsByScopeId).forEach(([scopeId, settingsByProvider]) => {
  127. set(output, [type, scopeType, scopeId].join('.'), settingsByProvider);
  128. })
  129. )
  130. )
  131. );
  132. return output;
  133. };
  134. /**
  135. * Get the mapping of providers to values that describe a user's parent-
  136. * independent notification preferences. The data from the API uses the user ID
  137. * rather than "me" so we assume the first ID is the user's.
  138. */
  139. export const getUserDefaultValues = (
  140. notificationType: string,
  141. notificationSettings: NotificationSettingsObject
  142. ): NotificationSettingsByProviderObject => {
  143. return (
  144. Object.values(notificationSettings[notificationType]?.user || {}).pop() ||
  145. Object.fromEntries(
  146. Object.entries(ALL_PROVIDERS).map(([provider, value]) => [
  147. provider,
  148. value === 'default' ? getFallBackValue(notificationType) : value,
  149. ])
  150. )
  151. );
  152. };
  153. /**
  154. * Get the list of providers currently active on this page. Note: this can be empty.
  155. */
  156. export const getCurrentProviders = (
  157. notificationType: string,
  158. notificationSettings: NotificationSettingsObject
  159. ): string[] => {
  160. const userData = getUserDefaultValues(notificationType, notificationSettings);
  161. return Object.entries(userData)
  162. .filter(([_, value]) => !['never'].includes(value))
  163. .map(([provider, _]) => provider);
  164. };
  165. /**
  166. * Calculate the currently selected provider.
  167. */
  168. export const getCurrentDefault = (
  169. notificationType: string,
  170. notificationSettings: NotificationSettingsObject
  171. ): string => {
  172. const providersList = getCurrentProviders(notificationType, notificationSettings);
  173. return providersList.length
  174. ? getUserDefaultValues(notificationType, notificationSettings)[providersList[0]]
  175. : 'never';
  176. };
  177. /**
  178. * For a given notificationType, are the parent-independent setting "never" for
  179. * all providers and are the parent-specific settings "default" or "never". If
  180. * so, the API is telling us that the user has opted out of all notifications.
  181. */
  182. export const decideDefault = (
  183. notificationType: string,
  184. notificationSettings: NotificationSettingsObject
  185. ): string => {
  186. const compare = (a: string, b: string): number => VALUE_MAPPING[a] - VALUE_MAPPING[b];
  187. const parentIndependentSetting =
  188. Object.values(getUserDefaultValues(notificationType, notificationSettings))
  189. .sort(compare)
  190. .pop() || 'never';
  191. if (parentIndependentSetting !== 'never') {
  192. return parentIndependentSetting;
  193. }
  194. const parentSpecificSetting =
  195. Object.values(
  196. notificationSettings[notificationType]?.[getParentKey(notificationType)] || {}
  197. )
  198. .flatMap(settingsByProvider => Object.values(settingsByProvider))
  199. .sort(compare)
  200. .pop() || 'default';
  201. return parentSpecificSetting === 'default' ? 'never' : parentSpecificSetting;
  202. };
  203. /**
  204. * For a given notificationType, are the parent-independent setting "never" for
  205. * all providers and are the parent-specific settings "default" or "never"? If
  206. * so, the API is telling us that the user has opted out of all notifications.
  207. */
  208. export const isEverythingDisabled = (
  209. notificationType: string,
  210. notificationSettings: NotificationSettingsObject
  211. ): boolean =>
  212. ['never', 'default'].includes(decideDefault(notificationType, notificationSettings));
  213. /**
  214. * Extract either the list of project or organization IDs from the notification
  215. * settings in state. This assumes that the notification settings object is
  216. * fully backfilled with settings for every parent.
  217. */
  218. export const getParentIds = (
  219. notificationType: string,
  220. notificationSettings: NotificationSettingsObject
  221. ): string[] =>
  222. Object.keys(
  223. notificationSettings[notificationType]?.[getParentKey(notificationType)] || {}
  224. );
  225. export const getParentValues = (
  226. notificationType: string,
  227. notificationSettings: NotificationSettingsObject,
  228. parentId: string
  229. ): NotificationSettingsByProviderObject =>
  230. notificationSettings[notificationType]?.[getParentKey(notificationType)]?.[
  231. parentId
  232. ] || {
  233. email: 'default',
  234. };
  235. /**
  236. * Get a mapping of all parent IDs to the notification setting for the current
  237. * providers.
  238. */
  239. export const getParentData = (
  240. notificationType: string,
  241. notificationSettings: NotificationSettingsObject,
  242. parents: OrganizationSummary[] | Project[]
  243. ): NotificationSettingsByProviderObject => {
  244. const provider = getCurrentProviders(notificationType, notificationSettings)[0];
  245. return Object.fromEntries(
  246. parents.map(parent => [
  247. parent.id,
  248. getParentValues(notificationType, notificationSettings, parent.id)[provider],
  249. ])
  250. );
  251. };
  252. /**
  253. * Are there are more than N project or organization settings?
  254. */
  255. export const isSufficientlyComplex = (
  256. notificationType: string,
  257. notificationSettings: NotificationSettingsObject
  258. ): boolean =>
  259. getParentIds(notificationType, notificationSettings).length >
  260. MIN_PROJECTS_FOR_CONFIRMATION;
  261. /**
  262. * This is triggered when we change the Delivery Method select. Don't update the
  263. * provider for EVERY one of the user's projects and organizations, just the user
  264. * and parents that have explicit settings.
  265. */
  266. export const getStateToPutForProvider = (
  267. notificationType: string,
  268. notificationSettings: NotificationSettingsObject,
  269. changedData: NotificationSettingsByProviderObject
  270. ): NotificationSettingsObject => {
  271. const providerList: string[] = changedData.provider
  272. ? Object.values(changedData.provider)
  273. : [];
  274. const fallbackValue = getFallBackValue(notificationType);
  275. // If the user has no settings, we need to create them.
  276. if (!Object.keys(notificationSettings).length) {
  277. return {
  278. [notificationType]: {
  279. user: {
  280. me: Object.fromEntries(providerList.map(provider => [provider, fallbackValue])),
  281. },
  282. },
  283. };
  284. }
  285. return {
  286. [notificationType]: Object.fromEntries(
  287. Object.entries(notificationSettings[notificationType]).map(
  288. ([scopeType, scopeTypeData]) => [
  289. scopeType,
  290. Object.fromEntries(
  291. Object.entries(scopeTypeData).map(([scopeId, scopeIdData]) => [
  292. scopeId,
  293. backfillMissingProvidersWithFallback(
  294. scopeIdData,
  295. providerList,
  296. fallbackValue,
  297. scopeType
  298. ),
  299. ])
  300. ),
  301. ]
  302. )
  303. ),
  304. };
  305. };
  306. /**
  307. * Update the current providers' parent-independent notification settings with
  308. * the new value. If the new value is "never", then also update all
  309. * parent-specific notification settings to "default". If the previous value
  310. * was "never", then assume providerList should be "email" only.
  311. */
  312. export const getStateToPutForDefault = (
  313. notificationType: string,
  314. notificationSettings: NotificationSettingsObject,
  315. changedData: NotificationSettingsByProviderObject,
  316. parentIds: string[]
  317. ): NotificationSettingsObject => {
  318. const newValue = Object.values(changedData)[0];
  319. let providerList = getCurrentProviders(notificationType, notificationSettings);
  320. if (!providerList.length) {
  321. providerList = ['email'];
  322. }
  323. const updatedNotificationSettings = {
  324. [notificationType]: {
  325. user: {
  326. me: Object.fromEntries(providerList.map(provider => [provider, newValue])),
  327. },
  328. },
  329. };
  330. if (newValue === 'never') {
  331. updatedNotificationSettings[notificationType][getParentKey(notificationType)] =
  332. Object.fromEntries(
  333. parentIds.map(parentId => [
  334. parentId,
  335. Object.fromEntries(providerList.map(provider => [provider, 'default'])),
  336. ])
  337. );
  338. }
  339. return updatedNotificationSettings;
  340. };
  341. /**
  342. * Get the diff of the Notification Settings for this parent ID.
  343. */
  344. export const getStateToPutForParent = (
  345. notificationType: string,
  346. notificationSettings: NotificationSettingsObject,
  347. changedData: NotificationSettingsByProviderObject,
  348. parentId: string
  349. ): NotificationSettingsObject => {
  350. const providerList = getCurrentProviders(notificationType, notificationSettings);
  351. const newValue = Object.values(changedData)[0];
  352. return {
  353. [notificationType]: {
  354. [getParentKey(notificationType)]: {
  355. [parentId]: Object.fromEntries(
  356. providerList.map(provider => [provider, newValue])
  357. ),
  358. },
  359. },
  360. };
  361. };
  362. /**
  363. * Render each parent and add a default option to the the field choices.
  364. */
  365. export const getParentField = (
  366. notificationType: string,
  367. notificationSettings: NotificationSettingsObject,
  368. parent: OrganizationSummary | Project,
  369. onChange: (
  370. changedData: NotificationSettingsByProviderObject,
  371. parentId: string
  372. ) => NotificationSettingsObject
  373. ): FieldObject => {
  374. const defaultFields = NOTIFICATION_SETTING_FIELDS[notificationType];
  375. let choices = defaultFields.choices;
  376. if (Array.isArray(choices)) {
  377. choices = choices.concat([
  378. [
  379. 'default',
  380. `${t('Default')} (${getChoiceString(
  381. choices,
  382. getCurrentDefault(notificationType, notificationSettings)
  383. )})`,
  384. ],
  385. ]);
  386. }
  387. return Object.assign({}, defaultFields, {
  388. label: <ParentLabel parent={parent} notificationType={notificationType} />,
  389. getData: data => onChange(data, parent.id),
  390. name: parent.id,
  391. choices,
  392. defaultValue: 'default',
  393. help: undefined,
  394. }) as any;
  395. };
  396. /**
  397. * Returns a link to docs on explaining how to manage quotas for that event type
  398. */
  399. export function getDocsLinkForEventType(
  400. event: 'error' | 'transaction' | 'attachment' | 'replay'
  401. ) {
  402. switch (event) {
  403. case 'transaction':
  404. return 'https://docs.sentry.io/product/performance/transaction-summary/#what-is-a-transaction';
  405. case 'attachment':
  406. return 'https://docs.sentry.io/product/accounts/quotas/manage-attachments-quota/#2-rate-limiting';
  407. case 'replay':
  408. return 'https://docs.sentry.io/product/session-replay/';
  409. default:
  410. return 'https://docs.sentry.io/product/accounts/quotas/manage-event-stream-guide/#common-workflows-for-managing-your-event-stream';
  411. }
  412. }
  413. /**
  414. * Returns the corresponding notification type name from the router path name
  415. */
  416. export function getNotificationTypeFromPathname(routerPathname: string) {
  417. const result = Object.entries(NOTIFICATION_SETTINGS_PATHNAMES).find(
  418. ([_, pathname]) => pathname === routerPathname
  419. ) ?? [routerPathname];
  420. return result[0];
  421. }