Skip to content
Open
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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
# Runtime stage (distroless, nonroot)
# Only copy what is required to run the built app.
#
FROM gcr.io/distroless/nodejs22:nonroot AS runner
FROM gcr.io/distroless/nodejs22 AS runner

Check warning on line 30 in Dockerfile

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a specific version tag for the image.

See more on https://sonarcloud.io/project/issues?id=UKHSA-Internal_winter-pressures-frontend&issues=AZ1zDvGK1MRvjmKtMSym&open=AZ1zDvGK1MRvjmKtMSym&pullRequest=914

WORKDIR /app

Expand Down
82 changes: 74 additions & 8 deletions src/api/models/cms/Page/Body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { z } from 'zod'
import { ChartLineColours } from '@/api/models/Chart'

import { HealthAlertTypes } from '../../Alerts'
import { Blocks } from './Blocks'
import { Blocks, HeadlineNumber, TrendNumber } from './Blocks'
import { Chart } from './Chart'
import { GlobalFilterRow, TimeRangeSchema } from './GlobalFilter'

Expand All @@ -29,10 +29,18 @@ export const ChartRelatedLinks = z.array(

export type ChartRelatedLink = z.infer<typeof ChartRelatedLinks>

const SourceLink = z.object({
link_display_text: z.string().nullable().optional(),
page: z.string().nullable().optional(),
external_url: z.string().nullable().optional(),
})

export const WithWeatherHealthAlertCard = z.object({
title: z.string(),
sub_title: z.string(),
description: z.string().nullable().optional(),
alert_type: HealthAlertTypes,
source: SourceLink.optional(),
})

export const WithHeadlineNumbersRowCard = z.object({
Expand Down Expand Up @@ -99,19 +107,72 @@ const WithChartCardWithDescription = z.object({
type: z.enum(['chart_with_description_card']),
value: chartCardValues
.extend({
sub_title: z.string(),
sub_title: z.string().optional(),
topic_page: z.string(),
description: z.string(),
show_tooltips: z.boolean(),
source: z.object({
link_display_text: z.string().optional().nullable(),
page: z.string().optional().nullable(),
external_url: z.string().optional().nullable(),
}),
source: SourceLink,
show_tooltips: z.boolean().optional(),
})
.omit({ body: true, date_prefix: true, about: true }),
})

/** Chart card value shape for popular topics (no body/date_prefix/about; has topic_page). */
const popularTopicsChartCardValue = chartCardValues
.extend({
sub_title: z.string().optional(),
topic_page: z.string(),
})
.omit({ body: true, date_prefix: true, about: true })

/** Chart card with description and source link (left column of popular topics card). */
export const ChartCardWithDescriptionValue = popularTopicsChartCardValue.extend({
description: z.string(),
source: SourceLink,
show_tooltips: z.boolean().optional(),
})

/** Headline metric card: title, date_prefix, and exactly 2 headline/trend blocks. */
const HeadlineMetricCardValue = z.object({
title: z.string(),
date_prefix: z.string(),
headline_metrics: z.array(z.discriminatedUnion('type', [HeadlineNumber, TrendNumber])).length(2),
})

/** Left column item: chart with description or weather health alert. */
const PopularTopicsLeftColumnItem = z.discriminatedUnion('type', [
z.object({
type: z.literal('chart_card_with_description'),
value: ChartCardWithDescriptionValue,
id: z.string(),
}),
z.object({
type: z.literal('weather_health_alert_card'),
value: WithWeatherHealthAlertCard,
id: z.string(),
}),
])

/** Right column top row: chart card linking to topic page. */
const PopularTopicsRightColumnTopItem = z.object({
type: z.literal('chart_card'),
value: popularTopicsChartCardValue,
id: z.string(),
})

/** Right column bottom row: headline metric card. */
const PopularTopicsRightColumnBottomItem = z.object({
type: z.literal('headline_metric_card'),
value: HeadlineMetricCardValue,
id: z.string(),
})

/** Popular topics card value (left column + right top/bottom rows). */
export const PopularTopicsCardValue = z.object({
left_column: z.array(PopularTopicsLeftColumnItem).length(1),
right_column_top_row: z.array(PopularTopicsRightColumnTopItem).length(1),
right_column_bottom_row: z.array(PopularTopicsRightColumnBottomItem).length(2),
})

export const ChartCardSchemas = z.discriminatedUnion('type', [
WithChartHeadlineAndTrendCard,
WithChartCard,
Expand Down Expand Up @@ -185,6 +246,11 @@ export const CardTypes = z.discriminatedUnion('type', [
}),
id: z.string(),
}),
z.object({
type: z.literal('popular_topics_card'),
value: PopularTopicsCardValue,
id: z.string(),
}),
])

export const Body = z.array(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import clsx from 'clsx'
import { snakeCase } from 'lodash'
import Link from 'next/link'

import { hasSource, SourceFooter } from '@/app/components/cms/SourceFooter/SourceFooter'
import { Card, Chart } from '@/app/components/ui/ukhsa'
import { getPath } from '@/app/utils/cms/slug'

Expand All @@ -13,7 +14,7 @@ type ChartWithDescriptionCardProps = {

export function ChartWithDescriptionCard({ value, cardsCount }: ChartWithDescriptionCardProps) {
const topicPagePath = getPath(value.topic_page)
const hasSource = value.source && (value.source.external_url || value.source.page)
const showSource = hasSource(value.source)

return (
<div className="group flex h-full flex-col">
Expand All @@ -22,7 +23,7 @@ export function ChartWithDescriptionCard({ value, cardsCount }: ChartWithDescrip
aria-labelledby={`chart-with-description-card-heading-${snakeCase(value.title)}`}
className={clsx(
'ukhsa-chart-card relative flex min-h-0 flex-1 flex-col border border-grey-2 bg-[var(--colour-home-chart-background)] no-underline transition-colors duration-200 ukhsa-focus focus:border-grey-2 focus:bg-[var(--colour-home-chart-background-hover)] group-hover:bg-[var(--colour-home-chart-background-hover)]',
hasSource && 'border-b-0 pb-2'
showSource && 'border-b-0 pb-2'
)}
>
<Link href={topicPagePath} prefetch className="flex h-full min-h-0 flex-col">
Expand Down Expand Up @@ -59,23 +60,11 @@ export function ChartWithDescriptionCard({ value, cardsCount }: ChartWithDescrip
</Link>
</Card>

{hasSource && (
<div
className="border-x border-b border-grey-2 bg-[var(--colour-home-chart-background)] !px-4 !py-2 pb-4 transition-colors duration-200 group-hover:bg-[var(--colour-home-chart-background-hover)]"
data-testid="chart-source"
>
<p className="govuk-body-s mb-0 text-grey-1">
Source:{' '}
<Link
className="govuk-link govuk-link--no-visited-state"
href={value.source.external_url ? value.source.external_url : value.source.page}
prefetch
>
{value.source.link_display_text}
</Link>
</p>
</div>
)}
<SourceFooter
source={value.source}
className="border-x border-b border-grey-2 bg-[var(--colour-home-chart-background)] !px-4 !py-2 pb-4 transition-colors duration-200 group-hover:bg-[var(--colour-home-chart-background-hover)]"
testId="chart-source"
/>
</div>
)
}
115 changes: 115 additions & 0 deletions src/app/components/cms/PopularTopicsCard/PopularTopicsCard.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
import type { z } from 'zod'

import { PopularTopicsCardValue as popularTopicsCardSchema } from '@/api/models/cms/Page/Body'
import { render, screen } from '@/config/test-utils'
import { landingPageMock } from '@/mock-server/handlers/cms/pages/fixtures/page/landing'

/** Props for the `next/link` jest mock (avoids `Record<string, unknown>` widening `children` to `unknown`). */
type NextLinkMockProps = Omit<ComponentPropsWithoutRef<'a'>, 'href'> & {
href?: string
prefetch?: boolean
}

jest.mock('@/app/hooks/queries/useWeatherHealthAlertList', () => ({
__esModule: true,
default: jest.fn(() => ({
isLoading: false,
data: [
{
slug: 'london',
status: 'Green',
geography_name: 'London',
geography_code: 'E12000007',
refresh_date: null,
},
],
})),
}))

jest.mock('@/app/utils/cms/slug', () => ({
getPath: jest.fn((url: string) => (url ? `/resolved-${url.replace(/^https?:\/\/[^/]*\//, '')}` : '/')),
}))

jest.mock('@/app/components/ui/ukhsa', () => ({
Card: ({ children, asChild: _asChild, ...props }: { children?: ReactNode; asChild?: boolean }) => (
<div data-testid="ukhsa-card" {...props}>
{children}
</div>
),
Chart: () => <div data-testid="popular-topics-chart">Chart</div>,
}))

jest.mock('next/link', () => {
return function MockLink({ children, href, prefetch: _prefetch, ...props }: NextLinkMockProps) {
return (
<a href={typeof href === 'string' ? href : '/'} {...props}>
{children}
</a>
)
}
})

jest.mock('@/app/utils/cms.utils', () => ({
renderBlock: jest.fn(() => <div data-testid="mock-render-block" />),
}))

jest.mock('../ChartWithDescriptionCard/ChartWithDescriptionCard', () => ({
ChartWithDescriptionCard: ({ value }: { value: { title: string } }) => (
<div data-testid="mock-chart-with-description">{value.title}</div>
),
}))

import { PopularTopicsCard } from './PopularTopicsCard'

type PopularTopicsCardData = z.infer<typeof popularTopicsCardSchema>

/**
* Loads a popular_topics_card value from the landing fixture and validates with zod.
*
* @param cardId - `id` of the `popular_topics_card` block in `landingPageMock`.
*/
function getPopularTopicsValue(cardId: string): PopularTopicsCardData {
for (const section of landingPageMock.body) {
for (const item of section.value.content) {
if (item.type === 'popular_topics_card' && item.id === cardId) {
const parsed = popularTopicsCardSchema.safeParse(item.value)
if (!parsed.success) {
throw new Error(`Invalid popular topics fixture: ${parsed.error.message}`)
}
return parsed.data
}
}
}
throw new Error(`popular_topics_card not found: ${cardId}`)
}

describe('PopularTopicsCard', () => {
test('renders chart-with-description left column, right chart, and headline metric blocks', () => {
const value = getPopularTopicsValue('9f390365-c8f2-41f5-ba48-fb1dc81b06e8')

render(<PopularTopicsCard value={value} />)

expect(screen.getByTestId('popular-topics-card')).toBeInTheDocument()
expect(screen.getByTestId('mock-chart-with-description')).toHaveTextContent('Childhood vaccination coverage')
expect(screen.getByRole('heading', { name: 'COVID-19 cases by day' })).toBeInTheDocument()
expect(screen.getByTestId('popular-topics-chart')).toBeInTheDocument()
expect(screen.getAllByTestId('mock-render-block').length).toBeGreaterThan(0)
expect(screen.getByTestId('headline-metric-card-4bb7768b-57fd-4090-a70c-a29d8dce91d1')).toBeInTheDocument()
})

test('renders weather health alert left column with description and source', () => {
const value = getPopularTopicsValue('7962648a-9493-463e-a9e9-067bfbdaea4d')

render(<PopularTopicsCard value={value} />)

expect(screen.getByTestId('popular-topics-card')).toBeInTheDocument()
expect(screen.getByText('Cold health alerts')).toBeInTheDocument()
expect(screen.getByText('Optional description for the weather health alerts card.')).toBeInTheDocument()
expect(screen.getByText('Source:')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Weather alerts source' })).toHaveAttribute(
'href',
'https://example.org/weather-alerts'
)
})
})
Loading
Loading