groupStore.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. import isArray from 'lodash/isArray';
  2. import isUndefined from 'lodash/isUndefined';
  3. import {createStore, StoreDefinition} 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. import {makeSafeRefluxStore} from 'sentry/utils/makeSafeRefluxStore';
  16. function showAlert(msg, type) {
  17. IndicatorStore.addMessage(msg, type, {duration: 4000});
  18. }
  19. // TODO(ts) Type this any better.
  20. type Change = [string, string, any];
  21. class PendingChangeQueue {
  22. changes: Change[] = [];
  23. getForItem(itemId: string) {
  24. return this.changes.filter((change: Change) => change[1] === itemId);
  25. }
  26. push(changeId: string, itemId: string, data: any) {
  27. this.changes.push([changeId, itemId, data]);
  28. }
  29. remove(changeId: string, itemId?: string) {
  30. this.changes = this.changes.filter(
  31. change => change[0] !== changeId || change[1] !== itemId
  32. );
  33. }
  34. forEach(...args: any[]) {
  35. this.changes.forEach.apply(this.changes, args);
  36. }
  37. }
  38. type Item = BaseGroup | Group | GroupCollapseRelease;
  39. interface InternalDefinition {
  40. addActivity: (groupId: string, data: Activity, index?: number) => void;
  41. indexOfActivity: (groupId: string, id: string) => number;
  42. items: Item[];
  43. pendingChanges: PendingChangeQueue;
  44. removeActivity: (groupId: string, id: string) => number;
  45. statuses: Record<string, Record<string, boolean>>;
  46. updateActivity: (groupId: string, id: string, data: Partial<Activity>) => void;
  47. }
  48. interface GroupStoreDefinition extends StoreDefinition, InternalDefinition {
  49. add: (items: Item[]) => void;
  50. addStatus: (id: string, status: string) => void;
  51. clearStatus: (id: string, status: string) => void;
  52. get: (id: string) => Item | undefined;
  53. getAllItemIds: () => string[];
  54. getAllItems: () => Item[];
  55. hasStatus: (id: string, status: string) => boolean;
  56. init: () => void;
  57. loadInitialData: (items: Item[]) => void;
  58. onAssignTo: (changeId: string, itemId: string, data: any) => void;
  59. onAssignToError: (changeId: string, itemId: string, error: Error) => void;
  60. onAssignToSuccess: (changeId: string, itemId: string, response: any) => void;
  61. onDelete: (changeId: string, itemIds: string[]) => void;
  62. onDeleteError: (changeId: string, itemIds: string[], error: Error) => void;
  63. onDeleteSuccess: (changeId: string, itemIds: string[], response: any) => void;
  64. onDiscard: (changeId: string, itemId: string) => void;
  65. onDiscardError: (changeId: string, itemId: string, response: any) => void;
  66. onDiscardSuccess: (changeId: string, itemId: string, response: any) => void;
  67. onMerge: (changeId: string, itemIds: string[]) => void;
  68. onMergeError: (changeId: string, itemIds: string[], response: any) => void;
  69. onMergeSuccess: (changeId: string, itemIds: string[], response: any) => void;
  70. onPopulateReleases: (itemId: string, releaseData: GroupRelease) => void;
  71. onPopulateStats: (itemIds: string[], response: GroupStats[]) => void;
  72. onUpdate: (changeId: string, itemIds: string[], data: any) => void;
  73. onUpdateError: (
  74. changeId: string,
  75. itemIds: string[],
  76. error: Error,
  77. silent: boolean
  78. ) => void;
  79. onUpdateSuccess: (
  80. changeId: string,
  81. itemIds: string[],
  82. response: Partial<Group>
  83. ) => void;
  84. remove: (itemIds: string[]) => void;
  85. reset: () => void;
  86. }
  87. const storeConfig: GroupStoreDefinition = {
  88. listenables: [GroupActions],
  89. pendingChanges: new PendingChangeQueue(),
  90. items: [],
  91. statuses: {},
  92. init() {
  93. this.reset();
  94. },
  95. reset() {
  96. this.pendingChanges = new PendingChangeQueue();
  97. this.items = [];
  98. this.statuses = {};
  99. },
  100. // TODO(dcramer): this should actually come from an action of some sorts
  101. loadInitialData(items) {
  102. this.reset();
  103. const itemIds = new Set<string>();
  104. items.forEach(item => {
  105. itemIds.add(item.id);
  106. this.items.push(item);
  107. });
  108. this.trigger(itemIds);
  109. },
  110. add(items) {
  111. if (!isArray(items)) {
  112. items = [items];
  113. }
  114. const itemsById: Record<string, Item> = {};
  115. const itemIds = new Set<string>();
  116. items.forEach(item => {
  117. itemsById[item.id] = item;
  118. itemIds.add(item.id);
  119. });
  120. // See if any existing items are updated by this new set of items
  121. this.items.forEach((item, idx) => {
  122. if (itemsById[item.id]) {
  123. this.items[idx] = {
  124. ...item,
  125. ...itemsById[item.id],
  126. };
  127. delete itemsById[item.id];
  128. }
  129. });
  130. // New items
  131. const newItems = items.filter(item => itemsById.hasOwnProperty(item.id));
  132. this.items = this.items.concat(newItems);
  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. if (itemIds.length > 1) {
  308. showAlert(t(`Deleted ${itemIds.length} Issues`), 'success');
  309. } else {
  310. const shortId = itemIds.map(item => GroupStore.get(item)?.shortId).join('');
  311. showAlert(t(`Deleted ${shortId}`), 'success');
  312. }
  313. const itemIdSet = new Set(itemIds);
  314. itemIds.forEach(itemId => {
  315. delete this.statuses[itemId];
  316. this.clearStatus(itemId, 'delete');
  317. });
  318. this.items = this.items.filter(item => !itemIdSet.has(item.id));
  319. this.trigger(new Set(itemIds));
  320. },
  321. onDiscard(_changeId, itemId) {
  322. this.addStatus(itemId, 'discard');
  323. this.trigger(new Set([itemId]));
  324. },
  325. onDiscardError(_changeId, itemId, _response) {
  326. this.clearStatus(itemId, 'discard');
  327. showAlert(t('Unable to discard event. Please try again.'), 'error');
  328. this.trigger(new Set([itemId]));
  329. },
  330. onDiscardSuccess(_changeId, itemId, _response) {
  331. delete this.statuses[itemId];
  332. this.clearStatus(itemId, 'discard');
  333. this.items = this.items.filter(item => item.id !== itemId);
  334. showAlert(t('Similar events will be filtered and discarded.'), 'success');
  335. this.trigger(new Set([itemId]));
  336. },
  337. onMerge(_changeId, itemIds) {
  338. itemIds = this._itemIdsOrAll(itemIds);
  339. itemIds.forEach(itemId => {
  340. this.addStatus(itemId, 'merge');
  341. });
  342. // XXX(billy): Not sure if this is a bug or not but do we need to publish all itemIds?
  343. // Seems like we only need to publish parent id
  344. this.trigger(new Set(itemIds));
  345. },
  346. onMergeError(_changeId, itemIds, _response) {
  347. itemIds = this._itemIdsOrAll(itemIds);
  348. itemIds.forEach(itemId => {
  349. this.clearStatus(itemId, 'merge');
  350. });
  351. showAlert(t('Unable to merge events. Please try again.'), 'error');
  352. this.trigger(new Set(itemIds));
  353. },
  354. onMergeSuccess(_changeId, mergedIds, response) {
  355. mergedIds = this._itemIdsOrAll(mergedIds); // everything on page
  356. mergedIds.forEach(itemId => {
  357. this.clearStatus(itemId, 'merge');
  358. });
  359. // Remove all but parent id (items were merged into this one)
  360. const mergedIdSet = new Set(mergedIds);
  361. // Looks like the `PUT /api/0/projects/:orgId/:projectId/issues/` endpoint
  362. // actually returns a 204, so there is no `response` body
  363. this.items = this.items.filter(
  364. item =>
  365. !mergedIdSet.has(item.id) ||
  366. (response && response.merge && item.id === response.merge.parent)
  367. );
  368. showAlert(t(`Merged ${mergedIds.length} Issues`), 'success');
  369. this.trigger(new Set(mergedIds));
  370. },
  371. /**
  372. * If itemIds is undefined, returns all ids in the store
  373. */
  374. _itemIdsOrAll(itemIds) {
  375. if (isUndefined(itemIds)) {
  376. itemIds = this.items.map(item => item.id);
  377. }
  378. return itemIds;
  379. },
  380. onUpdate(changeId, itemIds, data) {
  381. itemIds = this._itemIdsOrAll(itemIds);
  382. itemIds.forEach(itemId => {
  383. this.addStatus(itemId, 'update');
  384. this.pendingChanges.push(changeId, itemId, data);
  385. });
  386. this.trigger(new Set(itemIds));
  387. },
  388. onUpdateError(changeId, itemIds, _error, failSilently) {
  389. itemIds = this._itemIdsOrAll(itemIds);
  390. this.pendingChanges.remove(changeId);
  391. itemIds.forEach(itemId => {
  392. this.clearStatus(itemId, 'update');
  393. });
  394. if (!failSilently) {
  395. showAlert(t('Unable to update events. Please try again.'), 'error');
  396. }
  397. this.trigger(new Set(itemIds));
  398. },
  399. onUpdateSuccess(changeId, itemIds, response) {
  400. itemIds = this._itemIdsOrAll(itemIds);
  401. this.items.forEach((item, idx) => {
  402. if (itemIds.indexOf(item.id) !== -1) {
  403. this.items[idx] = {
  404. ...item,
  405. ...response,
  406. };
  407. this.clearStatus(item.id, 'update');
  408. }
  409. });
  410. this.pendingChanges.remove(changeId);
  411. this.trigger(new Set(itemIds));
  412. },
  413. onPopulateStats(itemIds: string[], response: GroupStats[]) {
  414. // Organize stats by id
  415. const groupStatsMap = response.reduce((map, stats) => {
  416. map[stats.id] = stats;
  417. return map;
  418. }, {});
  419. this.items.forEach((item, idx) => {
  420. if (itemIds.includes(item.id)) {
  421. this.items[idx] = {
  422. ...item,
  423. ...groupStatsMap[item.id],
  424. };
  425. }
  426. });
  427. this.trigger(new Set(this.items.map(item => item.id)));
  428. },
  429. onPopulateReleases(itemId: string, releaseData: GroupRelease) {
  430. this.items.forEach((item, idx) => {
  431. if (item.id === itemId) {
  432. this.items[idx] = {
  433. ...item,
  434. ...releaseData,
  435. };
  436. }
  437. });
  438. this.trigger(new Set([itemId]));
  439. },
  440. };
  441. const GroupStore = createStore(makeSafeRefluxStore(storeConfig));
  442. export default GroupStore;