draggableTabBar.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. import 'intersection-observer'; // polyfill
  2. import {useEffect, useState} from 'react';
  3. import styled from '@emotion/styled';
  4. import type {Key, Node} from '@react-types/shared';
  5. import Badge from 'sentry/components/badge/badge';
  6. import {DraggableTabList} from 'sentry/components/draggableTabs/draggableTabList';
  7. import type {DraggableTabListItemProps} from 'sentry/components/draggableTabs/item';
  8. import type {MenuItemProps} from 'sentry/components/dropdownMenu';
  9. import QueryCount from 'sentry/components/queryCount';
  10. import {TabPanels, Tabs} from 'sentry/components/tabs';
  11. import {t} from 'sentry/locale';
  12. import {space} from 'sentry/styles/space';
  13. import {defined} from 'sentry/utils';
  14. import {DraggableTabMenuButton} from 'sentry/views/issueList/draggableTabMenuButton';
  15. export interface Tab {
  16. content: React.ReactNode;
  17. key: string;
  18. label: string;
  19. hasUnsavedChanges?: boolean;
  20. queryCount?: number;
  21. }
  22. export interface DraggableTabBarProps {
  23. defaultNewTab: Tab;
  24. setTabs: (tabs: Tab[]) => void;
  25. showTempTab: boolean;
  26. tabs: Tab[];
  27. tempTab: Tab;
  28. onAddView?: React.MouseEventHandler;
  29. /**
  30. * Callback function to be called when user clicks the `Delete` button.
  31. * Note: The `Delete` button only appears for persistent views
  32. */
  33. onDelete?: (key: MenuItemProps['key']) => void;
  34. /**
  35. * Callback function to be called when user clicks on the `Discard Changes` button.
  36. * Note: The `Discard Changes` button only appears for persistent views when `isChanged=true`
  37. */
  38. onDiscard?: (key: MenuItemProps['key']) => void;
  39. /**
  40. * Callback function to be called when user clicks on the `Discard` button for temporary views.
  41. * Note: The `Discard` button only appears for temporary views
  42. */
  43. onDiscardTempView?: () => void;
  44. /**
  45. * Callback function to be called when user clicks the 'Duplicate' button.
  46. * Note: The `Duplicate` button only appears for persistent views
  47. */
  48. onDuplicate?: (key: MenuItemProps['key']) => void;
  49. /**
  50. * Callback function to be called when user clicks the 'Rename' button.
  51. * Note: The `Rename` button only appears for persistent views
  52. */
  53. onRename?: (key: MenuItemProps['key']) => void;
  54. /**
  55. * Callback function to be called when user clicks the 'Save' button.
  56. * Note: The `Save` button only appears for persistent views when `isChanged=true`
  57. */
  58. onSave?: (key: MenuItemProps['key']) => void;
  59. /**
  60. * Callback function to be called when user clicks the 'Save View' button for temporary views.
  61. */
  62. onSaveTempView?: () => void;
  63. tempTabContent?: React.ReactNode;
  64. tempTabLabel?: string;
  65. }
  66. export function DraggableTabBar({
  67. tabs,
  68. setTabs,
  69. tempTab,
  70. defaultNewTab,
  71. showTempTab,
  72. onAddView,
  73. onDelete,
  74. onDiscard,
  75. onDuplicate,
  76. onRename,
  77. onSave,
  78. onDiscardTempView,
  79. onSaveTempView,
  80. }: DraggableTabBarProps) {
  81. const [selectedTabKey, setSelectedTabKey] = useState<Key>(tabs[0].key);
  82. useEffect(() => {
  83. if (!showTempTab && selectedTabKey === 'temporary-tab') {
  84. setSelectedTabKey(tabs[0].key);
  85. }
  86. }, [showTempTab, selectedTabKey, tabs]);
  87. const onReorder: (newOrder: Node<DraggableTabListItemProps>[]) => void = newOrder => {
  88. setTabs(
  89. newOrder
  90. .map(node => {
  91. const foundTab = tabs.find(tab => tab.key === node.key);
  92. return foundTab?.key === node.key ? foundTab : null;
  93. })
  94. .filter(defined)
  95. );
  96. };
  97. const handleOnDelete = (tab: Tab) => {
  98. if (tabs.length > 1) {
  99. setTabs(tabs.filter(tb => tb.key !== tab.key));
  100. onDelete?.(tab.key);
  101. }
  102. };
  103. const handleOnDuplicate = (tab: Tab) => {
  104. const idx = tabs.findIndex(tb => tb.key === tab.key);
  105. if (idx !== -1) {
  106. setTabs([
  107. ...tabs.slice(0, idx + 1),
  108. {
  109. ...tab,
  110. key: `${tab.key}-copy`,
  111. label: `${tab.label} (Copy)`,
  112. hasUnsavedChanges: false,
  113. },
  114. ...tabs.slice(idx + 1),
  115. ]);
  116. onDuplicate?.(tab.key);
  117. }
  118. };
  119. const handleOnAddView = (e: React.MouseEvent<Element, MouseEvent>) => {
  120. setTabs([...tabs, defaultNewTab]);
  121. onAddView?.(e);
  122. };
  123. const makeMenuOptions = (tab: Tab): MenuItemProps[] => {
  124. if (tab.key === 'temporary-tab') {
  125. return makeTempViewMenuOptions({
  126. onSave: () => onSaveTempView?.(),
  127. onDiscard: () => onDiscardTempView?.(),
  128. });
  129. }
  130. if (tab.hasUnsavedChanges) {
  131. return makeUnsavedChangesMenuOptions({
  132. onRename,
  133. onDuplicate: () => handleOnDuplicate(tab),
  134. onDelete: tabs.length > 1 ? () => handleOnDelete(tab) : undefined,
  135. onSave,
  136. onDiscard,
  137. });
  138. }
  139. return makeDefaultMenuOptions({
  140. onRename,
  141. onDuplicate: () => handleOnDuplicate(tab),
  142. onDelete: tabs.length > 1 ? () => handleOnDelete(tab) : undefined,
  143. });
  144. };
  145. return (
  146. <Tabs>
  147. <DraggableTabList
  148. onReorder={onReorder}
  149. onSelectionChange={setSelectedTabKey}
  150. selectedKey={selectedTabKey}
  151. showTempTab={showTempTab}
  152. onAddView={e => handleOnAddView(e)}
  153. orientation="horizontal"
  154. >
  155. {[...tabs, tempTab].map(tab => (
  156. <DraggableTabList.Item
  157. textValue={`${tab.label} tab`}
  158. key={tab.key}
  159. hidden={tab.key === 'temporary-tab' && !showTempTab}
  160. >
  161. <TabContentWrap>
  162. {tab.label}
  163. {tab.key !== 'temporary-tab' && tab.queryCount && (
  164. <StyledBadge>
  165. <QueryCount hideParens count={tab.queryCount} max={1000} />
  166. </StyledBadge>
  167. )}
  168. {selectedTabKey === tab.key && (
  169. <DraggableTabMenuButton
  170. hasUnsavedChanges={tab.hasUnsavedChanges}
  171. menuOptions={makeMenuOptions(tab)}
  172. />
  173. )}
  174. </TabContentWrap>
  175. </DraggableTabList.Item>
  176. ))}
  177. </DraggableTabList>
  178. <TabPanels>
  179. {[...tabs, tempTab].map(tab => (
  180. <TabPanels.Item key={tab.key}>{tab.content}</TabPanels.Item>
  181. ))}
  182. </TabPanels>
  183. </Tabs>
  184. );
  185. }
  186. const makeDefaultMenuOptions = ({
  187. onRename,
  188. onDuplicate,
  189. onDelete,
  190. }: {
  191. onDelete?: (key: string) => void;
  192. onDuplicate?: (key: string) => void;
  193. onRename?: (key: string) => void;
  194. }): MenuItemProps[] => {
  195. const menuOptions: MenuItemProps[] = [
  196. {
  197. key: 'rename-tab',
  198. label: t('Rename'),
  199. onAction: onRename,
  200. },
  201. {
  202. key: 'duplicate-tab',
  203. label: t('Duplicate'),
  204. onAction: onDuplicate,
  205. },
  206. ];
  207. if (onDelete) {
  208. menuOptions.push({
  209. key: 'delete-tab',
  210. label: t('Delete'),
  211. priority: 'danger',
  212. onAction: onDelete,
  213. });
  214. }
  215. return menuOptions;
  216. };
  217. const makeUnsavedChangesMenuOptions = ({
  218. onRename,
  219. onDuplicate,
  220. onDelete,
  221. onSave,
  222. onDiscard,
  223. }: {
  224. onDelete?: (key: string) => void;
  225. onDiscard?: (key: string) => void;
  226. onDuplicate?: (key: string) => void;
  227. onRename?: (key: string) => void;
  228. onSave?: (key: string) => void;
  229. }): MenuItemProps[] => {
  230. return [
  231. {
  232. key: 'changed',
  233. children: [
  234. {
  235. key: 'save-changes',
  236. label: t('Save Changes'),
  237. priority: 'primary',
  238. onAction: onSave,
  239. },
  240. {
  241. key: 'discard-changes',
  242. label: t('Discard Changes'),
  243. onAction: onDiscard,
  244. },
  245. ],
  246. },
  247. {
  248. key: 'default',
  249. children: makeDefaultMenuOptions({onRename, onDuplicate, onDelete}),
  250. },
  251. ];
  252. };
  253. const makeTempViewMenuOptions = ({
  254. onSave,
  255. onDiscard,
  256. }: {
  257. onDiscard: () => void;
  258. onSave: () => void;
  259. }): MenuItemProps[] => {
  260. return [
  261. {
  262. key: 'save-changes',
  263. label: t('Save View'),
  264. priority: 'primary',
  265. onAction: onSave,
  266. },
  267. {
  268. key: 'discard-changes',
  269. label: t('Discard'),
  270. onAction: onDiscard,
  271. },
  272. ];
  273. };
  274. const TabContentWrap = styled('span')`
  275. white-space: nowrap;
  276. display: flex;
  277. align-items: center;
  278. flex-direction: row;
  279. padding: ${space(0)} ${space(0)};
  280. gap: 6px;
  281. `;
  282. const StyledBadge = styled(Badge)`
  283. display: flex;
  284. height: 16px;
  285. align-items: center;
  286. justify-content: center;
  287. border-radius: 10px;
  288. background: transparent;
  289. border: 1px solid ${p => p.theme.gray200};
  290. color: ${p => p.theme.gray300};
  291. margin-left: ${space(0)};
  292. `;