123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322 |
- 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 (
- <Layout.Header
- noActionWrap
- // No viewId in the URL query means that a temp view is selected, which has a dashed border
- borderStyle={
- groupSearchViews && !props.router?.location.query.viewId ? 'dashed' : 'solid'
- }
- >
- <Layout.HeaderContent>
- <Layout.Title>
- {t('Issues')}
- <PageHeadingQuestionTooltip
- docsUrl="https://docs.sentry.io/product/issues/"
- title={t(
- 'Detailed views of errors and performance problems in your application grouped by events with a similar set of characteristics.'
- )}
- />
- </Layout.Title>
- </Layout.HeaderContent>
- <Layout.HeaderActions />
- <StyledGlobalEventProcessingAlert projects={selectedProjects} />
- {groupSearchViews ? (
- <Tabs>
- <CustomViewsIssueListHeaderTabsContent {...props} views={groupSearchViews} />
- </Tabs>
- ) : (
- <div style={{height: 33}} />
- )}
- </Layout.Header>
- );
- }
- 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<Tab[]>(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<Tab | undefined>(
- 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 (
- <DraggableTabBar
- initialTabKey={getInitialTabKey()}
- tabs={draggableTabs}
- setTabs={setDraggableTabs}
- tempTab={tempTab}
- setTempTab={setTempTab}
- orgSlug={organization.slug}
- onReorder={debounceUpdateViews}
- onAddView={debounceUpdateViews}
- onDelete={debounceUpdateViews}
- onDuplicate={debounceUpdateViews}
- onTabRenamed={newTabs => 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;
- }
- `;
|