import isArray from 'lodash/isArray'; import isUndefined from 'lodash/isUndefined'; import {createStore, StoreDefinition} from 'reflux'; import GroupActions from 'sentry/actions/groupActions'; import {t} from 'sentry/locale'; import IndicatorStore from 'sentry/stores/indicatorStore'; import { Activity, BaseGroup, Group, GroupCollapseRelease, GroupRelease, GroupStats, } from 'sentry/types'; import {makeSafeRefluxStore} from 'sentry/utils/makeSafeRefluxStore'; function showAlert(msg, type) { IndicatorStore.addMessage(msg, type, {duration: 4000}); } // TODO(ts) Type this any better. type Change = [string, string, any]; class PendingChangeQueue { changes: Change[] = []; getForItem(itemId: string) { return this.changes.filter((change: Change) => change[1] === itemId); } push(changeId: string, itemId: string, data: any) { this.changes.push([changeId, itemId, data]); } remove(changeId: string, itemId?: string) { this.changes = this.changes.filter( change => change[0] !== changeId || change[1] !== itemId ); } forEach(...args: any[]) { this.changes.forEach.apply(this.changes, args); } } type Item = BaseGroup | Group | GroupCollapseRelease; interface InternalDefinition { addActivity: (groupId: string, data: Activity, index?: number) => void; indexOfActivity: (groupId: string, id: string) => number; items: Item[]; pendingChanges: PendingChangeQueue; removeActivity: (groupId: string, id: string) => number; statuses: Record>; updateActivity: (groupId: string, id: string, data: Partial) => void; } interface GroupStoreDefinition extends StoreDefinition, InternalDefinition { add: (items: Item[]) => void; addStatus: (id: string, status: string) => void; clearStatus: (id: string, status: string) => void; get: (id: string) => Item | undefined; getAllItemIds: () => string[]; getAllItems: () => Item[]; hasStatus: (id: string, status: string) => boolean; init: () => void; loadInitialData: (items: Item[]) => void; onAssignTo: (changeId: string, itemId: string, data: any) => void; onAssignToError: (changeId: string, itemId: string, error: Error) => void; onAssignToSuccess: (changeId: string, itemId: string, response: any) => void; onDelete: (changeId: string, itemIds: string[]) => void; onDeleteError: (changeId: string, itemIds: string[], error: Error) => void; onDeleteSuccess: (changeId: string, itemIds: string[], response: any) => void; onDiscard: (changeId: string, itemId: string) => void; onDiscardError: (changeId: string, itemId: string, response: any) => void; onDiscardSuccess: (changeId: string, itemId: string, response: any) => void; onMerge: (changeId: string, itemIds: string[]) => void; onMergeError: (changeId: string, itemIds: string[], response: any) => void; onMergeSuccess: (changeId: string, itemIds: string[], response: any) => void; onPopulateReleases: (itemId: string, releaseData: GroupRelease) => void; onPopulateStats: (itemIds: string[], response: GroupStats[]) => void; onUpdate: (changeId: string, itemIds: string[], data: any) => void; onUpdateError: ( changeId: string, itemIds: string[], error: Error, silent: boolean ) => void; onUpdateSuccess: ( changeId: string, itemIds: string[], response: Partial ) => void; remove: (itemIds: string[]) => void; reset: () => void; } const storeConfig: GroupStoreDefinition = { listenables: [GroupActions], pendingChanges: new PendingChangeQueue(), items: [], statuses: {}, init() { this.reset(); }, reset() { this.pendingChanges = new PendingChangeQueue(); this.items = []; this.statuses = {}; }, // TODO(dcramer): this should actually come from an action of some sorts loadInitialData(items) { this.reset(); const itemIds = new Set(); items.forEach(item => { itemIds.add(item.id); this.items.push(item); }); this.trigger(itemIds); }, add(items) { if (!isArray(items)) { items = [items]; } const itemsById = {}; const itemIds = new Set(); items.forEach(item => { itemsById[item.id] = item; itemIds.add(item.id); }); // See if any existing items are updated by this new set of items this.items.forEach((item, idx) => { if (itemsById[item.id]) { this.items[idx] = { ...item, ...itemsById[item.id], }; delete itemsById[item.id]; } }); // New items for (const itemId in itemsById) { this.items.push(itemsById[itemId]); } this.trigger(itemIds); }, remove(itemIds) { this.items = this.items.filter(item => !itemIds.includes(item.id)); this.trigger(new Set(itemIds)); }, addStatus(id, status) { if (isUndefined(this.statuses[id])) { this.statuses[id] = {}; } this.statuses[id][status] = true; }, clearStatus(id, status) { if (isUndefined(this.statuses[id])) { return; } this.statuses[id][status] = false; }, hasStatus(id, status) { if (isUndefined(this.statuses[id])) { return false; } return this.statuses[id][status] || false; }, indexOfActivity(group_id, id) { const group = this.get(group_id); if (!group) { return -1; } for (let i = 0; i < group.activity.length; i++) { if (group.activity[i].id === id) { return i; } } return -1; }, addActivity(id, data, index = -1) { const group = this.get(id); if (!group) { return; } // insert into beginning by default if (index === -1) { group.activity.unshift(data); } else { group.activity.splice(index, 0, data); } if (data.type === 'note') { group.numComments++; } this.trigger(new Set([id])); }, updateActivity(group_id, id, data) { const group = this.get(group_id); if (!group) { return; } const index = this.indexOfActivity(group_id, id); if (index === -1) { return; } // Here, we want to merge the new `data` being passed in // into the existing `data` object. This effectively // allows passing in an object of only changes. group.activity[index].data = Object.assign(group.activity[index].data, data); this.trigger(new Set([group.id])); }, removeActivity(group_id, id) { const group = this.get(group_id); if (!group) { return -1; } const index = this.indexOfActivity(group.id, id); if (index === -1) { return -1; } const activity = group.activity.splice(index, 1); if (activity[0].type === 'note') { group.numComments--; } this.trigger(new Set([group.id])); return index; }, get(id) { // TODO(ts) This needs to be constrained further. It was left as any // because the PendingChanges signatures and this were not aligned. const pendingForId: any[] = []; this.pendingChanges.forEach(change => { if (change.id === id) { pendingForId.push(change); } }); for (let i = 0; i < this.items.length; i++) { if (this.items[i].id === id) { let rItem = this.items[i]; if (pendingForId.length) { // copy the object so dirty state doesnt mutate original rItem = {...rItem}; for (let c = 0; c < pendingForId.length; c++) { rItem = { ...rItem, ...pendingForId[c].params, }; } } return rItem; } } return undefined; }, getAllItemIds() { return this.items.map(item => item.id); }, getAllItems() { // regroup pending changes by their itemID const pendingById = {}; this.pendingChanges.forEach(change => { if (isUndefined(pendingById[change.id])) { pendingById[change.id] = []; } pendingById[change.id].push(change); }); return this.items.map(item => { let rItem = item; if (!isUndefined(pendingById[item.id])) { // copy the object so dirty state doesnt mutate original rItem = {...rItem}; pendingById[item.id].forEach(change => { rItem = { ...rItem, ...change.params, }; }); } return rItem; }); }, onAssignTo(_changeId, itemId, _data) { this.addStatus(itemId, 'assignTo'); this.trigger(new Set([itemId])); }, // TODO(dcramer): This is not really the best place for this onAssignToError(_changeId, itemId, _error) { this.clearStatus(itemId, 'assignTo'); showAlert(t('Unable to change assignee. Please try again.'), 'error'); }, onAssignToSuccess(_changeId, itemId, response) { const item = this.get(itemId); if (!item) { return; } item.assignedTo = response.assignedTo; this.clearStatus(itemId, 'assignTo'); this.trigger(new Set([itemId])); }, onDelete(_changeId, itemIds) { itemIds = this._itemIdsOrAll(itemIds); itemIds.forEach(itemId => { this.addStatus(itemId, 'delete'); }); this.trigger(new Set(itemIds)); }, onDeleteError(_changeId, itemIds, _response) { showAlert(t('Unable to delete events. Please try again.'), 'error'); if (!itemIds) { return; } itemIds.forEach(itemId => { this.clearStatus(itemId, 'delete'); }); this.trigger(new Set(itemIds)); }, onDeleteSuccess(_changeId, itemIds, _response) { itemIds = this._itemIdsOrAll(itemIds); if (itemIds.length > 1) { showAlert(t(`Deleted ${itemIds.length} Issues`), 'success'); } else { const shortId = itemIds.map(item => GroupStore.get(item)?.shortId).join(''); showAlert(t(`Deleted ${shortId}`), 'success'); } const itemIdSet = new Set(itemIds); itemIds.forEach(itemId => { delete this.statuses[itemId]; this.clearStatus(itemId, 'delete'); }); this.items = this.items.filter(item => !itemIdSet.has(item.id)); this.trigger(new Set(itemIds)); }, onDiscard(_changeId, itemId) { this.addStatus(itemId, 'discard'); this.trigger(new Set([itemId])); }, onDiscardError(_changeId, itemId, _response) { this.clearStatus(itemId, 'discard'); showAlert(t('Unable to discard event. Please try again.'), 'error'); this.trigger(new Set([itemId])); }, onDiscardSuccess(_changeId, itemId, _response) { delete this.statuses[itemId]; this.clearStatus(itemId, 'discard'); this.items = this.items.filter(item => item.id !== itemId); showAlert(t('Similar events will be filtered and discarded.'), 'success'); this.trigger(new Set([itemId])); }, onMerge(_changeId, itemIds) { itemIds = this._itemIdsOrAll(itemIds); itemIds.forEach(itemId => { this.addStatus(itemId, 'merge'); }); // XXX(billy): Not sure if this is a bug or not but do we need to publish all itemIds? // Seems like we only need to publish parent id this.trigger(new Set(itemIds)); }, onMergeError(_changeId, itemIds, _response) { itemIds = this._itemIdsOrAll(itemIds); itemIds.forEach(itemId => { this.clearStatus(itemId, 'merge'); }); showAlert(t('Unable to merge events. Please try again.'), 'error'); this.trigger(new Set(itemIds)); }, onMergeSuccess(_changeId, mergedIds, response) { mergedIds = this._itemIdsOrAll(mergedIds); // everything on page mergedIds.forEach(itemId => { this.clearStatus(itemId, 'merge'); }); // Remove all but parent id (items were merged into this one) const mergedIdSet = new Set(mergedIds); // Looks like the `PUT /api/0/projects/:orgId/:projectId/issues/` endpoint // actually returns a 204, so there is no `response` body this.items = this.items.filter( item => !mergedIdSet.has(item.id) || (response && response.merge && item.id === response.merge.parent) ); showAlert(t(`Merged ${mergedIds.length} Issues`), 'success'); this.trigger(new Set(mergedIds)); }, /** * If itemIds is undefined, returns all ids in the store */ _itemIdsOrAll(itemIds) { if (isUndefined(itemIds)) { itemIds = this.items.map(item => item.id); } return itemIds; }, onUpdate(changeId, itemIds, data) { itemIds = this._itemIdsOrAll(itemIds); itemIds.forEach(itemId => { this.addStatus(itemId, 'update'); this.pendingChanges.push(changeId, itemId, data); }); this.trigger(new Set(itemIds)); }, onUpdateError(changeId, itemIds, _error, failSilently) { itemIds = this._itemIdsOrAll(itemIds); this.pendingChanges.remove(changeId); itemIds.forEach(itemId => { this.clearStatus(itemId, 'update'); }); if (!failSilently) { showAlert(t('Unable to update events. Please try again.'), 'error'); } this.trigger(new Set(itemIds)); }, onUpdateSuccess(changeId, itemIds, response) { itemIds = this._itemIdsOrAll(itemIds); this.items.forEach((item, idx) => { if (itemIds.indexOf(item.id) !== -1) { this.items[idx] = { ...item, ...response, }; this.clearStatus(item.id, 'update'); } }); this.pendingChanges.remove(changeId); this.trigger(new Set(itemIds)); }, onPopulateStats(itemIds: string[], response: GroupStats[]) { // Organize stats by id const groupStatsMap = response.reduce((map, stats) => { map[stats.id] = stats; return map; }, {}); this.items.forEach((item, idx) => { if (itemIds.includes(item.id)) { this.items[idx] = { ...item, ...groupStatsMap[item.id], }; } }); this.trigger(new Set(this.items.map(item => item.id))); }, onPopulateReleases(itemId: string, releaseData: GroupRelease) { this.items.forEach((item, idx) => { if (item.id === itemId) { this.items[idx] = { ...item, ...releaseData, }; } }); this.trigger(new Set([itemId])); }, }; const GroupStore = createStore(makeSafeRefluxStore(storeConfig)); export default GroupStore;