diff --git a/apps/content-production-dashboard/src/components/Dashboard.tsx b/apps/content-production-dashboard/src/components/Dashboard.tsx new file mode 100644 index 0000000000..ae112e7f06 --- /dev/null +++ b/apps/content-production-dashboard/src/components/Dashboard.tsx @@ -0,0 +1,58 @@ +import { Flex, Box, Heading } from '@contentful/f36-components'; +import { MetricCard } from './MetricCard'; +import { MetricsCalculator } from '../metrics/MetricsCalculator'; +import { useSDK } from '@contentful/react-apps-toolkit'; +import type { AppInstallationParameters } from '../locations/ConfigScreen'; +import { styles } from '../locations/Page.styles'; +import { ErrorDisplay } from './ErrorDisplay'; +import { LoadingSkeleton } from './LoadingSkeleton'; +import { useAllEntries } from '../hooks/useAllEntries'; +import { useScheduledActions } from '../hooks/useScheduledActions'; + +const Dashboard = () => { + const sdk = useSDK(); + const installation = (sdk.parameters.installation ?? {}) as AppInstallationParameters; + const { entries, isFetchingEntries, fetchingEntriesError } = useAllEntries(); + const { scheduledActions, isFetchingScheduledActions, fetchingScheduledActionsError } = + useScheduledActions(); + + const metricsCalculator = new MetricsCalculator(entries, scheduledActions, { + needsUpdateMonths: installation.needsUpdateMonths, + recentlyPublishedDays: installation.recentlyPublishedDays, + timeToPublishDays: installation.timeToPublishDays, + }); + const metrics = metricsCalculator.getAllMetrics(); + + return ( + + + Content Dashboard + + + {fetchingEntriesError || fetchingScheduledActionsError ? ( + + ) : isFetchingEntries || isFetchingScheduledActions ? ( + + ) : ( + <> + + {metrics.map((metric) => { + return ( + + ); + })} + + + )} + + ); +}; + +export default Dashboard; diff --git a/apps/content-production-dashboard/src/components/MetricCard.styles.ts b/apps/content-production-dashboard/src/components/MetricCard.styles.ts new file mode 100644 index 0000000000..3e9ec85b06 --- /dev/null +++ b/apps/content-production-dashboard/src/components/MetricCard.styles.ts @@ -0,0 +1,10 @@ +import tokens from '@contentful/f36-tokens'; +import { CSSProperties } from 'react'; + +export const styles = { + card: { + border: `1px solid ${tokens.gray300}`, + borderRadius: '4px', + boxShadow: 'none', + } as CSSProperties, +}; diff --git a/apps/content-production-dashboard/src/components/MetricCard.tsx b/apps/content-production-dashboard/src/components/MetricCard.tsx new file mode 100644 index 0000000000..ce8131d3d4 --- /dev/null +++ b/apps/content-production-dashboard/src/components/MetricCard.tsx @@ -0,0 +1,34 @@ +import { Card, Flex, Text } from '@contentful/f36-components'; +import type { IconProps } from '@contentful/f36-icons'; +import type { ComponentType } from 'react'; +import tokens from '@contentful/f36-tokens'; +import { styles } from './MetricCard.styles'; + +export type MetricCardProps = { + title: string; + value: string; + subtitle: string; + icon: ComponentType; + isNegative?: boolean; +}; + +export const MetricCard = ({ title, value, subtitle, icon: Icon, isNegative }: MetricCardProps) => { + return ( + + + + + {title} + + + {value} + + + {subtitle} + + + + + + ); +}; diff --git a/apps/content-production-dashboard/src/hooks/useAllEntries.ts b/apps/content-production-dashboard/src/hooks/useAllEntries.ts index fed287e811..a8932bc633 100644 --- a/apps/content-production-dashboard/src/hooks/useAllEntries.ts +++ b/apps/content-production-dashboard/src/hooks/useAllEntries.ts @@ -7,16 +7,15 @@ import { fetchAllEntries, FetchAllEntriesResult } from '../utils/fetchAllEntries export interface UseAllEntriesResult { entries: EntryProps[]; total: number; - isFetching: boolean; - error: Error | null; - refetch: () => void; + isFetchingEntries: boolean; + fetchingEntriesError: Error | null; fetchedAt: Date | undefined; } export function useAllEntries(): UseAllEntriesResult { const sdk = useSDK(); - const { data, isFetching, error, refetch } = useQuery({ + const { data, isFetching, error } = useQuery({ queryKey: ['entries', sdk.ids.space, sdk.ids.environment], queryFn: () => fetchAllEntries(sdk), }); @@ -24,9 +23,8 @@ export function useAllEntries(): UseAllEntriesResult { return { entries: data?.entries || [], total: data?.total || 0, - isFetching, - error, - refetch, + isFetchingEntries: isFetching, + fetchingEntriesError: error, fetchedAt: data?.fetchedAt, }; } diff --git a/apps/content-production-dashboard/src/hooks/useScheduledActions.ts b/apps/content-production-dashboard/src/hooks/useScheduledActions.ts index ede3580aa9..fef64ef3bb 100644 --- a/apps/content-production-dashboard/src/hooks/useScheduledActions.ts +++ b/apps/content-production-dashboard/src/hooks/useScheduledActions.ts @@ -1,32 +1,30 @@ -import { PageAppSDK } from "@contentful/app-sdk"; -import { useSDK } from "@contentful/react-apps-toolkit"; -import { useQuery } from "@tanstack/react-query"; -import { ScheduledActionProps } from "contentful-management"; -import { fetchScheduledActions, FetchScheduledActionsResult } from "../utils/fetchScheduledActions"; +import { PageAppSDK } from '@contentful/app-sdk'; +import { useSDK } from '@contentful/react-apps-toolkit'; +import { useQuery } from '@tanstack/react-query'; +import { ScheduledActionProps } from 'contentful-management'; +import { fetchScheduledActions, FetchScheduledActionsResult } from '../utils/fetchScheduledActions'; export interface UseScheduledActionsResult { - scheduledActions: ScheduledActionProps[]; - total: number; - isFetching: boolean; - error: Error | null; - refetch: () => void; - fetchedAt: Date | undefined; - } - - export function useScheduledActions(): UseScheduledActionsResult { - const sdk = useSDK(); - - const { data, isFetching, error, refetch } = useQuery({ - queryKey: ['scheduledActions', sdk.ids.space, sdk.ids.environment], - queryFn: () => fetchScheduledActions(sdk), - }); - - return { - scheduledActions: data?.scheduledActions || [], - total: data?.total || 0, - isFetching, - error, - refetch, - fetchedAt: data?.fetchedAt, - }; - } \ No newline at end of file + scheduledActions: ScheduledActionProps[]; + total: number; + isFetchingScheduledActions: boolean; + fetchingScheduledActionsError: Error | null; + fetchedAt: Date | undefined; +} + +export function useScheduledActions(): UseScheduledActionsResult { + const sdk = useSDK(); + + const { data, isFetching, error } = useQuery({ + queryKey: ['scheduledActions', sdk.ids.space, sdk.ids.environment], + queryFn: () => fetchScheduledActions(sdk), + }); + + return { + scheduledActions: data?.scheduledActions || [], + total: data?.total || 0, + isFetchingScheduledActions: isFetching, + fetchingScheduledActionsError: error, + fetchedAt: data?.fetchedAt, + }; +} diff --git a/apps/content-production-dashboard/src/locations/Home.tsx b/apps/content-production-dashboard/src/locations/Home.tsx index 5508bb22d1..fc75438e28 100644 --- a/apps/content-production-dashboard/src/locations/Home.tsx +++ b/apps/content-production-dashboard/src/locations/Home.tsx @@ -1,11 +1,7 @@ -import { HomeAppSDK } from '@contentful/app-sdk'; -import { Paragraph } from '@contentful/f36-components'; -import { useSDK } from '@contentful/react-apps-toolkit'; +import Dashboard from '../components/Dashboard'; const Home = () => { - const sdk = useSDK(); - - return Hello Home Component (AppId: {sdk.ids.app}); + return ; }; export default Home; diff --git a/apps/content-production-dashboard/src/locations/Page.styles.ts b/apps/content-production-dashboard/src/locations/Page.styles.ts index af6f6f60ad..0f2c2a2e46 100644 --- a/apps/content-production-dashboard/src/locations/Page.styles.ts +++ b/apps/content-production-dashboard/src/locations/Page.styles.ts @@ -1,9 +1,9 @@ import tokens from '@contentful/f36-tokens'; -import { css } from 'emotion'; +import { CSSProperties } from 'react'; export const styles = { - pageContainer: css({ + container: { + padding: tokens.spacingL, backgroundColor: tokens.colorWhite, - minHeight: '100vh', - }), + } as CSSProperties, }; diff --git a/apps/content-production-dashboard/src/locations/Page.tsx b/apps/content-production-dashboard/src/locations/Page.tsx index 3b609eb713..836647b7ef 100644 --- a/apps/content-production-dashboard/src/locations/Page.tsx +++ b/apps/content-production-dashboard/src/locations/Page.tsx @@ -1,30 +1,7 @@ -import { Box, Button, Flex, Heading } from '@contentful/f36-components'; -import { useAllEntries } from '../hooks/useAllEntries'; -import { LoadingSkeleton } from '../components/LoadingSkeleton'; -import { ErrorDisplay } from '../components/ErrorDisplay'; -import { styles } from './Page.styles'; +import Dashboard from '../components/Dashboard'; const Page = () => { - const { isFetching, error, refetch } = useAllEntries(); - - return ( - - - Content Dashboard - - - - {error ? ( - - ) : isFetching ? ( - - ) : ( - <>{/* TODO: implement the rest of the sections */} - )} - - ); + return ; }; export default Page; diff --git a/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts new file mode 100644 index 0000000000..94a97b0d01 --- /dev/null +++ b/apps/content-production-dashboard/src/metrics/MetricsCalculator.ts @@ -0,0 +1,191 @@ +import { FileIcon } from '@contentful/f36-icons'; +import type { EntryProps, ScheduledActionProps } from 'contentful-management'; +import { ClockIcon } from '@contentful/f36-icons'; +import type { MetricCardProps } from '../components/MetricCard'; +import { CalendarDotsIcon } from '@contentful/f36-icons'; +import { PenNibIcon } from '@contentful/f36-icons'; +import { + NEEDS_UPDATE_MONTHS_RANGE, + RECENTLY_PUBLISHED_DAYS_RANGE, + TIME_TO_PUBLISH_DAYS_RANGE, +} from '../utils/consts'; +import { DateCalculator, msPerDay } from '../utils/DateCalculator'; + +export class MetricsCalculator { + private readonly entries: ReadonlyArray; + private readonly scheduledActions: ReadonlyArray; + private readonly now: Date; // to maintain all the metrics consistent at the same current time + private readonly needsUpdateMonths: number; + private readonly recentlyPublishedDays: number; + private readonly timeToPublishDays: number; + + constructor( + entries: ReadonlyArray, + scheduledActions: ReadonlyArray, + options?: { + needsUpdateMonths?: number; + recentlyPublishedDays?: number; + timeToPublishDays?: number; + } + ) { + this.entries = entries; + this.scheduledActions = scheduledActions; + this.now = new Date(); + this.needsUpdateMonths = options?.needsUpdateMonths ?? NEEDS_UPDATE_MONTHS_RANGE.min; + this.recentlyPublishedDays = + options?.recentlyPublishedDays ?? RECENTLY_PUBLISHED_DAYS_RANGE.min; + this.timeToPublishDays = options?.timeToPublishDays ?? TIME_TO_PUBLISH_DAYS_RANGE.min; + } + + public getAllMetrics(): ReadonlyArray { + return [ + this.calculateTotalPublished(), + this.calculateAverageTimeToPublish(), + this.calculateScheduled(), + this.calculateRecentlyPublished(), + this.calculateNeedsUpdate(), + ]; + } + + private calculatePublishingChangeText( + current: number, + previous: number + ): { text: string; isNegative: boolean } { + if (previous === 0) { + if (current === 0) return { text: '0.0% publishing change MoM', isNegative: false }; + return { text: 'New publishing this month', isNegative: false }; + } + const pct = ((current - previous) / previous) * 100; + const abs = Math.abs(pct).toFixed(1); + const direction = pct < 0 ? 'decrease' : 'increase'; + return { text: `${abs}% publishing ${direction} MoM`, isNegative: pct < 0 }; + } + + private calculateTotalPublished(): MetricCardProps { + const startThisPeriod = DateCalculator.subDays(this.now, 30); + const startPrevPeriod = DateCalculator.subDays(this.now, 60); + const endPrevPeriod = startThisPeriod; + + let current = 0; + let previous = 0; + for (const entry of this.entries) { + const publishedAt = DateCalculator.parseDate(entry?.sys?.publishedAt); + if (!publishedAt) continue; + + if (DateCalculator.isWithin(publishedAt, startThisPeriod, this.now)) { + current += 1; + continue; + } + if (DateCalculator.isWithin(publishedAt, startPrevPeriod, endPrevPeriod)) { + previous += 1; + } + } + + const { text, isNegative } = this.calculatePublishingChangeText(current, previous); + + return { + title: 'Total Published', + value: String(current), + subtitle: text, + icon: FileIcon, + isNegative, + }; + } + + private calculateAverageTimeToPublish(): MetricCardProps { + const startThisPeriod = DateCalculator.subDays(this.now, this.timeToPublishDays); + + let sumDays = 0; + let count = 0; + for (const entry of this.entries) { + const publishedAt = DateCalculator.parseDate(entry?.sys?.publishedAt); + if (!publishedAt) continue; + if (!DateCalculator.isWithin(publishedAt, startThisPeriod, this.now)) continue; + + const createdAt = DateCalculator.parseDate(entry?.sys?.createdAt); + if (!createdAt) continue; + + const deltaDays = (publishedAt.getTime() - createdAt.getTime()) / msPerDay; + if (deltaDays < 0) continue; + + sumDays += deltaDays; + count += 1; + } + + const avg = count === 0 ? undefined : sumDays / count; + + return { + title: 'Average Time to Publish', + value: avg === undefined ? '—' : `${avg.toFixed(1)} days`, + subtitle: + count === 0 + ? `No entries published in the last ${this.timeToPublishDays} days` + : `For the last ${this.timeToPublishDays} days`, + icon: ClockIcon, + isNegative: false, + }; + } + + private calculateScheduled(): MetricCardProps { + const end = DateCalculator.addDays(this.now, 30); + + let count = 0; + for (const action of this.scheduledActions) { + const scheduledFor = DateCalculator.parseDate(action?.scheduledFor?.datetime); + if (!scheduledFor) continue; + if (DateCalculator.isWithin(scheduledFor, this.now, end)) { + count += 1; + } + } + + return { + title: 'Scheduled', + value: String(count), + subtitle: 'For the next 30 days', + icon: CalendarDotsIcon, + isNegative: false, + }; + } + + private calculateRecentlyPublished(): MetricCardProps { + const start = DateCalculator.subDays(this.now, this.recentlyPublishedDays); + + let count = 0; + for (const entry of this.entries) { + const publishedAt = DateCalculator.parseDate(entry?.sys?.publishedAt); + if (!publishedAt) continue; + if (DateCalculator.isWithin(publishedAt, start, this.now)) { + count += 1; + } + } + + return { + title: 'Recently Published', + value: String(count), + subtitle: `In the last ${this.recentlyPublishedDays} days`, + icon: ClockIcon, + isNegative: false, + }; + } + + private calculateNeedsUpdate(): MetricCardProps { + const cutoff = DateCalculator.subMonths(this.now, this.needsUpdateMonths); + + let count = 0; + for (const entry of this.entries) { + const updatedAt = DateCalculator.parseDate(entry?.sys?.updatedAt); + if (!updatedAt) continue; + if (updatedAt.getTime() < cutoff.getTime()) { + count += 1; + } + } + + return { + title: 'Needs Update', + value: String(count), + subtitle: `Content older than ${this.needsUpdateMonths} months`, + icon: PenNibIcon, + isNegative: false, + }; + } +} diff --git a/apps/content-production-dashboard/src/scripts/generateEntries.ts b/apps/content-production-dashboard/src/scripts/generateEntries.ts index 6ee1052dce..b38d72868c 100644 --- a/apps/content-production-dashboard/src/scripts/generateEntries.ts +++ b/apps/content-production-dashboard/src/scripts/generateEntries.ts @@ -86,7 +86,10 @@ const FIELD_TYPES = [ }, ]; -export async function createContentTypeWithAllFields(client: PlainClientAPI, contentTypeName: string) { +export async function createContentTypeWithAllFields( + client: PlainClientAPI, + contentTypeName: string +) { console.log(`Creating content type: ${contentTypeName}`); const fields = FIELD_TYPES.map((fieldType) => { @@ -189,41 +192,43 @@ export async function createSampleEntry( fields, }; - const { SPACE_ID, SCHEDULED_DATE, ENVIRONMENT_ID} = process.env; + const { SPACE_ID, SCHEDULED_DATE, ENVIRONMENT_ID } = process.env; try { const entryResult = await client.entry.create({ contentTypeId }, body); console.log(`✅ Created sample entry: ${entryResult.sys.id}`); - { SCHEDULED_DATE ? + if (SCHEDULED_DATE) { await client.scheduledActions.create( - { - spaceId: SPACE_ID ?? '', - }, - { - "entity": { - "sys": { - "type": "Link", - "linkType": "Entry", - "id": entryResult.sys.id - }, - }, - "environment": { - "sys": { - "type": "Link", - "linkType": "Environment", - "id": ENVIRONMENT_ID ?? '' - } - }, - "scheduledFor": { - "datetime": SCHEDULED_DATE ?? "2026-12-12T12:00:00.000Z", - "timezone": "UTC" + { + spaceId: SPACE_ID ?? '', }, - "action": "publish" - } - ) : await client.entry.publish({ entryId: entryResult.sys.id }, entryResult) } + { + entity: { + sys: { + type: 'Link', + linkType: 'Entry', + id: entryResult.sys.id, + }, + }, + environment: { + sys: { + type: 'Link', + linkType: 'Environment', + id: ENVIRONMENT_ID ?? '', + }, + }, + scheduledFor: { + datetime: SCHEDULED_DATE ?? '2026-12-12T12:00:00.000Z', + timezone: 'UTC', + }, + action: 'publish', + } + ); + } else { + await client.entry.publish({ entryId: entryResult.sys.id }, entryResult); + } - console.log(`Sample entry ${index + 1}`); return entryResult.sys.id; @@ -293,7 +298,9 @@ export async function generateEntries() { const rl = createReadlineInterface(); const { AMOUNT_OF_ENTRIES, SCHEDULED_DATE } = process.env; - const contentTypeName = SCHEDULED_DATE ? 'Scheduled - All Field Types': 'Publish - All Field Types'; + const contentTypeName = SCHEDULED_DATE + ? 'Scheduled - All Field Types' + : 'Publish - All Field Types'; const contentTypeId = await createContentTypeWithAllFields(client, contentTypeName); diff --git a/apps/content-production-dashboard/src/utils/DateCalculator.ts b/apps/content-production-dashboard/src/utils/DateCalculator.ts new file mode 100644 index 0000000000..2c8a193b5f --- /dev/null +++ b/apps/content-production-dashboard/src/utils/DateCalculator.ts @@ -0,0 +1,33 @@ +export const msPerDay = 24 * 60 * 60 * 1000; + +export type MaybeDate = Date | undefined; + +export class DateCalculator { + static parseDate(value: string | undefined): MaybeDate { + if (!value) return undefined; + const ms = Date.parse(value); + return Number.isNaN(ms) ? undefined : new Date(ms); + } + + static addDays(date: Date, days: number): Date { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; + } + + static subDays(date: Date, days: number): Date { + const result = new Date(date); + result.setDate(result.getDate() - days); + return result; + } + + static subMonths(base: Date, months: number): Date { + const date = new Date(base); + date.setMonth(date.getMonth() - months); + return date; + } + + static isWithin(d: Date, startInclusive: Date, endExclusive: Date): boolean { + return d.getTime() >= startInclusive.getTime() && d.getTime() < endExclusive.getTime(); + } +} diff --git a/apps/content-production-dashboard/src/utils/fetchScheduledActions.ts b/apps/content-production-dashboard/src/utils/fetchScheduledActions.ts index a6e4a88346..f2a70045bd 100644 --- a/apps/content-production-dashboard/src/utils/fetchScheduledActions.ts +++ b/apps/content-production-dashboard/src/utils/fetchScheduledActions.ts @@ -1,29 +1,30 @@ -import { PageAppSDK } from "@contentful/app-sdk"; -import { ScheduledActionProps } from "contentful-management"; - +import { PageAppSDK } from '@contentful/app-sdk'; +import { ScheduledActionProps } from 'contentful-management'; export interface FetchScheduledActionsResult { - scheduledActions: ScheduledActionProps[]; - total: number; - fetchedAt: Date; - } + scheduledActions: ScheduledActionProps[]; + total: number; + fetchedAt: Date; +} // The current limit of scheduled actions in scheduled status is 500. Once it's reached, no additional scheduled actions can be created. -export const fetchScheduledActions = async (sdk: PageAppSDK): Promise => { - const scheduledActions = await sdk.cma.scheduledActions.getMany({ - spaceId: sdk.ids.space, - query: { - 'environment.sys.id': sdk.ids.environment, - 'sys.status[in]': 'scheduled', - 'order': 'scheduledFor.datetime', - 'limit': 500 - } - }); - - return { - scheduledActions: scheduledActions.items, - total: scheduledActions.items.length, - fetchedAt: new Date(), - }; -}; \ No newline at end of file +export const fetchScheduledActions = async ( + sdk: PageAppSDK +): Promise => { + const scheduledActions = await sdk.cma.scheduledActions.getMany({ + spaceId: sdk.ids.space, + query: { + 'environment.sys.id': sdk.ids.environment, + 'sys.status[in]': 'scheduled', + order: 'scheduledFor.datetime', + limit: 500, + }, + }); + + return { + scheduledActions: scheduledActions.items, + total: scheduledActions.items.length, + fetchedAt: new Date(), + }; +}; diff --git a/apps/content-production-dashboard/test/components/Dashboard.spec.tsx b/apps/content-production-dashboard/test/components/Dashboard.spec.tsx new file mode 100644 index 0000000000..ea52dab2e2 --- /dev/null +++ b/apps/content-production-dashboard/test/components/Dashboard.spec.tsx @@ -0,0 +1,64 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { mockCma, mockSdk } from '../mocks'; +import Dashboard from '../../src/components/Dashboard'; +import { QueryProvider } from '../../src/providers/QueryProvider'; + +vi.mock('@contentful/react-apps-toolkit', () => ({ + useSDK: () => mockSdk, + useCMA: () => mockCma, +})); + +const mockRefetch = vi.fn(); + +vi.mock('../../src/hooks/useAllEntries', () => ({ + useAllEntries: () => ({ + entries: [], + total: 0, + isFetchingEntries: false, + fetchingEntriesError: null, + refetch: mockRefetch, + fetchedAt: new Date(), + }), +})); + +vi.mock('../../src/hooks/useScheduledActions', () => ({ + useScheduledActions: () => ({ + scheduledActions: [], + total: 0, + isFetchingScheduledActions: false, + fetchingScheduledActionsError: null, + refetch: mockRefetch, + fetchedAt: new Date(), + }), +})); + +const createWrapper = () => { + const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + TestWrapper.displayName = 'TestWrapper'; + return TestWrapper; +}; + +describe('Dashboard component', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the dashboard heading', () => { + render(, { wrapper: createWrapper() }); + + expect(screen.getByText('Content Dashboard')).toBeInTheDocument(); + }); + + it('renders all metric cards', () => { + render(, { wrapper: createWrapper() }); + + expect(screen.getByText('Total Published')).toBeInTheDocument(); + expect(screen.getByText('Average Time to Publish')).toBeInTheDocument(); + expect(screen.getByText('Scheduled')).toBeInTheDocument(); + expect(screen.getByText('Recently Published')).toBeInTheDocument(); + expect(screen.getByText('Needs Update')).toBeInTheDocument(); + }); +}); diff --git a/apps/content-production-dashboard/test/components/MetricCard.spec.tsx b/apps/content-production-dashboard/test/components/MetricCard.spec.tsx new file mode 100644 index 0000000000..8662d4a18d --- /dev/null +++ b/apps/content-production-dashboard/test/components/MetricCard.spec.tsx @@ -0,0 +1,64 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { MetricCard } from '../../src/components/MetricCard'; +import { FileIcon } from '@contentful/f36-icons'; + +describe('MetricCard component', () => { + it('renders title, value, and subtitle', () => { + render( + + ); + + expect(screen.getByText('Test Metric')).toBeInTheDocument(); + expect(screen.getByText('42')).toBeInTheDocument(); + expect(screen.getByText('Test subtitle')).toBeInTheDocument(); + }); + + it('renders the icon', () => { + render( + + ); + + expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument(); + }); + + it('applies negative styling when isNegative is true', () => { + render( + + ); + + expect(screen.getByText('Test subtitle')).toBeInTheDocument(); + }); + + it('applies default styling when isNegative is false', () => { + render( + + ); + + expect(screen.getByText('Test subtitle')).toBeInTheDocument(); + }); +}); diff --git a/apps/content-production-dashboard/test/locations/Home.spec.tsx b/apps/content-production-dashboard/test/locations/Home.spec.tsx index 6cb3e0eccf..8dea5c25fc 100644 --- a/apps/content-production-dashboard/test/locations/Home.spec.tsx +++ b/apps/content-production-dashboard/test/locations/Home.spec.tsx @@ -1,17 +1,72 @@ -import { render } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; -import { mockCma, mockSdk } from '../mocks'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { mockSdk } from '../mocks'; import Home from '../../src/locations/Home'; +import { EntryProps } from 'contentful-management'; +// Mock TanStack Query +const mockUseQuery = vi.fn(); +vi.mock('@tanstack/react-query', () => ({ + useQuery: (options: any) => mockUseQuery(options), +})); + +// Mock Contentful SDK vi.mock('@contentful/react-apps-toolkit', () => ({ useSDK: () => mockSdk, - useCMA: () => mockCma, })); describe('Home component', () => { - it('Component text exists', () => { - const { getByText } = render(); + const mockRefetch = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockUseQuery.mockReturnValue({ + data: undefined, + isLoading: false, + isFetching: false, + error: null, + refetch: mockRefetch, + }); + }); + + it('shows error display when error exists', () => { + const testError = new Error('Failed to fetch entries'); + mockUseQuery.mockReturnValue({ + data: undefined, + isLoading: false, + isFetching: false, + error: testError, + refetch: mockRefetch, + }); + + render(); + + expect(screen.getByText('Error loading entries')).toBeInTheDocument(); + expect(screen.getByText('Failed to fetch entries')).toBeInTheDocument(); + }); + + it('shows loading state when isFetching is true', () => { + const mockEntries: EntryProps[] = [ + { + sys: { id: 'entry-1', type: 'Entry' } as any, + fields: {}, + }, + ]; + + mockUseQuery.mockReturnValue({ + data: { + entries: mockEntries, + total: 1, + fetchedAt: new Date(), + }, + isLoading: false, + isFetching: true, + error: null, + refetch: mockRefetch, + }); + + render(); - expect(getByText('Hello Home Component (AppId: test-app)')).toBeTruthy(); + expect(screen.getByText('Loading component...')).toBeInTheDocument(); }); }); diff --git a/apps/content-production-dashboard/test/locations/Page.spec.tsx b/apps/content-production-dashboard/test/locations/Page.spec.tsx index 1ee8bb0d0b..f65bec7a1c 100644 --- a/apps/content-production-dashboard/test/locations/Page.spec.tsx +++ b/apps/content-production-dashboard/test/locations/Page.spec.tsx @@ -69,58 +69,4 @@ describe('Page component', () => { expect(screen.getByText('Loading component...')).toBeInTheDocument(); }); - - it('calls refetch when refresh button is clicked', () => { - const mockEntries: EntryProps[] = [ - { - sys: { id: 'entry-1', type: 'Entry' } as any, - fields: {}, - }, - ]; - - mockUseQuery.mockReturnValue({ - data: { - entries: mockEntries, - total: 1, - fetchedAt: new Date(), - }, - isLoading: false, - isFetching: false, - error: null, - refetch: mockRefetch, - }); - - render(); - - const refreshButton = screen.getByRole('button', { name: 'Refresh' }); - refreshButton.click(); - - expect(mockRefetch).toHaveBeenCalledTimes(1); - }); - - it('disables refresh button when isFetching is true', () => { - const mockEntries: EntryProps[] = [ - { - sys: { id: 'entry-1', type: 'Entry' } as any, - fields: {}, - }, - ]; - - mockUseQuery.mockReturnValue({ - data: { - entries: mockEntries, - total: 1, - fetchedAt: new Date(), - }, - isLoading: false, - isFetching: true, - error: null, - refetch: mockRefetch, - }); - - render(); - - const refreshButton = screen.getByRole('button', { name: 'Refreshing...' }); - expect(refreshButton).toBeDisabled(); - }); }); diff --git a/apps/content-production-dashboard/test/metrics/MetricsCalculator.spec.ts b/apps/content-production-dashboard/test/metrics/MetricsCalculator.spec.ts new file mode 100644 index 0000000000..afb6751f3f --- /dev/null +++ b/apps/content-production-dashboard/test/metrics/MetricsCalculator.spec.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from 'vitest'; +import { MetricsCalculator } from '../../src/metrics/MetricsCalculator'; +import type { EntryProps, ScheduledActionProps } from 'contentful-management'; +import { ScheduledActionStatus } from 'contentful-management'; +import { + NEEDS_UPDATE_MONTHS_RANGE, + RECENTLY_PUBLISHED_DAYS_RANGE, + TIME_TO_PUBLISH_DAYS_RANGE, +} from '../../src/utils/consts'; + +describe('MetricsCalculator', () => { + const now = new Date(); + const daysAgo = (days: number) => + new Date(now.getTime() - days * 24 * 60 * 60 * 1000).toISOString(); + const daysFromNow = (days: number) => + new Date(now.getTime() + days * 24 * 60 * 60 * 1000).toISOString(); + + describe('constructor', () => { + it('initializes with empty arrays', () => { + const calculator = new MetricsCalculator([], []); + + expect(calculator.getAllMetrics()).toHaveLength(5); + }); + + it('uses default values when options are not provided', () => { + const calculator = new MetricsCalculator([], []); + const metrics = calculator.getAllMetrics(); + const needsUpdateMetric = metrics.find((m) => m.title === 'Needs Update'); + const recentlyPublishedMetric = metrics.find((m) => m.title === 'Recently Published'); + const avgTimeMetric = metrics.find((m) => m.title === 'Average Time to Publish'); + + expect(needsUpdateMetric?.subtitle).toContain(`${NEEDS_UPDATE_MONTHS_RANGE.min} months`); + expect(recentlyPublishedMetric?.subtitle).toContain( + `${RECENTLY_PUBLISHED_DAYS_RANGE.min} days` + ); + expect(avgTimeMetric?.subtitle).toContain(`${TIME_TO_PUBLISH_DAYS_RANGE.min} days`); + }); + + it('uses provided options', () => { + const calculator = new MetricsCalculator([], [], { + needsUpdateMonths: 12, + recentlyPublishedDays: 14, + timeToPublishDays: 60, + }); + const metrics = calculator.getAllMetrics(); + const needsUpdateMetric = metrics.find((m) => m.title === 'Needs Update'); + const recentlyPublishedMetric = metrics.find((m) => m.title === 'Recently Published'); + const avgTimeMetric = metrics.find((m) => m.title === 'Average Time to Publish'); + + expect(needsUpdateMetric?.subtitle).toContain('12 months'); + expect(recentlyPublishedMetric?.subtitle).toContain('14 days'); + expect(avgTimeMetric?.subtitle).toContain('60 days'); + }); + }); + + describe('calculateTotalPublished', () => { + it('counts entries published in the last 30 days', () => { + const entries: EntryProps[] = [ + { sys: { publishedAt: daysAgo(10) } } as EntryProps, + { sys: { publishedAt: daysAgo(20) } } as EntryProps, + { sys: { publishedAt: daysAgo(40) } } as EntryProps, // Outside window + ]; + + const calculator = new MetricsCalculator(entries, []); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Total Published'); + + expect(metric?.value).toBe('2'); + }); + + it('calculates MoM percentage change correctly', () => { + const entries: EntryProps[] = [ + { sys: { publishedAt: daysAgo(10) } } as EntryProps, // Current period + { sys: { publishedAt: daysAgo(40) } } as EntryProps, // Previous period + ]; + + const calculator = new MetricsCalculator(entries, []); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Total Published'); + + expect(metric?.subtitle).toContain('% publishing'); + }); + + it('handles zero previous period', () => { + const entries: EntryProps[] = [{ sys: { publishedAt: daysAgo(10) } } as EntryProps]; + + const calculator = new MetricsCalculator(entries, []); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Total Published'); + + expect(metric?.subtitle).toContain('New publishing this month'); + }); + + it('handles zero current and previous period', () => { + const entries: EntryProps[] = [ + { sys: { publishedAt: daysAgo(100) } } as EntryProps, // Outside both periods + ]; + + const calculator = new MetricsCalculator(entries, []); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Total Published'); + + expect(metric?.value).toBe('0'); + expect(metric?.subtitle).toContain('0.0% publishing change MoM'); + }); + }); + + describe('calculateAverageTimeToPublish', () => { + it('calculates average time to publish correctly', () => { + const entries: EntryProps[] = [ + { + sys: { + createdAt: daysAgo(20), + publishedAt: daysAgo(10), // 10 days to publish + }, + } as EntryProps, + { + sys: { + createdAt: daysAgo(15), + publishedAt: daysAgo(10), // 5 days to publish + }, + } as EntryProps, + ]; + + const calculator = new MetricsCalculator(entries, [], { + timeToPublishDays: 30, + }); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Average Time to Publish'); + + expect(metric?.value).toBe('7.5 days'); + }); + + it('returns dash when no entries published in period', () => { + const entries: EntryProps[] = [ + { + sys: { + createdAt: daysAgo(50), + publishedAt: daysAgo(40), // Outside period + }, + } as EntryProps, + ]; + + const calculator = new MetricsCalculator(entries, [], { + timeToPublishDays: 30, + }); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Average Time to Publish'); + + expect(metric?.value).toBe('—'); + expect(metric?.subtitle).toContain('No entries published'); + }); + }); + + describe('calculateScheduled', () => { + it('counts scheduled actions in next 30 days', () => { + const scheduledActions: ScheduledActionProps[] = [ + { + scheduledFor: { datetime: daysFromNow(5), timezone: 'UTC' }, + sys: { status: ScheduledActionStatus.scheduled }, + } as ScheduledActionProps, + { + scheduledFor: { datetime: daysFromNow(20), timezone: 'UTC' }, + sys: { status: ScheduledActionStatus.scheduled }, + } as ScheduledActionProps, + { + scheduledFor: { datetime: daysFromNow(45), timezone: 'UTC' }, // Outside window + sys: { status: ScheduledActionStatus.scheduled }, + } as ScheduledActionProps, + ]; + + const calculator = new MetricsCalculator([], scheduledActions); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Scheduled'); + + expect(metric?.value).toBe('2'); + }); + }); + + describe('calculateRecentlyPublished', () => { + it('counts entries published in the specified days', () => { + const entries: EntryProps[] = [ + { sys: { publishedAt: daysAgo(3) } } as EntryProps, + { sys: { publishedAt: daysAgo(5) } } as EntryProps, + { sys: { publishedAt: daysAgo(10) } } as EntryProps, // Outside 7 day window + ]; + + const calculator = new MetricsCalculator(entries, [], { + recentlyPublishedDays: 7, + }); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Recently Published'); + + expect(metric?.value).toBe('2'); + expect(metric?.subtitle).toContain('7 days'); + }); + + it('uses custom recentlyPublishedDays', () => { + const entries: EntryProps[] = [ + { sys: { publishedAt: daysAgo(10) } } as EntryProps, // Within 14 day window + ]; + + const calculator = new MetricsCalculator(entries, [], { + recentlyPublishedDays: 14, + }); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Recently Published'); + + expect(metric?.value).toBe('1'); + expect(metric?.subtitle).toContain('14 days'); + }); + }); + + describe('calculateNeedsUpdate', () => { + it('counts entries older than specified months', () => { + const entries: EntryProps[] = [ + { sys: { updatedAt: daysAgo(30) } } as EntryProps, // Less than 6 months + { sys: { updatedAt: daysAgo(200) } } as EntryProps, // More than 6 months + { sys: { updatedAt: daysAgo(250) } } as EntryProps, // More than 6 months + ]; + + const calculator = new MetricsCalculator(entries, [], { + needsUpdateMonths: 6, + }); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Needs Update'); + + expect(metric?.value).toBe('2'); + expect(metric?.subtitle).toContain('6 months'); + }); + + it('uses custom needsUpdateMonths', () => { + const entries: EntryProps[] = [ + { sys: { updatedAt: daysAgo(400) } } as EntryProps, // More than 12 months + ]; + + const calculator = new MetricsCalculator(entries, [], { + needsUpdateMonths: 12, + }); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Needs Update'); + + expect(metric?.value).toBe('1'); + expect(metric?.subtitle).toContain('12 months'); + }); + + it('ignores entries without updatedAt', () => { + const entries: EntryProps[] = [ + { sys: {} } as EntryProps, + { sys: { updatedAt: daysAgo(200) } } as EntryProps, + ]; + + const calculator = new MetricsCalculator(entries, [], { + needsUpdateMonths: 6, + }); + const metric = calculator.getAllMetrics().find((m) => m.title === 'Needs Update'); + + expect(metric?.value).toBe('1'); + }); + }); +}); diff --git a/apps/content-production-dashboard/test/mocks/mockSdk.ts b/apps/content-production-dashboard/test/mocks/mockSdk.ts index ecc92d2786..115ae2a1ba 100644 --- a/apps/content-production-dashboard/test/mocks/mockSdk.ts +++ b/apps/content-production-dashboard/test/mocks/mockSdk.ts @@ -13,6 +13,9 @@ const mockSdk: any = { space: 'test-space', environment: 'test-environment', }, + parameters: { + installation: {}, + }, notifier: { error: vi.fn(), success: vi.fn(),