@fullstackdatasolutions/articles 0.3.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -5
- package/dist/index.cjs +287 -105
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +24 -2
- package/dist/index.d.ts +24 -2
- package/dist/index.js +280 -100
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +2 -1
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +2 -1
- package/dist/server.js.map +1 -1
- package/package.json +3 -2
- package/src/ArticleDetailHero.tsx +31 -5
- package/src/ArticleNavigation.tsx +56 -0
- package/src/ArticleSocialShare.tsx +96 -0
- package/src/CategoryArticlesPage.tsx +21 -4
- package/src/__tests__/ArticleCategoryGrid.test.tsx +34 -1
- package/src/__tests__/ArticleDetailHero.test.tsx +42 -10
- package/src/__tests__/ArticleNavigation.test.tsx +127 -0
- package/src/__tests__/ArticleSocialShare.test.tsx +187 -0
- package/src/__tests__/ArticlesPage.test.tsx +147 -1
- package/src/__tests__/CommentItem.test.tsx +110 -0
- package/src/__tests__/seoUtils.test.ts +35 -0
- package/src/index.ts +3 -0
- package/src/seoUtils.ts +7 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { Share2, Copy, Check, Mail, MessageCircle, Users, FileText } from 'lucide-react'
|
|
5
|
+
|
|
6
|
+
interface ArticleSocialShareProps {
|
|
7
|
+
readonly title: string
|
|
8
|
+
readonly url: string
|
|
9
|
+
readonly excerpt?: string
|
|
10
|
+
readonly shareMessage?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ArticleSocialShare({ title, url, excerpt, shareMessage }: ArticleSocialShareProps) {
|
|
14
|
+
const [copied, setCopied] = useState(false)
|
|
15
|
+
|
|
16
|
+
const encodedTitle = encodeURIComponent(title)
|
|
17
|
+
const encodedUrl = encodeURIComponent(url)
|
|
18
|
+
const encodedExcerpt = encodeURIComponent(excerpt || '')
|
|
19
|
+
|
|
20
|
+
const shareLinks = {
|
|
21
|
+
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`,
|
|
22
|
+
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
|
|
23
|
+
twitter: `https://twitter.com/intent/tweet?text=${encodedTitle}&url=${encodedUrl}`,
|
|
24
|
+
reddit: `https://reddit.com/submit?url=${encodedUrl}&title=${encodedTitle}`,
|
|
25
|
+
email: `mailto:?subject=${encodedTitle}&body=${encodedExcerpt}%0A%0A${encodedUrl}`,
|
|
26
|
+
whatsapp: `https://wa.me/?text=${encodedTitle}%20${encodedUrl}`,
|
|
27
|
+
telegram: `https://t.me/share/url?url=${encodedUrl}&text=${encodedTitle}`,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const copyToClipboard = async () => {
|
|
31
|
+
try {
|
|
32
|
+
await navigator.clipboard.writeText(url)
|
|
33
|
+
setCopied(true)
|
|
34
|
+
setTimeout(() => setCopied(false), 2000)
|
|
35
|
+
} catch {
|
|
36
|
+
// clipboard unavailable
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const openShareWindow = (shareUrl: string) => {
|
|
41
|
+
window.open(shareUrl, '_blank', 'width=600,height=400,scrollbars=yes,resizable=yes')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const btnClass =
|
|
45
|
+
'inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground hover:bg-primary/10 hover:border-primary hover:text-primary transition-colors cursor-pointer'
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="border-t border-border pt-8 mt-8">
|
|
49
|
+
<div className="flex items-center gap-2 mb-4">
|
|
50
|
+
<Share2 className="h-5 w-5 text-muted-foreground" />
|
|
51
|
+
<h3 className="text-lg font-semibold text-foreground">Share this article</h3>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div className="flex flex-wrap gap-2">
|
|
55
|
+
<button className={btnClass} onClick={() => openShareWindow(shareLinks.linkedin)}>
|
|
56
|
+
<Users className="h-4 w-4" />
|
|
57
|
+
LinkedIn
|
|
58
|
+
</button>
|
|
59
|
+
<button className={btnClass} onClick={() => openShareWindow(shareLinks.facebook)}>
|
|
60
|
+
<span className="text-xs font-bold">f</span> Facebook
|
|
61
|
+
</button>
|
|
62
|
+
<button className={btnClass} onClick={() => openShareWindow(shareLinks.twitter)}>
|
|
63
|
+
<MessageCircle className="h-4 w-4" />
|
|
64
|
+
Twitter
|
|
65
|
+
</button>
|
|
66
|
+
<button className={btnClass} onClick={() => openShareWindow(shareLinks.reddit)}>
|
|
67
|
+
<FileText className="h-4 w-4" />
|
|
68
|
+
Reddit
|
|
69
|
+
</button>
|
|
70
|
+
<button className={btnClass} onClick={() => openShareWindow(shareLinks.whatsapp)}>
|
|
71
|
+
<MessageCircle className="h-4 w-4" />
|
|
72
|
+
WhatsApp
|
|
73
|
+
</button>
|
|
74
|
+
<button className={btnClass} onClick={() => openShareWindow(shareLinks.telegram)}>
|
|
75
|
+
<MessageCircle className="h-4 w-4" />
|
|
76
|
+
Telegram
|
|
77
|
+
</button>
|
|
78
|
+
<button
|
|
79
|
+
className={btnClass}
|
|
80
|
+
onClick={() => {
|
|
81
|
+
globalThis.location.href = shareLinks.email
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
<Mail className="h-4 w-4" />
|
|
85
|
+
Email
|
|
86
|
+
</button>
|
|
87
|
+
<button className={btnClass} onClick={copyToClipboard}>
|
|
88
|
+
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
|
89
|
+
{copied ? 'Copied!' : 'Copy Link'}
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{shareMessage && <p className="text-sm text-muted-foreground mt-3">{shareMessage}</p>}
|
|
94
|
+
</div>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
@@ -43,17 +43,34 @@ export function CategoryArticlesPage({ category, articles, config }: CategoryArt
|
|
|
43
43
|
]}
|
|
44
44
|
/>
|
|
45
45
|
{/* Hero */}
|
|
46
|
-
<section
|
|
46
|
+
<section
|
|
47
|
+
style={{
|
|
48
|
+
position: 'relative',
|
|
49
|
+
height: '20rem',
|
|
50
|
+
display: 'flex',
|
|
51
|
+
alignItems: 'center',
|
|
52
|
+
justifyContent: 'center',
|
|
53
|
+
overflow: 'hidden',
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
47
56
|
<Image
|
|
48
57
|
src={heroImage}
|
|
49
58
|
alt={categoryName}
|
|
50
59
|
fill
|
|
51
60
|
sizes="100vw"
|
|
52
|
-
|
|
61
|
+
style={{ objectFit: 'cover' }}
|
|
53
62
|
priority
|
|
54
63
|
/>
|
|
55
|
-
<div
|
|
56
|
-
<div
|
|
64
|
+
<div style={{ position: 'absolute', inset: 0, backgroundColor: 'rgba(0,0,0,0.6)' }} />
|
|
65
|
+
<div
|
|
66
|
+
style={{
|
|
67
|
+
position: 'relative',
|
|
68
|
+
zIndex: 10,
|
|
69
|
+
textAlign: 'center',
|
|
70
|
+
color: 'white',
|
|
71
|
+
padding: '0 1rem',
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
57
74
|
<p className="text-sm font-semibold uppercase tracking-widest mb-3 opacity-80">
|
|
58
75
|
Category
|
|
59
76
|
</p>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* @jest-environment jsdom
|
|
3
3
|
*/
|
|
4
4
|
import React from 'react'
|
|
5
|
-
import { render, screen } from '@testing-library/react'
|
|
5
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
6
6
|
import type { CategoryInfo } from '../articleTypes'
|
|
7
7
|
import { ArticleCategoryGrid } from '../ArticleCategoryGrid'
|
|
8
8
|
|
|
@@ -101,4 +101,37 @@ describe('ArticleCategoryGrid', () => {
|
|
|
101
101
|
expect(screen.getByText(label)).toBeInTheDocument()
|
|
102
102
|
})
|
|
103
103
|
})
|
|
104
|
+
|
|
105
|
+
describe('Load more', () => {
|
|
106
|
+
it('shows Load more button when categories exceed pageSize', () => {
|
|
107
|
+
const cats: CategoryInfo[] = Array.from({ length: 5 }, (_, i) => ({
|
|
108
|
+
name: `Cat ${i + 1}`,
|
|
109
|
+
slug: `cat-${i + 1}`,
|
|
110
|
+
count: i + 1,
|
|
111
|
+
featuredImage: '/image.jpg',
|
|
112
|
+
}))
|
|
113
|
+
render(<ArticleCategoryGrid categories={cats} pageSize={3} />)
|
|
114
|
+
expect(screen.getByRole('button', { name: /load more categories/i })).toBeInTheDocument()
|
|
115
|
+
// only first 3 visible
|
|
116
|
+
expect(screen.getByText('Cat 1')).toBeInTheDocument()
|
|
117
|
+
expect(screen.queryByText('Cat 4')).not.toBeInTheDocument()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('clicking Load more reveals more categories', () => {
|
|
121
|
+
const cats: CategoryInfo[] = Array.from({ length: 5 }, (_, i) => ({
|
|
122
|
+
name: `Cat ${i + 1}`,
|
|
123
|
+
slug: `cat-${i + 1}`,
|
|
124
|
+
count: i + 1,
|
|
125
|
+
featuredImage: '/image.jpg',
|
|
126
|
+
}))
|
|
127
|
+
render(<ArticleCategoryGrid categories={cats} pageSize={3} />)
|
|
128
|
+
fireEvent.click(screen.getByRole('button', { name: /load more categories/i }))
|
|
129
|
+
expect(screen.getByText('Cat 4')).toBeInTheDocument()
|
|
130
|
+
expect(screen.getByText('Cat 5')).toBeInTheDocument()
|
|
131
|
+
// button disappears when all are shown
|
|
132
|
+
expect(
|
|
133
|
+
screen.queryByRole('button', { name: /load more categories/i })
|
|
134
|
+
).not.toBeInTheDocument()
|
|
135
|
+
})
|
|
136
|
+
})
|
|
104
137
|
})
|
|
@@ -62,9 +62,15 @@ describe('ArticleDetailHero', () => {
|
|
|
62
62
|
})
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
-
describe('category rendering
|
|
66
|
-
it('renders category tags as
|
|
65
|
+
describe('category rendering with default categoryBasePath', () => {
|
|
66
|
+
it('renders category tags as links to /articles/category/[slug] when categoryBasePath is not provided', () => {
|
|
67
67
|
render(<ArticleDetailHero article={articleWith3Categories} />)
|
|
68
|
+
const politicsLink = screen.getByRole('link', { name: 'Politics' })
|
|
69
|
+
expect(politicsLink).toHaveAttribute('href', '/articles/category/politics')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('renders category tags as spans when categoryBasePath is empty string', () => {
|
|
73
|
+
render(<ArticleDetailHero article={articleWith3Categories} categoryBasePath="" />)
|
|
68
74
|
const links = screen.queryAllByRole('link')
|
|
69
75
|
const categoryLinks = links.filter((el) =>
|
|
70
76
|
['Politics', 'Elections', 'Voting'].includes(el.textContent ?? '')
|
|
@@ -84,19 +90,45 @@ describe('ArticleDetailHero', () => {
|
|
|
84
90
|
|
|
85
91
|
it('caps linked category tags at 4 when article has more than 4 categories', () => {
|
|
86
92
|
render(
|
|
87
|
-
<ArticleDetailHero
|
|
88
|
-
article={articleWith6Categories}
|
|
89
|
-
categoryBasePath="/articles/category"
|
|
90
|
-
/>
|
|
93
|
+
<ArticleDetailHero article={articleWith6Categories} categoryBasePath="/articles/category" />
|
|
91
94
|
)
|
|
92
|
-
const links = screen
|
|
93
|
-
|
|
94
|
-
|
|
95
|
+
const links = screen
|
|
96
|
+
.getAllByRole('link')
|
|
97
|
+
.filter((el) =>
|
|
98
|
+
[
|
|
99
|
+
'Politics',
|
|
100
|
+
'Elections',
|
|
101
|
+
'Voting',
|
|
102
|
+
'Campaigns',
|
|
103
|
+
'Democracy',
|
|
104
|
+
'Civic Engagement',
|
|
105
|
+
].includes(el.textContent ?? '')
|
|
95
106
|
)
|
|
96
|
-
)
|
|
97
107
|
expect(links).toHaveLength(4)
|
|
98
108
|
expect(screen.queryByRole('link', { name: 'Democracy' })).not.toBeInTheDocument()
|
|
99
109
|
expect(screen.queryByRole('link', { name: 'Civic Engagement' })).not.toBeInTheDocument()
|
|
100
110
|
})
|
|
101
111
|
})
|
|
112
|
+
|
|
113
|
+
describe('showDate prop', () => {
|
|
114
|
+
const articleWithDate: Article = {
|
|
115
|
+
...baseArticle,
|
|
116
|
+
date: '2024-01-15',
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
it('does not render the date when showDate is omitted (default behavior)', () => {
|
|
120
|
+
render(<ArticleDetailHero article={articleWithDate} />)
|
|
121
|
+
expect(screen.queryByText('2024-01-15')).not.toBeInTheDocument()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('does not render the date when showDate={false}', () => {
|
|
125
|
+
render(<ArticleDetailHero article={articleWithDate} showDate={false} />)
|
|
126
|
+
expect(screen.queryByText('2024-01-15')).not.toBeInTheDocument()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('renders the date when showDate={true} and article.date is set', () => {
|
|
130
|
+
render(<ArticleDetailHero article={articleWithDate} showDate={true} />)
|
|
131
|
+
expect(screen.getByText('2024-01-15')).toBeInTheDocument()
|
|
132
|
+
})
|
|
133
|
+
})
|
|
102
134
|
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import { render, screen } from '@testing-library/react'
|
|
6
|
+
import { ArticleNavigation } from '../ArticleNavigation'
|
|
7
|
+
|
|
8
|
+
jest.mock('next/link', () => ({
|
|
9
|
+
__esModule: true,
|
|
10
|
+
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
|
|
11
|
+
<a href={href}>{children}</a>
|
|
12
|
+
),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
const basePath = '/articles'
|
|
16
|
+
|
|
17
|
+
const prevArticle = { slug: 'previous-article', title: 'Previous Article Title' }
|
|
18
|
+
const nextArticle = { slug: 'next-article', title: 'Next Article Title' }
|
|
19
|
+
|
|
20
|
+
describe('ArticleNavigation', () => {
|
|
21
|
+
describe('null rendering', () => {
|
|
22
|
+
it('renders nothing when both previous and next are null', () => {
|
|
23
|
+
const { container } = render(
|
|
24
|
+
<ArticleNavigation previous={null} next={null} basePath={basePath} />
|
|
25
|
+
)
|
|
26
|
+
expect(container.firstChild).toBeNull()
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('previous link', () => {
|
|
31
|
+
it('renders the previous link with the correct href', () => {
|
|
32
|
+
render(<ArticleNavigation previous={prevArticle} next={null} basePath={basePath} />)
|
|
33
|
+
const link = screen.getByRole('link', { name: /previous article title/i })
|
|
34
|
+
expect(link).toHaveAttribute('href', '/articles/previous-article')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('shows the "Previous Article" label', () => {
|
|
38
|
+
render(<ArticleNavigation previous={prevArticle} next={null} basePath={basePath} />)
|
|
39
|
+
expect(screen.getByText('Previous Article')).toBeInTheDocument()
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('next link', () => {
|
|
44
|
+
it('renders the next link with the correct href', () => {
|
|
45
|
+
render(<ArticleNavigation previous={null} next={nextArticle} basePath={basePath} />)
|
|
46
|
+
const link = screen.getByRole('link', { name: /next article title/i })
|
|
47
|
+
expect(link).toHaveAttribute('href', '/articles/next-article')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('shows the "Next Article" label', () => {
|
|
51
|
+
render(<ArticleNavigation previous={null} next={nextArticle} basePath={basePath} />)
|
|
52
|
+
expect(screen.getByText('Next Article')).toBeInTheDocument()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('both links', () => {
|
|
57
|
+
it('renders both previous and next links when both are provided', () => {
|
|
58
|
+
render(<ArticleNavigation previous={prevArticle} next={nextArticle} basePath={basePath} />)
|
|
59
|
+
expect(screen.getByRole('link', { name: /previous article title/i })).toHaveAttribute(
|
|
60
|
+
'href',
|
|
61
|
+
'/articles/previous-article'
|
|
62
|
+
)
|
|
63
|
+
expect(screen.getByRole('link', { name: /next article title/i })).toHaveAttribute(
|
|
64
|
+
'href',
|
|
65
|
+
'/articles/next-article'
|
|
66
|
+
)
|
|
67
|
+
expect(screen.getByText('Previous Article')).toBeInTheDocument()
|
|
68
|
+
expect(screen.getByText('Next Article')).toBeInTheDocument()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('title truncation', () => {
|
|
73
|
+
it('truncates titles longer than 60 characters to 60 chars followed by "..."', () => {
|
|
74
|
+
const longTitle = 'A'.repeat(61)
|
|
75
|
+
render(
|
|
76
|
+
<ArticleNavigation
|
|
77
|
+
previous={{ slug: 'long-prev', title: longTitle }}
|
|
78
|
+
next={null}
|
|
79
|
+
basePath={basePath}
|
|
80
|
+
/>
|
|
81
|
+
)
|
|
82
|
+
const truncated = 'A'.repeat(60) + '...'
|
|
83
|
+
expect(screen.getByText(truncated)).toBeInTheDocument()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('does not truncate a title of exactly 60 characters', () => {
|
|
87
|
+
const exactTitle = 'B'.repeat(60)
|
|
88
|
+
render(
|
|
89
|
+
<ArticleNavigation
|
|
90
|
+
previous={{ slug: 'exact-prev', title: exactTitle }}
|
|
91
|
+
next={null}
|
|
92
|
+
basePath={basePath}
|
|
93
|
+
/>
|
|
94
|
+
)
|
|
95
|
+
expect(screen.getByText(exactTitle)).toBeInTheDocument()
|
|
96
|
+
expect(screen.queryByText(exactTitle + '...')).not.toBeInTheDocument()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('does not truncate a title shorter than 60 characters', () => {
|
|
100
|
+
const shortTitle = 'Short Title'
|
|
101
|
+
render(
|
|
102
|
+
<ArticleNavigation
|
|
103
|
+
previous={{ slug: 'short-prev', title: shortTitle }}
|
|
104
|
+
next={null}
|
|
105
|
+
basePath={basePath}
|
|
106
|
+
/>
|
|
107
|
+
)
|
|
108
|
+
expect(screen.getByText('Short Title')).toBeInTheDocument()
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('basePath handling', () => {
|
|
113
|
+
it('uses a custom basePath in generated hrefs', () => {
|
|
114
|
+
render(
|
|
115
|
+
<ArticleNavigation previous={prevArticle} next={nextArticle} basePath="/news/articles" />
|
|
116
|
+
)
|
|
117
|
+
expect(screen.getByRole('link', { name: /previous article title/i })).toHaveAttribute(
|
|
118
|
+
'href',
|
|
119
|
+
'/news/articles/previous-article'
|
|
120
|
+
)
|
|
121
|
+
expect(screen.getByRole('link', { name: /next article title/i })).toHaveAttribute(
|
|
122
|
+
'href',
|
|
123
|
+
'/news/articles/next-article'
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
6
|
+
import { ArticleSocialShare } from '../ArticleSocialShare'
|
|
7
|
+
|
|
8
|
+
const mockOpen = jest.fn()
|
|
9
|
+
const mockWriteText = jest.fn()
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
jest.clearAllMocks()
|
|
13
|
+
window.open = mockOpen
|
|
14
|
+
Object.defineProperty(navigator, 'clipboard', {
|
|
15
|
+
value: { writeText: mockWriteText },
|
|
16
|
+
writable: true,
|
|
17
|
+
configurable: true,
|
|
18
|
+
})
|
|
19
|
+
mockWriteText.mockResolvedValue(undefined)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const defaultProps = {
|
|
23
|
+
title: 'My Test Article',
|
|
24
|
+
url: 'https://example.com/articles/my-test-article',
|
|
25
|
+
excerpt: 'A short excerpt.',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('ArticleSocialShare', () => {
|
|
29
|
+
describe('rendering', () => {
|
|
30
|
+
it('renders the "Share this article" heading', () => {
|
|
31
|
+
render(<ArticleSocialShare {...defaultProps} />)
|
|
32
|
+
expect(screen.getByText('Share this article')).toBeInTheDocument()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('renders all 8 share buttons', () => {
|
|
36
|
+
render(<ArticleSocialShare {...defaultProps} />)
|
|
37
|
+
expect(screen.getByRole('button', { name: /linkedin/i })).toBeInTheDocument()
|
|
38
|
+
expect(screen.getByRole('button', { name: /facebook/i })).toBeInTheDocument()
|
|
39
|
+
expect(screen.getByRole('button', { name: /twitter/i })).toBeInTheDocument()
|
|
40
|
+
expect(screen.getByRole('button', { name: /reddit/i })).toBeInTheDocument()
|
|
41
|
+
expect(screen.getByRole('button', { name: /whatsapp/i })).toBeInTheDocument()
|
|
42
|
+
expect(screen.getByRole('button', { name: /telegram/i })).toBeInTheDocument()
|
|
43
|
+
expect(screen.getByRole('button', { name: /email/i })).toBeInTheDocument()
|
|
44
|
+
expect(screen.getByRole('button', { name: /copy link/i })).toBeInTheDocument()
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('share window buttons', () => {
|
|
49
|
+
it('clicking LinkedIn calls window.open with the correct LinkedIn URL', () => {
|
|
50
|
+
render(<ArticleSocialShare {...defaultProps} />)
|
|
51
|
+
fireEvent.click(screen.getByRole('button', { name: /linkedin/i }))
|
|
52
|
+
const encodedUrl = encodeURIComponent(defaultProps.url)
|
|
53
|
+
expect(mockOpen).toHaveBeenCalledWith(
|
|
54
|
+
`https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`,
|
|
55
|
+
'_blank',
|
|
56
|
+
'width=600,height=400,scrollbars=yes,resizable=yes'
|
|
57
|
+
)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('clicking Facebook calls window.open with the correct Facebook URL', () => {
|
|
61
|
+
render(<ArticleSocialShare {...defaultProps} />)
|
|
62
|
+
fireEvent.click(screen.getByRole('button', { name: /facebook/i }))
|
|
63
|
+
const encodedUrl = encodeURIComponent(defaultProps.url)
|
|
64
|
+
expect(mockOpen).toHaveBeenCalledWith(
|
|
65
|
+
`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
|
|
66
|
+
'_blank',
|
|
67
|
+
'width=600,height=400,scrollbars=yes,resizable=yes'
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('clicking Twitter calls window.open with correct URL and encoded title', () => {
|
|
72
|
+
render(<ArticleSocialShare {...defaultProps} />)
|
|
73
|
+
fireEvent.click(screen.getByRole('button', { name: /twitter/i }))
|
|
74
|
+
const encodedTitle = encodeURIComponent(defaultProps.title)
|
|
75
|
+
const encodedUrl = encodeURIComponent(defaultProps.url)
|
|
76
|
+
expect(mockOpen).toHaveBeenCalledWith(
|
|
77
|
+
`https://twitter.com/intent/tweet?text=${encodedTitle}&url=${encodedUrl}`,
|
|
78
|
+
'_blank',
|
|
79
|
+
'width=600,height=400,scrollbars=yes,resizable=yes'
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('clicking Reddit calls window.open with correct URL and encoded title', () => {
|
|
84
|
+
render(<ArticleSocialShare {...defaultProps} />)
|
|
85
|
+
fireEvent.click(screen.getByRole('button', { name: /reddit/i }))
|
|
86
|
+
const encodedTitle = encodeURIComponent(defaultProps.title)
|
|
87
|
+
const encodedUrl = encodeURIComponent(defaultProps.url)
|
|
88
|
+
expect(mockOpen).toHaveBeenCalledWith(
|
|
89
|
+
`https://reddit.com/submit?url=${encodedUrl}&title=${encodedTitle}`,
|
|
90
|
+
'_blank',
|
|
91
|
+
'width=600,height=400,scrollbars=yes,resizable=yes'
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('clicking WhatsApp calls window.open with the correct WhatsApp URL', () => {
|
|
96
|
+
render(<ArticleSocialShare {...defaultProps} />)
|
|
97
|
+
fireEvent.click(screen.getByRole('button', { name: /whatsapp/i }))
|
|
98
|
+
const encodedTitle = encodeURIComponent(defaultProps.title)
|
|
99
|
+
const encodedUrl = encodeURIComponent(defaultProps.url)
|
|
100
|
+
expect(mockOpen).toHaveBeenCalledWith(
|
|
101
|
+
`https://wa.me/?text=${encodedTitle}%20${encodedUrl}`,
|
|
102
|
+
'_blank',
|
|
103
|
+
'width=600,height=400,scrollbars=yes,resizable=yes'
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('clicking Telegram calls window.open with the correct Telegram URL', () => {
|
|
108
|
+
render(<ArticleSocialShare {...defaultProps} />)
|
|
109
|
+
fireEvent.click(screen.getByRole('button', { name: /telegram/i }))
|
|
110
|
+
const encodedTitle = encodeURIComponent(defaultProps.title)
|
|
111
|
+
const encodedUrl = encodeURIComponent(defaultProps.url)
|
|
112
|
+
expect(mockOpen).toHaveBeenCalledWith(
|
|
113
|
+
`https://t.me/share/url?url=${encodedUrl}&text=${encodedTitle}`,
|
|
114
|
+
'_blank',
|
|
115
|
+
'width=600,height=400,scrollbars=yes,resizable=yes'
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('Copy Link button', () => {
|
|
121
|
+
it('clicking Copy Link calls navigator.clipboard.writeText with the URL', async () => {
|
|
122
|
+
render(<ArticleSocialShare {...defaultProps} />)
|
|
123
|
+
fireEvent.click(screen.getByRole('button', { name: /copy link/i }))
|
|
124
|
+
await waitFor(() => {
|
|
125
|
+
expect(mockWriteText).toHaveBeenCalledWith(defaultProps.url)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('shows "Copied!" text after clicking Copy Link', async () => {
|
|
130
|
+
render(<ArticleSocialShare {...defaultProps} />)
|
|
131
|
+
fireEvent.click(screen.getByRole('button', { name: /copy link/i }))
|
|
132
|
+
await waitFor(() => {
|
|
133
|
+
expect(screen.getByRole('button', { name: /copied!/i })).toBeInTheDocument()
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('does not throw when clipboard.writeText rejects', async () => {
|
|
138
|
+
mockWriteText.mockRejectedValueOnce(new Error('not allowed'))
|
|
139
|
+
render(<ArticleSocialShare {...defaultProps} />)
|
|
140
|
+
// should not throw - catch block silently swallows the error
|
|
141
|
+
expect(() =>
|
|
142
|
+
fireEvent.click(screen.getByRole('button', { name: /copy link/i }))
|
|
143
|
+
).not.toThrow()
|
|
144
|
+
// Copy Link label is still present (did not toggle to Copied!)
|
|
145
|
+
await waitFor(() =>
|
|
146
|
+
expect(screen.getByRole('button', { name: /copy link/i })).toBeInTheDocument()
|
|
147
|
+
)
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('Email button', () => {
|
|
152
|
+
it('clicking Email sets location.href to a mailto link', () => {
|
|
153
|
+
const originalLocation = globalThis.location
|
|
154
|
+
delete (globalThis as Record<string, unknown>).location
|
|
155
|
+
;(globalThis as Record<string, unknown>).location = { href: '' }
|
|
156
|
+
|
|
157
|
+
render(<ArticleSocialShare {...defaultProps} />)
|
|
158
|
+
fireEvent.click(screen.getByRole('button', { name: /email/i }))
|
|
159
|
+
|
|
160
|
+
expect((globalThis.location as { href: string }).href).toContain('mailto:')
|
|
161
|
+
expect((globalThis.location as { href: string }).href).toContain(
|
|
162
|
+
encodeURIComponent(defaultProps.title)
|
|
163
|
+
)
|
|
164
|
+
;(globalThis as Record<string, unknown>).location = originalLocation
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('shareMessage prop', () => {
|
|
169
|
+
it('renders shareMessage as a paragraph when provided', () => {
|
|
170
|
+
render(<ArticleSocialShare {...defaultProps} shareMessage="Help spread the word!" />)
|
|
171
|
+
expect(screen.getByText('Help spread the word!')).toBeInTheDocument()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('does not render a shareMessage paragraph when the prop is omitted', () => {
|
|
175
|
+
render(<ArticleSocialShare {...defaultProps} />)
|
|
176
|
+
expect(screen.queryByText('Help spread the word!')).not.toBeInTheDocument()
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('excerpt prop', () => {
|
|
181
|
+
it('renders without error when excerpt is not provided', () => {
|
|
182
|
+
render(<ArticleSocialShare title="No Excerpt Title" url="https://example.com/no-excerpt" />)
|
|
183
|
+
expect(screen.getByText('Share this article')).toBeInTheDocument()
|
|
184
|
+
expect(screen.getByRole('button', { name: /copy link/i })).toBeInTheDocument()
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
})
|