issueViewTab.tsx 6.8 KB

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