import {useContext, useEffect, useMemo, useState} from 'react';
import type {InjectedRouter} from 'react-router';
import styled from '@emotion/styled';
import debounce from 'lodash/debounce';
import GlobalEventProcessingAlert from 'sentry/components/globalEventProcessingAlert';
import * as Layout from 'sentry/components/layouts/thirds';
import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionTooltip';
import {Tabs, TabsContext} from 'sentry/components/tabs';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Organization} from 'sentry/types/organization';
import {useNavigate} from 'sentry/utils/useNavigate';
import useProjects from 'sentry/utils/useProjects';
import {
DraggableTabBar,
type Tab,
} from 'sentry/views/issueList/groupSearchViewTabs/draggableTabBar';
import {useUpdateGroupSearchViews} from 'sentry/views/issueList/mutations/useUpdateGroupSearchViews';
import {useFetchGroupSearchViews} from 'sentry/views/issueList/queries/useFetchGroupSearchViews';
import type {UpdateGroupSearchViewPayload} from 'sentry/views/issueList/types';
import {IssueSortOptions, type QueryCounts} from './utils';
type CustomViewsIssueListHeaderProps = {
organization: Organization;
queryCounts: QueryCounts;
router: InjectedRouter;
selectedProjectIds: number[];
};
type CustomViewsIssueListHeaderTabsContentProps = {
organization: Organization;
queryCounts: QueryCounts;
router: InjectedRouter;
views: UpdateGroupSearchViewPayload[];
};
function CustomViewsIssueListHeader({
selectedProjectIds,
...props
}: CustomViewsIssueListHeaderProps) {
const {projects} = useProjects();
const selectedProjects = projects.filter(({id}) =>
selectedProjectIds.includes(Number(id))
);
const {data: groupSearchViews} = useFetchGroupSearchViews({
orgSlug: props.organization.slug,
});
return (
{t('Issues')}
{groupSearchViews ? (
) : (
)}
);
}
function CustomViewsIssueListHeaderTabsContent({
organization,
queryCounts,
router,
views,
}: CustomViewsIssueListHeaderTabsContentProps) {
// Remove cursor and page when switching tabs
const navigate = useNavigate();
// TODO: Replace this with useLocation
const {cursor: _cursor, page: _page, ...queryParams} = router?.location.query;
const {query, sort, viewId} = queryParams;
const viewsToTabs = views.map(
({id, name, query: viewQuery, querySort: viewQuerySort}, index): Tab => {
const tabId = id ?? `default${index.toString()}`;
return {
id: tabId,
key: tabId,
label: name,
query: viewQuery,
querySort: viewQuerySort,
queryCount: queryCounts[viewQuery]?.count ?? undefined,
};
}
);
const [draggableTabs, setDraggableTabs] = useState(viewsToTabs);
const {tabListState} = useContext(TabsContext);
const getInitialTabKey = () => {
if (draggableTabs[0].key.startsWith('default')) {
return draggableTabs[0].key;
}
if (!query && !sort && !viewId) {
return draggableTabs[0].key;
}
if (viewId && draggableTabs.find(tab => tab.id === viewId)) {
return draggableTabs.find(tab => tab.id === viewId)!.key;
}
if (query) {
return 'temporary-tab';
}
return draggableTabs[0].key;
};
// TODO: Try to remove this state if possible
const [tempTab, setTempTab] = useState(
getInitialTabKey() === 'temporary-tab' && query
? {
id: 'temporary-tab',
key: 'temporary-tab',
label: t('Unsaved'),
query: query,
querySort: sort ?? IssueSortOptions.DATE,
}
: undefined
);
const {mutate: updateViews} = useUpdateGroupSearchViews();
const debounceUpdateViews = useMemo(
() =>
debounce((newTabs: Tab[]) => {
if (newTabs) {
updateViews({
orgSlug: organization.slug,
groupSearchViews: newTabs.map(tab => ({
// Do not send over an ID if it's a temporary or default tab so that
// the backend will save these and generate permanent Ids for them
...(tab.id[0] !== '_' && !tab.id.startsWith('default') ? {id: tab.id} : {}),
name: tab.label,
query: tab.query,
querySort: tab.querySort,
})),
});
}
}, 500),
[organization.slug, updateViews]
);
// This insane useEffect ensures that the correct tab is selected when the url updates
useEffect(() => {
// If no query, sort, or viewId is present, set the first tab as the selected tab, update query accordingly
if (!query && !sort && !viewId) {
navigate({
query: {
...queryParams,
query: draggableTabs[0].query,
sort: draggableTabs[0].querySort,
viewId: draggableTabs[0].id,
},
pathname: `/organizations/${organization.slug}/issues/`,
});
tabListState?.setSelectedKey(draggableTabs[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);
if (selectedTab && query && sort) {
// if a viewId exists but the query and sort are not what we expected, set them as unsaved changes
const isCurrentQuerySortDifferentFromExistingUnsavedChanges =
selectedTab.unsavedChanges &&
(selectedTab.unsavedChanges[0] !== query ||
selectedTab.unsavedChanges[1] !== sort);
const isCurrentQuerySortDifferentFromSelectedTabQuerySort =
query !== selectedTab.query || sort !== selectedTab.querySort;
if (
isCurrentQuerySortDifferentFromExistingUnsavedChanges ||
isCurrentQuerySortDifferentFromSelectedTabQuerySort
) {
setDraggableTabs(
draggableTabs.map(tab =>
tab.key === selectedTab!.key
? {
...tab,
unsavedChanges: [query, sort],
}
: tab
)
);
}
tabListState?.setSelectedKey(selectedTab.key);
return;
}
if (selectedTab && query === undefined) {
navigate({
query: {
...queryParams,
query: selectedTab.query,
sort: selectedTab.querySort,
viewId: selectedTab.id,
},
pathname: `/organizations/${organization.slug}/issues/`,
});
tabListState?.setSelectedKey(selectedTab.key);
return;
}
if (!selectedTab) {
// if a viewId does not exist, remove it from the query
tabListState?.setSelectedKey('temporary-tab');
navigate({
query: {
...queryParams,
viewId: undefined,
},
pathname: `/organizations/${organization.slug}/issues/`,
});
return;
}
return;
}
if (query) {
tabListState?.setSelectedKey('temporary-tab');
return;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigate, organization.slug, query, sort, viewId]);
// Update local tabs when new views are received from mutation request
useEffect(() => {
setDraggableTabs(
draggableTabs.map(tab => {
if (tab.id && tab.id[0] === '_') {
// Temp viewIds are prefixed with '_'
views.forEach(view => {
if (
view.id &&
tab.query === view.query &&
tab.querySort === view.querySort &&
tab.label === view.name
) {
tab.id = view.id;
}
});
navigate({
query: {
...queryParams,
viewId: tab.id,
},
pathname: `/organizations/${organization.slug}/issues/`,
});
}
return tab;
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [views]);
// Loads query counts when they are available
// TODO: fetch these dynamically instead of getting them from overview.tsx
useEffect(() => {
setDraggableTabs(
draggableTabs?.map(tab => {
if (tab.query && queryCounts[tab.query]) {
tab.queryCount = queryCounts[tab.query]?.count ?? 0; // TODO: Confirm null = 0 is correct
}
return tab;
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryCounts]);
return (
debounceUpdateViews(newTabs)}
onSave={debounceUpdateViews}
onSaveTempView={debounceUpdateViews}
router={router}
/>
);
}
export default CustomViewsIssueListHeader;
const StyledGlobalEventProcessingAlert = styled(GlobalEventProcessingAlert)`
grid-column: 1/-1;
margin-top: ${space(1)};
margin-bottom: ${space(1)};
@media (min-width: ${p => p.theme.breakpoints.medium}) {
margin-top: ${space(2)};
margin-bottom: 0;
}
`;