Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions static/app/components/charts/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ export const SIXTY_DAYS = 86400;
export const THIRTY_DAYS = 43200;
export const TWO_WEEKS = 20160;
export const ONE_WEEK = 10080;
export const FOUR_DAYS = 5760;
export const FORTY_EIGHT_HOURS = 2880;
export const TWENTY_FOUR_HOURS = 1440;
export const TWELVE_HOURS = 720;
export const SIX_HOURS = 360;
const THREE_HOURS = 180;
export const ONE_HOUR = 60;
Expand Down
8 changes: 3 additions & 5 deletions static/app/utils/useChartInterval.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ describe('useChartInterval', () => {
expect(intervalOptions).toEqual([
{value: '1h', label: '1 hour'},
{value: '3h', label: '3 hours'},
{value: '12h', label: '12 hours'},
{value: '1d', label: '1 day'},
{value: '6h', label: '6 hours'},
]);
expect(chartInterval).toBe('1h'); // default

Expand All @@ -47,12 +46,11 @@ describe('useChartInterval', () => {
expect(intervalOptions).toEqual([
{value: '1m', label: '1 minute'},
{value: '5m', label: '5 minutes'},
{value: '15m', label: '15 minutes'},
]);
act(() => {
setChartInterval('15m');
setChartInterval('5m');
});
expect(chartInterval).toBe('15m');
expect(chartInterval).toBe('5m');
});
});

Expand Down
29 changes: 16 additions & 13 deletions static/app/utils/useChartInterval.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import type {Location} from 'history';
import {
FIVE_MINUTES,
FORTY_EIGHT_HOURS,
FOUR_DAYS,
getDiffInMinutes,
GranularityLadder,
ONE_HOUR,
ONE_WEEK,
SIX_HOURS,
THIRTY_DAYS,
TWELVE_HOURS,
TWO_WEEKS,
} from 'sentry/components/charts/utils';
import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters';
Expand All @@ -20,7 +21,7 @@ import {decodeScalar} from 'sentry/utils/queryString';
import {useLocation} from 'sentry/utils/useLocation';
import {useNavigate} from 'sentry/utils/useNavigate';

