import type {Dispatch, Reducer} 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'; import debounce from 'lodash/debounce'; import isEqual from 'lodash/isEqual'; import type {TabContext, TabsProps} from 'sentry/components/tabs'; import {tabsShouldForwardProp} from 'sentry/components/tabs/utils'; import {t} from 'sentry/locale'; import type {PageFilters} from 'sentry/types/core'; import type {InjectedRouter} from 'sentry/types/legacyReactRouter'; import {defined} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser'; import normalizeUrl from 'sentry/utils/url/normalizeUrl'; import {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; import useProjects from 'sentry/utils/useProjects'; 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'; export const TEMPORARY_TAB_KEY = 'temporary-tab'; export const DEFAULT_TIME_FILTERS: PageFilters['datetime'] = { start: null, end: null, period: '14d', utc: null, }; export const DEFAULT_ENVIRONMENTS: string[] = []; export const generateTempViewId = () => `_${Math.random().toString().substring(2, 7)}`; /** * Savable properties of an IssueView, besides lable and position. * Changes to these properties are not automatically saved and can * trigger the unsaved changes indicator. */ export interface IssueViewParams { environments: string[]; projects: number[]; query: string; querySort: IssueSortOptions; timeFilters: PageFilters['datetime']; } export interface IssueView extends IssueViewParams { id: string; /** * False for tabs that were added view the "Add View" button, but * have not been edited in any way. Only tabs with isCommitted=true * will be saved to the backend. */ isCommitted: boolean; key: string; label: string; content?: React.ReactNode; unsavedChanges?: Partial; } type BaseIssueViewsAction = { /** If true, the new views state created by the action will be synced to the backend */ syncViews?: boolean; }; type ReorderTabsAction = { newKeyOrder: string[]; type: 'REORDER_TABS'; } & BaseIssueViewsAction; type SaveChangesAction = { type: 'SAVE_CHANGES'; } & BaseIssueViewsAction; type DiscardChangesAction = { type: 'DISCARD_CHANGES'; } & BaseIssueViewsAction; type RenameTabAction = { newLabel: string; type: 'RENAME_TAB'; } & BaseIssueViewsAction; type DuplicateViewAction = { newViewId: string; type: 'DUPLICATE_VIEW'; } & BaseIssueViewsAction; type DeleteViewAction = { type: 'DELETE_VIEW'; } & BaseIssueViewsAction; type CreateNewViewAction = { tempId: string; type: 'CREATE_NEW_VIEW'; } & BaseIssueViewsAction; type SetTempViewAction = { query: string; sort: IssueSortOptions; type: 'SET_TEMP_VIEW'; } & BaseIssueViewsAction; type DiscardTempViewAction = { type: 'DISCARD_TEMP_VIEW'; } & BaseIssueViewsAction; type SaveTempViewAction = { newViewId: string; type: 'SAVE_TEMP_VIEW'; } & BaseIssueViewsAction; type UpdateUnsavedChangesAction = { type: 'UPDATE_UNSAVED_CHANGES'; // Explicitly typed as | undefined instead of optional to make it clear that `undefined` = no unsaved changes unsavedChanges: Partial | undefined; isCommitted?: boolean; } & BaseIssueViewsAction; type UpdateViewIdsAction = { newViews: UpdateGroupSearchViewPayload[]; type: 'UPDATE_VIEW_IDS'; } & BaseIssueViewsAction; type SetViewsAction = { type: 'SET_VIEWS'; views: IssueView[]; } & BaseIssueViewsAction; type SyncViewsToBackendAction = { /** Syncs the current views state to the backend. Does not make any changes to the views state. */ type: 'SYNC_VIEWS_TO_BACKEND'; }; export type IssueViewsActions = | ReorderTabsAction | SaveChangesAction | DiscardChangesAction | RenameTabAction | DuplicateViewAction | DeleteViewAction | CreateNewViewAction | SetTempViewAction | DiscardTempViewAction | SaveTempViewAction | UpdateUnsavedChangesAction | UpdateViewIdsAction | SetViewsAction | SyncViewsToBackendAction; const ACTION_ANALYTICS_MAP: Partial> = { REORDER_TABS: 'issue_views.reordered_views', SAVE_CHANGES: 'issue_views.saved_changes', DISCARD_CHANGES: 'issue_views.discarded_changes', RENAME_TAB: 'issue_views.renamed_view', DUPLICATE_VIEW: 'issue_views.duplicated_view', DELETE_VIEW: 'issue_views.deleted_view', SAVE_TEMP_VIEW: 'issue_views.temp_view_saved', DISCARD_TEMP_VIEW: 'issue_views.temp_view_discarded', CREATE_NEW_VIEW: 'issue_views.add_view.clicked', }; export interface IssueViewsState { views: IssueView[]; tempView?: IssueView; } export interface IssueViewsContextType extends TabContext { defaultProject: number[]; dispatch: Dispatch; state: IssueViewsState; } export const IssueViewsContext = createContext({ rootProps: {orientation: 'horizontal'}, setTabListState: () => {}, // Issue Views specific state dispatch: () => {}, state: {views: []}, defaultProject: [], }); function reorderTabs(state: IssueViewsState, action: ReorderTabsAction) { const newTabs: IssueView[] = action.newKeyOrder .map(key => { const foundTab = state.views.find(tab => tab.key === key); return foundTab?.key === key ? foundTab : null; }) .filter(defined); return {...state, views: newTabs}; } function saveChanges(state: IssueViewsState, tabListState: TabListState) { const originalTab = state.views.find(tab => tab.key === tabListState?.selectedKey); if (originalTab) { const newViews = state.views.map(tab => { return tab.key === tabListState?.selectedKey && tab.unsavedChanges ? { ...tab, query: tab.unsavedChanges.query ?? tab.query, querySort: tab.unsavedChanges.querySort ?? tab.querySort, projects: tab.unsavedChanges.projects ?? tab.projects, environments: tab.unsavedChanges.environments ?? tab.environments, timeFilters: tab.unsavedChanges.timeFilters ?? tab.timeFilters, unsavedChanges: undefined, } : tab; }); return {...state, views: newViews}; } return state; } function discardChanges(state: IssueViewsState, tabListState: TabListState) { const originalTab = state.views.find(tab => tab.key === tabListState?.selectedKey); if (originalTab) { const newViews = state.views.map(tab => { return tab.key === tabListState?.selectedKey ? {...tab, unsavedChanges: undefined} : tab; }); return {...state, views: newViews}; } return state; } function renameView( state: IssueViewsState, action: RenameTabAction, tabListState: TabListState ) { const renamedTab = state.views.find(tab => tab.key === tabListState?.selectedKey); if (renamedTab && action.newLabel !== renamedTab.label) { const newViews = state.views.map(tab => tab.key === renamedTab.key ? {...tab, label: action.newLabel, isCommitted: true} : tab ); return {...state, views: newViews}; } return state; } function duplicateView( state: IssueViewsState, action: DuplicateViewAction, tabListState: TabListState ) { const idx = state.views.findIndex(tb => tb.key === tabListState?.selectedKey); if (idx !== -1) { const duplicatedTab = state.views[idx]!; const newTabs: IssueView[] = [ ...state.views.slice(0, idx + 1), { ...duplicatedTab, id: action.newViewId, key: action.newViewId, label: `${duplicatedTab.label} (Copy)`, isCommitted: true, }, ...state.views.slice(idx + 1), ]; return {...state, views: newTabs}; } return state; } function deleteView(state: IssueViewsState, tabListState: TabListState) { const newViews = state.views.filter(tab => tab.key !== tabListState?.selectedKey); return {...state, views: newViews}; } function createNewView( state: IssueViewsState, action: CreateNewViewAction, defaultProject: number[] ) { const newTabs: IssueView[] = [ ...state.views, { id: action.tempId, key: action.tempId, label: 'New View', query: '', querySort: IssueSortOptions.DATE, isCommitted: false, environments: DEFAULT_ENVIRONMENTS, projects: defaultProject, timeFilters: DEFAULT_TIME_FILTERS, }, ]; return {...state, views: newTabs}; } function setTempView( state: IssueViewsState, action: SetTempViewAction, defaultProject: number[] ) { const tempView: IssueView = { id: TEMPORARY_TAB_KEY, key: TEMPORARY_TAB_KEY, label: t('Unsaved'), query: action.query, querySort: action.sort ?? IssueSortOptions.DATE, isCommitted: true, environments: DEFAULT_ENVIRONMENTS, projects: defaultProject, timeFilters: DEFAULT_TIME_FILTERS, }; return {...state, tempView}; } function discardTempView(state: IssueViewsState, tabListState: TabListState) { tabListState?.setSelectedKey(state.views[0]!.key); return {...state, tempView: undefined}; } function saveTempView( state: IssueViewsState, action: SaveTempViewAction, tabListState: TabListState ) { if (state.tempView) { const newTab: IssueView = { id: action.newViewId, key: action.newViewId, label: 'New View', query: state.tempView?.query, querySort: state.tempView?.querySort, isCommitted: true, environments: state.tempView?.environments, projects: state.tempView?.projects, timeFilters: state.tempView?.timeFilters, }; tabListState?.setSelectedKey(action.newViewId); return {...state, views: [...state.views, newTab], tempView: undefined}; } return state; } function updateUnsavedChanges( state: IssueViewsState, action: UpdateUnsavedChangesAction, tabListState: TabListState ) { return { ...state, views: state.views.map(tab => tab.key === tabListState?.selectedKey ? { ...tab, unsavedChanges: action.unsavedChanges, isCommitted: action.isCommitted ?? tab.isCommitted, } : tab ), }; } function updateViewIds(state: IssueViewsState, action: UpdateViewIdsAction) { const assignedIds = new Set(); const updatedViews = state.views.map(tab => { if (tab.id && tab.id[0] === '_') { const matchingView = action.newViews.find( view => view.id && !assignedIds.has(view.id) && tab.query === view.query && tab.querySort === view.querySort && tab.label === view.name ); if (matchingView?.id) { assignedIds.add(matchingView.id); return {...tab, id: matchingView.id}; } } return tab; }); return {...state, views: updatedViews}; } function setViews(state: IssueViewsState, action: SetViewsAction) { return {...state, views: action.views}; } interface IssueViewsStateProviderProps extends Omit, 'children'> { children: React.ReactNode; initialViews: IssueView[]; // TODO(msun): Replace router with useLocation() / useUrlParams() / useSearchParams() in the future router: InjectedRouter; } export function IssueViewsStateProvider({ children, initialViews, router, ...props }: IssueViewsStateProviderProps) { const navigate = useNavigate(); const organization = useOrganization(); const {setNewViewActive, setOnNewViewsSaved} = useContext(NewTabContext); const [tabListState, setTabListState] = useState>(); const {className: _className, ...restProps} = props; const allowMultipleProjects = organization.features.includes('global-views'); const isSuperUser = isActiveSuperuser(); const {projects: allProjects} = useProjects(); const memberProjects = useMemo( () => allProjects.filter(project => project.isMember), [allProjects] ); const defaultProject = useMemo(() => { // Should not be possible for member projects to be empty if (allowMultipleProjects) { return []; } if (isSuperUser) { return allowMultipleProjects ? [] : [parseInt(allProjects[0]!.id, 10)]; } return [parseInt(memberProjects[0]!.id, 10)]; }, [memberProjects, allowMultipleProjects, isSuperUser, allProjects]); const {query, sort, viewId} = router.location.query; // This function is fired upon receiving new views from the backend - it replaces any previously // generated temporary view ids with the permanent view ids from the backend const replaceWithPersistentViewIds = (views: GroupSearchView[]) => { const newlyCreatedViews = views.filter( view => !state.views.find(tab => tab.id === view.id) ); if (newlyCreatedViews.length > 0) { dispatch({type: 'UPDATE_VIEW_IDS', newViews: newlyCreatedViews}); const currentView = state.views.find(tab => tab.id === viewId); if (viewId?.startsWith('_') && currentView) { const matchingView = newlyCreatedViews.find( view => view.id && currentView.query === view.query && currentView.querySort === view.querySort ); if (matchingView?.id) { navigate( normalizeUrl({ ...location, query: { ...router.location.query, viewId: matchingView.id, }, }), {replace: true} ); } } } return; }; const {mutate: updateViews} = useUpdateGroupSearchViews({ onSuccess: replaceWithPersistentViewIds, }); const debounceUpdateViews = useMemo( () => debounce((newTabs: IssueView[]) => { if (newTabs) { updateViews({ orgSlug: organization.slug, groupSearchViews: newTabs .filter(tab => tab.isCommitted) .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, projects: isEqual(tab.projects, [-1]) ? [] : tab.projects, isAllProjects: isEqual(tab.projects, [-1]), environments: tab.environments, timeFilters: tab.timeFilters, })), }); } }, 500), [organization.slug, updateViews] ); const reducer: Reducer = useCallback( (state, action): IssueViewsState => { if (!tabListState) { return state; } switch (action.type) { case 'REORDER_TABS': return reorderTabs(state, action); case 'SAVE_CHANGES': return saveChanges(state, tabListState); case 'DISCARD_CHANGES': return discardChanges(state, tabListState); case 'RENAME_TAB': return renameView(state, action, tabListState); case 'DUPLICATE_VIEW': return duplicateView(state, action, tabListState); case 'DELETE_VIEW': return deleteView(state, tabListState); case 'CREATE_NEW_VIEW': return createNewView(state, action, defaultProject); case 'SET_TEMP_VIEW': return setTempView(state, action, defaultProject); case 'DISCARD_TEMP_VIEW': return discardTempView(state, tabListState); case 'SAVE_TEMP_VIEW': return saveTempView(state, action, tabListState); case 'UPDATE_UNSAVED_CHANGES': return updateUnsavedChanges(state, action, tabListState); case 'UPDATE_VIEW_IDS': return updateViewIds(state, action); case 'SET_VIEWS': return setViews(state, action); case 'SYNC_VIEWS_TO_BACKEND': return state; default: return state; } }, [tabListState, defaultProject] ); const sortOption = sort && Object.values(IssueSortOptions).includes(sort.toString() as IssueSortOptions) ? (sort.toString() as IssueSortOptions) : IssueSortOptions.DATE; const initialTempView: IssueView | undefined = query && (!viewId || !initialViews.find(tab => tab.id === viewId)) ? { id: TEMPORARY_TAB_KEY, key: TEMPORARY_TAB_KEY, label: t('Unsaved'), query: query.toString(), querySort: sortOption, isCommitted: true, environments: DEFAULT_ENVIRONMENTS, projects: defaultProject, timeFilters: DEFAULT_TIME_FILTERS, } : undefined; const [state, dispatch] = useReducer(reducer, { views: initialViews, tempView: initialTempView, }); const dispatchWrapper = (action: IssueViewsActions) => { const newState = reducer(state, action); dispatch(action); if (action.type === 'SYNC_VIEWS_TO_BACKEND' || action.syncViews) { debounceUpdateViews(newState.views); } const actionAnalyticsKey = ACTION_ANALYTICS_MAP[action.type]; if (actionAnalyticsKey) { trackAnalytics(actionAnalyticsKey, { organization, }); } }; 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 : { query: view.query, querySort: IssueSortOptions.DATE, environments: DEFAULT_ENVIRONMENTS, projects: defaultProject, timeFilters: DEFAULT_TIME_FILTERS, }, environments: DEFAULT_ENVIRONMENTS, projects: defaultProject, timeFilters: DEFAULT_TIME_FILTERS, 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, querySort: IssueSortOptions.DATE, environments: DEFAULT_ENVIRONMENTS, projects: defaultProject, timeFilters: DEFAULT_TIME_FILTERS, }, isCommitted: true, environments: DEFAULT_ENVIRONMENTS, projects: defaultProject, timeFilters: DEFAULT_TIME_FILTERS, }; } return tab; }); if (remainingNewViews.length > 0) { updatedTabs = [...updatedTabs, ...remainingNewViews]; } dispatch({type: 'SET_VIEWS', views: updatedTabs, syncViews: true}); navigate( { ...location, query: { ...router.location.query, query: newQuery, 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 ( {children} ); } export function IssueViews({ orientation = 'horizontal', className, children, initialViews, router, ...props }: TabsProps & Omit) { return ( {children} ); } const TabsWrap = styled('div', {shouldForwardProp: tabsShouldForwardProp})<{ orientation: Orientation; }>` display: flex; flex-direction: ${p => (p.orientation === 'horizontal' ? 'column' : 'row')}; flex-grow: 1; ${p => p.orientation === 'vertical' && ` height: 100%; align-items: stretch; `}; `;