utils.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. import set from 'lodash/set';
  2. import {t} from 'app/locale';
  3. import {OrganizationSummary, Project} from 'app/types';
  4. import {
  5. ALL_PROVIDERS,
  6. MIN_PROJECTS_FOR_CONFIRMATION,
  7. NotificationSettingsByProviderObject,
  8. NotificationSettingsObject,
  9. VALUE_MAPPING,
  10. } from 'app/views/settings/account/notifications/constants';
  11. import {NOTIFICATION_SETTING_FIELDS} from 'app/views/settings/account/notifications/fields2';
  12. import ParentLabel from 'app/views/settings/account/notifications/parentLabel';
  13. import {FieldObject} from 'app/views/settings/components/forms/type';
  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. /**
  66. * Transform `data`, a mapping of providers to values, so that all providers in
  67. * `providerList` are "on" in the resulting object. The "on" value is
  68. * determined by checking `data` for non-"never" values and falling back to the
  69. * value `fallbackValue`. The "off" value is either "default" or "never"
  70. * depending on whether `scopeType` is "parent" or "user" respectively.
  71. */
  72. export const backfillMissingProvidersWithFallback = (
  73. data: {[key: string]: string},
  74. providerList: string[],
  75. fallbackValue: string,
  76. scopeType: string
  77. ): NotificationSettingsByProviderObject => {
  78. // First pass: determine the fallback value.
  79. const fallback = Object.values(data).reduce(
  80. (previousValue, currentValue) =>
  81. currentValue === 'never' ? previousValue : currentValue,
  82. fallbackValue
  83. );
  84. // Second pass: fill in values for every provider.
  85. return Object.fromEntries(
  86. Object.keys(ALL_PROVIDERS).map(provider => [
  87. provider,
  88. providerList.includes(provider)
  89. ? fallback
  90. : scopeType === 'user'
  91. ? 'never'
  92. : 'default',
  93. ])
  94. );
  95. };
  96. /**
  97. * Deeply merge N notification settings objects (usually just 2).
  98. */
  99. export const mergeNotificationSettings = (
  100. ...objects: NotificationSettingsObject[]
  101. ): NotificationSettingsObject => {
  102. const output = {};
  103. objects.map(settingsByType =>
  104. Object.entries(settingsByType).map(([type, settingsByScopeType]) =>
  105. Object.entries(settingsByScopeType).map(([scopeType, settingsByScopeId]) =>
  106. Object.entries(settingsByScopeId).map(([scopeId, settingsByProvider]) => {
  107. set(output, [type, scopeType, scopeId].join('.'), settingsByProvider);
  108. })
  109. )
  110. )
  111. );
  112. return output;
  113. };
  114. /**
  115. * Get the mapping of providers to values that describe a user's parent-
  116. * independent notification preferences. The data from the API uses the user ID
  117. * rather than "me" so we assume the first ID is the user's.
  118. */
  119. export const getUserDefaultValues = (
  120. notificationType: string,
  121. notificationSettings: NotificationSettingsObject
  122. ): NotificationSettingsByProviderObject => {
  123. return (
  124. Object.values(notificationSettings[notificationType]?.user || {}).pop() ||
  125. Object.fromEntries(
  126. Object.entries(ALL_PROVIDERS).map(([provider, value]) => [
  127. provider,
  128. value === 'default' ? getFallBackValue(notificationType) : value,
  129. ])
  130. )
  131. );
  132. };
  133. /**
  134. * Get the list of providers currently active on this page. Note: this can be empty.
  135. */
  136. export const getCurrentProviders = (
  137. notificationType: string,
  138. notificationSettings: NotificationSettingsObject
  139. ): string[] => {
  140. const userData = getUserDefaultValues(notificationType, notificationSettings);
  141. return Object.entries(userData)
  142. .filter(([_, value]) => !['never'].includes(value))
  143. .map(([provider, _]) => provider);
  144. };
  145. /**
  146. * Calculate the currently selected provider.
  147. */
  148. export const getCurrentDefault = (
  149. notificationType: string,
  150. notificationSettings: NotificationSettingsObject
  151. ): string => {
  152. const providersList = getCurrentProviders(notificationType, notificationSettings);
  153. return providersList.length
  154. ? getUserDefaultValues(notificationType, notificationSettings)[providersList[0]]
  155. : 'never';
  156. };
  157. /**
  158. * For a given notificationType, are the parent-independent setting "never" for
  159. * all providers and are the parent-specific settings "default" or "never". If
  160. * so, the API is telling us that the user has opted out of all notifications.
  161. */
  162. export const decideDefault = (
  163. notificationType: string,
  164. notificationSettings: NotificationSettingsObject
  165. ): string => {
  166. const compare = (a: string, b: string): number => VALUE_MAPPING[a] - VALUE_MAPPING[b];
  167. const parentIndependentSetting =
  168. Object.values(getUserDefaultValues(notificationType, notificationSettings))
  169. .sort(compare)
  170. .pop() || 'never';
  171. if (parentIndependentSetting !== 'never') {
  172. return parentIndependentSetting;
  173. }
  174. const parentSpecificSetting =
  175. Object.values(
  176. notificationSettings[notificationType]?.[getParentKey(notificationType)] || {}
  177. )
  178. .flatMap(settingsByProvider => Object.values(settingsByProvider))
  179. .sort(compare)
  180. .pop() || 'default';
  181. return parentSpecificSetting === 'default' ? 'never' : parentSpecificSetting;
  182. };
  183. /**
  184. * For a given notificationType, are the parent-independent setting "never" for
  185. * all providers and are the parent-specific settings "default" or "never"? If
  186. * so, the API is telling us that the user has opted out of all notifications.
  187. */
  188. export const isEverythingDisabled = (
  189. notificationType: string,
  190. notificationSettings: NotificationSettingsObject
  191. ): boolean =>
  192. ['never', 'default'].includes(decideDefault(notificationType, notificationSettings));
  193. /**
  194. * Extract either the list of project or organization IDs from the notification
  195. * settings in state. This assumes that the notification settings object is
  196. * fully backfilled with settings for every parent.
  197. */
  198. export const getParentIds = (
  199. notificationType: string,
  200. notificationSettings: NotificationSettingsObject
  201. ): string[] =>
  202. Object.keys(
  203. notificationSettings[notificationType]?.[getParentKey(notificationType)] || {}
  204. );
  205. export const getParentValues = (
  206. notificationType: string,
  207. notificationSettings: NotificationSettingsObject,
  208. parentId: string
  209. ): NotificationSettingsByProviderObject =>
  210. notificationSettings[notificationType]?.[getParentKey(notificationType)]?.[
  211. parentId
  212. ] || {
  213. email: 'default',
  214. };
  215. /**
  216. * Get a mapping of all parent IDs to the notification setting for the current
  217. * providers.
  218. */
  219. export const getParentData = (
  220. notificationType: string,
  221. notificationSettings: NotificationSettingsObject,
  222. parents: OrganizationSummary[] | Project[]
  223. ): NotificationSettingsByProviderObject => {
  224. const provider = getCurrentProviders(notificationType, notificationSettings)[0];
  225. return Object.fromEntries(
  226. parents.map(parent => [
  227. parent.id,
  228. getParentValues(notificationType, notificationSettings, parent.id)[provider],
  229. ])
  230. );
  231. };
  232. /**
  233. * Are there are more than N project or organization settings?
  234. */
  235. export const isSufficientlyComplex = (
  236. notificationType: string,
  237. notificationSettings: NotificationSettingsObject
  238. ): boolean =>
  239. getParentIds(notificationType, notificationSettings).length >
  240. MIN_PROJECTS_FOR_CONFIRMATION;
  241. /**
  242. * I don't need to update the provider for EVERY once of the user's projects
  243. * and organizations, just the user and parents that have explicit settings.
  244. */
  245. export const getStateToPutForProvider = (
  246. notificationType: string,
  247. notificationSettings: NotificationSettingsObject,
  248. changedData: NotificationSettingsByProviderObject
  249. ): NotificationSettingsObject => {
  250. const providerList: string[] = changedData.provider.split('+');
  251. const fallbackValue = getFallBackValue(notificationType);
  252. // If the user has no settings, we need to create them.
  253. if (!Object.keys(notificationSettings).length) {
  254. return {
  255. [notificationType]: {
  256. user: {
  257. me: Object.fromEntries(providerList.map(provider => [provider, fallbackValue])),
  258. },
  259. },
  260. };
  261. }
  262. return {
  263. [notificationType]: Object.fromEntries(
  264. Object.entries(notificationSettings[notificationType]).map(
  265. ([scopeType, scopeTypeData]) => [
  266. scopeType,
  267. Object.fromEntries(
  268. Object.entries(scopeTypeData).map(([scopeId, scopeIdData]) => [
  269. scopeId,
  270. backfillMissingProvidersWithFallback(
  271. scopeIdData,
  272. providerList,
  273. fallbackValue,
  274. scopeType
  275. ),
  276. ])
  277. ),
  278. ]
  279. )
  280. ),
  281. };
  282. };
  283. /**
  284. * Update the current providers' parent-independent notification settings with
  285. * the new value. If the new value is "never", then also update all
  286. * parent-specific notification settings to "default". If the previous value
  287. * was "never", then assume providerList should be "email" only.
  288. */
  289. export const getStateToPutForDefault = (
  290. notificationType: string,
  291. notificationSettings: NotificationSettingsObject,
  292. changedData: NotificationSettingsByProviderObject,
  293. parentIds: string[]
  294. ): NotificationSettingsObject => {
  295. const newValue = Object.values(changedData)[0];
  296. let providerList = getCurrentProviders(notificationType, notificationSettings);
  297. if (!providerList.length) {
  298. providerList = ['email'];
  299. }
  300. const updatedNotificationSettings = {
  301. [notificationType]: {
  302. user: {
  303. me: Object.fromEntries(providerList.map(provider => [provider, newValue])),
  304. },
  305. },
  306. };
  307. if (newValue === 'never') {
  308. updatedNotificationSettings[notificationType][getParentKey(notificationType)] =
  309. Object.fromEntries(
  310. parentIds.map(parentId => [
  311. parentId,
  312. Object.fromEntries(providerList.map(provider => [provider, 'default'])),
  313. ])
  314. );
  315. }
  316. return updatedNotificationSettings;
  317. };
  318. /**
  319. * Get the diff of the Notification Settings for this parent ID.
  320. */
  321. export const getStateToPutForParent = (
  322. notificationType: string,
  323. notificationSettings: NotificationSettingsObject,
  324. changedData: NotificationSettingsByProviderObject,
  325. parentId: string
  326. ): NotificationSettingsObject => {
  327. const providerList = getCurrentProviders(notificationType, notificationSettings);
  328. const newValue = Object.values(changedData)[0];
  329. return {
  330. [notificationType]: {
  331. [getParentKey(notificationType)]: {
  332. [parentId]: Object.fromEntries(
  333. providerList.map(provider => [provider, newValue])
  334. ),
  335. },
  336. },
  337. };
  338. };
  339. /**
  340. * Render each parent and add a default option to the the field choices.
  341. */
  342. export const getParentField = (
  343. notificationType: string,
  344. notificationSettings: NotificationSettingsObject,
  345. parent: OrganizationSummary | Project,
  346. onChange: (
  347. changedData: NotificationSettingsByProviderObject,
  348. parentId: string
  349. ) => NotificationSettingsObject
  350. ): FieldObject => {
  351. const defaultFields = NOTIFICATION_SETTING_FIELDS[notificationType];
  352. return Object.assign({}, defaultFields, {
  353. label: <ParentLabel parent={parent} notificationType={notificationType} />,
  354. getData: data => onChange(data, parent.id),
  355. name: parent.id,
  356. choices: defaultFields.choices?.concat([
  357. [
  358. 'default',
  359. `${t('Default')} (${getChoiceString(
  360. defaultFields.choices,
  361. getCurrentDefault(notificationType, notificationSettings)
  362. )})`,
  363. ],
  364. ]),
  365. defaultValue: 'default',
  366. help: undefined,
  367. }) as any;
  368. };