Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(releases): timezone selection updates filtering and dateTime display on release overview #7854

Merged
merged 16 commits into from
Nov 21, 2024
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
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -95,8 +99,8 @@ const ReleaseCalendarDay: CalendarProps['renderCalendarDay'] = (props) => {

return (
release.metadata.releaseType === 'scheduled' &&
publishDateUTC >= startOfDayUTC &&
publishDateUTC <= endOfDayUTC
publishDateUTC >= startOfDayForTimeZone &&
publishDateUTC <= endOfDayForTimeZone
)
})

Expand Down Expand Up @@ -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<TableRowProps> =>
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 (
<Flex direction="row" flex={1} style={{height: '100%'}}>
Expand Down Expand Up @@ -347,7 +368,9 @@ export function ReleasesOverview() {
mode="bleed"
padding={2}
text={`${timeZone.abbreviation} (${timeZone.namePretty})`}
onClick={dialogTimeZoneShow}
/>
{DialogTimeZone && <DialogTimeZone {...dialogProps} />}
{loadingOrHasReleases && createReleaseButton}
</Flex>
</Flex>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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')
}
Expand All @@ -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 (
<Text muted size={1}>
{getTimeString()}
{timeString}
</Text>
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -30,7 +37,12 @@ import {type ReleasesMetadata} from '../../../store/useReleasesMetadata'
import {useBundleDocumentsMockReturn} 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',
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -132,7 +150,10 @@ describe('ReleasesOverview', () => {
const releases: ReleaseDocument[] = [
{
...activeScheduledRelease,
metadata: {...activeScheduledRelease.metadata, intendedPublishAt: TODAY.toISOString()},
metadata: {
...activeScheduledRelease.metadata,
intendedPublishAt: TODAY.toISOString(),
},
},
activeASAPRelease,
activeUndecidedRelease,
Expand All @@ -142,6 +163,7 @@ describe('ReleasesOverview', () => {
let activeRender: ReturnType<typeof render>

beforeEach(async () => {
mockUseTimeZone.mockRestore()
mockUseReleases.mockReturnValue({
...useReleasesMockReturn,
archivedReleases: [archivedScheduledRelease, publishedASAPRelease],
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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(<ReleasesOverview />)

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(<ReleasesOverview />)
})

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'))
Expand Down
Original file line number Diff line number Diff line change
@@ -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 :)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ayyy 8)

namePretty: 'Sanity/Oslo',
offset: '+00:00',
name: 'SCT',
alternativeName: 'Sanity/Oslo',
mainCities: 'Oslo',
value: 'SCT',
}

// default export
export const useTimeZoneMockReturn: Mocked<ReturnType<typeof useTimeZone>> = {
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<ReturnType<typeof getLocalTimeZone>> = mockTimeZone

// default export
export const mockUseTimeZone = useTimeZone as Mock<typeof useTimeZone>

export const mockGetLocaleTimeZone = getLocalTimeZone as Mock<typeof getLocalTimeZone>
Loading