Browse Source

feat(draggableTabs): Add query count and tab menu button (#75114)

closes #73224

This PR adds the query count and tab dropdown and button to the
draggable tabs component. These changes are purely visual, no
functionality for the dropdown menu options have been implemented.

<img width="460" alt="image"
src="https://github.com/user-attachments/assets/b22ff7d8-be53-432d-8232-a12f5db7709e">

As an added bonus, I also have cleaned up the DraggableTabList and
DraggableTab components so that they are a lot more generic than the
previous implementation of this feature

Known Issues:
- Dragging a tab to the left makes it disappear into the overflow menu
for some reason
- Hovering over the elements in the dropdown menu causes the tab itself
to increase in opacity as if its being hovered over. Clicking on an
option in the dropdown menu also increases the opacity of the tab
Michael Sun 7 months ago
parent
commit
f68aa361b9

+ 0 - 68
static/app/components/draggableTabs/draggableTab.tsx

@@ -1,68 +0,0 @@
-import type React from 'react';
-import {forwardRef} from 'react';
-import styled from '@emotion/styled';
-import type {AriaTabProps} from '@react-aria/tabs';
-import {useTab} from '@react-aria/tabs';
-import {useObjectRef} from '@react-aria/utils';
-import type {TabListState} from '@react-stately/tabs';
-import type {Node, Orientation} from '@react-types/shared';
-
-import {BaseTab} from 'sentry/components/tabs/tab';
-
-interface DraggableTabProps extends AriaTabProps {
-  item: Node<any>;
-  orientation: Orientation;
-  /**
-   * Whether this tab is overflowing the TabList container. If so, the tab
-   * needs to be visually hidden. Users can instead select it via an overflow
-   * menu.
-   */
-  overflowing: boolean;
-  state: TabListState<any>;
-}
-
-export const DraggableTab = forwardRef(
-  (
-    {item, state, orientation, overflowing}: DraggableTabProps,
-    forwardedRef: React.ForwardedRef<HTMLLIElement>
-  ) => {
-    const ref = useObjectRef(forwardedRef);
-
-    const {
-      key,
-      rendered,
-      props: {to, hidden},
-    } = item;
-    const {tabProps, isSelected} = useTab({key, isDisabled: hidden}, state, ref);
-
-    return (
-      <StyledBaseTab
-        tabProps={tabProps}
-        isSelected={isSelected}
-        to={to}
-        hidden={hidden}
-        orientation={orientation}
-        overflowing={overflowing}
-        ref={ref}
-        variant="filled"
-      >
-        <TabContentWrap>{rendered}</TabContentWrap>
-      </StyledBaseTab>
-    );
-  }
-);
-
-const StyledBaseTab = styled(BaseTab)`
-  padding: 2px 12px 2px 12px;
-  gap: 8px;
-  border-radius: 6px 6px 0px 0px;
-  border: 1px solid ${p => p.theme.gray200};
-  opacity: 0px;
-`;
-
-const TabContentWrap = styled('span')`
-  display: flex;
-  align-items: center;
-  flex-direction: row;
-  gap: 6px;
-`;

+ 18 - 26
static/app/components/draggableTabs/draggableTabList.tsx

@@ -11,28 +11,25 @@ import {Reorder} from 'framer-motion';
 
 import type {SelectOption} from 'sentry/components/compactSelect';
 import {TabsContext} from 'sentry/components/tabs';
+import {type BaseTabProps, Tab} from 'sentry/components/tabs/tab';
 import {OverflowMenu, useOverflowTabs} from 'sentry/components/tabs/tabList';
 import {tabsShouldForwardProp} from 'sentry/components/tabs/utils';
 import {space} from 'sentry/styles/space';
 import {browserHistory} from 'sentry/utils/browserHistory';
-import type {Tab} from 'sentry/views/issueList/draggableTabBar';
 
-import {DraggableTab} from './draggableTab';
 import type {DraggableTabListItemProps} from './item';
 import {Item} from './item';
 
 interface BaseDraggableTabListProps extends DraggableTabListProps {
   items: DraggableTabListItemProps[];
-  setTabs: (tabs: Tab[]) => void;
-  tabs: Tab[];
 }
 
 function BaseDraggableTabList({
   hideBorder = false,
   className,
   outerWrapStyles,
-  tabs,
-  setTabs,
+  onReorder,
+  tabVariant = 'filled',
   ...props
 }: BaseDraggableTabListProps) {
   const tabListRef = useRef<HTMLUListElement>(null);
@@ -108,7 +105,12 @@ function BaseDraggableTabList({
 
   return (
     <TabListOuterWrap style={outerWrapStyles}>
-      <Reorder.Group axis="x" values={tabs} onReorder={setTabs} as="div">
+      <Reorder.Group
+        axis="x"
+        values={[...state.collection]}
+        onReorder={onReorder}
+        as="div"
+      >
         <TabListWrap
           {...tabListProps}
           orientation={orientation}
@@ -119,10 +121,10 @@ function BaseDraggableTabList({
           {[...state.collection].map(item => (
             <Reorder.Item
               key={item.key}
-              value={tabs.find(tab => tab.key === item.key)}
+              value={item}
               style={{display: 'flex', flexDirection: 'row'}}
             >
-              <DraggableTab
+              <Tab
                 key={item.key}
                 item={item}
                 state={state}
@@ -131,7 +133,9 @@ function BaseDraggableTabList({
                   orientation === 'horizontal' && overflowTabs.includes(item.key)
                 }
                 ref={element => (tabItemsRef.current[item.key] = element)}
+                variant={tabVariant}
               />
+
               {state.selectedKey !== item.key &&
                 state.collection.getKeyAfter(item.key) !== state.selectedKey && (
                   <TabDivider />
@@ -157,23 +161,18 @@ const collectionFactory = (nodes: Iterable<Node<any>>) => new ListCollection(nod
 export interface DraggableTabListProps
   extends AriaTabListOptions<DraggableTabListItemProps>,
     TabListStateOptions<DraggableTabListItemProps> {
-  setTabs: (tabs: Tab[]) => void;
-  tabs: Tab[];
+  onReorder: (newOrder: Node<DraggableTabListItemProps>[]) => void;
   className?: string;
   hideBorder?: boolean;
   outerWrapStyles?: React.CSSProperties;
+  tabVariant?: BaseTabProps['variant'];
 }
 
 /**
  * To be used as a direct child of the <Tabs /> component. See example usage
  * in tabs.stories.js
  */
-export function DraggableTabList({
-  items,
-  tabs,
-  setTabs,
-  ...props
-}: DraggableTabListProps) {
+export function DraggableTabList({items, ...props}: DraggableTabListProps) {
   const collection = useCollection({items, ...props}, collectionFactory);
 
   const parsedItems = useMemo(
@@ -191,13 +190,7 @@ export function DraggableTabList({
   );
 
   return (
-    <BaseDraggableTabList
-      tabs={tabs}
-      items={parsedItems}
-      disabledKeys={disabledKeys}
-      setTabs={setTabs}
-      {...props}
-    >
+    <BaseDraggableTabList items={parsedItems} disabledKeys={disabledKeys} {...props}>
       {item => <Item {...item} />}
     </BaseDraggableTabList>
   );
@@ -210,7 +203,7 @@ const TabDivider = styled('div')`
   width: 1px;
   border-radius: 6px;
   background-color: ${p => p.theme.gray200};
-  margin: 9px auto;
+  margin: 8px 4px;
 `;
 
 const TabListOuterWrap = styled('div')`
@@ -236,7 +229,6 @@ const TabListWrap = styled('ul', {
       ? `
         grid-auto-flow: column;
         justify-content: start;
-        gap: ${space(0.5)};
         ${!p.hideBorder && `border-bottom: solid 1px ${p.theme.border};`}
         stroke-dasharray: 4, 3;
       `

+ 9 - 8
static/app/components/draggableTabs/index.stories.tsx

@@ -4,7 +4,7 @@ import styled from '@emotion/styled';
 import JSXNode from 'sentry/components/stories/jsxNode';
 import SizingWindow from 'sentry/components/stories/sizingWindow';
 import storyBook from 'sentry/stories/storyBook';
-import {DraggableTabBar} from 'sentry/views/issueList/draggableTabBar';
+import {DraggableTabBar, type Tab} from 'sentry/views/issueList/draggableTabBar';
 
 const TabPanelContainer = styled('div')`
   width: 90%;
@@ -13,21 +13,27 @@ const TabPanelContainer = styled('div')`
 `;
 
 export default storyBook(DraggableTabBar, story => {
-  const TABS = [
+  const TABS: Tab[] = [
     {
       key: 'one',
       label: 'Inbox',
       content: <TabPanelContainer>This is the Inbox view</TabPanelContainer>,
+      queryCount: 1001,
+      hasUnsavedChanges: true,
     },
     {
       key: 'two',
       label: 'For Review',
       content: <TabPanelContainer>This is the For Review view</TabPanelContainer>,
+      queryCount: 50,
+      hasUnsavedChanges: false,
     },
     {
       key: 'three',
       label: 'Regressed',
       content: <TabPanelContainer>This is the Regressed view</TabPanelContainer>,
+      queryCount: 100,
+      hasUnsavedChanges: false,
     },
   ];
 
@@ -44,12 +50,7 @@ export default storyBook(DraggableTabBar, story => {
       </p>
       <SizingWindow>
         <TabBarContainer>
-          <DraggableTabBar
-            tabs={TABS}
-            tempTabContent={
-              <TabPanelContainer>This is a temporary tab</TabPanelContainer>
-            }
-          />
+          <DraggableTabBar tabs={TABS} />
         </TabBarContainer>
       </SizingWindow>
     </Fragment>

+ 17 - 9
static/app/icons/iconEllipsis.tsx

@@ -3,15 +3,23 @@ import {forwardRef} from 'react';
 import type {SVGIconProps} from './svgIcon';
 import {SvgIcon} from './svgIcon';
 
-const IconEllipsis = forwardRef<SVGSVGElement, SVGIconProps>((props, ref) => {
-  return (
-    <SvgIcon {...props} ref={ref}>
-      <circle cx="8" cy="8" r="1.31" />
-      <circle cx="1.31" cy="8" r="1.31" />
-      <circle cx="14.69" cy="8" r="1.31" />
-    </SvgIcon>
-  );
-});
+interface IconEllipsisProps extends SVGIconProps {
+  compact?: boolean;
+}
+
+const IconEllipsis = forwardRef<SVGSVGElement, IconEllipsisProps>(
+  ({compact = false, ...props}: IconEllipsisProps, ref) => {
+    const circleRadius = compact ? 1.11 : 1.31;
+    const circleSpacing = compact ? 5.5 : 6.69;
+    return (
+      <SvgIcon {...props} ref={ref}>
+        <circle cx="8" cy="8" r={circleRadius} />
+        <circle cx={8 - circleSpacing} cy="8" r={circleRadius} />
+        <circle cx={8 + circleSpacing} cy="8" r={circleRadius} />
+      </SvgIcon>
+    );
+  }
+);
 
 IconEllipsis.displayName = 'IconEllipsis';
 

+ 72 - 5
static/app/views/issueList/draggableTabBar.tsx

@@ -1,31 +1,77 @@
 import 'intersection-observer'; // polyfill
 
 import {useState} from 'react';
-import type {Key} from '@react-types/shared';
+import styled from '@emotion/styled';
+import type {Key, Node} from '@react-types/shared';
 
+import Badge from 'sentry/components/badge/badge';
 import {DraggableTabList} from 'sentry/components/draggableTabs/draggableTabList';
+import type {DraggableTabListItemProps} from 'sentry/components/draggableTabs/item';
+import type {MenuItemProps} from 'sentry/components/dropdownMenu';
+import QueryCount from 'sentry/components/queryCount';
 import {TabPanels, Tabs} from 'sentry/components/tabs';
+import {space} from 'sentry/styles/space';
+import {defined} from 'sentry/utils';
+import {DraggableTabMenuButton} from 'sentry/views/issueList/draggableTabMenuButton';
 
 export interface Tab {
   content: React.ReactNode;
   key: Key;
   label: string;
+  hasUnsavedChanges?: boolean;
   queryCount?: number;
 }
 
 export interface DraggableTabBarProps {
   tabs: Tab[];
-  tempTabContent: React.ReactNode;
+  onDelete?: (key: MenuItemProps['key']) => void;
+  onDiscard?: (key: MenuItemProps['key']) => void;
+  onDuplicate?: (key: MenuItemProps['key']) => void;
+  onRename?: (key: MenuItemProps['key']) => void;
+  onSave?: (key: MenuItemProps['key']) => void;
 }
 
 export function DraggableTabBar(props: DraggableTabBarProps) {
-  const [tabs, setTabs] = useState<Tab[]>([...props.tabs]);
+  const [tabs, setTabs] = useState<Tab[]>(props.tabs);
+  const [selectedTabKey, setSelectedTabKey] = useState<Key>(props.tabs[0].key);
+
+  const onReorder: (newOrder: Node<DraggableTabListItemProps>[]) => void = newOrder => {
+    setTabs(
+      newOrder
+        .map(node => {
+          const foundTab = tabs.find(tab => tab.key === node.key);
+          return foundTab?.key === node.key ? foundTab : null;
+        })
+        .filter(defined)
+    );
+  };
 
   return (
     <Tabs>
-      <DraggableTabList tabs={tabs} setTabs={setTabs} orientation="horizontal">
+      <DraggableTabList
+        onReorder={onReorder}
+        onSelectionChange={setSelectedTabKey}
+        orientation="horizontal"
+      >
         {tabs.map(tab => (
-          <DraggableTabList.Item key={tab.key}>{tab.label}</DraggableTabList.Item>
+          <DraggableTabList.Item key={tab.key}>
+            <TabContentWrap>
+              {tab.label}
+              <StyledBadge>
+                <QueryCount hideParens count={tab.queryCount} max={1000} />
+              </StyledBadge>
+              {selectedTabKey === tab.key && (
+                <DraggableTabMenuButton
+                  hasUnsavedChanges={tab.hasUnsavedChanges}
+                  onDelete={key => props.onDelete?.(key)}
+                  onDiscard={key => props.onDiscard?.(key)}
+                  onDuplicate={key => props.onDuplicate?.(key)}
+                  onRename={key => props.onRename?.(key)}
+                  onSave={key => props.onSave?.(key)}
+                />
+              )}
+            </TabContentWrap>
+          </DraggableTabList.Item>
         ))}
       </DraggableTabList>
       <TabPanels>
@@ -36,3 +82,24 @@ export function DraggableTabBar(props: DraggableTabBarProps) {
     </Tabs>
   );
 }
+
+const TabContentWrap = styled('span')`
+  white-space: nowrap;
+  display: flex;
+  align-items: center;
+  flex-direction: row;
+  padding: ${space(0)} ${space(0)};
+  gap: 6px;
+`;
+
+const StyledBadge = styled(Badge)`
+  display: flex;
+  height: 16px;
+  align-items: center;
+  justify-content: center;
+  border-radius: 10px;
+  background: transparent;
+  border: 1px solid ${p => p.theme.gray200};
+  color: ${p => p.theme.gray300};
+  margin-left: ${space(0)};
+`;

+ 154 - 0
static/app/views/issueList/draggableTabMenuButton.tsx

@@ -0,0 +1,154 @@
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
+
+import {Button} from 'sentry/components/button';
+import {DropdownMenu, type MenuItemProps} from 'sentry/components/dropdownMenu';
+import {IconEllipsis} from 'sentry/icons';
+import {t} from 'sentry/locale';
+
+interface DraggableTabMenuButtonProps {
+  hasUnsavedChanges?: boolean;
+
+  /**
+   * Callback function to be called when user clicks the `Delete` button (for persistent tabs)
+   * Note: The `Delete` button only appears when `isTempTab=false` (persistent tabs)
+   */
+  onDelete?: (key: MenuItemProps['key']) => void;
+
+  /**
+   * Callback function to be called when user clicks on the `Discard Changes` button
+   * Note: The `Discard Changes` button only appears for persistent tabs when `isChanged=true`
+   */
+  onDiscard?: (key: MenuItemProps['key']) => void;
+
+  /**
+   * Callback function to be called when user clicks the 'Duplicate' Button
+   * Note: The `Duplicate` button only appears when `isTempTab=false` (persistent tabs)
+   */
+  onDuplicate?: (key: MenuItemProps['key']) => void;
+
+  /**
+   * Callback function to be called when user clicks the 'Rename' Button
+   * Note: The `Rename` button only appears when `isTempTab=false` (persistent tabs)
+   * @returns
+   */
+  onRename?: (key: MenuItemProps['key']) => void;
+  /**
+   * Callback function to be called when user clicks the 'Save' button.
+   * Note: The `Save` button only appears for persistent tabs when `isChanged=true`, or when `isTempTab=true`
+   */
+  onSave?: (key: MenuItemProps['key']) => void;
+  triggerProps?: Omit<React.HTMLAttributes<HTMLElement>, 'children'>;
+}
+
+export function DraggableTabMenuButton({
+  triggerProps,
+  hasUnsavedChanges = false,
+  onDelete,
+  onDiscard,
+  onDuplicate,
+  onRename,
+  onSave,
+}: DraggableTabMenuButtonProps) {
+  const hasUnsavedChangesMenuOptions: MenuItemProps[] = [
+    {
+      key: 'save-changes',
+      label: t('Save Changes'),
+      priority: 'primary',
+      onAction: onSave,
+    },
+    {
+      key: 'discard-changes',
+      label: t('Discard Changes'),
+      onAction: onDiscard,
+    },
+  ];
+
+  const defaultMenuOptions: MenuItemProps[] = [
+    {
+      key: 'rename-tab',
+      label: t('Rename'),
+      onAction: onRename,
+    },
+    {
+      key: 'duplicate-tab',
+      label: t('Duplicate'),
+      onAction: onDuplicate,
+    },
+    {
+      key: 'delete-tab',
+      label: t('Delete'),
+      priority: 'danger',
+      onAction: onDelete,
+    },
+  ];
+
+  const menuOptions = hasUnsavedChanges
+    ? [
+        {
+          key: 'changed',
+          children: hasUnsavedChangesMenuOptions,
+        },
+        {
+          key: 'default',
+          children: defaultMenuOptions,
+        },
+      ]
+    : defaultMenuOptions;
+
+  return (
+    <TriggerIconWrap>
+      <StyledDropdownMenu
+        position="bottom-start"
+        triggerProps={{
+          size: 'zero',
+          showChevron: false,
+          borderless: true,
+          icon: (
+            <Fragment>
+              <StyledDropdownButton
+                {...triggerProps}
+                aria-label="Tab Options"
+                borderless
+                size="zero"
+                icon={<IconEllipsis compact />}
+              />
+              {hasUnsavedChanges && <UnsavedChangesIndicator role="presentation" />}
+            </Fragment>
+          ),
+          style: {width: '18px', height: '16px'},
+        }}
+        items={menuOptions}
+        offset={[-10, 5]}
+      />
+    </TriggerIconWrap>
+  );
+}
+
+const StyledDropdownMenu = styled(DropdownMenu)`
+  font-weight: ${p => p.theme.fontWeightNormal};
+`;
+
+const UnsavedChangesIndicator = styled('div')`
+  width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  background: ${p => p.theme.active};
+  border: solid 1px ${p => p.theme.background};
+  position: absolute;
+  top: -3px;
+  right: -3px;
+`;
+
+const StyledDropdownButton = styled(Button)`
+  width: 18px;
+  height: 16px;
+  border: 1px solid ${p => p.theme.gray200};
+  gap: 5px;
+  border-radius: 4px;
+`;
+const TriggerIconWrap = styled('div')`
+  position: relative;
+  display: flex;
+  align-items: center;
+`;