diff --git a/static/app/views/issueList/groupSearchViewTabs/draggableTabBar.tsx b/static/app/views/issueList/groupSearchViewTabs/draggableTabBar.tsx deleted file mode 100644 index 79851f4032ca92..00000000000000 --- a/static/app/views/issueList/groupSearchViewTabs/draggableTabBar.tsx +++ /dev/null @@ -1,387 +0,0 @@ -import 'intersection-observer'; // polyfill - -import {useCallback, useContext, useEffect, useState} from 'react'; -import styled from '@emotion/styled'; -import type {Node} from '@react-types/shared'; -import {motion} from 'framer-motion'; - -import {addSuccessMessage} from 'sentry/actionCreators/indicator'; -import { - DraggableTabList, - TEMPORARY_TAB_KEY, -} from 'sentry/components/draggableTabs/draggableTabList'; -import type {DraggableTabListItemProps} from 'sentry/components/draggableTabs/item'; -import type {MenuItemProps} from 'sentry/components/dropdownMenu'; -import {t} from 'sentry/locale'; -import type {InjectedRouter} from 'sentry/types/legacyReactRouter'; -import normalizeUrl from 'sentry/utils/url/normalizeUrl'; -import {useHotkeys} from 'sentry/utils/useHotkeys'; -import {useLocation} from 'sentry/utils/useLocation'; -import {useNavigate} from 'sentry/utils/useNavigate'; -import useOrganization from 'sentry/utils/useOrganization'; -import {DraggableTabMenuButton} from 'sentry/views/issueList/groupSearchViewTabs/draggableTabMenuButton'; -import EditableTabTitle from 'sentry/views/issueList/groupSearchViewTabs/editableTabTitle'; -import { - type IssueView, - IssueViewsContext, -} from 'sentry/views/issueList/groupSearchViewTabs/issueViews'; -import {IssueSortOptions} from 'sentry/views/issueList/utils'; -import {NewTabContext, type NewView} from 'sentry/views/issueList/utils/newTabContext'; - -export interface DraggableTabBarProps { - initialTabKey: string; - router: InjectedRouter; -} - -export const generateTempViewId = () => `_${Math.random().toString().substring(2, 7)}`; - -export function DraggableTabBar({initialTabKey, router}: DraggableTabBarProps) { - // TODO: Extract this to a separate component encompassing Tab.Item in the future - const [editingTabKey, setEditingTabKey] = useState(null); - - const organization = useOrganization(); - const navigate = useNavigate(); - const location = useLocation(); - - const {cursor: _cursor, page: _page, ...queryParams} = router?.location?.query ?? {}; - const {viewId} = queryParams; - - const {setNewViewActive, setOnNewViewsSaved} = useContext(NewTabContext); - const {tabListState, state, dispatch} = useContext(IssueViewsContext); - const {views: tabs, tempView: tempTab} = state; - - useHotkeys( - [ - { - match: ['command+s', 'ctrl+s'], - includeInputs: true, - callback: () => { - if (tabs.find(tab => tab.key === tabListState?.selectedKey)?.unsavedChanges) { - dispatch({type: 'SAVE_CHANGES', syncViews: true}); - addSuccessMessage(t('Changes saved to view')); - } - }, - }, - ], - [dispatch, tabListState?.selectedKey, tabs] - ); - - const handleDuplicateView = () => { - const newViewId = generateTempViewId(); - const duplicatedTab = state.views.find( - view => view.key === tabListState?.selectedKey - ); - if (!duplicatedTab) { - return; - } - dispatch({type: 'DUPLICATE_VIEW', newViewId, syncViews: true}); - navigate({ - ...location, - query: { - ...queryParams, - query: duplicatedTab.query, - sort: duplicatedTab.querySort, - viewId: newViewId, - }, - }); - }; - - const handleDiscardChanges = () => { - dispatch({type: 'DISCARD_CHANGES'}); - const originalTab = state.views.find(view => view.key === tabListState?.selectedKey); - if (originalTab) { - // TODO(msun): Move navigate logic to IssueViewsContext - navigate({ - ...location, - query: { - ...queryParams, - query: originalTab.query, - sort: originalTab.querySort, - viewId: originalTab.id, - }, - }); - } - }; - - const handleNewViewsSaved: NewTabContext['onNewViewsSaved'] = useCallback< - NewTabContext['onNewViewsSaved'] - >( - () => (newViews: NewView[]) => { - if (newViews.length === 0) { - return; - } - setNewViewActive(false); - const {label, query, saveQueryToView} = newViews[0]!; - const remainingNewViews: IssueView[] = newViews.slice(1)?.map(view => { - const newId = generateTempViewId(); - const viewToTab: IssueView = { - id: newId, - key: newId, - label: view.label, - query: view.query, - querySort: IssueSortOptions.DATE, - unsavedChanges: view.saveQueryToView - ? undefined - : [view.query, IssueSortOptions.DATE], - isCommitted: true, - }; - return viewToTab; - }); - let updatedTabs: IssueView[] = tabs.map(tab => { - if (tab.key === viewId) { - return { - ...tab, - label, - query: saveQueryToView ? query : '', - querySort: IssueSortOptions.DATE, - unsavedChanges: saveQueryToView ? undefined : [query, IssueSortOptions.DATE], - isCommitted: true, - }; - } - return tab; - }); - - if (remainingNewViews.length > 0) { - updatedTabs = [...updatedTabs, ...remainingNewViews]; - } - - dispatch({type: 'SET_VIEWS', views: updatedTabs, syncViews: true}); - navigate( - { - ...location, - query: { - ...queryParams, - query, - sort: IssueSortOptions.DATE, - }, - }, - {replace: true} - ); - }, - - // eslint-disable-next-line react-hooks/exhaustive-deps - [location, navigate, setNewViewActive, tabs, viewId] - ); - - const handleCreateNewView = () => { - const tempId = generateTempViewId(); - dispatch({type: 'CREATE_NEW_VIEW', tempId}); - tabListState?.setSelectedKey(tempId); - navigate({ - ...location, - query: { - ...queryParams, - query: '', - viewId: tempId, - }, - }); - }; - - const handleDeleteView = (tab: IssueView) => { - dispatch({type: 'DELETE_VIEW', syncViews: true}); - // Including this logic in the dispatch call breaks the tests for some reason - // so we're doing it here instead - tabListState?.setSelectedKey(tabs.filter(tb => tb.key !== tab.key)[0]!.key); - }; - - useEffect(() => { - setOnNewViewsSaved(handleNewViewsSaved); - }, [setOnNewViewsSaved, handleNewViewsSaved]); - - const makeMenuOptions = (tab: IssueView): MenuItemProps[] => { - if (tab.key === TEMPORARY_TAB_KEY) { - return makeTempViewMenuOptions({ - onSaveTempView: () => dispatch({type: 'SAVE_TEMP_VIEW', syncViews: true}), - onDiscardTempView: () => dispatch({type: 'DISCARD_TEMP_VIEW'}), - }); - } - if (tab.unsavedChanges) { - return makeUnsavedChangesMenuOptions({ - onRename: () => setEditingTabKey(tab.key), - onDuplicate: handleDuplicateView, - onDelete: tabs.length > 1 ? () => handleDeleteView(tab) : undefined, - onSave: () => dispatch({type: 'SAVE_CHANGES', syncViews: true}), - onDiscard: handleDiscardChanges, - }); - } - return makeDefaultMenuOptions({ - onRename: () => setEditingTabKey(tab.key), - onDuplicate: handleDuplicateView, - onDelete: tabs.length > 1 ? () => handleDeleteView(tab) : undefined, - }); - }; - - const allTabs = tempTab ? [...tabs, tempTab] : tabs; - - return ( - []) => - dispatch({ - type: 'REORDER_TABS', - newKeyOrder: newOrder.map(node => node.key.toString()), - }) - } - onReorderComplete={() => dispatch({type: 'SYNC_VIEWS_TO_BACKEND'})} - defaultSelectedKey={initialTabKey} - onAddView={handleCreateNewView} - orientation="horizontal" - editingTabKey={editingTabKey ?? undefined} - hideBorder - > - {allTabs.map(tab => ( - - - setEditingTabKey(isEditing ? tab.key : null)} - onChange={newLabel => - dispatch({type: 'RENAME_TAB', newLabel: newLabel.trim(), syncViews: true}) - } - isSelected={ - (tabListState && tabListState?.selectedKey === tab.key) || - (!tabListState && tab.key === initialTabKey) - } - /> - {/* If tablistState isn't initialized, we want to load the elipsis menu - for the initial tab, that way it won't load in a second later - and cause the tabs to shift and animate on load. - */} - {((tabListState && tabListState?.selectedKey === tab.key) || - (!tabListState && tab.key === initialTabKey)) && ( - - - - )} - - - ))} - - ); -} - -const makeDefaultMenuOptions = ({ - onRename, - onDuplicate, - onDelete, -}: { - onDelete?: () => void; - onDuplicate?: () => void; - onRename?: () => void; -}): MenuItemProps[] => { - const menuOptions: MenuItemProps[] = [ - { - key: 'rename-tab', - label: t('Rename'), - onAction: onRename, - }, - { - key: 'duplicate-tab', - label: t('Duplicate'), - onAction: onDuplicate, - }, - ]; - if (onDelete) { - return [ - ...menuOptions, - { - key: 'delete-tab', - label: t('Delete'), - priority: 'danger', - onAction: onDelete, - }, - ]; - } - return menuOptions; -}; - -const makeUnsavedChangesMenuOptions = ({ - onRename, - onDuplicate, - onDelete, - onSave, - onDiscard, -}: { - onDelete?: () => void; - onDiscard?: () => void; - onDuplicate?: () => void; - onRename?: () => void; - onSave?: () => void; -}): MenuItemProps[] => { - return [ - { - key: 'changed', - children: [ - { - key: 'save-changes', - label: t('Save Changes'), - priority: 'primary', - onAction: onSave, - }, - { - key: 'discard-changes', - label: t('Discard Changes'), - onAction: onDiscard, - }, - ], - }, - { - key: 'default', - children: makeDefaultMenuOptions({onRename, onDuplicate, onDelete}), - }, - ]; -}; - -const makeTempViewMenuOptions = ({ - onSaveTempView, - onDiscardTempView, -}: { - onDiscardTempView: () => void; - onSaveTempView: () => void; -}): MenuItemProps[] => { - return [ - { - key: 'save-changes', - label: t('Save View'), - priority: 'primary', - onAction: onSaveTempView, - }, - { - key: 'discard-changes', - label: t('Discard'), - onAction: onDiscardTempView, - }, - ]; -}; - -const TabContentWrap = styled('span')` - white-space: nowrap; - display: flex; - align-items: center; - flex-direction: row; - padding: 0; - gap: 6px; -`; diff --git a/static/app/views/issueList/groupSearchViewTabs/draggableTabMenuButton.tsx b/static/app/views/issueList/groupSearchViewTabs/issueViewEllipsisMenu.tsx similarity index 95% rename from static/app/views/issueList/groupSearchViewTabs/draggableTabMenuButton.tsx rename to static/app/views/issueList/groupSearchViewTabs/issueViewEllipsisMenu.tsx index aad5709094e3ce..2849cea0703618 100644 --- a/static/app/views/issueList/groupSearchViewTabs/draggableTabMenuButton.tsx +++ b/static/app/views/issueList/groupSearchViewTabs/issueViewEllipsisMenu.tsx @@ -7,17 +7,17 @@ import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {useFeedbackForm} from 'sentry/utils/useFeedbackForm'; -interface DraggableTabMenuButtonProps { +interface IssueViewEllipsisMenuProps { menuOptions: MenuItemProps[]; 'aria-label'?: string; hasUnsavedChanges?: boolean; } -export function DraggableTabMenuButton({ +export function IssueViewEllipsisMenu({ hasUnsavedChanges = false, menuOptions, ...props -}: DraggableTabMenuButtonProps) { +}: IssueViewEllipsisMenuProps) { return ( void; + view: IssueView; +} + +export function IssueViewTab({ + editingTabKey, + initialTabKey, + router, + setEditingTabKey, + view, +}: IssueViewTabProps) { + const navigate = useNavigate(); + + const {cursor: _cursor, page: _page, ...queryParams} = router?.location?.query ?? {}; + const {tabListState, state, dispatch} = useContext(IssueViewsContext); + const {views: tabs} = state; + + const handleDuplicateView = () => { + const newViewId = generateTempViewId(); + const duplicatedTab = state.views.find(tab => tab.key === tabListState?.selectedKey); + if (!duplicatedTab) { + return; + } + dispatch({type: 'DUPLICATE_VIEW', newViewId, syncViews: true}); + navigate({ + ...location, + query: { + ...queryParams, + query: duplicatedTab.query, + sort: duplicatedTab.querySort, + viewId: newViewId, + }, + }); + }; + + const handleDiscardChanges = () => { + dispatch({type: 'DISCARD_CHANGES'}); + const originalTab = state.views.find(tab => tab.key === tabListState?.selectedKey); + if (originalTab) { + navigate({ + ...location, + query: { + ...queryParams, + query: originalTab.query, + sort: originalTab.querySort, + viewId: originalTab.id, + }, + }); + } + }; + + const handleDeleteView = (tab: IssueView) => { + dispatch({type: 'DELETE_VIEW', syncViews: true}); + // Including this logic in the dispatch call breaks the tests for some reason + // so we're doing it here instead + tabListState?.setSelectedKey(tabs.filter(tb => tb.key !== tab.key)[0].key); + }; + + const makeMenuOptions = (tab: IssueView): MenuItemProps[] => { + if (tab.key === TEMPORARY_TAB_KEY) { + return makeTempViewMenuOptions({ + onSaveTempView: () => dispatch({type: 'SAVE_TEMP_VIEW', syncViews: true}), + onDiscardTempView: () => dispatch({type: 'DISCARD_TEMP_VIEW'}), + }); + } + if (tab.unsavedChanges) { + return makeUnsavedChangesMenuOptions({ + onRename: () => setEditingTabKey(tab.key), + onDuplicate: handleDuplicateView, + onDelete: tabs.length > 1 ? () => handleDeleteView(tab) : undefined, + onSave: () => dispatch({type: 'SAVE_CHANGES', syncViews: true}), + onDiscard: handleDiscardChanges, + }); + } + return makeDefaultMenuOptions({ + onRename: () => setEditingTabKey(tab.key), + onDuplicate: handleDuplicateView, + onDelete: tabs.length > 1 ? () => handleDeleteView(tab) : undefined, + }); + }; + + return ( + + setEditingTabKey(isEditing ? view.key : null)} + onChange={newLabel => + dispatch({type: 'RENAME_TAB', newLabel: newLabel.trim(), syncViews: true}) + } + isSelected={ + (tabListState && tabListState?.selectedKey === view.key) || + (!tabListState && view.key === initialTabKey) + } + /> + {/* If tablistState isn't initialized, we want to load the elipsis menu + for the initial tab, that way it won't load in a second later + and cause the tabs to shift and animate on load. + */} + {((tabListState && tabListState?.selectedKey === view.key) || + (!tabListState && view.key === initialTabKey)) && ( + + + + )} + + ); +} + +const makeDefaultMenuOptions = ({ + onRename, + onDuplicate, + onDelete, +}: { + onDelete?: () => void; + onDuplicate?: () => void; + onRename?: () => void; +}): MenuItemProps[] => { + const menuOptions: MenuItemProps[] = [ + { + key: 'rename-tab', + label: t('Rename'), + onAction: onRename, + }, + { + key: 'duplicate-tab', + label: t('Duplicate'), + onAction: onDuplicate, + }, + ]; + if (onDelete) { + return [ + ...menuOptions, + { + key: 'delete-tab', + label: t('Delete'), + priority: 'danger', + onAction: onDelete, + }, + ]; + } + return menuOptions; +}; + +const makeUnsavedChangesMenuOptions = ({ + onRename, + onDuplicate, + onDelete, + onSave, + onDiscard, +}: { + onDelete?: () => void; + onDiscard?: () => void; + onDuplicate?: () => void; + onRename?: () => void; + onSave?: () => void; +}): MenuItemProps[] => { + return [ + { + key: 'changed', + children: [ + { + key: 'save-changes', + label: t('Save Changes'), + priority: 'primary', + onAction: onSave, + }, + { + key: 'discard-changes', + label: t('Discard Changes'), + onAction: onDiscard, + }, + ], + }, + { + key: 'default', + children: makeDefaultMenuOptions({onRename, onDuplicate, onDelete}), + }, + ]; +}; + +const makeTempViewMenuOptions = ({ + onSaveTempView, + onDiscardTempView, +}: { + onDiscardTempView: () => void; + onSaveTempView: () => void; +}): MenuItemProps[] => { + return [ + { + key: 'save-changes', + label: t('Save View'), + priority: 'primary', + onAction: onSaveTempView, + }, + { + key: 'discard-changes', + label: t('Discard'), + onAction: onDiscardTempView, + }, + ]; +}; + +const TabContentWrap = styled('span')` + white-space: nowrap; + display: flex; + align-items: center; + flex-direction: row; + padding: 0; + gap: 6px; +`; diff --git a/static/app/views/issueList/groupSearchViewTabs/issueViews.tsx b/static/app/views/issueList/groupSearchViewTabs/issueViews.tsx index 441e97c766c004..265117fafc9a18 100644 --- a/static/app/views/issueList/groupSearchViewTabs/issueViews.tsx +++ b/static/app/views/issueList/groupSearchViewTabs/issueViews.tsx @@ -1,5 +1,13 @@ import type {Dispatch, Reducer} from 'react'; -import {createContext, useCallback, useMemo, useReducer, useState} from 'react'; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useReducer, + useState, +} from 'react'; import styled from '@emotion/styled'; import type {TabListState} from '@react-stately/tabs'; import type {Orientation} from '@react-types/shared'; @@ -16,16 +24,18 @@ import normalizeUrl from 'sentry/utils/url/normalizeUrl'; import {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; import usePageFilters from 'sentry/utils/usePageFilters'; -import {generateTempViewId} from 'sentry/views/issueList/groupSearchViewTabs/draggableTabBar'; import {useUpdateGroupSearchViews} from 'sentry/views/issueList/mutations/useUpdateGroupSearchViews'; import type { GroupSearchView, UpdateGroupSearchViewPayload, } from 'sentry/views/issueList/types'; import {IssueSortOptions} from 'sentry/views/issueList/utils'; +import {NewTabContext, type NewView} from 'sentry/views/issueList/utils/newTabContext'; const TEMPORARY_TAB_KEY = 'temporary-tab'; +export const generateTempViewId = () => `_${Math.random().toString().substring(2, 7)}`; + export interface IssueView { id: string; /** @@ -357,6 +367,7 @@ export function IssueViewsStateProvider({ const navigate = useNavigate(); const pageFilters = usePageFilters(); const organization = useOrganization(); + const {setNewViewActive, setOnNewViewsSaved} = useContext(NewTabContext); const [tabListState, setTabListState] = useState>(); const {className: _className, ...restProps} = props; @@ -520,6 +531,70 @@ export function IssueViewsStateProvider({ } }; + const handleNewViewsSaved: NewTabContext['onNewViewsSaved'] = useCallback< + NewTabContext['onNewViewsSaved'] + >( + () => (newViews: NewView[]) => { + if (newViews.length === 0) { + return; + } + setNewViewActive(false); + const {label, query: newQuery, saveQueryToView} = newViews[0]; + const remainingNewViews: IssueView[] = newViews.slice(1)?.map(view => { + const newId = generateTempViewId(); + const viewToTab: IssueView = { + id: newId, + key: newId, + label: view.label, + query: view.query, + querySort: IssueSortOptions.DATE, + unsavedChanges: view.saveQueryToView + ? undefined + : [view.query, IssueSortOptions.DATE], + isCommitted: true, + }; + return viewToTab; + }); + let updatedTabs: IssueView[] = state.views.map(tab => { + if (tab.key === viewId) { + return { + ...tab, + label, + query: saveQueryToView ? newQuery : '', + querySort: IssueSortOptions.DATE, + unsavedChanges: saveQueryToView ? undefined : [query, IssueSortOptions.DATE], + isCommitted: true, + }; + } + return tab; + }); + + if (remainingNewViews.length > 0) { + updatedTabs = [...updatedTabs, ...remainingNewViews]; + } + + dispatch({type: 'SET_VIEWS', views: updatedTabs, syncViews: true}); + navigate( + { + ...location, + query: { + ...queryParams, + query, + sort: IssueSortOptions.DATE, + }, + }, + {replace: true} + ); + }, + + // eslint-disable-next-line react-hooks/exhaustive-deps + [location, navigate, setNewViewActive, state.views, viewId] + ); + + useEffect(() => { + setOnNewViewsSaved(handleNewViewsSaved); + }, [setOnNewViewsSaved, handleNewViewsSaved]); + return ( { +describe('IssueViewsHeader', () => { const organization = OrganizationFixture(); const getRequestViews = [ @@ -76,7 +76,7 @@ describe('CustomViewsHeader', () => { }); it('renders all tabs, selects the first one by default, and replaces the query params accordingly', async () => { - render(, {router: defaultRouter}); + render(, {router: defaultRouter}); expect(await screen.findByRole('tab', {name: 'High Priority'})).toBeInTheDocument(); expect(screen.getByRole('tab', {name: 'Medium Priority'})).toBeInTheDocument(); @@ -121,7 +121,7 @@ describe('CustomViewsHeader', () => { ], }); - render(, {router: defaultRouter}); + render(, {router: defaultRouter}); expect(await screen.findByRole('tab', {name: 'Prioritized'})).toBeInTheDocument(); expect(screen.getByRole('tab', {name: 'Prioritized'})).toHaveAttribute( @@ -154,7 +154,7 @@ describe('CustomViewsHeader', () => { ], }); - render(, { + render(, { router: queryOnlyRouter, }); @@ -185,10 +185,9 @@ describe('CustomViewsHeader', () => { }), }); - render( - , - {router: specificTabRouter} - ); + render(, { + router: specificTabRouter, + }); expect(await screen.findByRole('tab', {name: 'Medium Priority'})).toHaveAttribute( 'aria-selected', @@ -207,7 +206,7 @@ describe('CustomViewsHeader', () => { }); it('initially selects a temporary tab when only a query is present in the url', async () => { - render(, { + render(, { router: queryOnlyRouter, }); @@ -240,10 +239,9 @@ describe('CustomViewsHeader', () => { }, }), }); - render( - , - {router: specificTabRouter} - ); + render(, { + router: specificTabRouter, + }); expect(await screen.findByRole('tab', {name: 'High Priority'})).toBeInTheDocument(); expect(screen.getByRole('tab', {name: 'Medium Priority'})).toBeInTheDocument(); @@ -291,7 +289,7 @@ describe('CustomViewsHeader', () => { }); render( - , @@ -325,7 +323,7 @@ describe('CustomViewsHeader', () => { }); it('switches tabs when clicked, and updates the query params accordingly', async () => { - render(, {router: defaultRouter}); + render(, {router: defaultRouter}); await userEvent.click(await screen.findByRole('tab', {name: 'Medium Priority'})); @@ -356,7 +354,7 @@ describe('CustomViewsHeader', () => { // eslint-disable-next-line jest/no-disabled-tests it.skip('retains unsaved changes after switching tabs', async () => { - render(, { + render(, { router: unsavedTabRouter, }); expect(await screen.findByTestId('unsaved-changes-indicator')).toBeInTheDocument(); @@ -384,7 +382,7 @@ describe('CustomViewsHeader', () => { }); render( - , @@ -421,7 +419,7 @@ describe('CustomViewsHeader', () => { }); render( - , @@ -464,7 +462,7 @@ describe('CustomViewsHeader', () => { body: getRequestViews, }); - render(); + render(); await userEvent.click( await screen.findByRole('button', {name: 'High Priority Ellipsis Menu'}) @@ -495,7 +493,7 @@ describe('CustomViewsHeader', () => { body: getRequestViews, }); - render(); + render(); await userEvent.click( await screen.findByRole('button', {name: 'High Priority Ellipsis Menu'}) @@ -525,7 +523,7 @@ describe('CustomViewsHeader', () => { body: [getRequestViews[0]], }); - render(); + render(); await userEvent.click( await screen.findByRole('button', {name: 'High Priority Ellipsis Menu'}) @@ -558,7 +556,7 @@ describe('CustomViewsHeader', () => { method: 'PUT', }); - render(, {router: defaultRouter}); + render(, {router: defaultRouter}); await userEvent.click( await screen.findByRole('button', {name: 'High Priority Ellipsis Menu'}) @@ -609,7 +607,7 @@ describe('CustomViewsHeader', () => { method: 'PUT', }); - render(, {router: defaultRouter}); + render(, {router: defaultRouter}); await userEvent.click( await screen.findByRole('button', {name: 'High Priority Ellipsis Menu'}) @@ -658,7 +656,7 @@ describe('CustomViewsHeader', () => { method: 'PUT', }); - render(, {router: defaultRouter}); + render(, {router: defaultRouter}); await userEvent.click( await screen.findByRole('button', {name: 'High Priority Ellipsis Menu'}) @@ -695,7 +693,7 @@ describe('CustomViewsHeader', () => { }); render( - , + , {router: unsavedTabRouter} ); @@ -733,7 +731,7 @@ describe('CustomViewsHeader', () => { }); render( - , + , {router: unsavedTabRouter} ); @@ -767,7 +765,7 @@ describe('CustomViewsHeader', () => { }); render( - , + , {router: unsavedTabRouter} ); diff --git a/static/app/views/issueList/customViewsHeader.tsx b/static/app/views/issueList/issueViewsHeader.tsx similarity index 74% rename from static/app/views/issueList/customViewsHeader.tsx rename to static/app/views/issueList/issueViewsHeader.tsx index 3f09bd16c6cbf3..cd91ed968b2b05 100644 --- a/static/app/views/issueList/customViewsHeader.tsx +++ b/static/app/views/issueList/issueViewsHeader.tsx @@ -1,9 +1,15 @@ -import {useContext, useEffect, useMemo} from 'react'; +import {useContext, useEffect, useMemo, useState} from 'react'; import styled from '@emotion/styled'; +import type {Node} from '@react-types/shared'; +import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import {Button} from 'sentry/components/button'; import ButtonBar from 'sentry/components/buttonBar'; -import {TEMPORARY_TAB_KEY} from 'sentry/components/draggableTabs/draggableTabList'; +import { + DraggableTabList, + TEMPORARY_TAB_KEY, +} from 'sentry/components/draggableTabs/draggableTabList'; +import type {DraggableTabListItemProps} from 'sentry/components/draggableTabs/item'; import GlobalEventProcessingAlert from 'sentry/components/globalEventProcessingAlert'; import * as Layout from 'sentry/components/layouts/thirds'; import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse'; @@ -16,22 +22,24 @@ import type {Organization} from 'sentry/types/organization'; import {trackAnalytics} from 'sentry/utils/analytics'; import normalizeUrl from 'sentry/utils/url/normalizeUrl'; import {useFeedbackForm} from 'sentry/utils/useFeedbackForm'; +import {useHotkeys} from 'sentry/utils/useHotkeys'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; import usePageFilters from 'sentry/utils/usePageFilters'; import useProjects from 'sentry/utils/useProjects'; -import {DraggableTabBar} from 'sentry/views/issueList/groupSearchViewTabs/draggableTabBar'; import { + generateTempViewId, type IssueView, IssueViews, IssueViewsContext, } from 'sentry/views/issueList/groupSearchViewTabs/issueViews'; +import {IssueViewTab} from 'sentry/views/issueList/groupSearchViewTabs/issueViewTab'; import {useFetchGroupSearchViews} from 'sentry/views/issueList/queries/useFetchGroupSearchViews'; import {NewTabContext} from 'sentry/views/issueList/utils/newTabContext'; import {IssueSortOptions} from './utils'; -type CustomViewsIssueListHeaderProps = { +type IssueViewsIssueListHeaderProps = { onRealtimeChange: (realtime: boolean) => void; organization: Organization; realtimeActive: boolean; @@ -39,17 +47,17 @@ type CustomViewsIssueListHeaderProps = { selectedProjectIds: number[]; }; -type CustomViewsIssueListHeaderTabsContentProps = { +type IssueViewsIssueListHeaderTabsContentProps = { organization: Organization; router: InjectedRouter; }; -function CustomViewsIssueListHeader({ +function IssueViewsIssueListHeader({ selectedProjectIds, realtimeActive, onRealtimeChange, ...props -}: CustomViewsIssueListHeaderProps) { +}: IssueViewsIssueListHeaderProps) { const {projects} = useProjects(); const selectedProjects = projects.filter(({id}) => selectedProjectIds.includes(Number(id)) @@ -143,7 +151,7 @@ function CustomViewsIssueListHeader({ } )} > - + ) : (
@@ -152,18 +160,19 @@ function CustomViewsIssueListHeader({ ); } -function CustomViewsIssueListHeaderTabsContent({ +function IssueViewsIssueListHeaderTabsContent({ organization, router, -}: CustomViewsIssueListHeaderTabsContentProps) { +}: IssueViewsIssueListHeaderTabsContentProps) { const navigate = useNavigate(); const location = useLocation(); const pageFilters = usePageFilters(); const {newViewActive, setNewViewActive} = useContext(NewTabContext); const {tabListState, state, dispatch} = useContext(IssueViewsContext); + const {views, tempView} = state; - const {views: draggableTabs} = state; + const [editingTabKey, setEditingTabKey] = useState(null); // TODO(msun): Use the location from useLocation instead of props router in the future const {cursor: _cursor, page: _page, ...queryParams} = router?.location.query; @@ -193,19 +202,19 @@ function CustomViewsIssueListHeaderTabsContent({ ...location, query: { ...queryParamsWithPageFilters, - query: draggableTabs[0]!.query, - sort: draggableTabs[0]!.querySort, - viewId: draggableTabs[0]!.id, + query: views[0].query, + sort: views[0].querySort, + viewId: views[0].id, }, }), {replace: true} ); - tabListState?.setSelectedKey(draggableTabs[0]!.key); + tabListState?.setSelectedKey(views[0].key); return; } // if a viewId is present, check if it exists in the existing views. if (viewId) { - const selectedTab = draggableTabs.find(tab => tab.id === viewId); + const selectedTab = views.find(tab => tab.id === viewId); if (selectedTab) { const issueSortOption = Object.values(IssueSortOptions).includes(sort) ? sort @@ -291,7 +300,7 @@ function CustomViewsIssueListHeaderTabsContent({ tabListState, location, queryParamsWithPageFilters, - draggableTabs, + views, organization, dispatch, ]); @@ -299,7 +308,7 @@ function CustomViewsIssueListHeaderTabsContent({ // This useEffect ensures the "new view" page is displayed/hidden correctly useEffect(() => { if (viewId?.startsWith('_')) { - if (draggableTabs.find(tab => tab.id === viewId)?.isCommitted) { + if (views.find(tab => tab.id === viewId)?.isCommitted) { return; } // If the user types in query manually while the new view flow is showing, @@ -326,20 +335,90 @@ function CustomViewsIssueListHeaderTabsContent({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [viewId, query]); + useHotkeys( + [ + { + match: ['command+s', 'ctrl+s'], + includeInputs: true, + callback: () => { + if (views.find(tab => tab.key === tabListState?.selectedKey)?.unsavedChanges) { + dispatch({type: 'SAVE_CHANGES', syncViews: true}); + addSuccessMessage(t('Changes saved to view')); + } + }, + }, + ], + [dispatch, tabListState?.selectedKey, views] + ); + + const handleCreateNewView = () => { + const tempId = generateTempViewId(); + dispatch({type: 'CREATE_NEW_VIEW', tempId}); + tabListState?.setSelectedKey(tempId); + navigate({ + ...location, + query: { + ...queryParams, + query: '', + viewId: tempId, + }, + }); + }; + + const allTabs = tempView ? [...views, tempView] : views; + const initialTabKey = - viewId && draggableTabs.find(tab => tab.id === viewId) - ? draggableTabs.find(tab => tab.id === viewId)!.key + viewId && views.find(tab => tab.id === viewId) + ? views.find(tab => tab.id === viewId)!.key : query ? TEMPORARY_TAB_KEY - : draggableTabs[0]!.key; + : views[0].key; return ( - // TODO(msun): look into possibly folding the DraggableTabBar component into this component - + []) => + dispatch({ + type: 'REORDER_TABS', + newKeyOrder: newOrder.map(node => node.key.toString()), + }) + } + onReorderComplete={() => dispatch({type: 'SYNC_VIEWS_TO_BACKEND'})} + defaultSelectedKey={initialTabKey} + onAddView={handleCreateNewView} + orientation="horizontal" + editingTabKey={editingTabKey ?? undefined} + hideBorder + > + {allTabs.map(view => ( + + + + ))} + ); } -export default CustomViewsIssueListHeader; +export default IssueViewsIssueListHeader; const StyledIssueViews = styled(IssueViews)` grid-column: 1 / -1; diff --git a/static/app/views/issueList/overview.tsx b/static/app/views/issueList/overview.tsx index 05eac62ccb7144..ca2ee0ed32c7e0 100644 --- a/static/app/views/issueList/overview.tsx +++ b/static/app/views/issueList/overview.tsx @@ -47,9 +47,9 @@ import withApi from 'sentry/utils/withApi'; import withOrganization from 'sentry/utils/withOrganization'; import withPageFilters from 'sentry/utils/withPageFilters'; import withSavedSearches from 'sentry/utils/withSavedSearches'; -import CustomViewsIssueListHeader from 'sentry/views/issueList/customViewsHeader'; import IssueListTable from 'sentry/views/issueList/issueListTable'; import {IssuesDataConsentBanner} from 'sentry/views/issueList/issuesDataConsentBanner'; +import IssueViewsIssueListHeader from 'sentry/views/issueList/issueViewsHeader'; import SavedIssueSearches from 'sentry/views/issueList/savedIssueSearches'; import type {IssueUpdateData} from 'sentry/views/issueList/types'; import {NewTabContextProvider} from 'sentry/views/issueList/utils/newTabContext'; @@ -1191,7 +1191,7 @@ class IssueListOverview extends Component { {organization.features.includes('issue-stream-custom-views') ? ( -