Skip to content

Commit b387643

Browse files
committed
Refactor server code in Carousel component to server/index.ts file, add unit test, add Astro Image component to default imports in markdown layout
1 parent 8f91dec commit b387643

8 files changed

Lines changed: 218 additions & 74 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { CollectionEntry } from 'astro:content'
2+
export type CarouselVariant = 'featured' | 'suggested' | 'random'
3+
4+
export const collectionMap = {
5+
'case-studies': 'caseStudies',
6+
articles: 'articles',
7+
services: 'services',
8+
} as const
9+
10+
export type CollectionMap = typeof collectionMap
11+
export type CollectionSlug = keyof CollectionMap
12+
type CollectionEntryMap = {
13+
[Slug in CollectionSlug]: CollectionEntry<CollectionMap[Slug]>
14+
}
15+
16+
export interface CarouselProps<T extends CollectionSlug = CollectionSlug> {
17+
title?: string
18+
limit?: number
19+
variant?: CarouselVariant
20+
currentSlug: string
21+
type: T
22+
}
23+
24+
export type ItemType<T extends CollectionSlug = CollectionSlug> = CollectionEntryMap[T]

src/components/Carousel/client/__tests__/index.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { beforeEach, describe, expect, it, vi } from 'vitest'
33
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
44
import CarouselComponent from '@components/Carousel/index.astro'
5-
import type { CarouselProps } from '@components/Carousel/props'
5+
import type { CarouselProps } from '@components/Carousel/@types'
66
import type { CarouselElement } from '@components/Carousel/client'
77
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
88
import { executeRender } from '@test/unit/helpers/litRuntime'

src/components/Carousel/index.astro

Lines changed: 17 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,32 @@
11
---
22
// Generic Carousel component for displaying content - both featured and suggested
33
import { getCollection } from 'astro:content'
4-
import type { CollectionEntry } from 'astro:content'
5-
import type { CarouselProps, CollectionKeyMap } from './props'
4+
import {
5+
type CollectionSlug,
6+
type CarouselVariant,
7+
type ItemType,
8+
collectionMap,
9+
} from './@types'
10+
import { prepareItems } from './server'
611
7-
export type { CarouselProps as Props } from './props'
12+
export interface Props {
13+
title?: string
14+
limit?: number
15+
variant?: CarouselVariant
16+
currentSlug: string
17+
type: CollectionSlug
18+
}
819
920
const {
1021
title = 'Featured Content',
1122
limit = 3,
1223
variant = 'featured',
1324
currentSlug,
1425
type,
15-
} = Astro.props as CarouselProps
16-
17-
// Map the prop type to the actual collection key with proper typing
18-
const collectionKey = {
19-
services: 'services',
20-
'case-studies': 'caseStudies',
21-
articles: 'articles',
22-
}[type] as CollectionKeyMap[typeof type]
23-
24-
// Get all content from the specified collection with proper typing
25-
const allItems = await getCollection(collectionKey)
26-
type ItemType = CollectionEntry<CollectionKeyMap[typeof type]>
27-
28-
// Filter and sort items based on variant
29-
let items: ItemType[] = allItems
30-
31-
switch (variant) {
32-
case 'featured':
33-
// Show only featured items, excluding current item
34-
items = allItems
35-
.filter((item: ItemType) => {
36-
if (item.id === currentSlug) return false
37-
// All collection types now use the consistent "featured" field
38-
return item.data.featured === true
39-
})
40-
.sort(
41-
(a: ItemType, b: ItemType) =>
42-
new Date(b.data.publishDate).getTime() - new Date(a.data.publishDate).getTime()
43-
)
44-
break
26+
} = Astro.props
4527
46-
case 'suggested':
47-
// Filter out current item and get latest items (suggested mode)
48-
items = allItems
49-
.filter((item: ItemType) => item.id !== currentSlug)
50-
.sort(
51-
(a: ItemType, b: ItemType) =>
52-
new Date(b.data.publishDate).getTime() - new Date(a.data.publishDate).getTime()
53-
)
54-
break
55-
56-
case 'random':
57-
// Show all items excluding current one in random order
58-
items = allItems
59-
.filter((item: ItemType) => item.id !== currentSlug)
60-
.sort(() => Math.random() - 0.5)
61-
break
62-
}
63-
64-
// Apply limit
65-
if (limit) {
66-
items = items.slice(0, limit)
67-
}
28+
const allItems = await getCollection(collectionMap[type])
29+
const items = prepareItems(allItems, variant, currentSlug, limit)
6830
---
6931

