From 7a6ff5f467c68ec2fa9497cdebaba0ffc0c7ea66 Mon Sep 17 00:00:00 2001 From: Jordan Lawrence Date: Thu, 21 Nov 2024 16:49:28 +0000 Subject: [PATCH] feat(releases): timezone selection updates filtering and dateTime display on release overview (#7854) --- .../tool/overview/ReleasesOverview.tsx | 57 ++++++++---- .../overview/ReleasesOverviewColumnDefs.tsx | 22 ++++- .../__tests__/ReleasesOverview.test.tsx | 92 ++++++++++++++++++- .../__tests__/__mocks__/useTimeZone.mock.ts | 31 +++++++ 4 files changed, 178 insertions(+), 24 deletions(-) create mode 100644 packages/sanity/src/core/scheduledPublishing/hooks/__tests__/__mocks__/useTimeZone.mock.ts diff --git a/packages/sanity/src/core/releases/tool/overview/ReleasesOverview.tsx b/packages/sanity/src/core/releases/tool/overview/ReleasesOverview.tsx index 9e0aa86d57e..ad6f451d723 100644 --- a/packages/sanity/src/core/releases/tool/overview/ReleasesOverview.tsx +++ b/packages/sanity/src/core/releases/tool/overview/ReleasesOverview.tsx @@ -1,7 +1,6 @@ import {AddIcon, ChevronDownIcon, CloseIcon, EarthGlobeIcon} from '@sanity/icons' import {Box, type ButtonMode, Card, Container, Flex, Stack, Text} from '@sanity/ui' import {endOfDay, format, isSameDay, startOfDay} from 'date-fns' -import {zonedTimeToUtc} from 'date-fns-tz' import {AnimatePresence, motion} from 'framer-motion' import {type MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState} from 'react' import {type RouterContextValue, type SearchParam, useRouter} from 'sanity/router' @@ -13,6 +12,7 @@ import { type CalendarProps, } from '../../../../ui-components/inputs/DateFilters/calendar/CalendarFilter' import {useTranslation} from '../../../i18n' +import useDialogTimeZone from '../../../scheduledPublishing/hooks/useDialogTimeZone' import useTimeZone from '../../../scheduledPublishing/hooks/useTimeZone' import {CreateReleaseDialog} from '../../components/dialog/CreateReleaseDialog' import {usePerspective} from '../../hooks/usePerspective' @@ -74,18 +74,22 @@ export interface TableRelease extends ReleaseDocument { isDeleted?: boolean } -// TODO: use the selected timezone rather than client -const getTimezoneAdjustedDateTimeRange = (date: Date) => { - const {timeZone} = Intl.DateTimeFormat().resolvedOptions() +const useTimezoneAdjustedDateTimeRange = () => { + const {zoneDateToUtc} = useTimeZone() - return [startOfDay(date), endOfDay(date)].map((time) => zonedTimeToUtc(time, timeZone)) + return useCallback( + (date: Date) => [startOfDay(date), endOfDay(date)].map(zoneDateToUtc), + [zoneDateToUtc], + ) } const ReleaseCalendarDay: CalendarProps['renderCalendarDay'] = (props) => { const {data: releases} = useReleases() + const getTimezoneAdjustedDateTimeRange = useTimezoneAdjustedDateTimeRange() + const {date} = props - const [startOfDayUTC, endOfDayUTC] = getTimezoneAdjustedDateTimeRange(date) + const [startOfDayForTimeZone, endOfDayForTimeZone] = getTimezoneAdjustedDateTimeRange(date) const dayHasReleases = releases?.some((release) => { const releasePublishAt = release.publishAt || release.metadata.intendedPublishAt @@ -95,8 +99,8 @@ const ReleaseCalendarDay: CalendarProps['renderCalendarDay'] = (props) => { return ( release.metadata.releaseType === 'scheduled' && - publishDateUTC >= startOfDayUTC && - publishDateUTC <= endOfDayUTC + publishDateUTC >= startOfDayForTimeZone && + publishDateUTC <= endOfDayForTimeZone ) }) @@ -135,8 +139,10 @@ export function ReleasesOverview() { const loadingTableData = loading || (!releasesMetadata && Boolean(releaseIds.length)) const {t} = useTranslation(releasesLocaleNamespace) const {t: tCore} = useTranslation() - const {timeZone} = useTimeZone() + const {timeZone, utcToCurrentZoneDate} = useTimeZone() const {currentGlobalBundleId} = usePerspective() + const {DialogTimeZone, dialogProps, dialogTimeZoneShow} = useDialogTimeZone() + const getTimezoneAdjustedDateTimeRange = useTimezoneAdjustedDateTimeRange() const getRowProps = useCallback( (datum: TableRelease): Partial => @@ -179,11 +185,19 @@ export function ReleasesOverview() { [], ) - const handleSelectFilterDate = useCallback((date?: Date) => { - setReleaseFilterDate((prevFilterDate) => - prevFilterDate && date && isSameDay(prevFilterDate, date) ? undefined : date, - ) - }, []) + const handleSelectFilterDate = useCallback( + (date?: Date) => + setReleaseFilterDate((prevFilterDate) => { + if (!date) return undefined + + const timeZoneAdjustedDate = utcToCurrentZoneDate(date) + + return prevFilterDate && isSameDay(prevFilterDate, timeZoneAdjustedDate) + ? undefined + : timeZoneAdjustedDate + }), + [utcToCurrentZoneDate], + ) const clearFilterDate = useCallback(() => { setReleaseFilterDate(undefined) @@ -301,15 +315,22 @@ export function ReleasesOverview() { const filteredReleases = useMemo(() => { if (!releaseFilterDate) return releaseGroupMode === 'open' ? tableReleases : archivedReleases - const [startOfDayUTC, endOfDayUTC] = getTimezoneAdjustedDateTimeRange(releaseFilterDate) + const [startOfDayForTimeZone, endOfDayForTimeZone] = + getTimezoneAdjustedDateTimeRange(releaseFilterDate) return tableReleases.filter((release) => { if (!release.publishAt || release.metadata.releaseType !== 'scheduled') return false const publishDateUTC = new Date(release.publishAt) - return publishDateUTC >= startOfDayUTC && publishDateUTC <= endOfDayUTC + return publishDateUTC >= startOfDayForTimeZone && publishDateUTC <= endOfDayForTimeZone }) - }, [releaseFilterDate, releaseGroupMode, tableReleases, archivedReleases]) + }, [ + releaseFilterDate, + releaseGroupMode, + tableReleases, + archivedReleases, + getTimezoneAdjustedDateTimeRange, + ]) return ( @@ -347,7 +368,9 @@ export function ReleasesOverview() { mode="bleed" padding={2} text={`${timeZone.abbreviation} (${timeZone.namePretty})`} + onClick={dialogTimeZoneShow} /> + {DialogTimeZone && } {loadingOrHasReleases && createReleaseButton} diff --git a/packages/sanity/src/core/releases/tool/overview/ReleasesOverviewColumnDefs.tsx b/packages/sanity/src/core/releases/tool/overview/ReleasesOverviewColumnDefs.tsx index 5ac8142128a..ab86219a426 100644 --- a/packages/sanity/src/core/releases/tool/overview/ReleasesOverviewColumnDefs.tsx +++ b/packages/sanity/src/core/releases/tool/overview/ReleasesOverviewColumnDefs.tsx @@ -2,12 +2,13 @@ import {LockIcon, PinFilledIcon, PinIcon} from '@sanity/icons' import {Box, Card, Flex, Stack, Text} from '@sanity/ui' import {format} from 'date-fns' import {type TFunction} from 'i18next' -import {useCallback} from 'react' +import {useCallback, useMemo} from 'react' import {useRouter} from 'sanity/router' import {Button, Tooltip} from '../../../../ui-components' import {RelativeTime} from '../../../components' import {Translate, useTranslation} from '../../../i18n' +import useTimeZone, {getLocalTimeZone} from '../../../scheduledPublishing/hooks/useTimeZone' import {ReleaseAvatar} from '../../components/ReleaseAvatar' import {usePerspective} from '../../hooks/usePerspective' import {releasesLocaleNamespace} from '../../i18n' @@ -22,9 +23,18 @@ import {type TableRelease} from './ReleasesOverview' const ReleaseTime = ({release}: {release: TableRelease}) => { const {t} = useTranslation() + const {timeZone, utcToCurrentZoneDate} = useTimeZone() + const {abbreviation: localeTimeZoneAbbreviation} = getLocalTimeZone() + const {metadata} = release - const getTimeString = () => { + const getTimezoneAbbreviation = useCallback( + () => + timeZone.abbreviation === localeTimeZoneAbbreviation ? '' : `(${timeZone.abbreviation})`, + [localeTimeZoneAbbreviation, timeZone.abbreviation], + ) + + const timeString = useMemo(() => { if (metadata.releaseType === 'asap') { return t('release.type.asap') } @@ -34,12 +44,14 @@ const ReleaseTime = ({release}: {release: TableRelease}) => { const publishDate = getPublishDateFromRelease(release) - return publishDate ? format(new Date(publishDate), 'PPpp') : null - } + return publishDate + ? `${format(utcToCurrentZoneDate(publishDate), 'PPpp')} ${getTimezoneAbbreviation()}` + : null + }, [metadata.releaseType, release, utcToCurrentZoneDate, getTimezoneAbbreviation, t]) return ( - {getTimeString()} + {timeString} ) } diff --git a/packages/sanity/src/core/releases/tool/overview/__tests__/ReleasesOverview.test.tsx b/packages/sanity/src/core/releases/tool/overview/__tests__/ReleasesOverview.test.tsx index 33339a26d84..fd621089114 100644 --- a/packages/sanity/src/core/releases/tool/overview/__tests__/ReleasesOverview.test.tsx +++ b/packages/sanity/src/core/releases/tool/overview/__tests__/ReleasesOverview.test.tsx @@ -1,9 +1,16 @@ import {act, fireEvent, render, screen, waitFor, within} from '@testing-library/react' +import {format, set} from 'date-fns' import {useRouter} from 'sanity/router' import {beforeEach, describe, expect, it, vi} from 'vitest' import {getByDataUi, queryByDataUi} from '../../../../../../test/setup/customQueries' import {createTestProvider} from '../../../../../../test/testUtils/TestProvider' +import { + getLocalTimeZoneMockReturn, + mockGetLocaleTimeZone, + mockUseTimeZone, + useTimeZoneMockReturn, +} from '../../../../scheduledPublishing/hooks/__tests__/__mocks__/useTimeZone.mock' import { activeASAPRelease, activeScheduledRelease, @@ -30,7 +37,12 @@ import {type ReleasesMetadata} from '../../../store/useReleasesMetadata' import {useBundleDocumentsMockReturnWithResults} from '../../detail/__tests__/__mocks__/useBundleDocuments.mock' import {ReleasesOverview} from '../ReleasesOverview' -const TODAY = new Date() +const TODAY = set(new Date(), { + hours: 22, + minutes: 0, + seconds: 0, + milliseconds: 0, +}) vi.mock('sanity', () => ({ SANITY_VERSION: '0.0.0', @@ -59,6 +71,12 @@ vi.mock('../../../hooks/usePerspective', () => ({ usePerspective: vi.fn(() => usePerspectiveMockReturn), })) +vi.mock('../../../../scheduledPublishing/hooks/useTimeZone', async (importOriginal) => ({ + ...(await importOriginal()), + getLocalTimeZone: vi.fn(() => getLocalTimeZoneMockReturn), + default: vi.fn(() => useTimeZoneMockReturn), +})) + describe('ReleasesOverview', () => { beforeEach(() => { mockUseReleases.mockRestore() @@ -132,7 +150,10 @@ describe('ReleasesOverview', () => { const releases: ReleaseDocument[] = [ { ...activeScheduledRelease, - metadata: {...activeScheduledRelease.metadata, intendedPublishAt: TODAY.toISOString()}, + metadata: { + ...activeScheduledRelease.metadata, + intendedPublishAt: TODAY.toISOString(), + }, }, activeASAPRelease, activeUndecidedRelease, @@ -142,6 +163,7 @@ describe('ReleasesOverview', () => { let activeRender: ReturnType beforeEach(async () => { + mockUseTimeZone.mockRestore() mockUseReleases.mockReturnValue({ ...useReleasesMockReturn, archivedReleases: [archivedScheduledRelease, publishedASAPRelease], @@ -191,6 +213,13 @@ describe('ReleasesOverview', () => { within(asapReleaseRow).getByText('Undecided') }) + it('shows time for scheduled releases', () => { + const scheduledReleaseRow = screen.getAllByTestId('table-row')[2] + + const date = format(TODAY, 'MMM d, yyyy') + within(scheduledReleaseRow).getByText(`${date}, 10:00:00 PM`) + }) + it('has release menu actions for each release', () => { const releaseRows = screen.getAllByTestId('table-row') releaseRows.forEach((row) => { @@ -283,6 +312,65 @@ describe('ReleasesOverview', () => { }) }) + describe('timezone selection', () => { + it('shows the selected timezone', () => { + screen.getByText('SCT (Sanity/Oslo)') + }) + + it('opens the timezone selector', () => { + fireEvent.click(screen.getByText('SCT (Sanity/Oslo)')) + + within(getByDataUi(document.body, 'DialogCard')).getByText('Select time zone') + }) + + it('shows dates with timezone abbreviation when it is not the locale', () => { + mockGetLocaleTimeZone.mockReturnValue({ + abbreviation: 'NST', // Not Sanity Time + namePretty: 'Not Sanity Time', + offset: '+00:00', + name: 'NST', + alternativeName: 'Not Sanity Time', + mainCities: 'Not Sanity City', + value: 'Not Sanity Time', + }) + + activeRender.rerender() + + const scheduledReleaseRow = screen.getAllByTestId('table-row')[2] + + const date = format(TODAY, 'MMM d, yyyy') + within(scheduledReleaseRow).getByText(`${date}, 10:00:00 PM (SCT)`) + }) + + describe('when a different timezone is selected', () => { + beforeEach(() => { + mockUseTimeZone.mockReturnValue({ + ...useTimeZoneMockReturn, + // spoof a timezone that is 8 hours ahead of UTC + zoneDateToUtc: vi.fn((date) => set(date, {hours: new Date(date).getHours() - 8})), + }) + + activeRender.rerender() + }) + + it('shows today as having no releases', () => { + const todayTile = within(getByDataUi(document.body, 'Calendar')).getByText( + TODAY.getDate(), + ) + expect(todayTile.parentNode).not.toHaveStyle('font-weight: 700') + }) + + it('shows no releases when filtered by today', () => { + const todayTile = within(getByDataUi(document.body, 'Calendar')).getByText( + TODAY.getDate(), + ) + fireEvent.click(todayTile) + + expect(screen.queryAllByTestId('table-row')).toHaveLength(0) + }) + }) + }) + describe('archived releases', () => { beforeEach(() => { fireEvent.click(screen.getByText('Archived')) diff --git a/packages/sanity/src/core/scheduledPublishing/hooks/__tests__/__mocks__/useTimeZone.mock.ts b/packages/sanity/src/core/scheduledPublishing/hooks/__tests__/__mocks__/useTimeZone.mock.ts new file mode 100644 index 00000000000..97b7cb64ff5 --- /dev/null +++ b/packages/sanity/src/core/scheduledPublishing/hooks/__tests__/__mocks__/useTimeZone.mock.ts @@ -0,0 +1,31 @@ +import {type Mock, type Mocked, vi} from 'vitest' + +import {type NormalizedTimeZone} from '../../../types' +import useTimeZone, {getLocalTimeZone} from '../../useTimeZone' + +const mockTimeZone: NormalizedTimeZone = { + abbreviation: 'SCT', // Sanity Central Time :) + namePretty: 'Sanity/Oslo', + offset: '+00:00', + name: 'SCT', + alternativeName: 'Sanity/Oslo', + mainCities: 'Oslo', + value: 'SCT', +} + +// default export +export const useTimeZoneMockReturn: Mocked> = { + zoneDateToUtc: vi.fn((date) => date), + utcToCurrentZoneDate: vi.fn((date) => date), + getCurrentZoneDate: vi.fn(() => new Date()), + timeZone: mockTimeZone, + setTimeZone: vi.fn(), + formatDateTz: vi.fn(), +} + +export const getLocalTimeZoneMockReturn: Mocked> = mockTimeZone + +// default export +export const mockUseTimeZone = useTimeZone as Mock + +export const mockGetLocaleTimeZone = getLocalTimeZone as Mock