diff --git a/src/components/ControlPlane/FluxList.tsx b/src/components/ControlPlane/FluxList.tsx index ea9a9287..d9865446 100644 --- a/src/components/ControlPlane/FluxList.tsx +++ b/src/components/ControlPlane/FluxList.tsx @@ -5,7 +5,7 @@ import { useApiResource } from '../../lib/api/useApiResource'; import { FluxRequest } from '../../lib/api/types/flux/listGitRepo'; import { FluxKustomization, KustomizationsResponse } from '../../lib/api/types/flux/listKustomization'; import { useTranslation } from 'react-i18next'; -import { timeAgo } from '../../utils/i18n/timeAgo.ts'; +import { formatDateAsTimeAgo } from '../../utils/i18n/timeAgo.ts'; import { YamlViewButton } from '../Yaml/YamlViewButton.tsx'; import { useMemo } from 'react'; @@ -148,7 +148,7 @@ export default function FluxList() { isReady: readyObject?.status === 'True', statusUpdateTime: readyObject?.lastTransitionTime, revision: shortenCommitHash(item.status.artifact?.revision ?? '-'), - created: timeAgo.format(new Date(item.metadata.creationTimestamp)), + created: formatDateAsTimeAgo(item.metadata.creationTimestamp), item: item, readyMessage: readyObject?.message ?? readyObject?.reason ?? '', }; @@ -161,7 +161,7 @@ export default function FluxList() { name: item.metadata.name, isReady: readyObject?.status === 'True', statusUpdateTime: readyObject?.lastTransitionTime, - created: timeAgo.format(new Date(item.metadata.creationTimestamp)), + created: formatDateAsTimeAgo(item.metadata.creationTimestamp), item: item, readyMessage: readyObject?.message ?? readyObject?.reason ?? '', }; diff --git a/src/components/ControlPlane/ManagedResources.tsx b/src/components/ControlPlane/ManagedResources.tsx index 2763cc58..fbae6506 100644 --- a/src/components/ControlPlane/ManagedResources.tsx +++ b/src/components/ControlPlane/ManagedResources.tsx @@ -7,7 +7,7 @@ import { } from '@ui5/webcomponents-react'; import { useApiResource } from '../../lib/api/useApiResource'; import { ManagedResourcesRequest } from '../../lib/api/types/crossplane/listManagedResources'; -import { timeAgo } from '../../utils/i18n/timeAgo'; +import { formatDateAsTimeAgo } from '../../utils/i18n/timeAgo'; import IllustratedError from '../Shared/IllustratedError'; import '@ui5/webcomponents-icons/dist/sys-enter-2'; import '@ui5/webcomponents-icons/dist/sys-cancel-2'; @@ -125,7 +125,7 @@ export function ManagedResources() { return { kind: item.kind, name: item.metadata.name, - created: timeAgo.format(new Date(item.metadata.creationTimestamp)), + created: formatDateAsTimeAgo(item.metadata.creationTimestamp), synced: conditionSynced?.status === 'True', syncedTransitionTime: conditionSynced?.lastTransitionTime ?? '', ready: conditionReady?.status === 'True', diff --git a/src/components/ControlPlane/Providers.tsx b/src/components/ControlPlane/Providers.tsx index b758d619..11dc4393 100644 --- a/src/components/ControlPlane/Providers.tsx +++ b/src/components/ControlPlane/Providers.tsx @@ -11,7 +11,7 @@ import { useApiResource } from '../../lib/api/useApiResource'; import IllustratedError from '../Shared/IllustratedError'; import { ProvidersListRequest } from '../../lib/api/types/crossplane/listProviders'; import { resourcesInterval } from '../../lib/shared/constants'; -import { timeAgo } from '../../utils/i18n/timeAgo'; +import { formatDateAsTimeAgo } from '../../utils/i18n/timeAgo'; import { YamlViewButton } from '../Yaml/YamlViewButton.tsx'; @@ -123,7 +123,7 @@ export function Providers() { const healthy = item.status?.conditions?.find((condition) => condition.type === 'Healthy'); return { name: item.metadata.name, - created: timeAgo.format(new Date(item.metadata.creationTimestamp)), + created: formatDateAsTimeAgo(item.metadata.creationTimestamp), installed: installed?.status === 'True' ? 'true' : 'false', installedTransitionTime: installed?.lastTransitionTime ?? '', healthy: healthy?.status === 'True' ? 'true' : 'false', diff --git a/src/components/ControlPlane/ProvidersConfig.tsx b/src/components/ControlPlane/ProvidersConfig.tsx index a4756116..7cd7b1c9 100644 --- a/src/components/ControlPlane/ProvidersConfig.tsx +++ b/src/components/ControlPlane/ProvidersConfig.tsx @@ -8,7 +8,7 @@ import { import '@ui5/webcomponents-icons/dist/sys-enter-2'; import '@ui5/webcomponents-icons/dist/sys-cancel-2'; import { useProvidersConfigResource } from '../../lib/api/useApiResource'; -import { timeAgo } from '../../utils/i18n/timeAgo'; +import { formatDateAsTimeAgo } from '../../utils/i18n/timeAgo'; import { YamlViewButton } from '../Yaml/YamlViewButton.tsx'; @@ -46,7 +46,7 @@ export function ProvidersConfig() { parent: provider.provider, name: config.metadata.name, usage: config?.status?.users ?? '0', - created: timeAgo.format(new Date(config.metadata.creationTimestamp)), + created: formatDateAsTimeAgo(config.metadata.creationTimestamp), resource: config, }); }); diff --git a/src/components/Shared/ResourceStatusCell.tsx b/src/components/Shared/ResourceStatusCell.tsx index d13f4b63..f14e6cee 100644 --- a/src/components/Shared/ResourceStatusCell.tsx +++ b/src/components/Shared/ResourceStatusCell.tsx @@ -1,5 +1,5 @@ import { ButtonDomRef, FlexBox, Icon, ResponsivePopover, Text } from '@ui5/webcomponents-react'; -import { timeAgo } from '../../utils/i18n/timeAgo'; +import { formatDateAsTimeAgo } from '../../utils/i18n/timeAgo'; import { useRef, useState } from 'react'; import { AnimatedHoverTextButton } from '../Helper/AnimatedHoverTextButton.tsx'; import PopoverPlacement from '@ui5/webcomponents/dist/types/PopoverPlacement.js'; @@ -36,7 +36,7 @@ export const ResourceStatusCell = ({ design={isOk ? 'Positive' : 'Negative'} name={isOk ? 'sys-enter-2' : 'sys-cancel-2'} showTooltip={true} - accessibleName={transitionTime ? timeAgo.format(new Date(transitionTime)) : '-'} + accessibleName={transitionTime ? formatDateAsTimeAgo(transitionTime) : '-'} /> } text={isOk ? positiveText : negativeText} @@ -64,7 +64,7 @@ export const ResourceStatusCell = ({ color: isOk ? 'var(--sapPositiveTextColor)' : 'var(--sapNegativeTextColor)', }} > - {timeAgo.format(new Date(transitionTime))} + {formatDateAsTimeAgo(transitionTime)} diff --git a/src/utils/i18n/timeAgo.spec.ts b/src/utils/i18n/timeAgo.spec.ts new file mode 100644 index 00000000..1819a077 --- /dev/null +++ b/src/utils/i18n/timeAgo.spec.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { formatDateAsTimeAgo } from './timeAgo.ts'; + +const MOCK_DATE = '2025-08-11T12:00:00.000Z'; + +describe('formatDateAsTimeAgo', () => { + beforeAll(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(MOCK_DATE)); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it('should format a recent ISO date string correctly', () => { + const date = '2025-08-11T11:59:00.000Z'; + expect(formatDateAsTimeAgo(date)).toBe('1 minute ago'); + }); + + it('should format a date string from a few hours ago', () => { + const date = '2025-08-11T09:00:00.000Z'; + expect(formatDateAsTimeAgo(date)).toBe('3 hours ago'); + }); + + it('should return an empty string for an invalid date string', () => { + const invalidDate = '[INVALID DATE]'; + expect(formatDateAsTimeAgo(invalidDate)).toBe(''); + }); + + it('should return an empty string for an empty input string', () => { + expect(formatDateAsTimeAgo('')).toBe(''); + }); + + it('should return an empty string for null input', () => { + // @ts-expect-error: Ensuring the function is robust against non-string runtime values + expect(formatDateAsTimeAgo(null)).toBe(''); + }); + + it('should return an empty string for an undefined input', () => { + // @ts-expect-error: Ensuring the function is robust against non-string runtime values + expect(formatDateAsTimeAgo(undefined)).toBe(''); + }); +}); diff --git a/src/utils/i18n/timeAgo.ts b/src/utils/i18n/timeAgo.ts index 43077ad3..320dd60f 100644 --- a/src/utils/i18n/timeAgo.ts +++ b/src/utils/i18n/timeAgo.ts @@ -2,4 +2,13 @@ import en from 'javascript-time-ago/locale/en'; import TimeAgo from 'javascript-time-ago'; TimeAgo.addDefaultLocale(en); -export const timeAgo = new TimeAgo('en-US'); +const timeAgo = new TimeAgo('en-US'); + +export function formatDateAsTimeAgo(dateString: string) { + const date = new Date(dateString); + + if (!dateString || isNaN(date.getTime())) { + return ''; + } + return timeAgo.format(date); +}