groupStore.tsx 14 KB

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