draggableTabBar.tsx 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  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: Key;
  18. label: string;
  19. hasUnsavedChanges?: boolean;
  20. queryCount?: number;
  21. }
  22. export interface DraggableTabBarProps {
  23. tabs: Tab[];
  24. onAddView?: React.MouseEventHandler;
  25. /**
  26. * Callback function to be called when user clicks the `Delete` button.
  27. * Note: The `Delete` button only appears for persistent views
  28. */
  29. onDelete?: (key: MenuItemProps['key']) => void;
  30. /**
  31. * Callback function to be called when user clicks on the `Discard Changes` button.
  32. * Note: The `Discard Changes` button only appears for persistent views when `isChanged=true`
  33. */
  34. onDiscard?: (key: MenuItemProps['key']) => void;
  35. /**
  36. * Callback function to be called when user clicks on the `Discard` button for temporary views.
  37. * Note: The `Discard` button only appears for temporary views
  38. */
  39. onDiscardTempView?: () => void;
  40. /**
  41. * Callback function to be called when user clicks the 'Duplicate' button.
  42. * Note: The `Duplicate` button only appears for persistent views
  43. */
  44. onDuplicate?: (key: MenuItemProps['key']) => void;
  45. /**
  46. * Callback function to be called when user clicks the 'Rename' button.
  47. * Note: The `Rename` button only appears for persistent views
  48. */
  49. onRename?: (key: MenuItemProps['key']) => void;
  50. /**
  51. * Callback function to be called when user clicks the 'Save' button.
  52. * Note: The `Save` button only appears for persistent views when `isChanged=true`
  53. */
  54. onSave?: (key: MenuItemProps['key']) => void;
  55. /**
  56. * Callback function to be called when user clicks the 'Save View' button for temporary views.
  57. */
  58. onSaveTempView?: () => void;
  59. showTempTab?: boolean;
  60. tempTabContent?: React.ReactNode;
  61. tempTabLabel?: string;
  62. }
  63. export function DraggableTabBar({
  64. showTempTab = false,
  65. tempTabContent,
  66. tempTabLabel = 'Unsaved',
  67. onAddView,
  68. onDelete,
  69. onDiscard,
  70. onDuplicate,
  71. onRename,
  72. onSave,
  73. onDiscardTempView,
  74. onSaveTempView,
  75. ...props
  76. }: DraggableTabBarProps) {
  77. const tempTab = {
  78. key: 'temporary-tab',
  79. label: tempTabLabel,
  80. content: tempTabContent,
  81. };
  82. const [tabs, setTabs] = useState<Tab[]>([...props.tabs, tempTab]);
  83. const [selectedTabKey, setSelectedTabKey] = useState<Key>(props.tabs[0].key);
  84. useEffect(() => {
  85. if (!showTempTab && selectedTabKey === 'temporary-tab') {
  86. setSelectedTabKey(props.tabs[0].key);
  87. }
  88. }, [showTempTab, selectedTabKey, props.tabs]);
  89. const onReorder: (newOrder: Node<DraggableTabListItemProps>[]) => void = newOrder => {
  90. setTabs(
  91. newOrder
  92. .map(node => {
  93. const foundTab = tabs.find(tab => tab.key === node.key);
  94. return foundTab?.key === node.key ? foundTab : null;
  95. })
  96. .filter(defined)
  97. );
  98. };
  99. const makeMenuOptions = (tab: Tab): MenuItemProps[] => {
  100. if (tab.key === 'temporary-tab') {
  101. return makeTempViewMenuOptions({
  102. onSave: onSaveTempView,
  103. onDiscard: onDiscardTempView,
  104. });
  105. }
  106. if (tab.hasUnsavedChanges) {
  107. return makeUnsavedChangesMenuOptions({
  108. onRename,
  109. onDuplicate,
  110. onDelete,
  111. onSave,
  112. onDiscard,
  113. });
  114. }
  115. return makeDefaultMenuOptions({onRename, onDuplicate, onDelete});
  116. };
  117. return (
  118. <Tabs>
  119. <DraggableTabList
  120. onReorder={onReorder}
  121. onSelectionChange={setSelectedTabKey}
  122. selectedKey={selectedTabKey}
  123. showTempTab={showTempTab}
  124. onAddView={onAddView}
  125. orientation="horizontal"
  126. >
  127. {tabs.map(tab => (
  128. <DraggableTabList.Item
  129. textValue={`${tab.label} tab`}
  130. key={tab.key}
  131. hidden={tab.key === 'temporary-tab' && !showTempTab}
  132. >
  133. <TabContentWrap>
  134. {tab.label}
  135. {tab.key !== 'temporary-tab' && (
  136. <StyledBadge>
  137. <QueryCount hideParens count={tab.queryCount} max={1000} />
  138. </StyledBadge>
  139. )}
  140. {selectedTabKey === tab.key && (
  141. <DraggableTabMenuButton
  142. hasUnsavedChanges={tab.hasUnsavedChanges}
  143. menuOptions={makeMenuOptions(tab)}
  144. />
  145. )}
  146. </TabContentWrap>
  147. </DraggableTabList.Item>
  148. ))}
  149. </DraggableTabList>
  150. <TabPanels>
  151. {tabs.map(tab => (
  152. <TabPanels.Item key={tab.key}>{tab.content}</TabPanels.Item>
  153. ))}
  154. </TabPanels>
  155. </Tabs>
  156. );
  157. }
  158. const makeDefaultMenuOptions = ({onRename, onDuplicate, onDelete}): MenuItemProps[] => {
  159. return [
  160. {
  161. key: 'rename-tab',
  162. label: t('Rename'),
  163. onAction: onRename,
  164. },
  165. {
  166. key: 'duplicate-tab',
  167. label: t('Duplicate'),
  168. onAction: onDuplicate,
  169. },
  170. {
  171. key: 'delete-tab',
  172. label: t('Delete'),
  173. priority: 'danger',
  174. onAction: onDelete,
  175. },
  176. ];
  177. };
  178. const makeUnsavedChangesMenuOptions = ({
  179. onRename,
  180. onDuplicate,
  181. onDelete,
  182. onSave,
  183. onDiscard,
  184. }): MenuItemProps[] => {
  185. return [
  186. {
  187. key: 'changed',
  188. children: [
  189. {
  190. key: 'save-changes',
  191. label: t('Save Changes'),
  192. priority: 'primary',
  193. onAction: onSave,
  194. },
  195. {
  196. key: 'discard-changes',
  197. label: t('Discard Changes'),
  198. onAction: onDiscard,
  199. },
  200. ],
  201. },
  202. {
  203. key: 'default',
  204. children: makeDefaultMenuOptions({onRename, onDuplicate, onDelete}),
  205. },
  206. ];
  207. };
  208. const makeTempViewMenuOptions = ({onSave, onDiscard}): MenuItemProps[] => {
  209. return [
  210. {
  211. key: 'save-changes',
  212. label: t('Save View'),
  213. priority: 'primary',
  214. onAction: onSave,
  215. },
  216. {
  217. key: 'discard-changes',
  218. label: t('Discard'),
  219. onAction: onDiscard,
  220. },
  221. ];
  222. };
  223. const TabContentWrap = styled('span')`
  224. white-space: nowrap;
  225. display: flex;
  226. align-items: center;
  227. flex-direction: row;
  228. padding: ${space(0)} ${space(0)};
  229. gap: 6px;
  230. `;
  231. const StyledBadge = styled(Badge)`
  232. display: flex;
  233. height: 16px;
  234. align-items: center;
  235. justify-content: center;
  236. border-radius: 10px;
  237. background: transparent;
  238. border: 1px solid ${p => p.theme.gray200};
  239. color: ${p => p.theme.gray300};
  240. margin-left: ${space(0)};
  241. `;