7032
{

src/components/Carousel/props.ts

Lines changed: 0 additions & 17 deletions
This file was deleted.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { ItemType } from '@components/Carousel/@types'
2+
3+
type MinimalCarouselEntry = {
4+
id: string
5+
slug: string
6+
collection: 'articles'
7+
data: {
8+
title: string
9+
description: string
10+
publishDate: Date
11+
featured: boolean
12+
icon?: string
13+
}
14+
}
15+
16+
const entries: MinimalCarouselEntry[] = [
17+
{
18+
id: 'article-delta',
19+
slug: 'article-delta',
20+
collection: 'articles',
21+
data: {
22+
title: 'Article Delta',
23+
description: 'Latest launch deep-dive',
24+
publishDate: new Date('2024-04-15T00:00:00.000Z'),
25+
featured: true,
26+
icon: '/icons/delta.svg',
27+
},
28+
},
29+
{
30+
id: 'article-charlie',
31+
slug: 'article-charlie',
32+
collection: 'articles',
33+
data: {
34+
title: 'Article Charlie',
35+
description: 'Scaling case study',
36+
publishDate: new Date('2024-03-10T00:00:00.000Z'),
37+
featured: false,
38+
},
39+
},
40+
{
41+
id: 'article-bravo',
42+
slug: 'article-bravo',
43+
collection: 'articles',
44+
data: {
45+
title: 'Article Bravo',
46+
description: 'Platform roadmap',
47+
publishDate: new Date('2024-02-20T00:00:00.000Z'),
48+
featured: true,
49+
},
50+
},
51+
{
52+
id: 'article-alpha',
53+
slug: 'article-alpha',
54+
collection: 'articles',
55+
data: {
56+
title: 'Article Alpha',
57+
description: 'Foundational principles',
58+
publishDate: new Date('2024-01-05T00:00:00.000Z'),
59+
featured: true,
60+
},
61+
},
62+
]
63+
64+
export const articleCollectionFixture = entries as unknown as ItemType<'articles'>[]
65+
66+
export const cloneArticleCollection = () =>
67+
articleCollectionFixture.map(item => ({
68+
...item,
69+
data: {
70+
...item.data,
71+
publishDate: new Date(item.data.publishDate),
72+
},
73+
})) as ItemType<'articles'>[]
74+
75+
export default articleCollectionFixture
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
import { prepareItems } from '@components/Carousel/server'
3+
import { cloneArticleCollection } from '@components/Carousel/server/__fixtures__/collection.fixture'
4+
5+
const createItems = () => cloneArticleCollection()
6+
7+
describe('prepareItems', () => {
8+
afterEach(() => {
9+
vi.restoreAllMocks()
10+
})
11+
12+
it('filters featured items and sorts by newest publish date', () => {
13+
const result = prepareItems(createItems(), 'featured', 'article-bravo')
14+
15+
expect(result.map(item => item.id)).toEqual(['article-delta', 'article-alpha'])
16+
expect(result.every(item => item.data.featured)).toBe(true)
17+
expect(result).toHaveLength(2)
18+
})
19+
20+
it('returns suggested items ordered by publish date without the current slug', () => {
21+
const result = prepareItems(createItems(), 'suggested', 'article-alpha')
22+
23+
expect(result.map(item => item.id)).toEqual([
24+
'article-delta',
25+
'article-charlie',
26+
'article-bravo',
27+
])
28+
expect(result).toHaveLength(3)
29+
expect(result.find(item => item.id === 'article-alpha')).toBeUndefined()
30+
})
31+
32+
it('applies the requested limit after filtering', () => {
33+
const limited = prepareItems(createItems(), 'suggested', 'article-alpha', 1)
34+
35+
expect(limited).toHaveLength(1)
36+
expect(limited[0]?.id).toBe('article-delta')
37+
})
38+
39+
it('invokes Math.random when shuffling for the random variant and excludes the current slug', () => {
40+
const mathSpy = vi.spyOn(Math, 'random').mockReturnValue(0.9)
41+
42+
const randomItems = prepareItems(createItems(), 'random', 'article-charlie', 3)
43+
44+
expect(mathSpy).toHaveBeenCalled()
45+
expect(randomItems).toHaveLength(3)
46+
expect(randomItems.find(item => item.id === 'article-charlie')).toBeUndefined()
47+
})
48+
})
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { CarouselVariant, ItemType } from '@components/Carousel/@types'
2+
3+
export const prepareItems = (
4+
allItems: ItemType[],
5+
variant: CarouselVariant,
6+
currentSlug: string,
7+
limit?: number
8+
) => {
9+
// Filter and sort items based on variant
10+
let items: ItemType[] = allItems
11+
12+
switch (variant) {
13+
case 'featured':
14+
// Show only featured items, excluding current item
15+
items = allItems
16+
.filter((item: ItemType) => {
17+
if (item.id === currentSlug) return false
18+
// All collection types now use the consistent "featured" field
19+
return item.data.featured === true
20+
})
21+
.sort(
22+
(a: ItemType, b: ItemType) =>
23+
new Date(b.data.publishDate).getTime() - new Date(a.data.publishDate).getTime()
24+
)
25+
break
26+
27+
case 'suggested':
28+
// Filter out current item and get latest items (suggested mode)
29+
items = allItems
30+
.filter((item: ItemType) => item.id !== currentSlug)
31+
.sort(
32+
(a: ItemType, b: ItemType) =>
33+
new Date(b.data.publishDate).getTime() - new Date(a.data.publishDate).getTime()
34+
)
35+
break
36+
37+
case 'random':
38+
// Show all items excluding current one in random order
39+
items = allItems
40+
.filter((item: ItemType) => item.id !== currentSlug)
41+
.sort(() => Math.random() - 0.5)
42+
break
43+
}
44+
45+
// Apply limit
46+
if (limit) {
47+
items = items.slice(0, limit)
48+
}
49+
50+
return items
51+
}

src/layouts/MarkdownLayout.astro

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
import { type CollectionEntry, render } from 'astro:content'
3+
import { Image } from 'astro:assets'
34
import type { MarkdownHeading } from 'astro'
45
56
/**
@@ -26,7 +27,7 @@ import Shares from '@components/Social/Shares/index.astro'
2627
import Testimonials from '@components/Testimonials/index.astro'
2728
2829
/** Export components for use in MDX */
29-
const Components = { Avatar, Callout, Carousel, Contact, Embed, Featured, Highlighter, Icon, MastodonModal, Newsletter, Shares, Testimonials }
30+
const Components = { Avatar, Callout, Carousel, Contact, Embed, Featured, Highlighter, Icon, Image, MastodonModal, Newsletter, Shares, Testimonials }
3031
3132
export interface Props {
3233
/** Page title shown in <title> and H1 */

0 commit comments

Comments
 (0)