draggableTabBar.tsx 17 KB

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