draggableTabBar.tsx 16 KB

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