groupStore.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  1. import isArray from 'lodash/isArray';
  2. import isUndefined from 'lodash/isUndefined';
  3. import Reflux from 'reflux';
  4. import GroupActions from 'sentry/actions/groupActions';
  5. import {t} from 'sentry/locale';
  6. import IndicatorStore from 'sentry/stores/indicatorStore';
  7. import {
  8. Activity,
  9. BaseGroup,
  10. Group,
  11. GroupCollapseRelease,
  12. GroupRelease,
  13. GroupStats,
  14. } from 'sentry/types';
  15. function showAlert(msg, type) {
  16. IndicatorStore.addMessage(msg, type, {duration: 4000});
  17. }
  18. // TODO(ts) Type this any better.
  19. type Change = [string, string, any];
  20. class PendingChangeQueue {
  21. changes: Change[] = [];
  22. getForItem(itemId: string) {
  23. return this.changes.filter((change: Change) => change[1] === itemId);
  24. }
  25. push(changeId: string, itemId: string, data: any) {
  26. this.changes.push([changeId, itemId, data]);
  27. }
  28. remove(changeId: string, itemId?: string) {
  29. this.changes = this.changes.filter(
  30. change => change[0] !== changeId || change[1] !== itemId
  31. );
  32. }
  33. forEach(...args: any[]) {
  34. this.changes.forEach.apply(this.changes, args);
  35. }
  36. }
  37. type Item = BaseGroup | Group | GroupCollapseRelease;
  38. type Internals = {
  39. addActivity: (groupId: string, data: Activity, index?: number) => void;
  40. indexOfActivity: (groupId: string, id: string) => number;
  41. items: Item[];
  42. pendingChanges: PendingChangeQueue;
  43. removeActivity: (groupId: string, id: string) => number;
  44. statuses: Record<string, Record<string, boolean>>;
  45. updateActivity: (groupId: string, id: string, data: Partial<Activity>) => void;
  46. };
  47. type GroupStoreInterface = Reflux.StoreDefinition & {
  48. add: (items: Item[]) => void;
  49. addStatus: (id: string, status: string) => void;
  50. clearStatus: (id: string, status: string) => void;
  51. get: (id: string) => Item | undefined;
  52. getAllItemIds: () => string[];
  53. getAllItems: () => Item[];
  54. hasStatus: (id: string, status: string) => boolean;
  55. init: () => void;
  56. loadInitialData: (items: Item[]) => void;
  57. onAssignTo: (changeId: string, itemId: string, data: any) => void;
  58. onAssignToError: (changeId: string, itemId: string, error: Error) => void;
  59. onAssignToSuccess: (changeId: string, itemId: string, response: any) => void;
  60. onDelete: (changeId: string, itemIds: string[]) => void;
  61. onDeleteError: (changeId: string, itemIds: string[], error: Error) => void;
  62. onDeleteSuccess: (changeId: string, itemIds: string[], response: any) => void;
  63. onDiscard: (changeId: string, itemId: string) => void;
  64. onDiscardError: (changeId: string, itemId: string, response: any) => void;
  65. onDiscardSuccess: (changeId: string, itemId: string, response: any) => void;
  66. onMerge: (changeId: string, itemIds: string[]) => void;
  67. onMergeError: (changeId: string, itemIds: string[], response: any) => void;
  68. onMergeSuccess: (changeId: string, itemIds: string[], response: any) => void;
  69. onPopulateReleases: (itemId: string, releaseData: GroupRelease) => void;
  70. onPopulateStats: (itemIds: string[], response: GroupStats[]) => void;
  71. onUpdate: (changeId: string, itemIds: string[], data: any) => void;
  72. onUpdateError: (
  73. changeId: string,
  74. itemIds: string[],
  75. error: Error,
  76. silent: boolean
  77. ) => void;
  78. onUpdateSuccess: (
  79. changeId: string,
  80. itemIds: string[],
  81. response: Partial<Group>
  82. ) => void;
  83. remove: (itemIds: string[]) => void;
  84. reset: () => void;
  85. };
  86. const storeConfig: Reflux.StoreDefinition & Internals & GroupStoreInterface = {
  87. listenables: [GroupActions],
  88. pendingChanges: new PendingChangeQueue(),
  89. items: [],
  90. statuses: {},
  91. init() {
  92. this.reset();
  93. },
  94. reset() {
  95. this.pendingChanges = new PendingChangeQueue();
  96. this.items = [];
  97. this.statuses = {};
  98. },
  99. // TODO(dcramer): this should actually come from an action of some sorts
  100. loadInitialData(items) {
  101. this.reset();
  102. const itemIds = new Set<string>();
  103. items.forEach(item => {
  104. itemIds.add(item.id);
  105. this.items.push(item);
  106. });
  107. this.trigger(itemIds);
  108. },
  109. add(items) {
  110. if (!isArray(items)) {
  111. items = [items];
  112. }
  113. const itemsById = {};
  114. const itemIds = new Set<string>();
  115. items.forEach(item => {
  116. itemsById[item.id] = item;
  117. itemIds.add(item.id);
  118. });
  119. // See if any existing items are updated by this new set of items
  120. this.items.forEach((item, idx) => {
  121. if (itemsById[item.id]) {
  122. this.items[idx] = {
  123. ...item,
  124. ...itemsById[item.id],
  125. };
  126. delete itemsById[item.id];
  127. }
  128. });
  129. // New items
  130. for (const itemId in itemsById) {
  131. this.items.push(itemsById[itemId]);
  132. }
  133. this.trigger(itemIds);
  134. },
  135. remove(itemIds) {
  136. this.items = this.items.filter(item => !itemIds.includes(item.id));
  137. this.trigger(new Set(itemIds));
  138. },
  139. addStatus(id, status) {
  140. if (isUndefined(this.statuses[id])) {
  141. this.statuses[id] = {};
  142. }
  143. this.statuses[id][status] = true;
  144. },
  145. clearStatus(id, status) {
  146. if (isUndefined(this.statuses[id])) {
  147. return;
  148. }
  149. this.statuses[id][status] = false;
  150. },
  151. hasStatus(id, status) {
  152. if (isUndefined(this.statuses[id])) {
  153. return false;
  154. }
  155. return this.statuses[id][status] || false;
  156. },
  157. indexOfActivity(group_id, id) {
  158. const group = this.get(group_id);
  159. if (!group) {
  160. return -1;
  161. }
  162. for (let i = 0; i < group.activity.length; i++) {
  163. if (group.activity[i].id === id) {
  164. return i;
  165. }
  166. }
  167. return -1;
  168. },
  169. addActivity(id, data, index = -1) {
  170. const group = this.get(id);
  171. if (!group) {
  172. return;
  173. }
  174. // insert into beginning by default
  175. if (index === -1) {
  176. group.activity.unshift(data);
  177. } else {
  178. group.activity.splice(index, 0, data);
  179. }
  180. if (data.type === 'note') {
  181. group.numComments++;
  182. }
  183. this.trigger(new Set([id]));
  184. },
  185. updateActivity(group_id, id, data) {
  186. const group = this.get(group_id);
  187. if (!group) {
  188. return;
  189. }
  190. const index = this.indexOfActivity(group_id, id);
  191. if (index === -1) {
  192. return;
  193. }
  194. // Here, we want to merge the new `data` being passed in
  195. // into the existing `data` object. This effectively
  196. // allows passing in an object of only changes.
  197. group.activity[index].data = Object.assign(group.activity[index].data, data);
  198. this.trigger(new Set([group.id]));
  199. },
  200. removeActivity(group_id, id) {
  201. const group = this.get(group_id);
  202. if (!group) {
  203. return -1;
  204. }
  205. const index = this.indexOfActivity(group.id, id);
  206. if (index === -1) {
  207. return -1;
  208. }
  209. const activity = group.activity.splice(index, 1);
  210. if (activity[0].type === 'note') {
  211. group.numComments--;
  212. }
  213. this.trigger(new Set([group.id]));
  214. return index;
  215. },
  216. get(id) {
  217. // TODO(ts) This needs to be constrained further. It was left as any
  218. // because the PendingChanges signatures and this were not aligned.
  219. const pendingForId: any[] = [];
  220. this.pendingChanges.forEach(change => {
  221. if (change.id === id) {
  222. pendingForId.push(change);
  223. }
  224. });
  225. for (let i = 0; i < this.items.length; i++) {
  226. if (this.items[i].id === id) {
  227. let rItem = this.items[i];
  228. if (pendingForId.length) {
  229. // copy the object so dirty state doesnt mutate original
  230. rItem = {...rItem};
  231. for (let c = 0; c < pendingForId.length; c++) {
  232. rItem = {
  233. ...rItem,
  234. ...pendingForId[c].params,
  235. };
  236. }
  237. }
  238. return rItem;
  239. }
  240. }
  241. return undefined;
  242. },
  243. getAllItemIds() {
  244. return this.items.map(item => item.id);
  245. },
  246. getAllItems() {
  247. // regroup pending changes by their itemID
  248. const pendingById = {};
  249. this.pendingChanges.forEach(change => {
  250. if (isUndefined(pendingById[change.id])) {
  251. pendingById[change.id] = [];
  252. }
  253. pendingById[change.id].push(change);
  254. });
  255. return this.items.map(item => {
  256. let rItem = item;
  257. if (!isUndefined(pendingById[item.id])) {
  258. // copy the object so dirty state doesnt mutate original
  259. rItem = {...rItem};
  260. pendingById[item.id].forEach(change => {
  261. rItem = {
  262. ...rItem,
  263. ...change.params,
  264. };
  265. });
  266. }
  267. return rItem;
  268. });
  269. },
  270. onAssignTo(_changeId, itemId, _data) {
  271. this.addStatus(itemId, 'assignTo');
  272. this.trigger(new Set([itemId]));
  273. },
  274. // TODO(dcramer): This is not really the best place for this
  275. onAssignToError(_changeId, itemId, _error) {
  276. this.clearStatus(itemId, 'assignTo');
  277. showAlert(t('Unable to change assignee. Please try again.'), 'error');
  278. },
  279. onAssignToSuccess(_changeId, itemId, response) {
  280. const item = this.get(itemId);
  281. if (!item) {
  282. return;
  283. }
  284. item.assignedTo = response.assignedTo;
  285. this.clearStatus(itemId, 'assignTo');
  286. this.trigger(new Set([itemId]));
  287. },
  288. onDelete(_changeId, itemIds) {
  289. itemIds = this._itemIdsOrAll(itemIds);
  290. itemIds.forEach(itemId => {
  291. this.addStatus(itemId, 'delete');
  292. });
  293. this.trigger(new Set(itemIds));
  294. },
  295. onDeleteError(_changeId, itemIds, _response) {
  296. showAlert(t('Unable to delete events. Please try again.'), 'error');
  297. if (!itemIds) {
  298. return;
  299. }
  300. itemIds.forEach(itemId => {
  301. this.clearStatus(itemId, 'delete');
  302. });
  303. this.trigger(new Set(itemIds));
  304. },
  305. onDeleteSuccess(_changeId, itemIds, _response) {
  306. itemIds = this._itemIdsOrAll(itemIds);
  307. const itemIdSet = new Set(itemIds);
  308. itemIds.forEach(itemId => {
  309. delete this.statuses[itemId];
  310. this.clearStatus(itemId, 'delete');
  311. });
  312. this.items = this.items.filter(item => !itemIdSet.has(item.id));
  313. showAlert(t('The selected events have been scheduled for deletion.'), 'success');
  314. this.trigger(new Set(itemIds));
  315. },
  316. onDiscard(_changeId, itemId) {
  317. this.addStatus(itemId, 'discard');
  318. this.trigger(new Set([itemId]));
  319. },
  320. onDiscardError(_changeId, itemId, _response) {
  321. this.clearStatus(itemId, 'discard');
  322. showAlert(t('Unable to discard event. Please try again.'), 'error');
  323. this.trigger(new Set([itemId]));
  324. },
  325. onDiscardSuccess(_changeId, itemId, _response) {
  326. delete this.statuses[itemId];
  327. this.clearStatus(itemId, 'discard');
  328. this.items = this.items.filter(item => item.id !== itemId);
  329. showAlert(t('Similar events will be filtered and discarded.'), 'success');
  330. this.trigger(new Set([itemId]));
  331. },
  332. onMerge(_changeId, itemIds) {
  333. itemIds = this._itemIdsOrAll(itemIds);
  334. itemIds.forEach(itemId => {
  335. this.addStatus(itemId, 'merge');
  336. });
  337. // XXX(billy): Not sure if this is a bug or not but do we need to publish all itemIds?
  338. // Seems like we only need to publish parent id
  339. this.trigger(new Set(itemIds));
  340. },
  341. onMergeError(_changeId, itemIds, _response) {
  342. itemIds = this._itemIdsOrAll(itemIds);
  343. itemIds.forEach(itemId => {
  344. this.clearStatus(itemId, 'merge');
  345. });
  346. showAlert(t('Unable to merge events. Please try again.'), 'error');
  347. this.trigger(new Set(itemIds));
  348. },
  349. onMergeSuccess(_changeId, mergedIds, response) {
  350. mergedIds = this._itemIdsOrAll(mergedIds); // everything on page
  351. mergedIds.forEach(itemId => {
  352. this.clearStatus(itemId, 'merge');
  353. });
  354. // Remove all but parent id (items were merged into this one)
  355. const mergedIdSet = new Set(mergedIds);
  356. // Looks like the `PUT /api/0/projects/:orgId/:projectId/issues/` endpoint
  357. // actually returns a 204, so there is no `response` body
  358. this.items = this.items.filter(
  359. item =>
  360. !mergedIdSet.has(item.id) ||
  361. (response && response.merge && item.id === response.merge.parent)
  362. );
  363. showAlert(t('The selected events have been scheduled for merge.'), 'success');
  364. this.trigger(new Set(mergedIds));
  365. },
  366. /**
  367. * If itemIds is undefined, returns all ids in the store
  368. */
  369. _itemIdsOrAll(itemIds) {
  370. if (isUndefined(itemIds)) {
  371. itemIds = this.items.map(item => item.id);
  372. }
  373. return itemIds;
  374. },
  375. onUpdate(changeId, itemIds, data) {
  376. itemIds = this._itemIdsOrAll(itemIds);
  377. itemIds.forEach(itemId => {
  378. this.addStatus(itemId, 'update');
  379. this.pendingChanges.push(changeId, itemId, data);
  380. });
  381. this.trigger(new Set(itemIds));
  382. },
  383. onUpdateError(changeId, itemIds, _error, failSilently) {
  384. itemIds = this._itemIdsOrAll(itemIds);
  385. this.pendingChanges.remove(changeId);
  386. itemIds.forEach(itemId => {
  387. this.clearStatus(itemId, 'update');
  388. });
  389. if (!failSilently) {
  390. showAlert(t('Unable to update events. Please try again.'), 'error');
  391. }
  392. this.trigger(new Set(itemIds));
  393. },
  394. onUpdateSuccess(changeId, itemIds, response) {
  395. itemIds = this._itemIdsOrAll(itemIds);
  396. this.items.forEach((item, idx) => {
  397. if (itemIds.indexOf(item.id) !== -1) {
  398. this.items[idx] = {
  399. ...item,
  400. ...response,
  401. };
  402. this.clearStatus(item.id, 'update');
  403. }
  404. });
  405. this.pendingChanges.remove(changeId);
  406. this.trigger(new Set(itemIds));
  407. },
  408. onPopulateStats(itemIds: string[], response: GroupStats[]) {
  409. // Organize stats by id
  410. const groupStatsMap = response.reduce((map, stats) => {
  411. map[stats.id] = stats;
  412. return map;
  413. }, {});
  414. this.items.forEach((item, idx) => {
  415. if (itemIds.includes(item.id)) {
  416. this.items[idx] = {
  417. ...item,
  418. ...groupStatsMap[item.id],
  419. };
  420. }
  421. });
  422. this.trigger(new Set(this.items.map(item => item.id)));
  423. },
  424. onPopulateReleases(itemId: string, releaseData: GroupRelease) {
  425. this.items.forEach((item, idx) => {
  426. if (item.id === itemId) {
  427. this.items[idx] = {
  428. ...item,
  429. ...releaseData,
  430. };
  431. }
  432. });
  433. this.trigger(new Set([itemId]));
  434. },
  435. };
  436. const GroupStore = Reflux.createStore(storeConfig) as Reflux.Store & GroupStoreInterface;
  437. export default GroupStore;