draggableTabBar.tsx 13 KB

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