diff --git a/app/components/RefetchIntervalPicker.tsx b/app/components/RefetchIntervalPicker.tsx index f874c400a8..200189a737 100644 --- a/app/components/RefetchIntervalPicker.tsx +++ b/app/components/RefetchIntervalPicker.tsx @@ -6,7 +6,6 @@ * Copyright Oxide Computer Company */ import cn from 'classnames' -import { format } from 'date-fns' import { useEffect, useState } from 'react' import { Refresh16Icon, Time16Icon } from '@oxide/design-system/icons/react' @@ -14,6 +13,7 @@ import { Refresh16Icon, Time16Icon } from '@oxide/design-system/icons/react' import { Listbox, type ListboxItem } from '~/ui/lib/Listbox' import { SpinnerLoader } from '~/ui/lib/Spinner' import { useInterval } from '~/ui/lib/use-interval' +import { toLocaleTimeString } from '~/util/date' const intervalPresets = { Off: undefined, @@ -55,7 +55,8 @@ export function useIntervalPicker({ enabled, isLoading, fn }: Props) { intervalPicker: (
- Refreshed {format(lastFetched, 'HH:mm')} + Refreshed{' '} + {toLocaleTimeString(lastFetched)}
) return ( diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index d20c0f928d..ea0ae12d80 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -5,7 +5,6 @@ * * Copyright Oxide Computer Company */ -import { format } from 'date-fns' import { filesize } from 'filesize' import { useMemo } from 'react' import { useController, type Control } from 'react-hook-form' @@ -35,6 +34,7 @@ import { FormDivider } from '~/ui/lib/Divider' import { FieldLabel } from '~/ui/lib/FieldLabel' import { Radio } from '~/ui/lib/Radio' import { RadioGroup } from '~/ui/lib/RadioGroup' +import { toLocaleDateString } from '~/util/date' import { bytesToGiB, GiB } from '~/util/units' const blankDiskSource: DiskSource = { @@ -258,7 +258,7 @@ const SnapshotSelectField = ({ control }: { control: Control }) => { <>
{i.name}
- Created on {format(i.timeCreated, 'MMM d, yyyy')} + Created on {toLocaleDateString(i.timeCreated)} {' '} /{' '} {formattedSize.value} {formattedSize.unit} diff --git a/app/forms/idp/edit.tsx b/app/forms/idp/edit.tsx index 0ab889d0f0..dea56cd58f 100644 --- a/app/forms/idp/edit.tsx +++ b/app/forms/idp/edit.tsx @@ -15,10 +15,10 @@ import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' import { getIdpSelector, useForm, useIdpSelector } from '~/hooks' +import { DateTime } from '~/ui/lib/DateTime' import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { ResourceLabel } from '~/ui/lib/SideModal' import { Truncate } from '~/ui/lib/Truncate' -import { formatDateTime } from '~/util/date' import { pb } from '~/util/path-builder' EditIdpSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { @@ -62,10 +62,10 @@ export function EditIdpSideModalForm() { - {formatDateTime(idp.timeCreated)} + - {formatDateTime(idp.timeModified)} + diff --git a/app/forms/image-edit.tsx b/app/forms/image-edit.tsx index 1871960436..85bf2ba275 100644 --- a/app/forms/image-edit.tsx +++ b/app/forms/image-edit.tsx @@ -21,10 +21,10 @@ import { useProjectImageSelector, useSiloImageSelector, } from '~/hooks' +import { DateTime } from '~/ui/lib/DateTime' import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { ResourceLabel } from '~/ui/lib/SideModal' import { Truncate } from '~/ui/lib/Truncate' -import { formatDateTime } from '~/util/date' import { pb } from '~/util/path-builder' import { bytesToGiB } from '~/util/units' @@ -94,10 +94,10 @@ export function EditImageSideModalForm({ GiB - {formatDateTime(image.timeCreated)} + - {formatDateTime(image.timeModified)} + diff --git a/app/pages/project/instances/instance/InstancePage.tsx b/app/pages/project/instances/instance/InstancePage.tsx index 1d5a36fe8d..51e4082621 100644 --- a/app/pages/project/instances/instance/InstancePage.tsx +++ b/app/pages/project/instances/instance/InstancePage.tsx @@ -5,7 +5,6 @@ * * Copyright Oxide Computer Company */ -import { format } from 'date-fns' import { filesize } from 'filesize' import { useMemo } from 'react' import { Link, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' @@ -25,6 +24,7 @@ import { RouteTabs, Tab } from '~/components/RouteTabs' import { InstanceStatusBadge } from '~/components/StatusBadge' import { getInstanceSelector, useInstanceSelector, useQuickActions } from '~/hooks' import { EmptyCell } from '~/table/cells/EmptyCell' +import { DateTime } from '~/ui/lib/DateTime' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { Truncate } from '~/ui/lib/Truncate' @@ -172,12 +172,7 @@ export function InstancePage() { - - {format(instance.timeCreated, 'MMM d, yyyy')}{' '} - - - {format(instance.timeCreated, 'p')} - + diff --git a/app/pages/project/vpcs/VpcPage/VpcPage.tsx b/app/pages/project/vpcs/VpcPage/VpcPage.tsx index 864a0098c7..f8ffdcb21d 100644 --- a/app/pages/project/vpcs/VpcPage/VpcPage.tsx +++ b/app/pages/project/vpcs/VpcPage/VpcPage.tsx @@ -13,10 +13,10 @@ import { Networking24Icon } from '@oxide/design-system/icons/react' import { QueryParamTabs } from '~/components/QueryParamTabs' import { getVpcSelector, useVpcSelector } from '~/hooks' import { EmptyCell } from '~/table/cells/EmptyCell' +import { DateTime } from '~/ui/lib/DateTime' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { Tabs } from '~/ui/lib/Tabs' -import { formatDateTime } from '~/util/date' import { VpcFirewallRulesTab } from './tabs/VpcFirewallRulesTab' import { VpcSubnetsTab } from './tabs/VpcSubnetsTab' @@ -56,10 +56,10 @@ export function VpcPage() { - {formatDateTime(vpc.timeCreated)} + - {formatDateTime(vpc.timeModified)} + diff --git a/app/pages/system/silos/SiloPage.tsx b/app/pages/system/silos/SiloPage.tsx index 91c8f2f9f8..5142e81b41 100644 --- a/app/pages/system/silos/SiloPage.tsx +++ b/app/pages/system/silos/SiloPage.tsx @@ -14,12 +14,12 @@ import { QueryParamTabs } from '~/components/QueryParamTabs' import { getSiloSelector, useSiloSelector } from '~/hooks' import { EmptyCell } from '~/table/cells/EmptyCell' import { Badge } from '~/ui/lib/Badge' +import { DateTime } from '~/ui/lib/DateTime' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { TableEmptyBox } from '~/ui/lib/Table' import { Tabs } from '~/ui/lib/Tabs' -import { formatDateTime } from '~/util/date' import { SiloIdpsTab } from './SiloIdpsTab' import { SiloIpPoolsTab } from './SiloIpPoolsTab' @@ -64,10 +64,10 @@ export function SiloPage() { - {formatDateTime(silo.timeCreated)} + - {formatDateTime(silo.timeModified)} + diff --git a/app/table/columns/common.tsx b/app/table/columns/common.tsx index 727268463b..c8de914199 100644 --- a/app/table/columns/common.tsx +++ b/app/table/columns/common.tsx @@ -6,21 +6,19 @@ * Copyright Oxide Computer Company */ -import { format } from 'date-fns/format' import { filesize } from 'filesize' +import { DateTime } from '~/ui/lib/DateTime' import { Truncate } from '~/ui/lib/Truncate' import { EmptyCell } from '../cells/EmptyCell' -import { TwoLineCell } from '../cells/TwoLineCell' // the full type of the info arg is CellContext from RT, but in these // cells we only care about the return value of getValue type Info = { getValue: () => T } function dateCell(info: Info) { - const date = info.getValue() - return + return } function sizeCell(info: Info) { diff --git a/app/ui/lib/DateTime.tsx b/app/ui/lib/DateTime.tsx new file mode 100644 index 0000000000..828d1bf1ed --- /dev/null +++ b/app/ui/lib/DateTime.tsx @@ -0,0 +1,16 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { toLocaleDateString, toLocaleTimeString } from '~/util/date' + +export const DateTime = ({ date, locale }: { date: Date; locale?: string }) => ( + +) diff --git a/app/util/date.spec.ts b/app/util/date.spec.ts index d59ea38e11..63382e082a 100644 --- a/app/util/date.spec.ts +++ b/app/util/date.spec.ts @@ -8,7 +8,12 @@ import { subDays, subHours, subMinutes, subSeconds } from 'date-fns' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { timeAgoAbbr } from './date' +import { + timeAgoAbbr, + toLocaleDateString, + toLocaleDateTimeString, + toLocaleTimeString, +} from './date' const baseDate = new Date(2021, 5, 7) @@ -54,4 +59,28 @@ describe('timeAgoAbbr', () => { expect(timeAgoAbbr(subDays(baseDate, 200), { addSuffix: true })).toEqual('7mo ago') expect(timeAgoAbbr(subDays(baseDate, 3), { addSuffix: true })).toEqual('3d ago') }) + + it('formats toLocaleDateString', () => { + expect(toLocaleDateString(baseDate)).toEqual('Jun 7, 2021') + expect(toLocaleDateString(baseDate, 'en-US')).toEqual('Jun 7, 2021') + expect(toLocaleDateString(baseDate, 'fr-FR')).toEqual('7 juin 2021') + expect(toLocaleDateString(baseDate, 'de-DE')).toEqual('07.06.2021') + expect(toLocaleDateString(baseDate, 'ja-JP')).toEqual('2021/06/07') + }) + + it('formats toLocaleTimeString', () => { + expect(toLocaleTimeString(baseDate)).toEqual('12:00 AM') + expect(toLocaleTimeString(baseDate, 'en-US')).toEqual('12:00 AM') + expect(toLocaleTimeString(baseDate, 'fr-FR')).toEqual('00:00') + expect(toLocaleTimeString(baseDate, 'de-DE')).toEqual('00:00') + expect(toLocaleTimeString(baseDate, 'ja-JP')).toEqual('0:00') + }) + + it('formats toLocaleDateTimeString', () => { + expect(toLocaleDateTimeString(baseDate)).toEqual('Jun 7, 2021, 12:00 AM') + expect(toLocaleDateTimeString(baseDate, 'en-US')).toEqual('Jun 7, 2021, 12:00 AM') + expect(toLocaleDateTimeString(baseDate, 'fr-FR')).toEqual('7 juin 2021, 00:00') + expect(toLocaleDateTimeString(baseDate, 'de-DE')).toEqual('07.06.2021, 00:00') + expect(toLocaleDateTimeString(baseDate, 'ja-JP')).toEqual('2021/06/07 0:00') + }) }) diff --git a/app/util/date.ts b/app/util/date.ts index 8715641522..9f504267df 100644 --- a/app/util/date.ts +++ b/app/util/date.ts @@ -5,11 +5,7 @@ * * Copyright Oxide Computer Company */ -import { - format, - formatDistanceToNowStrict, - type FormatDistanceToNowStrictOptions, -} from 'date-fns' +import { formatDistanceToNowStrict, type FormatDistanceToNowStrictOptions } from 'date-fns' // locale setup and formatDistance function copied from here and modified // https://github.com/date-fns/date-fns/blob/56a3856/src/locale/en-US/_lib/formatDistance/index.js @@ -47,4 +43,13 @@ export const timeAgoAbbr = (d: Date, options?: FormatDistanceToNowStrictOptions) }, }) -export const formatDateTime = (d: Date) => format(d, 'MMM d, yyyy H:mm aa') +// dateStyle: 'medium' looks like `Apr 16, 2024` for en-US +export const toLocaleDateString = (d: Date, locale?: string) => + new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(d) + +// timeStyle: 'short' looks like `8:33 PM` for en-US +export const toLocaleTimeString = (d: Date, locale?: string) => + new Intl.DateTimeFormat(locale, { timeStyle: 'short' }).format(d) + +export const toLocaleDateTimeString = (d: Date, locale?: string) => + new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(d) diff --git a/test/e2e/dates.e2e.ts b/test/e2e/dates.e2e.ts new file mode 100644 index 0000000000..df5cd7b405 --- /dev/null +++ b/test/e2e/dates.e2e.ts @@ -0,0 +1,29 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { expect, test } from '@playwright/test' + +test('date formatting - English locale', async ({ page }) => { + await page.goto('/system/silos') + await expect(page.getByText('Feb 28, 202312:00 AM')).toBeVisible() +}) + +test.describe('date formatting - German locale', () => { + test.use({ locale: 'de-DE' }) + test('date formatting - German locale', async ({ page }) => { + await page.goto('/system/silos') + await expect(page.getByText('28.02.202300:00')).toBeVisible() + }) +}) + +test.describe('date formatting - French locale', () => { + test.use({ locale: 'fr-FR' }) + test('date formatting - French locale', async ({ page }) => { + await page.goto('/system/silos') + await expect(page.getByText('28 févr. 202300:00')).toBeVisible() + }) +}) diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index af733c3afa..9d08ef2e8a 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -28,6 +28,7 @@ test('Create silo', async ({ page }) => { // not easy to assert this until we can calculate accessible name instead of text content // discoverable: 'true', }) + await expect(page.getByText('Feb 28, 202312:00 AM')).toBeVisible() await page.click('role=link[name="New silo"]') diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts index 2a0d0a1484..17527f38f7 100644 --- a/test/e2e/vpcs.e2e.ts +++ b/test/e2e/vpcs.e2e.ts @@ -12,6 +12,7 @@ test('can nav to VpcPage from /', async ({ page }) => { await page.getByRole('table').getByRole('link', { name: 'mock-project' }).click() await page.getByRole('link', { name: 'VPCs' }).click() await page.getByRole('link', { name: 'mock-vpc' }).click() + await expect(page.getByText('Jan 1, 202112:00 AM')).toBeVisible() await expect(page.getByRole('tab', { name: 'Firewall rules' })).toBeVisible() await expect(page.getByRole('cell', { name: 'allow-icmp' })).toBeVisible() expect(await page.title()).toEqual('mock-vpc / VPCs / mock-project / Oxide Console')