issueViews.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  1. import type {Dispatch, Reducer} from 'react';
  2. import {
  3. createContext,
  4. useCallback,
  5. useContext,
  6. useEffect,
  7. useMemo,
  8. useReducer,
  9. useState,
  10. } from 'react';
  11. import styled from '@emotion/styled';
  12. import type {TabListState} from '@react-stately/tabs';
  13. import type {Orientation} from '@react-types/shared';
  14. import debounce from 'lodash/debounce';
  15. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  16. import type {TabContext, TabsProps} from 'sentry/components/tabs';
  17. import {tabsShouldForwardProp} from 'sentry/components/tabs/utils';
  18. import {t} from 'sentry/locale';
  19. import type {InjectedRouter} from 'sentry/types/legacyReactRouter';
  20. import {defined} from 'sentry/utils';
  21. import {trackAnalytics} from 'sentry/utils/analytics';
  22. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  23. import {useNavigate} from 'sentry/utils/useNavigate';
  24. import useOrganization from 'sentry/utils/useOrganization';
  25. import usePageFilters from 'sentry/utils/usePageFilters';
  26. import {useUpdateGroupSearchViews} from 'sentry/views/issueList/mutations/useUpdateGroupSearchViews';
  27. import type {
  28. GroupSearchView,
  29. UpdateGroupSearchViewPayload,
  30. } from 'sentry/views/issueList/types';
  31. import {IssueSortOptions} from 'sentry/views/issueList/utils';
  32. import {NewTabContext, type NewView} from 'sentry/views/issueList/utils/newTabContext';
  33. const TEMPORARY_TAB_KEY = 'temporary-tab';
  34. export const generateTempViewId = () => `_${Math.random().toString().substring(2, 7)}`;
  35. export interface IssueView {
  36. id: string;
  37. /**
  38. * False for tabs that were added view the "Add View" button, but
  39. * have not been edited in any way. Only tabs with isCommitted=true
  40. * will be saved to the backend.
  41. */
  42. isCommitted: boolean;
  43. key: string;
  44. label: string;
  45. query: string;
  46. querySort: IssueSortOptions;
  47. content?: React.ReactNode;
  48. unsavedChanges?: [string, IssueSortOptions];
  49. }
  50. type BaseIssueViewsAction = {
  51. /** If true, the new views state created by the action will be synced to the backend */
  52. syncViews?: boolean;
  53. };
  54. type ReorderTabsAction = {
  55. newKeyOrder: string[];
  56. type: 'REORDER_TABS';
  57. } & BaseIssueViewsAction;
  58. type SaveChangesAction = {
  59. type: 'SAVE_CHANGES';
  60. } & BaseIssueViewsAction;
  61. type DiscardChangesAction = {
  62. type: 'DISCARD_CHANGES';
  63. } & BaseIssueViewsAction;
  64. type RenameTabAction = {
  65. newLabel: string;
  66. type: 'RENAME_TAB';
  67. } & BaseIssueViewsAction;
  68. type DuplicateViewAction = {
  69. newViewId: string;
  70. type: 'DUPLICATE_VIEW';
  71. } & BaseIssueViewsAction;
  72. type DeleteViewAction = {
  73. type: 'DELETE_VIEW';
  74. } & BaseIssueViewsAction;
  75. type CreateNewViewAction = {
  76. tempId: string;
  77. type: 'CREATE_NEW_VIEW';
  78. } & BaseIssueViewsAction;
  79. type SetTempViewAction = {
  80. query: string;
  81. sort: IssueSortOptions;
  82. type: 'SET_TEMP_VIEW';
  83. } & BaseIssueViewsAction;
  84. type DiscardTempViewAction = {
  85. type: 'DISCARD_TEMP_VIEW';
  86. } & BaseIssueViewsAction;
  87. type SaveTempViewAction = {
  88. type: 'SAVE_TEMP_VIEW';
  89. } & BaseIssueViewsAction;
  90. type UpdateUnsavedChangesAction = {
  91. type: 'UPDATE_UNSAVED_CHANGES';
  92. unsavedChanges: [string, IssueSortOptions] | undefined;
  93. isCommitted?: boolean;
  94. } & BaseIssueViewsAction;
  95. type UpdateViewIdsAction = {
  96. newViews: UpdateGroupSearchViewPayload[];
  97. type: 'UPDATE_VIEW_IDS';
  98. } & BaseIssueViewsAction;
  99. type SetViewsAction = {
  100. type: 'SET_VIEWS';
  101. views: IssueView[];
  102. } & BaseIssueViewsAction;
  103. type SyncViewsToBackendAction = {
  104. /** Syncs the current views state to the backend. Does not make any changes to the views state. */
  105. type: 'SYNC_VIEWS_TO_BACKEND';
  106. };
  107. export type IssueViewsActions =
  108. | ReorderTabsAction
  109. | SaveChangesAction
  110. | DiscardChangesAction
  111. | RenameTabAction
  112. | DuplicateViewAction
  113. | DeleteViewAction
  114. | CreateNewViewAction
  115. | SetTempViewAction
  116. | DiscardTempViewAction
  117. | SaveTempViewAction
  118. | UpdateUnsavedChangesAction
  119. | UpdateViewIdsAction
  120. | SetViewsAction
  121. | SyncViewsToBackendAction;
  122. const ACTION_ANALYTICS_MAP: Partial<Record<IssueViewsActions['type'], string>> = {
  123. REORDER_TABS: 'issue_views.reordered_views',
  124. SAVE_CHANGES: 'issue_views.saved_changes',
  125. DISCARD_CHANGES: 'issue_views.discarded_changes',
  126. RENAME_TAB: 'issue_views.renamed_view',
  127. DUPLICATE_VIEW: 'issue_views.duplicated_view',
  128. DELETE_VIEW: 'issue_views.deleted_view',
  129. SAVE_TEMP_VIEW: 'issue_views.temp_view_saved',
  130. DISCARD_TEMP_VIEW: 'issue_views.temp_view_discarded',
  131. CREATE_NEW_VIEW: 'issue_views.add_view.clicked',
  132. };
  133. export interface IssueViewsState {
  134. views: IssueView[];
  135. tempView?: IssueView;
  136. }
  137. export interface IssueViewsContextType extends TabContext {
  138. dispatch: Dispatch<IssueViewsActions>;
  139. state: IssueViewsState;
  140. }
  141. export const IssueViewsContext = createContext<IssueViewsContextType>({
  142. rootProps: {orientation: 'horizontal'},
  143. setTabListState: () => {},
  144. // Issue Views specific state
  145. dispatch: () => {},
  146. state: {views: []},
  147. });
  148. function reorderTabs(state: IssueViewsState, action: ReorderTabsAction) {
  149. const newTabs: IssueView[] = action.newKeyOrder
  150. .map(key => {
  151. const foundTab = state.views.find(tab => tab.key === key);
  152. return foundTab?.key === key ? foundTab : null;
  153. })
  154. .filter(defined);
  155. return {...state, views: newTabs};
  156. }
  157. function saveChanges(state: IssueViewsState, tabListState: TabListState<any>) {
  158. const originalTab = state.views.find(tab => tab.key === tabListState?.selectedKey);
  159. if (originalTab) {
  160. const newViews = state.views.map(tab => {
  161. return tab.key === tabListState?.selectedKey && tab.unsavedChanges
  162. ? {
  163. ...tab,
  164. query: tab.unsavedChanges[0],
  165. querySort: tab.unsavedChanges[1],
  166. unsavedChanges: undefined,
  167. }
  168. : tab;
  169. });
  170. return {...state, views: newViews};
  171. }
  172. return state;
  173. }
  174. function discardChanges(state: IssueViewsState, tabListState: TabListState<any>) {
  175. const originalTab = state.views.find(tab => tab.key === tabListState?.selectedKey);
  176. if (originalTab) {
  177. const newViews = state.views.map(tab => {
  178. return tab.key === tabListState?.selectedKey
  179. ? {...tab, unsavedChanges: undefined}
  180. : tab;
  181. });
  182. return {...state, views: newViews};
  183. }
  184. return state;
  185. }
  186. function renameView(
  187. state: IssueViewsState,
  188. action: RenameTabAction,
  189. tabListState: TabListState<any>
  190. ) {
  191. const renamedTab = state.views.find(tab => tab.key === tabListState?.selectedKey);
  192. if (renamedTab && action.newLabel !== renamedTab.label) {
  193. const newViews = state.views.map(tab =>
  194. tab.key === renamedTab.key
  195. ? {...tab, label: action.newLabel, isCommitted: true}
  196. : tab
  197. );
  198. return {...state, views: newViews};
  199. }
  200. return state;
  201. }
  202. function duplicateView(
  203. state: IssueViewsState,
  204. action: DuplicateViewAction,
  205. tabListState: TabListState<any>
  206. ) {
  207. const idx = state.views.findIndex(tb => tb.key === tabListState?.selectedKey);
  208. if (idx !== -1) {
  209. const duplicatedTab = state.views[idx]!;
  210. const newTabs: IssueView[] = [
  211. ...state.views.slice(0, idx + 1),
  212. {
  213. ...duplicatedTab,
  214. id: action.newViewId,
  215. key: action.newViewId,
  216. label: `${duplicatedTab.label} (Copy)`,
  217. isCommitted: true,
  218. },
  219. ...state.views.slice(idx + 1),
  220. ];
  221. return {...state, views: newTabs};
  222. }
  223. return state;
  224. }
  225. function deleteView(state: IssueViewsState, tabListState: TabListState<any>) {
  226. const newViews = state.views.filter(tab => tab.key !== tabListState?.selectedKey);
  227. return {...state, views: newViews};
  228. }
  229. function createNewView(state: IssueViewsState, action: CreateNewViewAction) {
  230. const newTabs: IssueView[] = [
  231. ...state.views,
  232. {
  233. id: action.tempId,
  234. key: action.tempId,
  235. label: 'New View',
  236. query: '',
  237. querySort: IssueSortOptions.DATE,
  238. isCommitted: false,
  239. },
  240. ];
  241. return {...state, views: newTabs};
  242. }
  243. function setTempView(state: IssueViewsState, action: SetTempViewAction) {
  244. const tempView: IssueView = {
  245. id: TEMPORARY_TAB_KEY,
  246. key: TEMPORARY_TAB_KEY,
  247. label: t('Unsaved'),
  248. query: action.query,
  249. querySort: action.sort ?? IssueSortOptions.DATE,
  250. isCommitted: true,
  251. };
  252. return {...state, tempView};
  253. }
  254. function discardTempView(state: IssueViewsState, tabListState: TabListState<any>) {
  255. tabListState?.setSelectedKey(state.views[0]!.key);
  256. return {...state, tempView: undefined};
  257. }
  258. function saveTempView(state: IssueViewsState, tabListState: TabListState<any>) {
  259. if (state.tempView) {
  260. const tempId = generateTempViewId();
  261. const newTab: IssueView = {
  262. id: tempId,
  263. key: tempId,
  264. label: 'New View',
  265. query: state.tempView?.query,
  266. querySort: state.tempView?.querySort,
  267. isCommitted: true,
  268. };
  269. tabListState?.setSelectedKey(tempId);
  270. return {...state, views: [...state.views, newTab], tempView: undefined};
  271. }
  272. return state;
  273. }
  274. function updateUnsavedChanges(
  275. state: IssueViewsState,
  276. action: UpdateUnsavedChangesAction,
  277. tabListState: TabListState<any>
  278. ) {
  279. return {
  280. ...state,
  281. views: state.views.map(tab =>
  282. tab.key === tabListState?.selectedKey
  283. ? {
  284. ...tab,
  285. unsavedChanges: action.unsavedChanges,
  286. isCommitted: action.isCommitted ?? tab.isCommitted,
  287. }
  288. : tab
  289. ),
  290. };
  291. }
  292. function updateViewIds(state: IssueViewsState, action: UpdateViewIdsAction) {
  293. const assignedIds = new Set();
  294. const updatedViews = state.views.map(tab => {
  295. if (tab.id && tab.id[0] === '_') {
  296. const matchingView = action.newViews.find(
  297. view =>
  298. view.id &&
  299. !assignedIds.has(view.id) &&
  300. tab.query === view.query &&
  301. tab.querySort === view.querySort &&
  302. tab.label === view.name
  303. );
  304. if (matchingView?.id) {
  305. assignedIds.add(matchingView.id);
  306. return {...tab, id: matchingView.id};
  307. }
  308. }
  309. return tab;
  310. });
  311. return {...state, views: updatedViews};
  312. }
  313. function setViews(state: IssueViewsState, action: SetViewsAction) {
  314. return {...state, views: action.views};
  315. }
  316. interface IssueViewsStateProviderProps extends Omit<TabsProps<any>, 'children'> {
  317. children: React.ReactNode;
  318. initialViews: IssueView[];
  319. // TODO(msun): Replace router with useLocation() / useUrlParams() / useSearchParams() in the future
  320. router: InjectedRouter;
  321. }
  322. export function IssueViewsStateProvider({
  323. children,
  324. initialViews,
  325. router,
  326. ...props
  327. }: IssueViewsStateProviderProps) {
  328. const navigate = useNavigate();
  329. const pageFilters = usePageFilters();
  330. const organization = useOrganization();
  331. const {setNewViewActive, setOnNewViewsSaved} = useContext(NewTabContext);
  332. const [tabListState, setTabListState] = useState<TabListState<any>>();
  333. const {className: _className, ...restProps} = props;
  334. const {cursor: _cursor, page: _page, ...queryParams} = router?.location.query;
  335. const {query, sort, viewId, project, environment} = queryParams;
  336. const queryParamsWithPageFilters = useMemo(() => {
  337. return {
  338. ...queryParams,
  339. project: project ?? pageFilters.selection.projects,
  340. environment: environment ?? pageFilters.selection.environments,
  341. ...normalizeDateTimeParams(pageFilters.selection.datetime),
  342. };
  343. }, [
  344. environment,
  345. pageFilters.selection.datetime,
  346. pageFilters.selection.environments,
  347. pageFilters.selection.projects,
  348. project,
  349. queryParams,
  350. ]);
  351. // This function is fired upon receiving new views from the backend - it replaces any previously
  352. // generated temporary view ids with the permanent view ids from the backend
  353. const replaceWithPersistantViewIds = (views: GroupSearchView[]) => {
  354. const newlyCreatedViews = views.filter(
  355. view => !state.views.find(tab => tab.id === view.id)
  356. );
  357. if (newlyCreatedViews.length > 0) {
  358. dispatch({type: 'UPDATE_VIEW_IDS', newViews: newlyCreatedViews});
  359. const currentView = state.views.find(tab => tab.id === viewId);
  360. if (viewId?.startsWith('_') && currentView) {
  361. const matchingView = newlyCreatedViews.find(
  362. view =>
  363. view.id &&
  364. currentView.query === view.query &&
  365. currentView.querySort === view.querySort
  366. );
  367. if (matchingView?.id) {
  368. navigate(
  369. normalizeUrl({
  370. ...location,
  371. query: {
  372. ...queryParamsWithPageFilters,
  373. viewId: matchingView.id,
  374. },
  375. }),
  376. {replace: true}
  377. );
  378. }
  379. }
  380. }
  381. return;
  382. };
  383. const {mutate: updateViews} = useUpdateGroupSearchViews({
  384. onSuccess: replaceWithPersistantViewIds,
  385. });
  386. const debounceUpdateViews = useMemo(
  387. () =>
  388. debounce((newTabs: IssueView[]) => {
  389. if (newTabs) {
  390. updateViews({
  391. orgSlug: organization.slug,
  392. groupSearchViews: newTabs
  393. .filter(tab => tab.isCommitted)
  394. .map(tab => ({
  395. // Do not send over an ID if it's a temporary or default tab so that
  396. // the backend will save these and generate permanent Ids for them
  397. ...(tab.id[0] !== '_' && !tab.id.startsWith('default')
  398. ? {id: tab.id}
  399. : {}),
  400. name: tab.label,
  401. query: tab.query,
  402. querySort: tab.querySort,
  403. })),
  404. });
  405. }
  406. }, 500),
  407. [organization.slug, updateViews]
  408. );
  409. const reducer: Reducer<IssueViewsState, IssueViewsActions> = useCallback(
  410. (state, action): IssueViewsState => {
  411. if (!tabListState) {
  412. return state;
  413. }
  414. switch (action.type) {
  415. case 'REORDER_TABS':
  416. return reorderTabs(state, action);
  417. case 'SAVE_CHANGES':
  418. return saveChanges(state, tabListState);
  419. case 'DISCARD_CHANGES':
  420. return discardChanges(state, tabListState);
  421. case 'RENAME_TAB':
  422. return renameView(state, action, tabListState);
  423. case 'DUPLICATE_VIEW':
  424. return duplicateView(state, action, tabListState);
  425. case 'DELETE_VIEW':
  426. return deleteView(state, tabListState);
  427. case 'CREATE_NEW_VIEW':
  428. return createNewView(state, action);
  429. case 'SET_TEMP_VIEW':
  430. return setTempView(state, action);
  431. case 'DISCARD_TEMP_VIEW':
  432. return discardTempView(state, tabListState);
  433. case 'SAVE_TEMP_VIEW':
  434. return saveTempView(state, tabListState);
  435. case 'UPDATE_UNSAVED_CHANGES':
  436. return updateUnsavedChanges(state, action, tabListState);
  437. case 'UPDATE_VIEW_IDS':
  438. return updateViewIds(state, action);
  439. case 'SET_VIEWS':
  440. return setViews(state, action);
  441. case 'SYNC_VIEWS_TO_BACKEND':
  442. return state;
  443. default:
  444. return state;
  445. }
  446. },
  447. [tabListState]
  448. );
  449. const sortOption =
  450. sort && Object.values(IssueSortOptions).includes(sort.toString() as IssueSortOptions)
  451. ? (sort.toString() as IssueSortOptions)
  452. : IssueSortOptions.DATE;
  453. const initialTempView: IssueView | undefined =
  454. query && (!viewId || !initialViews.find(tab => tab.id === viewId))
  455. ? {
  456. id: TEMPORARY_TAB_KEY,
  457. key: TEMPORARY_TAB_KEY,
  458. label: t('Unsaved'),
  459. query: query.toString(),
  460. querySort: sortOption,
  461. isCommitted: true,
  462. }
  463. : undefined;
  464. const [state, dispatch] = useReducer(reducer, {
  465. views: initialViews,
  466. tempView: initialTempView,
  467. });
  468. const dispatchWrapper = (action: IssueViewsActions) => {
  469. const newState = reducer(state, action);
  470. dispatch(action);
  471. if (action.type === 'SYNC_VIEWS_TO_BACKEND' || action.syncViews) {
  472. debounceUpdateViews(newState.views);
  473. }
  474. const actionAnalyticsKey = ACTION_ANALYTICS_MAP[action.type];
  475. if (actionAnalyticsKey) {
  476. trackAnalytics(actionAnalyticsKey, {
  477. organization,
  478. });
  479. }
  480. };
  481. const handleNewViewsSaved: NewTabContext['onNewViewsSaved'] = useCallback<
  482. NewTabContext['onNewViewsSaved']
  483. >(
  484. () => (newViews: NewView[]) => {
  485. if (newViews.length === 0) {
  486. return;
  487. }
  488. setNewViewActive(false);
  489. const {label, query: newQuery, saveQueryToView} = newViews[0]!;
  490. const remainingNewViews: IssueView[] = newViews.slice(1)?.map(view => {
  491. const newId = generateTempViewId();
  492. const viewToTab: IssueView = {
  493. id: newId,
  494. key: newId,
  495. label: view.label,
  496. query: view.query,
  497. querySort: IssueSortOptions.DATE,
  498. unsavedChanges: view.saveQueryToView
  499. ? undefined
  500. : [view.query, IssueSortOptions.DATE],
  501. isCommitted: true,
  502. };
  503. return viewToTab;
  504. });
  505. let updatedTabs: IssueView[] = state.views.map(tab => {
  506. if (tab.key === viewId) {
  507. return {
  508. ...tab,
  509. label,
  510. query: saveQueryToView ? newQuery : '',
  511. querySort: IssueSortOptions.DATE,
  512. unsavedChanges: saveQueryToView ? undefined : [query, IssueSortOptions.DATE],
  513. isCommitted: true,
  514. };
  515. }
  516. return tab;
  517. });
  518. if (remainingNewViews.length > 0) {
  519. updatedTabs = [...updatedTabs, ...remainingNewViews];
  520. }
  521. dispatch({type: 'SET_VIEWS', views: updatedTabs, syncViews: true});
  522. navigate(
  523. {
  524. ...location,
  525. query: {
  526. ...queryParams,
  527. query,
  528. sort: IssueSortOptions.DATE,
  529. },
  530. },
  531. {replace: true}
  532. );
  533. },
  534. // eslint-disable-next-line react-hooks/exhaustive-deps
  535. [location, navigate, setNewViewActive, state.views, viewId]
  536. );
  537. useEffect(() => {
  538. setOnNewViewsSaved(handleNewViewsSaved);
  539. }, [setOnNewViewsSaved, handleNewViewsSaved]);
  540. return (
  541. <IssueViewsContext.Provider
  542. value={{
  543. rootProps: {...restProps, orientation: 'horizontal'},
  544. tabListState,
  545. setTabListState,
  546. dispatch: dispatchWrapper,
  547. state,
  548. }}
  549. >
  550. {children}
  551. </IssueViewsContext.Provider>
  552. );
  553. }
  554. export function IssueViews<T extends string | number>({
  555. orientation = 'horizontal',
  556. className,
  557. children,
  558. initialViews,
  559. router,
  560. ...props
  561. }: TabsProps<T> & Omit<IssueViewsStateProviderProps, 'children'>) {
  562. return (
  563. <IssueViewsStateProvider initialViews={initialViews} router={router} {...props}>
  564. <TabsWrap orientation={orientation} className={className}>
  565. {children}
  566. </TabsWrap>
  567. </IssueViewsStateProvider>
  568. );
  569. }
  570. const TabsWrap = styled('div', {shouldForwardProp: tabsShouldForwardProp})<{
  571. orientation: Orientation;
  572. }>`
  573. display: flex;
  574. flex-direction: ${p => (p.orientation === 'horizontal' ? 'column' : 'row')};
  575. flex-grow: 1;
  576. ${p =>
  577. p.orientation === 'vertical' &&
  578. `
  579. height: 100%;
  580. align-items: stretch;
  581. `};
  582. `;