utils.tsx 14 KB

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