issueViewTab.tsx 7.4 KB

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