import {createContext, useContext, useEffect, useMemo, useRef} from 'react'; import {dropUndefinedKeys} from '@sentry/utils'; import * as reactQuery from '@tanstack/react-query'; import {ApiResult} from 'sentry/api'; import type {Group, GroupStats, Organization, PageFilters} from 'sentry/types'; import {getUtcDateString} from 'sentry/utils/dates'; import {UseQueryResult} from 'sentry/utils/queryClient'; import RequestError from 'sentry/utils/requestError/requestError'; import useApi from 'sentry/utils/useApi'; function getEndpointParams( p: Pick ): StatEndpointParams { const params: StatEndpointParams = { project: p.selection.projects, environment: p.selection.environments, groupStatsPeriod: p.period, query: p.query, groups: p.groupIds, ...p.selection.datetime, }; if (p.selection.datetime.period) { delete params.period; params.statsPeriod = p.selection.datetime.period; } if (params.end) { params.end = getUtcDateString(params.end); } if (params.start) { params.start = getUtcDateString(params.start); } return dropUndefinedKeys(params); } const GroupStatsContext = createContext | null>(null); export function useGroupStats(group: Group): GroupStats { const ctx = useContext(GroupStatsContext); if (!ctx) { return group; } return ctx?.[group.id] ?? group; } interface StatEndpointParams extends Partial { environment: string[]; groups: Group['id'][]; project: number[]; cursor?: string; expand?: string | string[]; groupStatsPeriod?: string | null; page?: number | string; query?: string | undefined; sort?: string; statsPeriod?: string | null; } export type GroupStatsQuery = UseQueryResult, RequestError>; export interface GroupStatsProviderProps { children: React.ReactNode; groupIds: Group['id'][]; organization: Organization; period: string; selection: PageFilters; onStatsQuery?: ( query: UseQueryResult, RequestError> ) => void; query?: string; } class CacheNode { value: T; constructor(value: T) { this.value = value; } } class Cache { private map: Map>; constructor() { this.map = new Map(); } get(key: string): T | undefined { return this.map.get(key)?.value; } set(key: string, value: T): T { const node = new CacheNode(value); this.map.set(key, node); return node.value; } } export function GroupStatsProvider(props: GroupStatsProviderProps) { const api = useApi(); const cache = useRef> | null>(null); if (!cache.current) { cache.current = new Cache>(); } const cacheKey = useMemo(() => { const query = getEndpointParams({ selection: props.selection, period: props.period, query: props.query, groupIds: props.groupIds, }); const {groups: _groups, ...rest} = query; return JSON.stringify({...rest, organization: props.organization.slug}); }, [ props.selection, props.period, props.query, props.groupIds, props.organization.slug, ]); const queryFn = (): Promise> => { const query = getEndpointParams({ selection: props.selection, period: props.period, query: props.query, groupIds: props.groupIds, }); const entry = cache.current && cache.current.get(cacheKey); if (entry) { query.groups = query.groups.filter(id => !entry[id]); } // Dont make a request if there are no groups to fetch data from // and we have a cached entry that we can resolve with if (!query.groups.length && entry) { return Promise.resolve(entry); } // Dont make a request if there are no groups if (!query.groups.length) { return Promise.resolve({}); } const promise = api .requestPromise(`/organizations/${props.organization.slug}/issues-stats/`, { method: 'GET', query, includeAllArgs: true, }) .then((resp: ApiResult): Record => { const map = cache.current?.get(cacheKey) ?? {}; if (!resp || !Array.isArray(resp[0])) { return {...map}; } for (const stat of resp[0]) { map[stat.id] = stat; } if (cache.current) { cache.current.set(cacheKey, map); } // Return a copy so that callers cannot mutate the cache return {...map}; }) .catch(() => { return {}; }); return promise; }; const statsQuery = reactQuery.useQuery, RequestError>( [ `/organizations/${props.organization.slug}/issues-stats/`, props.selection, props.period, props.query, props.groupIds, ], queryFn, { enabled: props.groupIds.length > 0, staleTime: Infinity, } ); const onStatsQuery = props.onStatsQuery; useEffect(() => { onStatsQuery?.(statsQuery); // We only want to fire the observer when the status changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [statsQuery.status, onStatsQuery]); return ( {props.children} ); }