issueViewTab.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. import {useContext, useEffect} from 'react';
  2. import styled from '@emotion/styled';
  3. import {motion} from 'framer-motion';
  4. import type {MenuItemProps} from 'sentry/components/dropdownMenu';
  5. import {useUpdateGroupSearchViewLastVisited} from 'sentry/components/nav/issueViews/useUpdateGroupSearchViewLastVisited';
  6. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  7. import {t} from 'sentry/locale';
  8. import type {InjectedRouter} from 'sentry/types/legacyReactRouter';
  9. import {useNavigate} from 'sentry/utils/useNavigate';
  10. import EditableTabTitle from 'sentry/views/issueList/issueViews/editableTabTitle';
  11. import {IssueViewEllipsisMenu} from 'sentry/views/issueList/issueViews/issueViewEllipsisMenu';
  12. import {IssueViewQueryCount} from 'sentry/views/issueList/issueViews/issueViewQueryCount';
  13. import {
  14. generateTempViewId,
  15. type IssueView,
  16. IssueViewsContext,
  17. TEMPORARY_TAB_KEY,
  18. } from 'sentry/views/issueList/issueViews/issueViews';
  19. interface IssueViewTabProps {
  20. editingTabKey: string | null;
  21. initialTabKey: string;
  22. router: InjectedRouter;
  23. setEditingTabKey: (key: string | null) => void;
  24. view: IssueView;
  25. }
  26. export function IssueViewTab({
  27. editingTabKey,
  28. initialTabKey,
  29. router,
  30. setEditingTabKey,
  31. view,
  32. }: IssueViewTabProps) {
  33. const navigate = useNavigate();
  34. const {cursor: _cursor, page: _page, ...queryParams} = router?.location?.query ?? {};
  35. const {tabListState, state, dispatch} = useContext(IssueViewsContext);
  36. const {views} = state;
  37. const {mutate: updateViewLastVisited} = useUpdateGroupSearchViewLastVisited();
  38. function updateViewLastVisitedIfExists() {
  39. if (
  40. initialTabKey !== TEMPORARY_TAB_KEY &&
  41. !initialTabKey.startsWith('default') &&
  42. view.id === initialTabKey
  43. ) {
  44. updateViewLastVisited({viewId: view.id});
  45. }
  46. }
  47. useEffect(() => {
  48. updateViewLastVisitedIfExists();
  49. // eslint-disable-next-line react-hooks/exhaustive-deps
  50. }, []);
  51. const handleDuplicateView = () => {
  52. const newViewId = generateTempViewId();
  53. const duplicatedTab = views.find(tab => tab.key === tabListState?.selectedKey);
  54. if (!duplicatedTab) {
  55. return;
  56. }
  57. dispatch({type: 'DUPLICATE_VIEW', newViewId, syncViews: true});
  58. navigate({
  59. ...location,
  60. query: {
  61. ...queryParams,
  62. query: duplicatedTab.query,
  63. sort: duplicatedTab.querySort,
  64. viewId: newViewId,
  65. project: duplicatedTab.projects,
  66. environment: duplicatedTab.environments,
  67. ...normalizeDateTimeParams(duplicatedTab.timeFilters),
  68. },
  69. });
  70. tabListState?.setSelectedKey(newViewId);
  71. };
  72. const handleDiscardChanges = () => {
  73. dispatch({type: 'DISCARD_CHANGES'});
  74. const originalTab = views.find(tab => tab.key === tabListState?.selectedKey);
  75. if (originalTab) {
  76. navigate({
  77. ...location,
  78. query: {
  79. ...queryParams,
  80. query: originalTab.query,
  81. sort: originalTab.querySort,
  82. viewId: originalTab.id,
  83. project: originalTab.projects,
  84. environment: originalTab.environments,
  85. ...normalizeDateTimeParams(originalTab.timeFilters),
  86. },
  87. });
  88. }
  89. };
  90. const handleDeleteView = (tab: IssueView) => {
  91. dispatch({type: 'DELETE_VIEW', syncViews: true});
  92. // Including this logic in the dispatch call breaks the tests for some reason
  93. // so we're doing it here instead
  94. const nextTab = views.find(tb => tb.key !== tab.key);
  95. if (nextTab) {
  96. tabListState?.setSelectedKey(nextTab.key);
  97. }
  98. };
  99. const handleSaveTempView = () => {
  100. const newViewId = generateTempViewId();
  101. dispatch({type: 'SAVE_TEMP_VIEW', newViewId, syncViews: true});
  102. navigate({
  103. ...location,
  104. query: {
  105. ...queryParams,
  106. viewId: newViewId,
  107. },
  108. });
  109. };
  110. const makeMenuOptions = (tab: IssueView): MenuItemProps[] => {
  111. if (tab.key === TEMPORARY_TAB_KEY) {
  112. return makeTempViewMenuOptions({
  113. onSaveTempView: handleSaveTempView,
  114. onDiscardTempView: () => dispatch({type: 'DISCARD_TEMP_VIEW'}),
  115. });
  116. }
  117. if (tab.unsavedChanges) {
  118. return makeUnsavedChangesMenuOptions({
  119. onRename: () => setEditingTabKey(tab.key),
  120. onDuplicate: handleDuplicateView,
  121. onDelete: views.length > 1 ? () => handleDeleteView(tab) : undefined,
  122. onSave: () => dispatch({type: 'SAVE_CHANGES', syncViews: true}),
  123. onDiscard: handleDiscardChanges,
  124. });
  125. }
  126. return makeDefaultMenuOptions({
  127. onRename: () => setEditingTabKey(tab.key),
  128. onDuplicate: handleDuplicateView,
  129. onDelete: views.length > 1 ? () => handleDeleteView(tab) : undefined,
  130. });
  131. };
  132. return (
  133. <TabContentWrap onClick={() => updateViewLastVisitedIfExists()}>
  134. <EditableTabTitle
  135. label={view.label}
  136. isEditing={editingTabKey === view.key}
  137. setIsEditing={isEditing => setEditingTabKey(isEditing ? view.key : null)}
  138. onChange={newLabel =>
  139. dispatch({type: 'RENAME_TAB', newLabel: newLabel.trim(), syncViews: true})
  140. }
  141. isSelected={
  142. (tabListState && tabListState?.selectedKey === view.key) ||
  143. (!tabListState && view.key === initialTabKey)
  144. }
  145. disableEditing={view.key === TEMPORARY_TAB_KEY}
  146. />
  147. <IssueViewQueryCount view={view} />
  148. {/* If tablistState isn't initialized, we want to load the elipsis menu
  149. for the initial tab, that way it won't load in a second later
  150. and cause the tabs to shift and animate on load. */}
  151. {((tabListState && tabListState?.selectedKey === view.key) ||
  152. (!tabListState && view.key === initialTabKey)) && (
  153. <motion.div
  154. // This stops the ellipsis menu from animating in on load (when tabListState isn't initialized yet),
  155. // but enables the animation later on when switching tabs
  156. initial={tabListState ? {opacity: 0} : false}
  157. animate={{opacity: 1}}
  158. transition={{delay: 0.1, duration: 0.1}}
  159. >
  160. <IssueViewEllipsisMenu
  161. hasUnsavedChanges={!!view.unsavedChanges}
  162. menuOptions={makeMenuOptions(view)}
  163. aria-label={t(`%s Ellipsis Menu`, view.label)}
  164. />
  165. </motion.div>
  166. )}
  167. </TabContentWrap>
  168. );
  169. }
  170. const makeDefaultMenuOptions = ({
  171. onRename,
  172. onDuplicate,
  173. onDelete,
  174. }: {
  175. onDelete?: () => void;
  176. onDuplicate?: () => void;
  177. onRename?: () => void;
  178. }): MenuItemProps[] => {
  179. const menuOptions: MenuItemProps[] = [
  180. {
  181. key: 'rename-tab',
  182. label: t('Rename'),
  183. onAction: onRename,
  184. },
  185. {
  186. key: 'duplicate-tab',
  187. label: t('Duplicate'),
  188. onAction: onDuplicate,
  189. },
  190. ];
  191. if (onDelete) {
  192. return [
  193. ...menuOptions,
  194. {
  195. key: 'delete-tab',
  196. label: t('Delete'),
  197. priority: 'danger',
  198. onAction: onDelete,
  199. },
  200. ];
  201. }
  202. return menuOptions;
  203. };
  204. const makeUnsavedChangesMenuOptions = ({
  205. onRename,
  206. onDuplicate,
  207. onDelete,
  208. onSave,
  209. onDiscard,
  210. }: {
  211. onDelete?: () => void;
  212. onDiscard?: () => void;
  213. onDuplicate?: () => void;
  214. onRename?: () => void;
  215. onSave?: () => void;
  216. }): MenuItemProps[] => {
  217. return [
  218. {
  219. key: 'changed',
  220. children: [
  221. {
  222. key: 'save-changes',
  223. label: t('Save Changes'),
  224. priority: 'primary',
  225. onAction: onSave,
  226. },
  227. {
  228. key: 'discard-changes',
  229. label: t('Discard Changes'),
  230. onAction: onDiscard,
  231. },
  232. ],
  233. },
  234. {
  235. key: 'default',
  236. children: makeDefaultMenuOptions({onRename, onDuplicate, onDelete}),
  237. },
  238. ];
  239. };
  240. const makeTempViewMenuOptions = ({
  241. onSaveTempView,
  242. onDiscardTempView,
  243. }: {
  244. onDiscardTempView: () => void;
  245. onSaveTempView: () => void;
  246. }): MenuItemProps[] => {
  247. return [
  248. {
  249. key: 'save-changes',
  250. label: t('Save View'),
  251. priority: 'primary',
  252. onAction: onSaveTempView,
  253. },
  254. {
  255. key: 'discard-changes',
  256. label: t('Discard'),
  257. onAction: onDiscardTempView,
  258. },
  259. ];
  260. };
  261. const TabContentWrap = styled('span')`
  262. white-space: nowrap;
  263. display: flex;
  264. align-items: center;
  265. flex-direction: row;
  266. padding: 0;
  267. gap: 6px;
  268. `;