@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.
@@ -1,7 +1,7 @@
1
1
  import React from 'react'
2
2
  import { ArticlesPage } from '../ArticlesPage'
3
3
  import type { ArticlesConfig } from '../articlesConfig'
4
- import { render, screen, waitFor } from '@testing-library/react'
4
+ import { render, screen, waitFor, fireEvent } from '@testing-library/react'
5
5
 
6
6
  jest.mock('next/link', () => ({
7
7
  __esModule: true,
@@ -127,6 +127,152 @@ describe('ArticlesPage', () => {
127
127
  expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument()
128
128
  })
129
129
 
130
+ it('clicking Try again in the error state resets the search', async () => {
131
+ // First call fails, second call succeeds (after Try again click)
132
+ ;(globalThis.fetch as jest.Mock).mockResolvedValueOnce({ ok: false }).mockResolvedValue({
133
+ ok: true,
134
+ json: async () => ({ articles: mockArticles }),
135
+ })
136
+ render(<ArticlesPage config={{ ...baseConfig, layout: ['latest'] }} />)
137
+
138
+ await waitFor(() => expect(screen.getByText('Error Loading Articles')).toBeInTheDocument())
139
+
140
+ // Click "Try again" - invokes the onClick={() => handleSearch('')} on line 116
141
+ fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
142
+
143
+ await waitFor(() => expect(screen.getByText('Latest Articles')).toBeInTheDocument())
144
+ })
145
+
146
+ it('clicking Clear search in LatestArticlesSection invokes onClearSearch', async () => {
147
+ jest.useFakeTimers({ legacyFakeTimers: false })
148
+
149
+ // Initial load returns articles; search returns empty
150
+ ;(globalThis.fetch as jest.Mock)
151
+ .mockResolvedValueOnce({
152
+ ok: true,
153
+ json: async () => ({ articles: mockArticles }),
154
+ })
155
+ .mockResolvedValue({
156
+ ok: true,
157
+ json: async () => ({ articles: [] }),
158
+ })
159
+ render(<ArticlesPage config={{ ...baseConfig, layout: ['search', 'latest'] }} />)
160
+
161
+ // Let the initial fetch resolve - use runAllTimersAsync to flush timers + microtasks
162
+ await (jest as unknown as { runAllTimersAsync(): Promise<void> }).runAllTimersAsync()
163
+ await waitFor(() => expect(screen.getByText('Latest Articles')).toBeInTheDocument())
164
+
165
+ // Type a search query (3+ chars triggers debounce)
166
+ const searchInput = screen.getByPlaceholderText('Search articles (3+ characters required)...')
167
+ fireEvent.change(searchInput, { target: { value: 'xyz' } })
168
+
169
+ // Advance timers past the 1000ms debounce and flush microtasks
170
+ await (jest as unknown as { runAllTimersAsync(): Promise<void> }).runAllTimersAsync()
171
+
172
+ // Now articles=[] and searchQuery='xyz' so "Clear search" button appears
173
+ await waitFor(() =>
174
+ expect(screen.getByRole('button', { name: /clear search/i })).toBeInTheDocument()
175
+ )
176
+
177
+ // Click "Clear search" - triggers onClearSearch={() => handleSearch('')} on line 131
178
+ fireEvent.click(screen.getByRole('button', { name: /clear search/i }))
179
+
180
+ await (jest as unknown as { runAllTimersAsync(): Promise<void> }).runAllTimersAsync()
181
+
182
+ jest.useRealTimers()
183
+
184
+ await waitFor(() => expect(screen.getByText('Latest Articles')).toBeInTheDocument())
185
+ })
186
+
187
+ it('renders null for an unknown/unhandled layout section', async () => {
188
+ mockFetchSuccess()
189
+ // 'newsletter' is a valid ArticlesSection type but has no case in the switch -> default: return null
190
+ const { container } = render(
191
+ <ArticlesPage config={{ ...baseConfig, layout: ['newsletter'] }} />
192
+ )
193
+ await waitFor(() => {})
194
+ // The wrapper div exists but has no section content
195
+ expect(container.firstChild).toBeInTheDocument()
196
+ expect(container.querySelector('section')).not.toBeInTheDocument()
197
+ })
198
+
199
+ it('applies all theme properties as CSS custom properties', async () => {
200
+ mockFetchSuccess()
201
+ const config: ArticlesConfig = {
202
+ ...baseConfig,
203
+ layout: ['latest'],
204
+ theme: {
205
+ fontFamily: "'Inter', sans-serif",
206
+ headerColor: '#111827',
207
+ textColor: '#6b7280',
208
+ backgroundColor: '#ffffff',
209
+ linkColor: '#4f46e5',
210
+ linkHoverColor: '#4338ca',
211
+ linkTextDecoration: 'underline',
212
+ headerFontWeight: 700,
213
+ headerFontSize: '1.25rem',
214
+ bodyFontSize: '1rem',
215
+ lineHeight: '1.75',
216
+ borderRadius: '0.5rem',
217
+ },
218
+ }
219
+ const { container } = render(<ArticlesPage config={config} />)
220
+
221
+ await waitFor(() => expect(screen.getByText('Latest Articles')).toBeInTheDocument())
222
+
223
+ const wrapper = container.firstChild as HTMLElement
224
+ expect(wrapper.style.getPropertyValue('--articles-font-family')).toBe("'Inter', sans-serif")
225
+ expect(wrapper.style.getPropertyValue('--articles-text-color')).toBe('#6b7280')
226
+ expect(wrapper.style.getPropertyValue('--articles-bg-color')).toBe('#ffffff')
227
+ expect(wrapper.style.getPropertyValue('--articles-link-hover-color')).toBe('#4338ca')
228
+ expect(wrapper.style.getPropertyValue('--articles-link-decoration')).toBe('underline')
229
+ expect(wrapper.style.getPropertyValue('--articles-header-font-weight')).toBe('700')
230
+ expect(wrapper.style.getPropertyValue('--articles-header-font-size')).toBe('1.25rem')
231
+ expect(wrapper.style.getPropertyValue('--articles-body-font-size')).toBe('1rem')
232
+ expect(wrapper.style.getPropertyValue('--articles-line-height')).toBe('1.75')
233
+ expect(wrapper.style.getPropertyValue('--articles-border-radius')).toBe('0.5rem')
234
+ })
235
+
236
+ it('hides categories section when search query is active', async () => {
237
+ ;(globalThis.fetch as jest.Mock).mockResolvedValue({
238
+ ok: true,
239
+ json: async () => ({ articles: mockArticles }),
240
+ })
241
+ render(<ArticlesPage config={{ ...baseConfig, layout: ['search', 'categories'] }} />)
242
+
243
+ await waitFor(() => expect(screen.getByText('Browse by Category')).toBeInTheDocument())
244
+
245
+ const searchInput = screen.getByPlaceholderText('Search articles (3+ characters required)...')
246
+ fireEvent.change(searchInput, { target: { value: 'vol' } })
247
+
248
+ // searchQuery set synchronously -> categories returns null
249
+ await waitFor(() => expect(screen.queryByText('Browse by Category')).not.toBeInTheDocument())
250
+ })
251
+
252
+ it('uses DEFAULT_PAGE_SIZE when pageSize is not in config', async () => {
253
+ mockFetchSuccess()
254
+ // Omit pageSize to hit the ?? DEFAULT_PAGE_SIZE branch
255
+ const config: ArticlesConfig = {
256
+ siteUrl: 'https://example.com',
257
+ siteName: 'Test Site',
258
+ layout: ['latest'],
259
+ }
260
+ render(<ArticlesPage config={config} />)
261
+ await waitFor(() => expect(screen.getByText('Latest Articles')).toBeInTheDocument())
262
+ })
263
+
264
+ it('uses provided categoriesPageSize when set', async () => {
265
+ mockFetchSuccess()
266
+ // Set categoriesPageSize to hit the left branch of ?? DEFAULT_CATEGORIES_PAGE_SIZE
267
+ const config: ArticlesConfig = {
268
+ ...baseConfig,
269
+ layout: ['categories'],
270
+ categoriesPageSize: 4,
271
+ }
272
+ render(<ArticlesPage config={config} />)
273
+ await waitFor(() => expect(screen.getByText('Browse by Category')).toBeInTheDocument())
274
+ })
275
+
130
276
  it('uses pageSize from config', async () => {
131
277
  const manyArticles = Array.from({ length: 10 }, (_, i) => ({
132
278
  ...mockArticles[0],
@@ -21,6 +21,13 @@ const baseComment: ArticleCommentWithReplies = {
21
21
  replies: [],
22
22
  }
23
23
 
24
+ function makeComment(msAgo: number): ArticleCommentWithReplies {
25
+ return {
26
+ ...baseComment,
27
+ createdAt: new Date(Date.now() - msAgo).toISOString(),
28
+ }
29
+ }
30
+
24
31
  describe('CommentItem', () => {
25
32
  beforeEach(() => jest.clearAllMocks())
26
33
 
@@ -146,4 +153,107 @@ describe('CommentItem', () => {
146
153
  fireEvent.click(screen.getAllByRole('button', { name: /cancel/i })[0])
147
154
  expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
148
155
  })
156
+
157
+ it('clicking the form Cancel button hides the reply form via onCancel', () => {
158
+ render(
159
+ <CommentItem
160
+ comment={baseComment}
161
+ articleSlug="my-article"
162
+ currentUserId="other@example.com"
163
+ onRefresh={jest.fn()}
164
+ />
165
+ )
166
+ fireEvent.click(screen.getByRole('button', { name: /reply/i }))
167
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
168
+
169
+ // The CommentForm renders its own Cancel button (the second Cancel button)
170
+ const cancelButtons = screen.getAllByRole('button', { name: /cancel/i })
171
+ // Click the last one - that is the CommentForm's Cancel, which calls onCancel
172
+ fireEvent.click(cancelButtons[cancelButtons.length - 1])
173
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
174
+ })
175
+
176
+ it('calls onRefresh and hides the form after reply is submitted', async () => {
177
+ const onRefresh = jest.fn()
178
+ mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) })
179
+
180
+ render(
181
+ <CommentItem
182
+ comment={baseComment}
183
+ articleSlug="my-article"
184
+ currentUserId="other@example.com"
185
+ onRefresh={onRefresh}
186
+ />
187
+ )
188
+ // Open reply form
189
+ fireEvent.click(screen.getByRole('button', { name: /reply/i }))
190
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
191
+
192
+ // Fill the textarea
193
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'My reply text' } })
194
+
195
+ // The CommentForm submit button is labeled "Reply" when parentId is set
196
+ // There are now two "Reply" buttons: the toggle and the form submit
197
+ // The form submit is a type="submit" button; click the second one
198
+ const replyButtons = screen.getAllByRole('button', { name: /reply/i })
199
+ fireEvent.click(replyButtons[replyButtons.length - 1])
200
+
201
+ // After successful submit: form hides and onRefresh called
202
+ await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument())
203
+ expect(onRefresh).toHaveBeenCalledTimes(1)
204
+ })
205
+
206
+ describe('relativeTime display', () => {
207
+ it('shows "just now" for timestamps less than 1 minute ago', () => {
208
+ render(<CommentItem comment={makeComment(30_000)} articleSlug="a" onRefresh={jest.fn()} />)
209
+ expect(screen.getByText('just now')).toBeInTheDocument()
210
+ })
211
+
212
+ it('shows "Xm ago" for timestamps 1-59 minutes ago', () => {
213
+ render(
214
+ <CommentItem comment={makeComment(5 * 60_000)} articleSlug="a" onRefresh={jest.fn()} />
215
+ )
216
+ expect(screen.getByText('5m ago')).toBeInTheDocument()
217
+ })
218
+
219
+ it('shows "Xh ago" for timestamps 1-23 hours ago', () => {
220
+ render(
221
+ <CommentItem comment={makeComment(3 * 60 * 60_000)} articleSlug="a" onRefresh={jest.fn()} />
222
+ )
223
+ expect(screen.getByText('3h ago')).toBeInTheDocument()
224
+ })
225
+
226
+ it('shows "Xd ago" for timestamps 1-29 days ago', () => {
227
+ render(
228
+ <CommentItem
229
+ comment={makeComment(10 * 24 * 60 * 60_000)}
230
+ articleSlug="a"
231
+ onRefresh={jest.fn()}
232
+ />
233
+ )
234
+ expect(screen.getByText('10d ago')).toBeInTheDocument()
235
+ })
236
+
237
+ it('shows "Xmo ago" for timestamps 1-11 months ago', () => {
238
+ render(
239
+ <CommentItem
240
+ comment={makeComment(60 * 24 * 60 * 60_000)}
241
+ articleSlug="a"
242
+ onRefresh={jest.fn()}
243
+ />
244
+ )
245
+ expect(screen.getByText('2mo ago')).toBeInTheDocument()
246
+ })
247
+
248
+ it('shows "Xy ago" for timestamps 12+ months ago', () => {
249
+ render(
250
+ <CommentItem
251
+ comment={makeComment(400 * 24 * 60 * 60_000)}
252
+ articleSlug="a"
253
+ onRefresh={jest.fn()}
254
+ />
255
+ )
256
+ expect(screen.getByText('1y ago')).toBeInTheDocument()
257
+ })
258
+ })
149
259
  })
@@ -214,4 +214,39 @@ describe('getArticleSitemapEntries', () => {
214
214
  const entries = await getArticleSitemapEntries('https://example.com')
215
215
  expect(entries).toEqual([])
216
216
  })
217
+
218
+ it('accepts ArticlesConfig and uses siteUrl from config', async () => {
219
+ const entries = await getArticleSitemapEntries(config)
220
+ expect(entries).toHaveLength(4)
221
+
222
+ const articleUrls = entries.filter((e) => !e.url.includes('/category/')).map((e) => e.url)
223
+ expect(articleUrls).toEqual([
224
+ 'https://example.com/articles/article-one',
225
+ 'https://example.com/articles/article-two',
226
+ ])
227
+
228
+ const categoryUrls = entries.filter((e) => e.url.includes('/category/')).map((e) => e.url)
229
+ expect(categoryUrls).toEqual([
230
+ 'https://example.com/articles/category/campaigns',
231
+ 'https://example.com/articles/category/volunteers',
232
+ ])
233
+ })
234
+
235
+ it('accepts ArticlesConfig with trailing slash and strips it', async () => {
236
+ const entries = await getArticleSitemapEntries({
237
+ siteUrl: 'https://example.com/',
238
+ siteName: 'Example Site',
239
+ })
240
+ expect(entries).toHaveLength(4)
241
+ entries.forEach((e) => {
242
+ // protocol (https://) is fine; double slash in the path is not
243
+ expect(e.url.replace(/^https?:\/\//, '')).not.toContain('//')
244
+ })
245
+ })
246
+
247
+ it('returns same results whether called with string or ArticlesConfig', async () => {
248
+ const stringResult = await getArticleSitemapEntries('https://example.com')
249
+ const configResult = await getArticleSitemapEntries(config)
250
+ expect(JSON.stringify(stringResult)).toBe(JSON.stringify(configResult))
251
+ })
217
252
  })
package/src/index.ts CHANGED
@@ -13,6 +13,9 @@ export { LatestArticlesSection } from './LatestArticlesSection'
13
13
  export { CategoryArticlesPage } from './CategoryArticlesPage'
14
14
  export { ArticleSchema, BreadcrumbSchema, CollectionPageSchema } from './ArticleSchemas'
15
15
 
16
+ export { ArticleSocialShare } from './ArticleSocialShare'
17
+ export { ArticleNavigation } from './ArticleNavigation'
18
+
16
19
  // Comments
17
20
  export { CommentsSection } from './CommentsSection'
18
21
  export { CommentThread } from './CommentThread'
package/src/seoUtils.ts CHANGED
@@ -130,7 +130,13 @@ export async function generateCategoryMetadata(
130
130
  }
131
131
  }
132
132
 
133
- export async function getArticleSitemapEntries(baseUrl: string): Promise<MetadataRoute.Sitemap> {
133
+ export async function getArticleSitemapEntries(
134
+ baseUrlOrConfig: string | ArticlesConfig
135
+ ): Promise<MetadataRoute.Sitemap> {
136
+ const baseUrl = (
137
+ typeof baseUrlOrConfig === 'string' ? baseUrlOrConfig : baseUrlOrConfig.siteUrl
138
+ ).replace(/\/$/, '')
139
+
134
140
  try {
135
141
  const [articles, categories] = await Promise.all([getAllArticles(), getAllCategories()])
136
142