enum ChartIntervalUnspecifiedStrategy {
export enum ChartIntervalUnspecifiedStrategy {
/** Use the second biggest possible interval (e.g., pretty big buckets) */
USE_SECOND_BIGGEST = 'use_second_biggest',
/** Use the smallest possible interval (e.g., the smallest possible buckets) */
Expand Down Expand Up @@ -104,12 +105,12 @@ function useChartIntervalImpl({
const ALL_INTERVAL_OPTIONS = [
{value: '1m', label: t('1 minute')},
{value: '5m', label: t('5 minutes')},
{value: '15m', label: t('15 minutes')},
{value: '10m', label: t('10 minutes')},
{value: '30m', label: t('30 minutes')},
{value: '1h', label: t('1 hour')},
{value: '3h', label: t('3 hours')},
{value: '6h', label: t('6 hours')},
{value: '12h', label: t('12 hours')},
{value: '1d', label: t('1 day')},
];

/**
Expand All @@ -119,19 +120,21 @@ const ALL_INTERVAL_OPTIONS = [
const MINIMUM_INTERVAL = new GranularityLadder([
[THIRTY_DAYS, '3h'],
[TWO_WEEKS, '1h'],
[ONE_WEEK, '30m'],
[FORTY_EIGHT_HOURS, '15m'],
[SIX_HOURS, '5m'],
[FOUR_DAYS, '30m'],
[FORTY_EIGHT_HOURS, '10m'],
[TWELVE_HOURS, '5m'],
[SIX_HOURS, '1m'],
[0, '1m'],
]);

const MAXIMUM_INTERVAL = new GranularityLadder([
[THIRTY_DAYS, '1d'],
[TWO_WEEKS, '1d'],
[ONE_WEEK, '12h'],
[FORTY_EIGHT_HOURS, '4h'],
[SIX_HOURS, '1h'],
[ONE_HOUR, '15m'],
[THIRTY_DAYS, '12h'],
[TWO_WEEKS, '6h'],
[FOUR_DAYS, '3h'],
[FORTY_EIGHT_HOURS, '1h'],
[TWELVE_HOURS, '30m'],
[SIX_HOURS, '10m'],
[ONE_HOUR, '5m'],
[FIVE_MINUTES, '5m'],
[0, '1m'],
]);
Expand Down
69 changes: 35 additions & 34 deletions static/app/views/dashboards/dashboard.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ import {resetMockDate, setMockDate} from 'sentry-test/utils';
import {PageFiltersStore} from 'sentry/components/pageFilters/store';
import {MemberListStore} from 'sentry/stores/memberListStore';
import {MEPSettingProvider} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
import {useChartInterval} from 'sentry/utils/useChartInterval';
import {useLocation} from 'sentry/utils/useLocation';
import {Dashboard} from 'sentry/views/dashboards/dashboard';
import {FiltersBar} from 'sentry/views/dashboards/filtersBar';
import {useDashboardChartInterval} from 'sentry/views/dashboards/hooks/useDashboardChartInterval';
import type {
DashboardDetails,
DashboardFilters,
Expand Down Expand Up @@ -499,7 +499,7 @@ describe('Dashboards > Dashboard', () => {
dashboard?: DashboardDetails;
} = {}) {
const location = useLocation();
const [widgetInterval] = useChartInterval();
const [widgetInterval] = useDashboardChartInterval();
return (
<MEPSettingProvider forceTransactions={false}>
<FiltersBar
Expand Down Expand Up @@ -541,39 +541,39 @@ describe('Dashboards > Dashboard', () => {
});

describe('no interval set in URL', () => {
it('defaults to the smallest valid interval for the dashboard period', async () => {
const fiveMinuteMock = MockApiClient.addMockResponse({
it('defaults to the second-biggest valid interval for the dashboard period', async () => {
const tenMinuteMock = MockApiClient.addMockResponse({
url: '/organizations/org-slug/events-stats/',
method: 'GET',
body: [],
match: [MockApiClient.matchQuery({interval: '5m'})],
match: [MockApiClient.matchQuery({interval: '10m'})],
});
const hourlyIntervalMock = MockApiClient.addMockResponse({
const thirtyMinuteMock = MockApiClient.addMockResponse({
url: '/organizations/org-slug/events-stats/',
method: 'GET',
body: [],
match: [MockApiClient.matchQuery({interval: '1h'})],
match: [MockApiClient.matchQuery({interval: '30m'})],
});

// No interval in the URL — the 5m default is derived purely from the
// dashboard's saved 24h period via PageFiltersStore → useChartInterval.
// No interval in the URL — the 10m default (second-biggest of [5m, 10m, 30m])
// is derived from the dashboard's saved 24h period via useDashboardChartInterval.
const {router} = render(<DashboardWithIntervalSelector />, {
organization: orgWithFlag,
initialRouterConfig: {location: {pathname: '/'}},
});

await screen.findByText('Test Spans Widget');
await waitFor(() => expect(fiveMinuteMock).toHaveBeenCalled());
expect(hourlyIntervalMock).not.toHaveBeenCalled();
await waitFor(() => expect(tenMinuteMock).toHaveBeenCalled());
expect(thirtyMinuteMock).not.toHaveBeenCalled();

// Click the interval selector and choose '1 hour'. FiltersBar writes
// interval=1h to the URL, DashboardWithIntervalSelector re-renders with
// Click the interval selector and choose '30 minutes'. FiltersBar writes
// interval=30m to the URL, DashboardWithIntervalSelector re-renders with
// the new widgetInterval, and the widget re-fetches with the new interval.
await userEvent.click(screen.getByRole('button', {name: '5 minutes'}));
await userEvent.click(screen.getByRole('option', {name: '1 hour'}));
await userEvent.click(screen.getByRole('button', {name: '10 minutes'}));
await userEvent.click(screen.getByRole('option', {name: '30 minutes'}));

await waitFor(() => expect(hourlyIntervalMock).toHaveBeenCalled());
expect(router.location.query.interval).toBe('1h');
await waitFor(() => expect(thirtyMinuteMock).toHaveBeenCalled());
expect(router.location.query.interval).toBe('30m');
});
});

Expand All @@ -592,11 +592,11 @@ describe('Dashboards > Dashboard', () => {
match: [MockApiClient.matchQuery({interval: '5m'})],
});

const hourlyIntervalMock = MockApiClient.addMockResponse({
const tenMinuteMock = MockApiClient.addMockResponse({
url: '/organizations/org-slug/events-stats/',
method: 'GET',
body: [],
match: [MockApiClient.matchQuery({interval: '1h'})],
match: [MockApiClient.matchQuery({interval: '10m'})],
});

const {router} = render(<DashboardWithIntervalSelector />, {
Expand All @@ -615,28 +615,28 @@ describe('Dashboards > Dashboard', () => {

// Selecting a new interval updates the URL and triggers a re-fetch.
await userEvent.click(screen.getByRole('button', {name: '30 minutes'}));
await userEvent.click(screen.getByRole('option', {name: '1 hour'}));
await userEvent.click(screen.getByRole('option', {name: '10 minutes'}));

await waitFor(() => expect(hourlyIntervalMock).toHaveBeenCalled());
expect(router.location.query.interval).toBe('1h');
await waitFor(() => expect(tenMinuteMock).toHaveBeenCalled());
expect(router.location.query.interval).toBe('10m');
});
});

describe('URL interval not valid for the dashboard period', () => {
beforeEach(() => {
// Override the outer 24h store setup — valid intervals for 30d are 3h, 12h, 1d.
// Override the outer 24h store setup — valid intervals for 30d are 3h, 6h, 12h.
PageFiltersStore.init();
PageFiltersStore.onInitializeUrlState(
getSavedFiltersAsPageFilters(thirtyDayDashboard)
);
});

it('ignores the URL interval and falls back to the period default', async () => {
const threeHourMock = MockApiClient.addMockResponse({
const sixHourMock = MockApiClient.addMockResponse({
url: '/organizations/org-slug/events-stats/',
method: 'GET',
body: [],
match: [MockApiClient.matchQuery({interval: '3h'})],
match: [MockApiClient.matchQuery({interval: '6h'})],
});
const fiveMinuteMock = MockApiClient.addMockResponse({
url: '/organizations/org-slug/events-stats/',
Expand All @@ -653,11 +653,12 @@ describe('Dashboards > Dashboard', () => {

await screen.findByText('Test Spans Widget');

// The selector should show the period-derived default, not the invalid URL value.
expect(screen.getByRole('button', {name: '3 hours'})).toBeInTheDocument();
// The selector should show the period-derived default (6h = second-biggest
// of [3h, 6h, 12h]), not the invalid URL value (5m).
expect(screen.getByRole('button', {name: '6 hours'})).toBeInTheDocument();

// The widget should query with the valid default interval, not the URL value.
await waitFor(() => expect(threeHourMock).toHaveBeenCalled());
await waitFor(() => expect(sixHourMock).toHaveBeenCalled());
expect(fiveMinuteMock).not.toHaveBeenCalled();
});
});
Expand All @@ -681,11 +682,11 @@ describe('Dashboards > Dashboard', () => {
it('ignores the URL interval and falls back to the period default', async () => {
// Uses the /sessions/ endpoint (session.status in columns → useSessionAPI=true),
// which surfaces the "intervals too granular" error in production.
const threeHourMock = MockApiClient.addMockResponse({
const sixHourMock = MockApiClient.addMockResponse({
url: '/organizations/org-slug/sessions/',
method: 'GET',
body: emptySessionsBody,
match: [MockApiClient.matchQuery({interval: '3h'})],
match: [MockApiClient.matchQuery({interval: '6h'})],
});
const fiveMinuteMock = MockApiClient.addMockResponse({
url: '/organizations/org-slug/sessions/',
Expand All @@ -701,13 +702,13 @@ describe('Dashboards > Dashboard', () => {

await screen.findByText('Test Releases Widget');

// The selector should show the period-derived default (3h), not the
// invalid URL value (5m).
expect(screen.getByRole('button', {name: '3 hours'})).toBeInTheDocument();
// The selector should show the period-derived default (6h = second-biggest
// of [3h, 6h, 12h]), not the invalid URL value (5m).
expect(screen.getByRole('button', {name: '6 hours'})).toBeInTheDocument();

// The widget should query the sessions endpoint with the valid default
// interval, not the 5m value from the URL.
await waitFor(() => expect(threeHourMock).toHaveBeenCalled());
await waitFor(() => expect(sixHourMock).toHaveBeenCalled());
expect(fiveMinuteMock).not.toHaveBeenCalled();
});
});
Expand Down
4 changes: 2 additions & 2 deletions static/app/views/dashboards/detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,14 @@ import {OnRouteLeave} from 'sentry/utils/reactRouter6Compat/onRouteLeave';
import {scheduleMicroTask} from 'sentry/utils/scheduleMicroTask';
import {normalizeUrl} from 'sentry/utils/url/normalizeUrl';
import {useApi} from 'sentry/utils/useApi';
import {useChartInterval} from 'sentry/utils/useChartInterval';
import {useLocation} from 'sentry/utils/useLocation';
import type {ReactRouter3Navigate} from 'sentry/utils/useNavigate';
import {useNavigate} from 'sentry/utils/useNavigate';
import {useOrganization} from 'sentry/utils/useOrganization';
import {useParams} from 'sentry/utils/useParams';
import {useProjects} from 'sentry/utils/useProjects';
import {useRouter} from 'sentry/utils/useRouter';
import {useDashboardChartInterval} from 'sentry/views/dashboards/hooks/useDashboardChartInterval';
import {
cloneDashboard,
getCurrentPageFilters,
Expand Down Expand Up @@ -1462,7 +1462,7 @@ export function DashboardDetailWithInjectedProps(
const location = useLocation();
const params = useParams<RouteParams>();
const router = useRouter();
const [chartInterval] = useChartInterval();
const [chartInterval] = useDashboardChartInterval();
const queryClient = useQueryClient();
// Always use the validated chart interval so the UI dropdown and widget
// requests stay in sync. chartInterval is validated against the current page
Expand Down
4 changes: 2 additions & 2 deletions static/app/views/dashboards/filtersBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ import type {User} from 'sentry/types/user';
import {defined} from 'sentry/utils';
import {trackAnalytics} from 'sentry/utils/analytics';
import {ToggleOnDemand} from 'sentry/utils/performance/contexts/onDemandControl';
import {useChartInterval} from 'sentry/utils/useChartInterval';
import {useMaxPickableDays} from 'sentry/utils/useMaxPickableDays';
import {useOrganization} from 'sentry/utils/useOrganization';
import {useUser} from 'sentry/utils/useUser';
import {useUserTeams} from 'sentry/utils/useUserTeams';
import {AddFilter} from 'sentry/views/dashboards/globalFilter/addFilter';
import {GenericFilterSelector} from 'sentry/views/dashboards/globalFilter/genericFilterSelector';
import {globalFilterKeysAreEqual} from 'sentry/views/dashboards/globalFilter/utils';
import {useDashboardChartInterval} from 'sentry/views/dashboards/hooks/useDashboardChartInterval';
import {useDatasetSearchBarData} from 'sentry/views/dashboards/hooks/useDatasetSearchBarData';
import {useInvalidateStarredDashboards} from 'sentry/views/dashboards/hooks/useInvalidateStarredDashboards';
import {getDashboardFiltersFromURL} from 'sentry/views/dashboards/utils';
Expand Down Expand Up @@ -234,7 +234,7 @@ export function FiltersBar({
const hasIntervalSelection = organization.features.includes(
'dashboards-interval-selection'
);
const [interval, setInterval, intervalOptions] = useChartInterval();
const [interval, setInterval, intervalOptions] = useDashboardChartInterval();

return (
<Wrapper>
Expand Down
15 changes: 15 additions & 0 deletions static/app/views/dashboards/hooks/useDashboardChartInterval.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {
ChartIntervalUnspecifiedStrategy,
useChartInterval,
} from 'sentry/utils/useChartInterval';

/**
* Wrapper around `useChartInterval` that uses the `USE_SECOND_BIGGEST`
* strategy for dashboards. This keeps the dashboard detail page, widget
* builder preview, and filters bar in sync.
*/
export function useDashboardChartInterval() {
return useChartInterval({
unspecifiedStrategy: ChartIntervalUnspecifiedStrategy.USE_SECOND_BIGGEST,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import {useState} from 'react';
import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters';
import {dedupeArray} from 'sentry/utils/dedupeArray';
import type {Sort} from 'sentry/utils/discover/fields';
import {useChartInterval} from 'sentry/utils/useChartInterval';
import {useLocation} from 'sentry/utils/useLocation';
import {useNavigate} from 'sentry/utils/useNavigate';
import {useOrganization} from 'sentry/utils/useOrganization';
import {useDashboardChartInterval} from 'sentry/views/dashboards/hooks/useDashboardChartInterval';
import {
DisplayType,
WidgetType,
Expand Down Expand Up @@ -44,7 +44,7 @@ export function WidgetPreview({
const location = useLocation();
const navigate = useNavigate();
const pageFilters = usePageFilters();
const [chartInterval] = useChartInterval();
const [chartInterval] = useDashboardChartInterval();

const {state, dispatch} = useWidgetBuilderContext();
const [tableWidths, setTableWidths] = useState<number[]>();
Expand Down
Loading