utils.tsx 14 KB

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