issueViews.tsx 19 KB

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