issueViewTab.tsx 6.6 KB

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