utils.tsx 14 KB

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