groupStore.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  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 = {};
  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. for (const itemId in itemsById) {
  132. this.items.push(itemsById[itemId]);
  133. }
  134. this.trigger(itemIds);
  135. },
  136. remove(itemIds) {
  137. this.items = this.items.filter(item => !itemIds.includes(item.id));
  138. this.trigger(new Set(itemIds));
  139. },
  140. addStatus(id, status) {
  141. if (isUndefined(this.statuses[id])) {
  142. this.statuses[id] = {};
  143. }
  144. this.statuses[id][status] = true;
  145. },
  146. clearStatus(id, status) {
  147. if (isUndefined(this.statuses[id])) {
  148. return;
  149. }
  150. this.statuses[id][status] = false;
  151. },
  152. hasStatus(id, status) {
  153. if (isUndefined(this.statuses[id])) {
  154. return false;
  155. }
  156. return this.statuses[id][status] || false;
  157. },
  158. indexOfActivity(group_id, id) {
  159. const group = this.get(group_id);
  160. if (!group) {
  161. return -1;
  162. }
  163. for (let i = 0; i < group.activity.length; i++) {
  164. if (group.activity[i].id === id) {
  165. return i;
  166. }
  167. }
  168. return -1;
  169. },
  170. addActivity(id, data, index = -1) {
  171. const group = this.get(id);
  172. if (!group) {
  173. return;
  174. }
  175. // insert into beginning by default
  176. if (index === -1) {
  177. group.activity.unshift(data);
  178. } else {
  179. group.activity.splice(index, 0, data);
  180. }
  181. if (data.type === 'note') {
  182. group.numComments++;
  183. }
  184. this.trigger(new Set([id]));
  185. },
  186. updateActivity(group_id, id, data) {
  187. const group = this.get(group_id);
  188. if (!group) {
  189. return;
  190. }
  191. const index = this.indexOfActivity(group_id, id);
  192. if (index === -1) {
  193. return;
  194. }
  195. // Here, we want to merge the new `data` being passed in
  196. // into the existing `data` object. This effectively
  197. // allows passing in an object of only changes.
  198. group.activity[index].data = Object.assign(group.activity[index].data, data);
  199. this.trigger(new Set([group.id]));
  200. },
  201. removeActivity(group_id, id) {
  202. const group = this.get(group_id);
  203. if (!group) {
  204. return -1;
  205. }
  206. const index = this.indexOfActivity(group.id, id);
  207. if (index === -1) {
  208. return -1;
  209. }
  210. const activity = group.activity.splice(index, 1);
  211. if (activity[0].type === 'note') {
  212. group.numComments--;
  213. }
  214. this.trigger(new Set([group.id]));
  215. return index;
  216. },
  217. get(id) {
  218. // TODO(ts) This needs to be constrained further. It was left as any
  219. // because the PendingChanges signatures and this were not aligned.
  220. const pendingForId: any[] = [];
  221. this.pendingChanges.forEach(change => {
  222. if (change.id === id) {
  223. pendingForId.push(change);
  224. }
  225. });
  226. for (let i = 0; i < this.items.length; i++) {
  227. if (this.items[i].id === id) {
  228. let rItem = this.items[i];
  229. if (pendingForId.length) {
  230. // copy the object so dirty state doesnt mutate original
  231. rItem = {...rItem};
  232. for (let c = 0; c < pendingForId.length; c++) {
  233. rItem = {
  234. ...rItem,
  235. ...pendingForId[c].params,
  236. };
  237. }
  238. }
  239. return rItem;
  240. }
  241. }
  242. return undefined;
  243. },
  244. getAllItemIds() {
  245. return this.items.map(item => item.id);
  246. },
  247. getAllItems() {
  248. // regroup pending changes by their itemID
  249. const pendingById = {};
  250. this.pendingChanges.forEach(change => {
  251. if (isUndefined(pendingById[change.id])) {
  252. pendingById[change.id] = [];
  253. }
  254. pendingById[change.id].push(change);
  255. });
  256. return this.items.map(item => {
  257. let rItem = item;
  258. if (!isUndefined(pendingById[item.id])) {
  259. // copy the object so dirty state doesnt mutate original
  260. rItem = {...rItem};
  261. pendingById[item.id].forEach(change => {
  262. rItem = {
  263. ...rItem,
  264. ...change.params,
  265. };
  266. });
  267. }
  268. return rItem;
  269. });
  270. },
  271. onAssignTo(_changeId, itemId, _data) {
  272. this.addStatus(itemId, 'assignTo');
  273. this.trigger(new Set([itemId]));
  274. },
  275. // TODO(dcramer): This is not really the best place for this
  276. onAssignToError(_changeId, itemId, _error) {
  277. this.clearStatus(itemId, 'assignTo');
  278. showAlert(t('Unable to change assignee. Please try again.'), 'error');
  279. },
  280. onAssignToSuccess(_changeId, itemId, response) {
  281. const item = this.get(itemId);
  282. if (!item) {
  283. return;
  284. }
  285. item.assignedTo = response.assignedTo;
  286. this.clearStatus(itemId, 'assignTo');
  287. this.trigger(new Set([itemId]));
  288. },
  289. onDelete(_changeId, itemIds) {
  290. itemIds = this._itemIdsOrAll(itemIds);
  291. itemIds.forEach(itemId => {
  292. this.addStatus(itemId, 'delete');
  293. });
  294. this.trigger(new Set(itemIds));
  295. },
  296. onDeleteError(_changeId, itemIds, _response) {
  297. showAlert(t('Unable to delete events. Please try again.'), 'error');
  298. if (!itemIds) {
  299. return;
  300. }
  301. itemIds.forEach(itemId => {
  302. this.clearStatus(itemId, 'delete');
  303. });
  304. this.trigger(new Set(itemIds));
  305. },
  306. onDeleteSuccess(_changeId, itemIds, _response) {
  307. itemIds = this._itemIdsOrAll(itemIds);
  308. if (itemIds.length > 1) {
  309. showAlert(t(`Deleted ${itemIds.length} Issues`), 'success');
  310. } else {
  311. const shortId = itemIds.map(item => GroupStore.get(item)?.shortId).join('');
  312. showAlert(t(`Deleted ${shortId}`), 'success');
  313. }
  314. const itemIdSet = new Set(itemIds);
  315. itemIds.forEach(itemId => {
  316. delete this.statuses[itemId];
  317. this.clearStatus(itemId, 'delete');
  318. });
  319. this.items = this.items.filter(item => !itemIdSet.has(item.id));
  320. this.trigger(new Set(itemIds));
  321. },
  322. onDiscard(_changeId, itemId) {
  323. this.addStatus(itemId, 'discard');
  324. this.trigger(new Set([itemId]));
  325. },
  326. onDiscardError(_changeId, itemId, _response) {
  327. this.clearStatus(itemId, 'discard');
  328. showAlert(t('Unable to discard event. Please try again.'), 'error');
  329. this.trigger(new Set([itemId]));
  330. },
  331. onDiscardSuccess(_changeId, itemId, _response) {
  332. delete this.statuses[itemId];
  333. this.clearStatus(itemId, 'discard');
  334. this.items = this.items.filter(item => item.id !== itemId);
  335. showAlert(t('Similar events will be filtered and discarded.'), 'success');
  336. this.trigger(new Set([itemId]));
  337. },
  338. onMerge(_changeId, itemIds) {
  339. itemIds = this._itemIdsOrAll(itemIds);
  340. itemIds.forEach(itemId => {
  341. this.addStatus(itemId, 'merge');
  342. });
  343. // XXX(billy): Not sure if this is a bug or not but do we need to publish all itemIds?
  344. // Seems like we only need to publish parent id
  345. this.trigger(new Set(itemIds));
  346. },
  347. onMergeError(_changeId, itemIds, _response) {
  348. itemIds = this._itemIdsOrAll(itemIds);
  349. itemIds.forEach(itemId => {
  350. this.clearStatus(itemId, 'merge');
  351. });
  352. showAlert(t('Unable to merge events. Please try again.'), 'error');
  353. this.trigger(new Set(itemIds));
  354. },
  355. onMergeSuccess(_changeId, mergedIds, response) {
  356. mergedIds = this._itemIdsOrAll(mergedIds); // everything on page
  357. mergedIds.forEach(itemId => {
  358. this.clearStatus(itemId, 'merge');
  359. });
  360. // Remove all but parent id (items were merged into this one)
  361. const mergedIdSet = new Set(mergedIds);
  362. // Looks like the `PUT /api/0/projects/:orgId/:projectId/issues/` endpoint
  363. // actually returns a 204, so there is no `response` body
  364. this.items = this.items.filter(
  365. item =>
  366. !mergedIdSet.has(item.id) ||
  367. (response && response.merge && item.id === response.merge.parent)
  368. );
  369. showAlert(t(`Merged ${mergedIds.length} Issues`), 'success');
  370. this.trigger(new Set(mergedIds));
  371. },
  372. /**
  373. * If itemIds is undefined, returns all ids in the store
  374. */
  375. _itemIdsOrAll(itemIds) {
  376. if (isUndefined(itemIds)) {
  377. itemIds = this.items.map(item => item.id);
  378. }
  379. return itemIds;
  380. },
  381. onUpdate(changeId, itemIds, data) {
  382. itemIds = this._itemIdsOrAll(itemIds);
  383. itemIds.forEach(itemId => {
  384. this.addStatus(itemId, 'update');
  385. this.pendingChanges.push(changeId, itemId, data);
  386. });
  387. this.trigger(new Set(itemIds));
  388. },
  389. onUpdateError(changeId, itemIds, _error, failSilently) {
  390. itemIds = this._itemIdsOrAll(itemIds);
  391. this.pendingChanges.remove(changeId);
  392. itemIds.forEach(itemId => {
  393. this.clearStatus(itemId, 'update');
  394. });
  395. if (!failSilently) {
  396. showAlert(t('Unable to update events. Please try again.'), 'error');
  397. }
  398. this.trigger(new Set(itemIds));
  399. },
  400. onUpdateSuccess(changeId, itemIds, response) {
  401. itemIds = this._itemIdsOrAll(itemIds);
  402. this.items.forEach((item, idx) => {
  403. if (itemIds.indexOf(item.id) !== -1) {
  404. this.items[idx] = {
  405. ...item,
  406. ...response,
  407. };
  408. this.clearStatus(item.id, 'update');
  409. }
  410. });
  411. this.pendingChanges.remove(changeId);
  412. this.trigger(new Set(itemIds));
  413. },
  414. onPopulateStats(itemIds: string[], response: GroupStats[]) {
  415. // Organize stats by id
  416. const groupStatsMap = response.reduce((map, stats) => {
  417. map[stats.id] = stats;
  418. return map;
  419. }, {});
  420. this.items.forEach((item, idx) => {
  421. if (itemIds.includes(item.id)) {
  422. this.items[idx] = {
  423. ...item,
  424. ...groupStatsMap[item.id],
  425. };
  426. }
  427. });
  428. this.trigger(new Set(this.items.map(item => item.id)));
  429. },
  430. onPopulateReleases(itemId: string, releaseData: GroupRelease) {
  431. this.items.forEach((item, idx) => {
  432. if (item.id === itemId) {
  433. this.items[idx] = {
  434. ...item,
  435. ...releaseData,
  436. };
  437. }
  438. });
  439. this.trigger(new Set([itemId]));
  440. },
  441. };
  442. const GroupStore = createStore(makeSafeRefluxStore(storeConfig));
  443. export default GroupStore;