From a578a870711329f28dd80265b5eccc5fe0169039 Mon Sep 17 00:00:00 2001 From: colivi Date: Thu, 17 Sep 2026 16:22:17 +0200 Subject: [PATCH] fix(dashboards): apply dashboard.spec.timezone to TimeZoneProvider Pass resolveDashboardTimeZone(spec, userPref) as initialTimeZone into TimeRangeProviderWithQueryParams (single TimeZoneProvider). Runtime order via useTimeZoneParams: 1. URL ?tz= 2. dashboard.spec.timezone || user preference || local No second nested TimeZoneProvider. Sticky toolbar seeds from context. Signed-off-by: colivi --- .../DashboardStickyToolbar.tsx | 4 +- dashboards/src/utils/index.ts | 1 + dashboards/src/utils/timezone.test.ts | 52 +++++++++++ dashboards/src/utils/timezone.ts | 27 ++++++ .../src/views/ViewDashboard/DashboardApp.tsx | 5 +- .../src/views/ViewDashboard/ViewDashboard.tsx | 7 ++ .../tests/dashboardTimezone.test.tsx | 87 +++++++++++++++++++ .../TimeRangeProvider/TimeRangeProviders.tsx | 5 +- 8 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 dashboards/src/utils/timezone.test.ts create mode 100644 dashboards/src/utils/timezone.ts create mode 100644 dashboards/src/views/ViewDashboard/tests/dashboardTimezone.test.tsx diff --git a/dashboards/src/components/DashboardStickyToolbar/DashboardStickyToolbar.tsx b/dashboards/src/components/DashboardStickyToolbar/DashboardStickyToolbar.tsx index fb2463a8..dcbb04a3 100644 --- a/dashboards/src/components/DashboardStickyToolbar/DashboardStickyToolbar.tsx +++ b/dashboards/src/components/DashboardStickyToolbar/DashboardStickyToolbar.tsx @@ -13,6 +13,7 @@ import type { SxProps, Theme } from '@mui/material'; import { AppBar, Box, IconButton, Stack, useMediaQuery, useScrollTrigger, useTheme } from '@mui/material'; +import { useTimeZone } from '@perses-dev/components'; import { TimeRangeControls, useTimeZoneParams } from '@perses-dev/plugin-system'; import PinOffOutline from 'mdi-material-ui/PinOffOutline'; import PinOutline from 'mdi-material-ui/PinOutline'; @@ -34,7 +35,8 @@ export function DashboardStickyToolbar(props: DashboardStickyToolbarProps): Reac const isBiggerThanMd = useMediaQuery(useTheme().breakpoints.up('md')); - const { timeZone, setTimeZone } = useTimeZoneParams('local'); + const { timeZone: contextTimeZone } = useTimeZone(); + const { timeZone, setTimeZone } = useTimeZoneParams(contextTimeZone); return ( // marginBottom={-1} counteracts the marginBottom={1} on every variable input. diff --git a/dashboards/src/utils/index.ts b/dashboards/src/utils/index.ts index f821efa9..74e6c0d1 100644 --- a/dashboards/src/utils/index.ts +++ b/dashboards/src/utils/index.ts @@ -15,3 +15,4 @@ export * from './panelUtils'; export * from './pluginVersioning'; export * from './repeatLayoutUtils'; export * from './gridLayoutUtils'; +export * from './timezone'; diff --git a/dashboards/src/utils/timezone.test.ts b/dashboards/src/utils/timezone.test.ts new file mode 100644 index 00000000..97be4161 --- /dev/null +++ b/dashboards/src/utils/timezone.test.ts @@ -0,0 +1,52 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, expect, it } from 'vitest'; + +import { resolveDashboardTimeZone } from './timezone'; + +describe('resolveDashboardTimeZone', () => { + it.each([ + // [dashboard, user, expected] + ['UTC', 'Europe/Berlin', 'UTC'], + ['America/New_York', 'UTC', 'America/New_York'], + ['Europe/London', 'local', 'Europe/London'], + ['local', 'UTC', 'local'], + ['Etc/UTC', undefined, 'Etc/UTC'], + ['GMT', null, 'GMT'], + [' UTC ', 'Europe/Berlin', 'UTC'], + [undefined, 'Europe/Paris', 'Europe/Paris'], + [null, 'Asia/Tokyo', 'Asia/Tokyo'], + ['', 'Europe/Berlin', 'Europe/Berlin'], + [' ', 'UTC', 'UTC'], + [undefined, ' Europe/Berlin ', 'Europe/Berlin'], + [undefined, undefined, 'local'], + [null, null, 'local'], + ['', '', 'local'], + [' ', ' ', 'local'], + [undefined, '', 'local'], + ['', undefined, 'local'], + ] as const)('dashboard=%j user=%j → %j', (dashboard, user, expected) => { + expect(resolveDashboardTimeZone(dashboard, user)).toBe(expected); + }); + + it('matches docs hierarchy: panel/dashboard before user before browser local', () => { + // Documented order for dashboard-level resolution (URL ?tz= is separate). + const steps = [ + resolveDashboardTimeZone('UTC', 'Europe/Berlin'), + resolveDashboardTimeZone(undefined, 'Europe/Berlin'), + resolveDashboardTimeZone(undefined, undefined), + ]; + expect(steps).toEqual(['UTC', 'Europe/Berlin', 'local']); + }); +}); diff --git a/dashboards/src/utils/timezone.ts b/dashboards/src/utils/timezone.ts new file mode 100644 index 00000000..a3eb9218 --- /dev/null +++ b/dashboards/src/utils/timezone.ts @@ -0,0 +1,27 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Default timezone when URL `?tz=` is absent. + * Full runtime order (see useTimeZoneParams): + * 1. URL query `tz` + * 2. dashboard.spec.timezone + * 3. user preference timezone + * 4. browser `local` + */ +export function resolveDashboardTimeZone( + dashboardTimezone?: string | null, + userPreferenceTimezone?: string | null, +): string { + return dashboardTimezone?.trim() || userPreferenceTimezone?.trim() || 'local'; +} diff --git a/dashboards/src/views/ViewDashboard/DashboardApp.tsx b/dashboards/src/views/ViewDashboard/DashboardApp.tsx index 2f2d9a7f..34b09a3e 100644 --- a/dashboards/src/views/ViewDashboard/DashboardApp.tsx +++ b/dashboards/src/views/ViewDashboard/DashboardApp.tsx @@ -36,6 +36,7 @@ import { import type { OnSaveDashboard } from '../../context'; import { useDashboard, useDiscardChangesConfirmationDialog, useEditMode } from '../../context'; import { PanelFocusProvider } from '../../keyboard-shortcuts'; +import { resolveDashboardTimeZone } from '../../utils/timezone'; export interface DashboardAppProps { dashboardResource: DashboardResource; @@ -142,8 +143,8 @@ const DashboardAppContent = (props: DashboardAppProps): ReactElement => { }); const toolBarTimezone = useMemo((): string => { - return dashboardResource.spec.timezone || userPreferenceTimezone || 'local'; - }, [dashboardResource.spec, userPreferenceTimezone]); + return resolveDashboardTimeZone(dashboardResource.spec.timezone, userPreferenceTimezone); + }, [dashboardResource.spec.timezone, userPreferenceTimezone]); return ( resolveDashboardTimeZone(spec.timezone, userPreferenceTimezone), + [spec.timezone, userPreferenceTimezone], + ); const { data } = usePluginBuiltinVariableDefinitions(); const builtinVariables = useMemo(() => { @@ -121,6 +127,7 @@ export function ViewDashboard(props: ViewDashboardProps): ReactElement { {timeZone}; +} + +/** + * Mirrors ViewDashboard: resolveDashboardTimeZone → initialTimeZone on the helper. + */ +function DashboardTimeZoneHarness(props: { + dashboardTimezone?: string; + userPreferenceTimezone?: string; +}): ReactElement { + const initialTimeZone = resolveDashboardTimeZone(props.dashboardTimezone, props.userPreferenceTimezone); + return ( + + + {initialTimeZone} + + ); +} + +describe('Dashboard timezone wiring (single TimeZoneProvider path)', () => { + it('charts see dashboard.spec.timezone when set (wins over user pref)', () => { + renderWithContext(); + + expect(screen.getByLabelText('resolved-initial')).toHaveTextContent('UTC'); + expect(screen.getByLabelText('chart-timezone')).toHaveTextContent('UTC'); + }); + + it('charts see user preference when dashboard timezone is empty', () => { + renderWithContext(); + + expect(screen.getByLabelText('chart-timezone')).toHaveTextContent('Europe/Paris'); + }); + + it('charts fall back to local when neither is set', () => { + renderWithContext(); + + expect(screen.getByLabelText('chart-timezone')).toHaveTextContent('local'); + }); + + it('explicit dashboard local wins over user UTC', () => { + renderWithContext(); + + expect(screen.getByLabelText('chart-timezone')).toHaveTextContent('local'); + }); + + it('URL ?tz= overrides the resolved initial', () => { + const history = createMemoryHistory({ + initialEntries: ['/?tz=Asia/Tokyo'], + }); + renderWithContext( + , + undefined, + history, + ); + + expect(screen.getByLabelText('chart-timezone')).toHaveTextContent('Asia/Tokyo'); + expect(screen.getByLabelText('resolved-initial')).toHaveTextContent('UTC'); + }); +}); diff --git a/plugin-system/src/runtime/TimeRangeProvider/TimeRangeProviders.tsx b/plugin-system/src/runtime/TimeRangeProvider/TimeRangeProviders.tsx index 6dcd0ece..06ba056c 100644 --- a/plugin-system/src/runtime/TimeRangeProvider/TimeRangeProviders.tsx +++ b/plugin-system/src/runtime/TimeRangeProvider/TimeRangeProviders.tsx @@ -22,17 +22,20 @@ import { TimeRangeProvider } from './TimeRangeProvider'; export interface TimeRangeProvidersProps { initialTimeRange: TimeRangeValue; initialRefreshInterval?: DurationString; + /** Seed for useTimeZoneParams when URL ?tz= is absent (default: local). */ + initialTimeZone?: string; children?: React.ReactNode; } export function TimeRangeProviderWithQueryParams({ initialTimeRange, initialRefreshInterval, + initialTimeZone = 'local', children, }: TimeRangeProvidersProps): ReactElement { const { timeRange, setTimeRange } = useTimeRangeParams(initialTimeRange); const { refreshInterval, setRefreshInterval } = useSetRefreshIntervalParams(initialRefreshInterval); - const { timeZone } = useTimeZoneParams('local'); + const { timeZone } = useTimeZoneParams(initialTimeZone); return (