mirror of
https://github.com/supabase/supabase.git
synced 2026-05-06 08:56:46 -04:00
feat(studio): add timezone picker to user dropdown (#45517)
## Problem The dashboard renders all timestamps in the browser's local timezone. When debugging app issues, users often want to see logs and timestamps in a different timezone (e.g. their app's deployment region) without changing their OS clock. ## Fix - New Timezone submenu in the user-avatar dropdown, sitting next to the existing Theme picker. Search-as-you-type combobox over the full IANA catalog plus an Auto detect option. - Selection persists in localStorage (`supabase-ui-timezone`) and survives `clearLocalStorage()`. No backend schema change. - New `lib/datetime.tsx` exposes pure timezone-aware formatters (`formatDateTime`, `formatDate`, `formatTime`, `formatFromNow`, `toTimezone`) plus a `TimezoneProvider` and matching React hooks (`useTimezone`, `useFormatDateTime`, ...). The pure functions take `tz` explicitly so they're easy to unit test (17 vitest cases covering DST transitions, multi-tz formatting, unix-micro/Date inputs, invalid-tz fallback). - The selected timezone propagates to every existing `<TimestampInfo>` in Studio via a new `TimestampInfoProvider` context exported from `ui-patterns`. No per-callsite changes needed for those ~20+ surfaces. - The `UnifiedLogs` date column migrates off `date-fns` to the new `useFormatDateTime` hook (the rest of the date-fns callers stay as-is, since they're either internal range math or non-display). - `ALL_TIMEZONES` (~600 entries) moves out of `PITR.constants.ts` into a shared `lib/constants/timezones.ts`. PITR keeps a re-export shim so its callers don't move. New `TIMEZONES_BY_IANA` dedupes the catalog by primary IANA name (the original list contains both PDT and PST rows for `America/Los_Angeles`, etc.) and `findTimezoneByIana` provides reverse lookup. - Telemetry: `timezone_picker_clicked` PostHog event with `previousTimezone`, `nextTimezone`, `isAutoDetected` properties. Notes for reviewers: - Bare `dayjs(x).format(...)` calls (~157 files) intentionally still render in browser-local time. Surfaces opt in by switching to the new wrappers, so this PR is the abstraction plus logs adoption; broader migration is a follow-up. - Two `// prettier-ignore` lines (`apps/studio/pages/_app.tsx`, `apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx`) work around a pre-existing local-tooling issue where `prettier-plugin-sql-cst` strips angle-bracket type arguments under certain conditions. Project's pinned prettier (3.8.1) does not strip; the issue surfaces with a globally-installed prettier. Worth tracking separately. - Hydration: `guessLocalTimezone()` and `useLocalStorageQuery` are client-only. Studio is mostly CSR via the Pages Router, but any SSR'd `<TimestampInfo>` may briefly render in the server's tz before client hydration. Existing behavior already had this mismatch with `.local()`; this PR does not regress it. - Backend timestamps round-tripped through query params and mutations stay UTC. The picker is display-only. ## How to test - Run `pnpm dev:studio`, sign in. - Open the user avatar dropdown (top right). Hover Timezone. - Search for "tokyo", pick `(UTC+09:00) Osaka, Sapporo, Tokyo`. - Open any project, navigate to Logs (e.g. `Project > Logs > Edge Functions`). Hover a log row's timestamp; the popover should show UTC, the chosen tz (`Asia/Tokyo`), and the relative time. Visible cell text should be in JST. - Visit any page that uses `<TimestampInfo>` (Database > Backups, Project Pause state, Edge Function details). Same tooltip should reflect Asia/Tokyo. - Refresh the page; timezone is still Asia/Tokyo. - Reopen the picker, choose Auto detect; timestamps revert to browser local. - Run `pnpm --filter studio test lib/datetime.test.ts`. 17 tests should pass. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Timezone selector added to the user menu with auto-detect and manual override * App-wide timezone provider and hooks plus a shared timezone catalog for consistent timezone-aware display * Timestamp components accept an optional timezone prop and respect user preference (persisted) * **Bug Fixes / Improvements** * Logs and timestamp displays now use the new timezone formatting hooks * **Tests** * Added comprehensive datetime and timezone catalog tests * **Telemetry** * Telemetry event added for timezone picker interactions <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
import { useFlag } from 'common'
|
||||
import { CheckIcon, ChevronsUpDown, Globe } from 'lucide-react'
|
||||
import { useId, useMemo, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
cn,
|
||||
Command_Shadcn_,
|
||||
CommandEmpty_Shadcn_,
|
||||
CommandGroup_Shadcn_,
|
||||
CommandInput_Shadcn_,
|
||||
CommandItem_Shadcn_,
|
||||
CommandList_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
ScrollArea,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import {
|
||||
PageSection,
|
||||
PageSectionContent,
|
||||
PageSectionDescription,
|
||||
PageSectionMeta,
|
||||
PageSectionSummary,
|
||||
PageSectionTitle,
|
||||
} from 'ui-patterns/PageSection'
|
||||
|
||||
import { findTimezoneByIana, TIMEZONES_BY_IANA } from '@/lib/constants/timezones'
|
||||
import { useTimezone } from '@/lib/datetime'
|
||||
import { guessLocalTimezone } from '@/lib/dayjs'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
|
||||
const AUTO_OPTION_VALUE = '__auto__'
|
||||
|
||||
export const TimezoneSettings = () => {
|
||||
const timezonePickerEnabled = useFlag('timezonePicker')
|
||||
const { timezone, storedTimezone, setTimezone, isAutoDetected } = useTimezone()
|
||||
const track = useTrack()
|
||||
const [open, setOpen] = useState(false)
|
||||
const listboxId = useId()
|
||||
|
||||
// Browser timezone is captured once and stays stable even when the user has
|
||||
// overridden the dashboard timezone — that's the value the "Auto detect"
|
||||
// option will revert to.
|
||||
const browserTimezone = useMemo(() => guessLocalTimezone(), [])
|
||||
|
||||
const triggerLabel = useMemo(() => findTimezoneByIana(timezone)?.text ?? timezone, [timezone])
|
||||
|
||||
if (!timezonePickerEnabled) return null
|
||||
|
||||
const handleSelect = (nextStored: string) => {
|
||||
setTimezone(nextStored)
|
||||
const resolvedNext = nextStored || guessLocalTimezone()
|
||||
track('timezone_picker_clicked', {
|
||||
previousTimezone: timezone,
|
||||
nextTimezone: resolvedNext,
|
||||
isAutoDetected: nextStored === '',
|
||||
source: 'account_preferences',
|
||||
})
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Timezone</PageSectionTitle>
|
||||
<PageSectionDescription>
|
||||
Choose how dates and times in logs and other dashboard surfaces are displayed.
|
||||
</PageSectionDescription>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
label="Display timezone"
|
||||
layout="flex-row-reverse"
|
||||
description={
|
||||
isAutoDetected
|
||||
? `Auto detected from your browser (${browserTimezone}).`
|
||||
: 'Pick "Auto detect" to follow your browser timezone again.'
|
||||
}
|
||||
>
|
||||
<Popover_Shadcn_ open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
className="w-full justify-between"
|
||||
type="default"
|
||||
size="small"
|
||||
icon={<Globe />}
|
||||
iconRight={<ChevronsUpDown size={14} strokeWidth={1.5} />}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{isAutoDetected ? `Auto detect (${timezone})` : triggerLabel}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_
|
||||
id={listboxId}
|
||||
className="w-[--radix-popover-trigger-width] p-0"
|
||||
>
|
||||
<Command_Shadcn_>
|
||||
<CommandInput_Shadcn_ placeholder="Search timezone..." className="h-9" />
|
||||
<CommandList_Shadcn_>
|
||||
<CommandEmpty_Shadcn_>No timezones found</CommandEmpty_Shadcn_>
|
||||
<CommandGroup_Shadcn_>
|
||||
<ScrollArea className="h-72">
|
||||
<CommandItem_Shadcn_
|
||||
key={AUTO_OPTION_VALUE}
|
||||
value={`Auto detect ${browserTimezone}`}
|
||||
onSelect={() => handleSelect('')}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span>Auto detect</span>
|
||||
<span className="text-xs text-foreground-lighter">
|
||||
{browserTimezone}
|
||||
</span>
|
||||
</div>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
'ml-auto h-4 w-4',
|
||||
isAutoDetected ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</CommandItem_Shadcn_>
|
||||
{TIMEZONES_BY_IANA.map((entry) => {
|
||||
const ianaName = entry.utc[0]
|
||||
const isSelected = !isAutoDetected && storedTimezone === ianaName
|
||||
return (
|
||||
<CommandItem_Shadcn_
|
||||
key={ianaName}
|
||||
// CommandItem matches against the `value` prop for the input filter — include
|
||||
// both the human label and the IANA name so search works for either.
|
||||
value={`${entry.text} ${ianaName}`}
|
||||
onSelect={() => handleSelect(ianaName)}
|
||||
>
|
||||
{entry.text}
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
'ml-auto h-4 w-4',
|
||||
isSelected ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</CommandItem_Shadcn_>
|
||||
)
|
||||
})}
|
||||
</ScrollArea>
|
||||
</CommandGroup_Shadcn_>
|
||||
</CommandList_Shadcn_>
|
||||
</Command_Shadcn_>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
</FormItemLayout>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
||||
import { format } from 'date-fns'
|
||||
import { User } from 'lucide-react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
@@ -9,6 +8,27 @@ import { getLevelLabel } from './UnifiedLogs.utils'
|
||||
import { LEVELS } from '@/components/ui/DataTable/DataTable.constants'
|
||||
import { DataTableFilterField, Option } from '@/components/ui/DataTable/DataTable.types'
|
||||
import { getLevelColor } from '@/components/ui/DataTable/DataTable.utils'
|
||||
import { useFormatDateTime } from '@/lib/datetime'
|
||||
|
||||
const DateCell = (props: { date: ColumnSchema['date'] }) => {
|
||||
const formatDateTime = useFormatDateTime()
|
||||
const month = formatDateTime(props.date, 'MMM')
|
||||
const day = formatDateTime(props.date, 'DD')
|
||||
const year = formatDateTime(props.date, 'YYYY')
|
||||
const time = formatDateTime(props.date, 'HH:mm:ss')
|
||||
|
||||
return (
|
||||
<div className="font-mono whitespace-nowrap flex items-center gap-1 justify-end">
|
||||
<span>{month}</span>
|
||||
<span className="text-foreground/50">·</span>
|
||||
<span>{day}</span>
|
||||
<span className="text-foreground/50">·</span>
|
||||
<span>{year}</span>
|
||||
<span className="text-foreground/50">·</span>
|
||||
<span>{time}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// instead of filterFields, maybe just 'fields' with a filterDisabled prop?
|
||||
// that way, we could have 'message' or 'headers' field with label and value as well as type!
|
||||
@@ -127,25 +147,7 @@ export const sheetFields = [
|
||||
id: 'date',
|
||||
label: 'Date',
|
||||
type: 'timerange',
|
||||
component: (props) => {
|
||||
const date = new Date(props.date)
|
||||
const month = format(date, 'LLL')
|
||||
const day = format(date, 'dd')
|
||||
const year = format(date, 'y')
|
||||
const time = format(date, 'HH:mm:ss')
|
||||
|
||||
return (
|
||||
<div className="font-mono whitespace-nowrap flex items-center gap-1 justify-end">
|
||||
<span>{month}</span>
|
||||
<span className="text-foreground/50">·</span>
|
||||
<span>{day}</span>
|
||||
<span className="text-foreground/50">·</span>
|
||||
<span>{year}</span>
|
||||
<span className="text-foreground/50">·</span>
|
||||
<span>{time}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
component: DateCell,
|
||||
skeletonClassName: 'w-36',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useFlag } from 'common'
|
||||
import { FlaskConical, Loader2, ScrollText, Settings } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import Link from 'next/link'
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
|
||||
import { ButtonTooltip } from '../ui/ButtonTooltip'
|
||||
import { useFeaturePreviewModal } from './App/FeaturePreview/FeaturePreviewContext'
|
||||
import { TimezoneDropdown } from './UserDropdown/TimezoneDropdown'
|
||||
import { ProfileImage } from '@/components/ui/ProfileImage'
|
||||
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
@@ -37,6 +39,7 @@ export function UserDropdown({
|
||||
const { theme, setTheme } = useTheme()
|
||||
const appStateSnapshot = useAppStateSnapshot()
|
||||
const profileShowEmailEnabled = useIsFeatureEnabled('profile:show_email')
|
||||
const timezonePickerEnabled = useFlag('timezonePicker')
|
||||
const { username, avatarUrl, primaryEmail, isLoading } = useProfileNameAndPicture()
|
||||
|
||||
const { toggleFeaturePreviewModal } = useFeaturePreviewModal()
|
||||
@@ -144,6 +147,14 @@ export function UserDropdown({
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuGroup>
|
||||
{timezonePickerEnabled && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<TimezoneDropdown />
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
{IS_PLATFORM && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { CheckIcon } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
cn,
|
||||
Command_Shadcn_,
|
||||
CommandEmpty_Shadcn_,
|
||||
CommandGroup_Shadcn_,
|
||||
CommandInput_Shadcn_,
|
||||
CommandItem_Shadcn_,
|
||||
CommandList_Shadcn_,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
ScrollArea,
|
||||
} from 'ui'
|
||||
|
||||
import { findTimezoneByIana, TIMEZONES_BY_IANA } from '@/lib/constants/timezones'
|
||||
import { useTimezone } from '@/lib/datetime'
|
||||
import { guessLocalTimezone } from '@/lib/dayjs'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
|
||||
const AUTO_OPTION_VALUE = '__auto__'
|
||||
|
||||
export const TimezoneDropdown = () => {
|
||||
const { timezone, storedTimezone, setTimezone, isAutoDetected } = useTimezone()
|
||||
const track = useTrack()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// The "Auto detect" row always advertises the browser's own timezone, even
|
||||
// when the user is currently overriding it with a manual pick.
|
||||
const browserTimezone = useMemo(() => guessLocalTimezone(), [])
|
||||
|
||||
const triggerLabel = useMemo(() => {
|
||||
return findTimezoneByIana(timezone)?.text ?? timezone
|
||||
}, [timezone])
|
||||
|
||||
const handleSelect = (nextStored: string) => {
|
||||
setTimezone(nextStored)
|
||||
const resolvedNext = nextStored || guessLocalTimezone()
|
||||
track('timezone_picker_clicked', {
|
||||
previousTimezone: timezone,
|
||||
nextTimezone: resolvedNext,
|
||||
isAutoDetected: nextStored === '',
|
||||
source: 'user_dropdown',
|
||||
})
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSub open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuSubTrigger className="flex gap-2 cursor-pointer">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span>Timezone</span>
|
||||
<span className="text-xs text-foreground-lighter truncate" title={triggerLabel}>
|
||||
{isAutoDetected ? `Auto (${timezone})` : triggerLabel}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent className="p-0 w-[320px]" sideOffset={4}>
|
||||
<Command_Shadcn_>
|
||||
<CommandInput_Shadcn_ placeholder="Search timezone..." className="h-9" />
|
||||
<CommandList_Shadcn_>
|
||||
<CommandEmpty_Shadcn_>No timezones found</CommandEmpty_Shadcn_>
|
||||
<CommandGroup_Shadcn_>
|
||||
<ScrollArea className="h-72">
|
||||
<CommandItem_Shadcn_
|
||||
key={AUTO_OPTION_VALUE}
|
||||
value={`Auto detect ${browserTimezone}`}
|
||||
onSelect={() => handleSelect('')}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span>Auto detect</span>
|
||||
<span className="text-xs text-foreground-lighter">{browserTimezone}</span>
|
||||
</div>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
'ml-auto h-4 w-4',
|
||||
isAutoDetected ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</CommandItem_Shadcn_>
|
||||
{TIMEZONES_BY_IANA.map((entry) => {
|
||||
const ianaName = entry.utc[0]
|
||||
const isSelected = !isAutoDetected && storedTimezone === ianaName
|
||||
return (
|
||||
<CommandItem_Shadcn_
|
||||
key={ianaName}
|
||||
// CommandItem matches against the `value` prop for the input filter — include
|
||||
// both the human label and the IANA name so search works for either.
|
||||
value={`${entry.text} ${ianaName}`}
|
||||
onSelect={() => handleSelect(ianaName)}
|
||||
>
|
||||
{entry.text}
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
'ml-auto h-4 w-4',
|
||||
isSelected ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</CommandItem_Shadcn_>
|
||||
)
|
||||
})}
|
||||
</ScrollArea>
|
||||
</CommandGroup_Shadcn_>
|
||||
</CommandList_Shadcn_>
|
||||
</Command_Shadcn_>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ALL_TIMEZONES, findTimezoneByIana, TIMEZONES_BY_IANA } from '@/lib/constants/timezones'
|
||||
|
||||
describe('TIMEZONES_BY_IANA', () => {
|
||||
it('produces one row per primary IANA name', () => {
|
||||
const ianas = TIMEZONES_BY_IANA.map((entry) => entry.utc[0])
|
||||
expect(new Set(ianas).size).toBe(ianas.length)
|
||||
})
|
||||
|
||||
it('prefers the standard-time row when multiple catalog rows share a primary IANA', () => {
|
||||
// ALL_TIMEZONES has both PDT (isdst: true) and PST (isdst: false) pointing
|
||||
// at America/Los_Angeles. The deduped view should pick the standard one
|
||||
// so the picker label doesn't flip on DST changes.
|
||||
const collisions = ALL_TIMEZONES.filter((entry) => entry.utc[0] === 'America/Los_Angeles')
|
||||
expect(collisions.length).toBeGreaterThan(1)
|
||||
|
||||
const winner = TIMEZONES_BY_IANA.find((entry) => entry.utc[0] === 'America/Los_Angeles')
|
||||
expect(winner).toBeDefined()
|
||||
expect(winner!.isdst).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves entries that have no collision', () => {
|
||||
// Most rows have a unique primary IANA. The UTC catalog row's primary is
|
||||
// 'America/Danmarkshavn' and survives the dedupe pass unchanged.
|
||||
const utcRow = TIMEZONES_BY_IANA.find((entry) => entry.value === 'UTC')
|
||||
expect(utcRow?.text).toContain('Coordinated Universal Time')
|
||||
expect(utcRow?.utc[0]).toBe('America/Danmarkshavn')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findTimezoneByIana', () => {
|
||||
it('matches the entry by its primary IANA name', () => {
|
||||
const entry = findTimezoneByIana('America/Danmarkshavn')
|
||||
expect(entry?.text).toContain('Coordinated Universal Time')
|
||||
})
|
||||
|
||||
it('matches the entry by any of its secondary IANA names', () => {
|
||||
// 'Asia/Tokyo' is one of the IANA aliases on the JST row whose primary
|
||||
// IANA is 'Asia/Dili'. Lookup must walk the full alias list.
|
||||
const entry = findTimezoneByIana('Asia/Tokyo')
|
||||
expect(entry?.utc).toContain('Asia/Tokyo')
|
||||
})
|
||||
|
||||
it('returns undefined for an unknown IANA name', () => {
|
||||
expect(findTimezoneByIana('Not/A/Real_Zone')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
formatFromNow,
|
||||
formatTime,
|
||||
resolveTimezone,
|
||||
toTimezone,
|
||||
} from '@/lib/datetime'
|
||||
|
||||
// Fixed reference points so the assertions don't depend on the host machine's
|
||||
// timezone or current wall-clock time.
|
||||
const SUMMER_UTC = '2025-06-15T12:34:56Z'
|
||||
const WINTER_UTC = '2025-01-15T12:34:56Z'
|
||||
// 16-digit unix microseconds equivalent of SUMMER_UTC.
|
||||
const SUMMER_UNIX_MICRO = '1749990896000000'
|
||||
|
||||
describe('formatDateTime', () => {
|
||||
it('renders a UTC instant in Asia/Tokyo (UTC+9, no DST)', () => {
|
||||
expect(formatDateTime(SUMMER_UTC, { tz: 'Asia/Tokyo' })).toBe('15 Jun 2025 21:34:56')
|
||||
})
|
||||
|
||||
it('renders a UTC instant in America/Los_Angeles during DST (UTC-7)', () => {
|
||||
expect(formatDateTime(SUMMER_UTC, { tz: 'America/Los_Angeles' })).toBe('15 Jun 2025 05:34:56')
|
||||
})
|
||||
|
||||
it('renders a UTC instant in America/Los_Angeles outside DST (UTC-8)', () => {
|
||||
expect(formatDateTime(WINTER_UTC, { tz: 'America/Los_Angeles' })).toBe('15 Jan 2025 04:34:56')
|
||||
})
|
||||
|
||||
it('flips the wall-clock day when crossing date boundaries', () => {
|
||||
const lateUtc = '2025-06-15T22:00:00Z'
|
||||
expect(formatDateTime(lateUtc, { tz: 'Asia/Tokyo', format: 'YYYY-MM-DD' })).toBe('2025-06-16')
|
||||
expect(formatDateTime(lateUtc, { tz: 'Pacific/Honolulu', format: 'YYYY-MM-DD' })).toBe(
|
||||
'2025-06-15'
|
||||
)
|
||||
})
|
||||
|
||||
it('respects an explicit format string', () => {
|
||||
expect(formatDateTime(SUMMER_UTC, { tz: 'UTC', format: 'YYYY-MM-DDTHH:mm:ssZ' })).toBe(
|
||||
'2025-06-15T12:34:56+00:00'
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts unix microsecond timestamps', () => {
|
||||
expect(formatDateTime(SUMMER_UNIX_MICRO, { tz: 'UTC' })).toBe('15 Jun 2025 12:34:56')
|
||||
})
|
||||
|
||||
it('accepts Date instances', () => {
|
||||
expect(formatDateTime(new Date(SUMMER_UTC), { tz: 'UTC' })).toBe('15 Jun 2025 12:34:56')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DST transitions in Europe/Berlin', () => {
|
||||
it('shows +01:00 before the spring transition', () => {
|
||||
expect(formatDateTime('2025-03-30T00:00:00Z', { tz: 'Europe/Berlin', format: 'HH:mm Z' })).toBe(
|
||||
'01:00 +01:00'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows +02:00 after the spring transition', () => {
|
||||
expect(formatDateTime('2025-03-30T02:00:00Z', { tz: 'Europe/Berlin', format: 'HH:mm Z' })).toBe(
|
||||
'04:00 +02:00'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDate / formatTime', () => {
|
||||
it('formatDate uses the date-only default', () => {
|
||||
expect(formatDate(SUMMER_UTC, { tz: 'UTC' })).toBe('15 Jun 2025')
|
||||
})
|
||||
|
||||
it('formatTime uses the time-only default', () => {
|
||||
expect(formatTime(SUMMER_UTC, { tz: 'UTC' })).toBe('12:34:56')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTimezone', () => {
|
||||
it('returns the input when it is a valid IANA name', () => {
|
||||
expect(resolveTimezone('Asia/Tokyo')).toBe('Asia/Tokyo')
|
||||
})
|
||||
|
||||
it('falls back to the guessed local timezone when input is empty', () => {
|
||||
expect(resolveTimezone('')).toBeTruthy()
|
||||
expect(resolveTimezone(null)).toBeTruthy()
|
||||
expect(resolveTimezone(undefined)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back when the input is not a real IANA zone', () => {
|
||||
// Should not throw, should resolve to something we can format with.
|
||||
const tz = resolveTimezone('Not/A/Real_Zone')
|
||||
expect(() => formatDateTime(SUMMER_UTC, { tz })).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('toTimezone', () => {
|
||||
it('returns a Dayjs pinned to the given timezone for chained calls', () => {
|
||||
const d = toTimezone(SUMMER_UTC, 'Asia/Tokyo')
|
||||
expect(d.format('HH:mm')).toBe('21:34')
|
||||
expect(d.hour()).toBe(21)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatFromNow', () => {
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2025-06-15T13:34:56Z'))
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('renders ISO inputs as a relative duration', () => {
|
||||
expect(formatFromNow(SUMMER_UTC)).toBe('an hour ago')
|
||||
})
|
||||
|
||||
it('accepts unix microsecond inputs', () => {
|
||||
expect(formatFromNow(SUMMER_UNIX_MICRO)).toBe('an hour ago')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
import { LOCAL_STORAGE_KEYS } from 'common'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, type ReactNode } from 'react'
|
||||
|
||||
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
||||
import { guessLocalTimezone } from '@/lib/dayjs'
|
||||
|
||||
// dayjs.extend is idempotent. Extending here removes the implicit dependency
|
||||
// on _app.tsx running first (e.g. Storybook, isolated scripts).
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
export type DateInput = string | number | Date | Dayjs
|
||||
|
||||
const isUnixMicro = (value: string | number): boolean => {
|
||||
const digits = String(value).length
|
||||
const isNum = !Number.isNaN(Number(value))
|
||||
return isNum && digits === 16
|
||||
}
|
||||
|
||||
const unixMicroToIso = (value: string | number): string =>
|
||||
dayjs.unix(Number(value) / 1_000_000).toISOString()
|
||||
|
||||
const normalize = (input: DateInput): Dayjs => {
|
||||
if (dayjs.isDayjs(input)) return input
|
||||
if (input instanceof Date) return dayjs(input)
|
||||
if ((typeof input === 'string' || typeof input === 'number') && isUnixMicro(input)) {
|
||||
return dayjs.utc(unixMicroToIso(input))
|
||||
}
|
||||
return dayjs.utc(input)
|
||||
}
|
||||
|
||||
const isValidTimezone = (tz: string): boolean => {
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: tz })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user-supplied timezone to a valid IANA name. Falls back to the
|
||||
* browser's guessed timezone, then UTC. Pass `undefined`/empty string to opt
|
||||
* into the guessed default.
|
||||
*/
|
||||
export const resolveTimezone = (tz: string | undefined | null): string => {
|
||||
if (tz && isValidTimezone(tz)) return tz
|
||||
return guessLocalTimezone()
|
||||
}
|
||||
|
||||
const DEFAULT_DATETIME_FORMAT = 'DD MMM YYYY HH:mm:ss'
|
||||
const DEFAULT_DATE_FORMAT = 'DD MMM YYYY'
|
||||
const DEFAULT_TIME_FORMAT = 'HH:mm:ss'
|
||||
|
||||
interface FormatOptions {
|
||||
/** IANA timezone (e.g. 'Asia/Tokyo'). Falls back to guessed local. */
|
||||
tz?: string
|
||||
/** dayjs format string. */
|
||||
format?: string
|
||||
}
|
||||
|
||||
export const formatDateTime = (input: DateInput, opts: FormatOptions = {}): string =>
|
||||
normalize(input)
|
||||
.tz(resolveTimezone(opts.tz))
|
||||
.format(opts.format ?? DEFAULT_DATETIME_FORMAT)
|
||||
|
||||
export const formatDate = (input: DateInput, opts: FormatOptions = {}): string =>
|
||||
normalize(input)
|
||||
.tz(resolveTimezone(opts.tz))
|
||||
.format(opts.format ?? DEFAULT_DATE_FORMAT)
|
||||
|
||||
export const formatTime = (input: DateInput, opts: FormatOptions = {}): string =>
|
||||
normalize(input)
|
||||
.tz(resolveTimezone(opts.tz))
|
||||
.format(opts.format ?? DEFAULT_TIME_FORMAT)
|
||||
|
||||
/** Returns a humanised relative time, e.g. "3 minutes ago". */
|
||||
export const formatFromNow = (input: DateInput): string => normalize(input).fromNow()
|
||||
|
||||
/** Returns the input as a Dayjs instance pinned to the given timezone. */
|
||||
export const toTimezone = (input: DateInput, tz?: string): Dayjs =>
|
||||
normalize(input).tz(resolveTimezone(tz))
|
||||
|
||||
interface TimezoneContextValue {
|
||||
/** The resolved IANA timezone currently in use. Always valid. */
|
||||
timezone: string
|
||||
/** The user's stored preference. Empty string means "use guessed local". */
|
||||
storedTimezone: string
|
||||
/** Update the stored preference. Pass an empty string to clear (use guessed). */
|
||||
setTimezone: (tz: string) => void
|
||||
/** Whether the current selection is the auto-detected default. */
|
||||
isAutoDetected: boolean
|
||||
}
|
||||
|
||||
const TimezoneContext = createContext<TimezoneContextValue | undefined>(undefined)
|
||||
|
||||
export const TimezoneProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [storedTimezone, setStoredTimezone] = useLocalStorageQuery<string>(
|
||||
LOCAL_STORAGE_KEYS.UI_TIMEZONE,
|
||||
''
|
||||
)
|
||||
|
||||
const timezone = useMemo(() => resolveTimezone(storedTimezone), [storedTimezone])
|
||||
|
||||
// Apply the selected timezone as the dayjs default so anything calling
|
||||
// `dayjs.tz()` or `.tz()` without an argument picks it up. Bare `dayjs()`
|
||||
// calls are unaffected by design — those continue to render in the host
|
||||
// browser's timezone until they're intentionally migrated to the wrappers
|
||||
// below.
|
||||
useEffect(() => {
|
||||
dayjs.tz.setDefault(timezone)
|
||||
}, [timezone])
|
||||
|
||||
const setTimezone = useCallback(
|
||||
(tz: string) => {
|
||||
setStoredTimezone(tz)
|
||||
},
|
||||
[setStoredTimezone]
|
||||
)
|
||||
|
||||
const value = useMemo<TimezoneContextValue>(
|
||||
() => ({
|
||||
timezone,
|
||||
storedTimezone,
|
||||
setTimezone,
|
||||
isAutoDetected: !storedTimezone,
|
||||
}),
|
||||
[timezone, storedTimezone, setTimezone]
|
||||
)
|
||||
|
||||
return <TimezoneContext.Provider value={value}>{children}</TimezoneContext.Provider>
|
||||
}
|
||||
|
||||
// Stable fallback so callers outside the provider (e.g. unit tests, isolated
|
||||
// stories) don't get a fresh object identity every render.
|
||||
const NO_OP_SET_TIMEZONE = () => {}
|
||||
|
||||
export const useTimezone = (): TimezoneContextValue => {
|
||||
const ctx = useContext(TimezoneContext)
|
||||
return useMemo<TimezoneContextValue>(
|
||||
() =>
|
||||
ctx ?? {
|
||||
timezone: guessLocalTimezone(),
|
||||
storedTimezone: '',
|
||||
setTimezone: NO_OP_SET_TIMEZONE,
|
||||
isAutoDetected: true,
|
||||
},
|
||||
[ctx]
|
||||
)
|
||||
}
|
||||
|
||||
/** Returns a memoised `(input, format?) => string` bound to the active timezone. */
|
||||
export const useFormatDateTime = () => {
|
||||
const { timezone } = useTimezone()
|
||||
return useCallback(
|
||||
(input: DateInput, format?: string) => formatDateTime(input, { tz: timezone, format }),
|
||||
[timezone]
|
||||
)
|
||||
}
|
||||
|
||||
export const useFormatDate = () => {
|
||||
const { timezone } = useTimezone()
|
||||
return useCallback(
|
||||
(input: DateInput, format?: string) => formatDate(input, { tz: timezone, format }),
|
||||
[timezone]
|
||||
)
|
||||
}
|
||||
|
||||
export const useFormatTime = () => {
|
||||
const { timezone } = useTimezone()
|
||||
return useCallback(
|
||||
(input: DateInput, format?: string) => formatTime(input, { tz: timezone, format }),
|
||||
[timezone]
|
||||
)
|
||||
}
|
||||
|
||||
export const useToTimezone = () => {
|
||||
const { timezone } = useTimezone()
|
||||
return useCallback((input: DateInput) => toTimezone(input, timezone), [timezone])
|
||||
}
|
||||
+63
-52
@@ -39,6 +39,7 @@ import { NuqsAdapter } from 'nuqs/adapters/next/pages'
|
||||
import { ErrorInfo, useCallback, type ComponentProps } from 'react'
|
||||
import { ErrorBoundary } from 'react-error-boundary'
|
||||
import { TooltipProvider } from 'ui'
|
||||
import { TimestampInfoProvider } from 'ui-patterns'
|
||||
|
||||
import { StudioCommandMenu } from '@/components/interfaces/App/CommandMenu'
|
||||
import { StudioCommandProvider as CommandProvider } from '@/components/interfaces/App/CommandMenu/StudioCommandProvider'
|
||||
@@ -57,6 +58,7 @@ import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { AuthProvider } from '@/lib/auth'
|
||||
import { API_URL, BASE_PATH, IS_PLATFORM, useDefaultProvider } from '@/lib/constants'
|
||||
import { TimezoneProvider, useTimezone } from '@/lib/datetime'
|
||||
import { ProfileProvider } from '@/lib/profile'
|
||||
import { Telemetry } from '@/lib/telemetry'
|
||||
import { Toaster } from '@/lib/toaster'
|
||||
@@ -96,6 +98,11 @@ const FeatureFlagProviderWithOrgContext = ({
|
||||
)
|
||||
}
|
||||
|
||||
const TimestampInfoTimezoneBridge = ({ children }: { children: React.ReactNode }) => {
|
||||
const { timezone } = useTimezone()
|
||||
return <TimestampInfoProvider timezone={timezone}>{children}</TimestampInfoProvider>
|
||||
}
|
||||
|
||||
loader.config({
|
||||
// [Joshen] Attempt for offline support/bypass ISP issues is to store the assets required for monaco
|
||||
// locally. We're however, only storing the assets which we need (based on what the network tab loads
|
||||
@@ -159,58 +166,62 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
getConfigCatFlags={getConfigCatFlags}
|
||||
>
|
||||
<ProfileProvider>
|
||||
<Head>
|
||||
<title>{appTitle ?? 'Supabase'}</title>
|
||||
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
|
||||
<meta property="og:image" content={`${BASE_PATH}/img/supabase-logo.png`} />
|
||||
<meta name="googlebot" content="notranslate" />
|
||||
{/* [Alaister]: This has to be an inline style tag here and not a separate component due to next/font */}
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `:root{--font-custom:${customFont.style.fontFamily};--font-source-code-pro:${sourceCodePro.style.fontFamily};}`,
|
||||
}}
|
||||
/>
|
||||
{/* Speed up initial API loading times by pre-connecting to the API domain */}
|
||||
{IS_PLATFORM && (
|
||||
<link
|
||||
rel="preconnect"
|
||||
href={new URL(API_URL).origin}
|
||||
crossOrigin="use-credentials"
|
||||
/>
|
||||
)}
|
||||
</Head>
|
||||
<MetaFaviconsPagesRouter applicationName="Supabase Studio" includeManifest />
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<RouteValidationWrapper>
|
||||
<ThemeProvider>
|
||||
<DevToolbarProvider apiUrl={API_URL}>
|
||||
<AiAssistantStateContextProvider>
|
||||
<CommandProvider>
|
||||
<BannerStackProvider>
|
||||
<FeaturePreviewContextProvider>
|
||||
<MainScrollContainerProvider>
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
</MainScrollContainerProvider>
|
||||
<GlobalShortcuts />
|
||||
<StudioCommandMenu />
|
||||
<FeaturePreviewModal />
|
||||
<UpdateBillingAddressModal />
|
||||
</FeaturePreviewContextProvider>
|
||||
</BannerStackProvider>
|
||||
<Toaster />
|
||||
<MonacoThemeProvider />
|
||||
</CommandProvider>
|
||||
</AiAssistantStateContextProvider>
|
||||
<DevToolbar extraTabs={devToolbarExtraTabs} />
|
||||
<DevToolbarTrigger />
|
||||
</DevToolbarProvider>
|
||||
</ThemeProvider>
|
||||
</RouteValidationWrapper>
|
||||
</TooltipProvider>
|
||||
<Telemetry />
|
||||
{!isTestEnv && (
|
||||
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
|
||||
)}
|
||||
<TimezoneProvider>
|
||||
<TimestampInfoTimezoneBridge>
|
||||
<Head>
|
||||
<title>{appTitle ?? 'Supabase'}</title>
|
||||
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
|
||||
<meta property="og:image" content={`${BASE_PATH}/img/supabase-logo.png`} />
|
||||
<meta name="googlebot" content="notranslate" />
|
||||
{/* [Alaister]: This has to be an inline style tag here and not a separate component due to next/font */}
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `:root{--font-custom:${customFont.style.fontFamily};--font-source-code-pro:${sourceCodePro.style.fontFamily};}`,
|
||||
}}
|
||||
/>
|
||||
{/* Speed up initial API loading times by pre-connecting to the API domain */}
|
||||
{IS_PLATFORM && (
|
||||
<link
|
||||
rel="preconnect"
|
||||
href={new URL(API_URL).origin}
|
||||
crossOrigin="use-credentials"
|
||||
/>
|
||||
)}
|
||||
</Head>
|
||||
<MetaFaviconsPagesRouter applicationName="Supabase Studio" includeManifest />
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<RouteValidationWrapper>
|
||||
<ThemeProvider>
|
||||
<DevToolbarProvider apiUrl={API_URL}>
|
||||
<AiAssistantStateContextProvider>
|
||||
<CommandProvider>
|
||||
<BannerStackProvider>
|
||||
<FeaturePreviewContextProvider>
|
||||
<MainScrollContainerProvider>
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
</MainScrollContainerProvider>
|
||||
<GlobalShortcuts />
|
||||
<StudioCommandMenu />
|
||||
<FeaturePreviewModal />
|
||||
<UpdateBillingAddressModal />
|
||||
</FeaturePreviewContextProvider>
|
||||
</BannerStackProvider>
|
||||
<Toaster />
|
||||
<MonacoThemeProvider />
|
||||
</CommandProvider>
|
||||
</AiAssistantStateContextProvider>
|
||||
<DevToolbar extraTabs={devToolbarExtraTabs} />
|
||||
<DevToolbarTrigger />
|
||||
</DevToolbarProvider>
|
||||
</ThemeProvider>
|
||||
</RouteValidationWrapper>
|
||||
</TooltipProvider>
|
||||
<Telemetry />
|
||||
{!isTestEnv && (
|
||||
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
|
||||
)}
|
||||
</TimestampInfoTimezoneBridge>
|
||||
</TimezoneProvider>
|
||||
</ProfileProvider>
|
||||
</FeatureFlagProviderWithOrgContext>
|
||||
</AuthProvider>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { DashboardSettings } from '@/components/interfaces/Account/Preferences/D
|
||||
import { HotkeySettings } from '@/components/interfaces/Account/Preferences/HotkeySettings'
|
||||
import { ProfileInformation } from '@/components/interfaces/Account/Preferences/ProfileInformation'
|
||||
import { ThemeSettings } from '@/components/interfaces/Account/Preferences/ThemeSettings'
|
||||
import { TimezoneSettings } from '@/components/interfaces/Account/Preferences/TimezoneSettings'
|
||||
import AccountLayout from '@/components/layouts/AccountLayout/AccountLayout'
|
||||
import { AppLayout } from '@/components/layouts/AppLayout/AppLayout'
|
||||
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
|
||||
@@ -95,6 +96,8 @@ const PlatformPreferences = () => {
|
||||
|
||||
<ThemeSettings />
|
||||
|
||||
<TimezoneSettings />
|
||||
|
||||
<HotkeySettings />
|
||||
|
||||
<DashboardSettings />
|
||||
@@ -191,6 +194,8 @@ const SelfHostedPreferences = () => {
|
||||
<PageContainer size="small">
|
||||
<ThemeSettings />
|
||||
|
||||
<TimezoneSettings />
|
||||
|
||||
<HotkeySettings />
|
||||
|
||||
<DashboardSettings />
|
||||
|
||||
@@ -57,6 +57,10 @@ vi.mock('@/components/interfaces/Account/Preferences/ThemeSettings', () => ({
|
||||
ThemeSettings: () => <div>ThemeSettings</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/interfaces/Account/Preferences/TimezoneSettings', () => ({
|
||||
TimezoneSettings: () => <div>TimezoneSettings</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/interfaces/Account/Preferences/HotkeySettings', () => ({
|
||||
HotkeySettings: () => <div>HotkeySettings</div>,
|
||||
}))
|
||||
@@ -103,6 +107,7 @@ describe('/account/me', () => {
|
||||
expect(screen.getByText('AccountIdentities')).toBeInTheDocument()
|
||||
expect(screen.getByText('AccountConnections')).toBeInTheDocument()
|
||||
expect(screen.getByText('ThemeSettings')).toBeInTheDocument()
|
||||
expect(screen.getByText('TimezoneSettings')).toBeInTheDocument()
|
||||
expect(screen.getByText('HotkeySettings')).toBeInTheDocument()
|
||||
expect(screen.getByText('DashboardSettings')).toBeInTheDocument()
|
||||
expect(screen.getByText('AnalyticsSettings')).toBeInTheDocument()
|
||||
@@ -115,6 +120,7 @@ describe('/account/me', () => {
|
||||
render(<PreferencesPage dehydratedState={{}} />)
|
||||
|
||||
expect(screen.getByText('ThemeSettings')).toBeInTheDocument()
|
||||
expect(screen.getByText('TimezoneSettings')).toBeInTheDocument()
|
||||
expect(screen.getByText('HotkeySettings')).toBeInTheDocument()
|
||||
expect(screen.getByText('DashboardSettings')).toBeInTheDocument()
|
||||
expect(screen.queryByText('ProfileInformation')).not.toBeInTheDocument()
|
||||
|
||||
Reference in New Issue
Block a user