draggableTabBar.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  1. import 'intersection-observer'; // polyfill
  2. import {useCallback, useContext, useEffect, useState} from 'react';
  3. import styled from '@emotion/styled';
  4. import type {Node} from '@react-types/shared';
  5. import {
  6. DraggableTabList,
  7. TEMPORARY_TAB_KEY,
  8. } from 'sentry/components/draggableTabs/draggableTabList';
  9. import type {DraggableTabListItemProps} from 'sentry/components/draggableTabs/item';
  10. import type {MenuItemProps} from 'sentry/components/dropdownMenu';
  11. import {TabsContext} from 'sentry/components/tabs';
  12. import {t} from 'sentry/locale';
  13. import type {InjectedRouter} from 'sentry/types/legacyReactRouter';
  14. import {defined} from 'sentry/utils';
  15. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  16. import {useLocation} from 'sentry/utils/useLocation';
  17. import {useNavigate} from 'sentry/utils/useNavigate';
  18. import {DraggableTabMenuButton} from 'sentry/views/issueList/groupSearchViewTabs/draggableTabMenuButton';
  19. import EditableTabTitle from 'sentry/views/issueList/groupSearchViewTabs/editableTabTitle';
  20. import {IssueSortOptions} from 'sentry/views/issueList/utils';
  21. import {NewTabContext} from 'sentry/views/issueList/utils/newTabContext';
  22. export interface Tab {
  23. id: string;
  24. key: string;
  25. label: string;
  26. query: string;
  27. querySort: IssueSortOptions;
  28. content?: React.ReactNode;
  29. unsavedChanges?: [string, IssueSortOptions];
  30. }
  31. export interface DraggableTabBarProps {
  32. initialTabKey: string;
  33. orgSlug: string;
  34. router: InjectedRouter;
  35. setTabs: (tabs: Tab[]) => void;
  36. setTempTab: (tab: Tab | undefined) => void;
  37. tabs: Tab[];
  38. /**
  39. * Callback function to be called when user clicks the `Add View` button.
  40. */
  41. onAddView?: (newTabs: Tab[]) => void;
  42. /**
  43. * Callback function to be called when user clicks the `Delete` button.
  44. * Note: The `Delete` button only appears for persistent views
  45. */
  46. onDelete?: (newTabs: Tab[]) => void;
  47. /**
  48. * Callback function to be called when user clicks on the `Discard Changes` button.
  49. * Note: The `Discard Changes` button only appears for persistent views when `isChanged=true`
  50. */
  51. onDiscard?: () => void;
  52. /**
  53. * Callback function to be called when user clicks on the `Discard` button for temporary views.
  54. * Note: The `Discard` button only appears for temporary views
  55. */
  56. onDiscardTempView?: () => void;
  57. /**
  58. * Callback function to be called when user clicks the 'Duplicate' button.
  59. * Note: The `Duplicate` button only appears for persistent views
  60. */
  61. onDuplicate?: (newTabs: Tab[]) => void;
  62. /**
  63. * Callback function to be called when the user reorders the tabs. Returns the
  64. * new order of the tabs along with their props.
  65. */
  66. onReorder?: (newTabs: Tab[]) => void;
  67. /**
  68. * Callback function to be called when user clicks the 'Save' button.
  69. * Note: The `Save` button only appears for persistent views when `isChanged=true`
  70. */
  71. onSave?: (newTabs: Tab[]) => void;
  72. /**
  73. * Callback function to be called when user clicks the 'Save View' button for temporary views.
  74. */
  75. onSaveTempView?: (newTabs: Tab[]) => void;
  76. /**
  77. * Callback function to be called when user renames a tab.
  78. * Note: The `Rename` button only appears for persistent views
  79. */
  80. onTabRenamed?: (newTabs: Tab[], newLabel: string) => void;
  81. tempTab?: Tab;
  82. }
  83. export const generateTempViewId = () => `_${Math.random().toString().substring(2, 7)}`;
  84. export function DraggableTabBar({
  85. initialTabKey,
  86. tabs,
  87. setTabs,
  88. tempTab,
  89. setTempTab,
  90. orgSlug,
  91. router,
  92. onReorder,
  93. onAddView,
  94. onDelete,
  95. onDiscard,
  96. onDuplicate,
  97. onTabRenamed,
  98. onSave,
  99. onDiscardTempView,
  100. onSaveTempView,
  101. }: DraggableTabBarProps) {
  102. // TODO: Extract this to a separate component encompassing Tab.Item in the future
  103. const [editingTabKey, setEditingTabKey] = useState<string | null>(null);
  104. const navigate = useNavigate();
  105. const location = useLocation();
  106. const {cursor: _cursor, page: _page, ...queryParams} = router?.location?.query ?? {};
  107. const {viewId} = queryParams;
  108. const {tabListState} = useContext(TabsContext);
  109. const {setNewViewActive, setOnNewViewSaved} = useContext(NewTabContext);
  110. const handleOnReorder = (newOrder: Node<DraggableTabListItemProps>[]) => {
  111. const newTabs = newOrder
  112. .map(node => {
  113. const foundTab = tabs.find(tab => tab.key === node.key);
  114. return foundTab?.key === node.key ? foundTab : null;
  115. })
  116. .filter(defined);
  117. setTabs(newTabs);
  118. onReorder?.(newTabs);
  119. };
  120. const handleOnSaveChanges = () => {
  121. const originalTab = tabs.find(tab => tab.key === tabListState?.selectedKey);
  122. if (originalTab) {
  123. const newTabs = tabs.map(tab => {
  124. return tab.key === tabListState?.selectedKey && tab.unsavedChanges
  125. ? {
  126. ...tab,
  127. query: tab.unsavedChanges[0],
  128. querySort: tab.unsavedChanges[1],
  129. unsavedChanges: undefined,
  130. }
  131. : tab;
  132. });
  133. setTabs(newTabs);
  134. onSave?.(newTabs);
  135. }
  136. };
  137. const handleOnDiscardChanges = () => {
  138. const originalTab = tabs.find(tab => tab.key === tabListState?.selectedKey);
  139. if (originalTab) {
  140. setTabs(
  141. tabs.map(tab => {
  142. return tab.key === tabListState?.selectedKey
  143. ? {...tab, unsavedChanges: undefined}
  144. : tab;
  145. })
  146. );
  147. navigate({
  148. ...location,
  149. query: {
  150. ...queryParams,
  151. query: originalTab.query,
  152. sort: originalTab.querySort,
  153. ...(originalTab.id ? {viewId: originalTab.id} : {}),
  154. },
  155. });
  156. onDiscard?.();
  157. }
  158. };
  159. const handleOnTabRenamed = (newLabel: string, tabKey: string) => {
  160. const renamedTab = tabs.find(tb => tb.key === tabKey);
  161. if (renamedTab && newLabel !== renamedTab.label) {
  162. const newTabs = tabs.map(tab =>
  163. tab.key === renamedTab.key ? {...tab, label: newLabel} : tab
  164. );
  165. setTabs(newTabs);
  166. onTabRenamed?.(newTabs, newLabel);
  167. }
  168. };
  169. const handleOnDuplicate = () => {
  170. const idx = tabs.findIndex(tb => tb.key === tabListState?.selectedKey);
  171. if (idx !== -1) {
  172. const tempId = generateTempViewId();
  173. const duplicatedTab = tabs[idx];
  174. const newTabs = [
  175. ...tabs.slice(0, idx + 1),
  176. {
  177. ...duplicatedTab,
  178. id: tempId,
  179. key: tempId,
  180. label: `${duplicatedTab.label} (Copy)`,
  181. },
  182. ...tabs.slice(idx + 1),
  183. ];
  184. navigate({
  185. ...location,
  186. query: {
  187. ...queryParams,
  188. viewId: tempId,
  189. },
  190. });
  191. setTabs(newTabs);
  192. tabListState?.setSelectedKey(tempId);
  193. onDuplicate?.(newTabs);
  194. }
  195. };
  196. const handleOnDelete = () => {
  197. if (tabs.length > 1) {
  198. const newTabs = tabs.filter(tb => tb.key !== tabListState?.selectedKey);
  199. setTabs(newTabs);
  200. tabListState?.setSelectedKey(newTabs[0].key);
  201. onDelete?.(newTabs);
  202. }
  203. };
  204. const handleOnSaveTempView = () => {
  205. if (tempTab) {
  206. const tempId = generateTempViewId();
  207. const newTab: Tab = {
  208. id: tempId,
  209. key: tempId,
  210. label: 'New View',
  211. query: tempTab.query,
  212. querySort: tempTab.querySort,
  213. };
  214. const newTabs = [...tabs, newTab];
  215. setTabs(newTabs);
  216. setTempTab(undefined);
  217. tabListState?.setSelectedKey(tempId);
  218. onSaveTempView?.(newTabs);
  219. }
  220. };
  221. const handleOnDiscardTempView = () => {
  222. tabListState?.setSelectedKey(tabs[0].key);
  223. setTempTab(undefined);
  224. onDiscardTempView?.();
  225. };
  226. const handleCreateNewView = () => {
  227. // Triggers the add view flow page
  228. setNewViewActive(true);
  229. const tempId = generateTempViewId();
  230. const currentTab = tabs.find(tab => tab.key === tabListState?.selectedKey);
  231. if (currentTab) {
  232. const newTabs = [
  233. ...tabs,
  234. {
  235. id: tempId,
  236. key: tempId,
  237. label: 'New View',
  238. query: '',
  239. querySort: IssueSortOptions.DATE,
  240. },
  241. ];
  242. navigate({
  243. ...location,
  244. query: {
  245. ...queryParams,
  246. query: '',
  247. viewId: tempId,
  248. },
  249. });
  250. setTabs(newTabs);
  251. tabListState?.setSelectedKey(tempId);
  252. }
  253. };
  254. const handleNewViewSaved: NewTabContext['onNewViewSaved'] = useCallback(
  255. () => (label: string, query: string, saveQueryToView: boolean) => {
  256. setNewViewActive(false);
  257. const updatedTabs: Tab[] = tabs.map(tab => {
  258. if (tab.key === viewId) {
  259. return {
  260. ...tab,
  261. label: label,
  262. query: saveQueryToView ? query : '',
  263. querySort: IssueSortOptions.DATE,
  264. unsavedChanges: saveQueryToView ? undefined : [query, IssueSortOptions.DATE],
  265. };
  266. }
  267. return tab;
  268. });
  269. setTabs(updatedTabs);
  270. navigate(
  271. {
  272. ...location,
  273. query: {
  274. ...queryParams,
  275. query: query,
  276. sort: IssueSortOptions.DATE,
  277. },
  278. },
  279. {replace: true}
  280. );
  281. onAddView?.(updatedTabs);
  282. },
  283. // eslint-disable-next-line react-hooks/exhaustive-deps
  284. [location, navigate, onAddView, setNewViewActive, setTabs, tabs, viewId]
  285. );
  286. useEffect(() => {
  287. setOnNewViewSaved(handleNewViewSaved);
  288. }, [setOnNewViewSaved, handleNewViewSaved]);
  289. const makeMenuOptions = (tab: Tab): MenuItemProps[] => {
  290. if (tab.key === TEMPORARY_TAB_KEY) {
  291. return makeTempViewMenuOptions({
  292. onSaveTempView: handleOnSaveTempView,
  293. onDiscardTempView: handleOnDiscardTempView,
  294. });
  295. }
  296. if (tab.unsavedChanges) {
  297. return makeUnsavedChangesMenuOptions({
  298. onRename: () => setEditingTabKey(tab.key),
  299. onDuplicate: handleOnDuplicate,
  300. onDelete: tabs.length > 1 ? handleOnDelete : undefined,
  301. onSave: handleOnSaveChanges,
  302. onDiscard: handleOnDiscardChanges,
  303. });
  304. }
  305. return makeDefaultMenuOptions({
  306. onRename: () => setEditingTabKey(tab.key),
  307. onDuplicate: handleOnDuplicate,
  308. onDelete: tabs.length > 1 ? handleOnDelete : undefined,
  309. });
  310. };
  311. const allTabs = tempTab ? [...tabs, tempTab] : tabs;
  312. return (
  313. <DraggableTabList
  314. onReorder={handleOnReorder}
  315. defaultSelectedKey={initialTabKey}
  316. onAddView={handleCreateNewView}
  317. orientation="horizontal"
  318. hideBorder
  319. >
  320. {allTabs.map(tab => (
  321. <DraggableTabList.Item
  322. textValue={tab.label}
  323. key={tab.key}
  324. to={normalizeUrl({
  325. query: {
  326. ...queryParams,
  327. query: tab.unsavedChanges?.[0] ?? tab.query,
  328. sort: tab.unsavedChanges?.[1] ?? tab.querySort,
  329. viewId: tab.id !== TEMPORARY_TAB_KEY ? tab.id : undefined,
  330. },
  331. pathname: `/organizations/${orgSlug}/issues/`,
  332. })}
  333. disabled={tab.key === editingTabKey}
  334. >
  335. <TabContentWrap>
  336. <EditableTabTitle
  337. label={tab.label}
  338. isEditing={editingTabKey === tab.key}
  339. setIsEditing={isEditing => setEditingTabKey(isEditing ? tab.key : null)}
  340. onChange={newLabel => handleOnTabRenamed(newLabel.trim(), tab.key)}
  341. tabKey={tab.key}
  342. />
  343. {/* If tablistState isn't initialized, we want to load the elipsis menu
  344. for the initial tab, that way it won't load in a second later
  345. and cause the tabs to shift and animate on load.
  346. */}
  347. {((tabListState && tabListState?.selectedKey === tab.key) ||
  348. (!tabListState && tab.key === initialTabKey)) && (
  349. <DraggableTabMenuButton
  350. hasUnsavedChanges={!!tab.unsavedChanges}
  351. menuOptions={makeMenuOptions(tab)}
  352. aria-label={`${tab.label} Tab Options`}
  353. />
  354. )}
  355. </TabContentWrap>
  356. </DraggableTabList.Item>
  357. ))}
  358. </DraggableTabList>
  359. );
  360. }
  361. const makeDefaultMenuOptions = ({
  362. onRename,
  363. onDuplicate,
  364. onDelete,
  365. }: {
  366. onDelete?: () => void;
  367. onDuplicate?: () => void;
  368. onRename?: () => void;
  369. }): MenuItemProps[] => {
  370. const menuOptions: MenuItemProps[] = [
  371. {
  372. key: 'rename-tab',
  373. label: t('Rename'),
  374. onAction: onRename,
  375. },
  376. {
  377. key: 'duplicate-tab',
  378. label: t('Duplicate'),
  379. onAction: onDuplicate,
  380. },
  381. ];
  382. if (onDelete) {
  383. menuOptions.push({
  384. key: 'delete-tab',
  385. label: t('Delete'),
  386. priority: 'danger',
  387. onAction: onDelete,
  388. });
  389. }
  390. return menuOptions;
  391. };
  392. const makeUnsavedChangesMenuOptions = ({
  393. onRename,
  394. onDuplicate,
  395. onDelete,
  396. onSave,
  397. onDiscard,
  398. }: {
  399. onDelete?: () => void;
  400. onDiscard?: () => void;
  401. onDuplicate?: () => void;
  402. onRename?: () => void;
  403. onSave?: () => void;
  404. }): MenuItemProps[] => {
  405. return [
  406. {
  407. key: 'changed',
  408. children: [
  409. {
  410. key: 'save-changes',
  411. label: t('Save Changes'),
  412. priority: 'primary',
  413. onAction: onSave,
  414. },
  415. {
  416. key: 'discard-changes',
  417. label: t('Discard Changes'),
  418. onAction: onDiscard,
  419. },
  420. ],
  421. },
  422. {
  423. key: 'default',
  424. children: makeDefaultMenuOptions({onRename, onDuplicate, onDelete}),
  425. },
  426. ];
  427. };
  428. const makeTempViewMenuOptions = ({
  429. onSaveTempView,
  430. onDiscardTempView,
  431. }: {
  432. onDiscardTempView: () => void;
  433. onSaveTempView: () => void;
  434. }): MenuItemProps[] => {
  435. return [
  436. {
  437. key: 'save-changes',
  438. label: t('Save View'),
  439. priority: 'primary',
  440. onAction: onSaveTempView,
  441. },
  442. {
  443. key: 'discard-changes',
  444. label: t('Discard'),
  445. onAction: onDiscardTempView,
  446. },
  447. ];
  448. };
  449. const TabContentWrap = styled('span')`
  450. white-space: nowrap;
  451. display: flex;
  452. align-items: center;
  453. flex-direction: row;
  454. padding: 0;
  455. gap: 6px;
  456. `;