From 53b63ac27c2275262db9a04be02210a3287aa71d Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Fri, 1 Dec 2023 19:34:58 +0100 Subject: refactor(pages): refine Blog pages * replace usePostsList with useArticlesList to keep names coherent * remove useIsMounted hook * rewrite useRedirection hook * add redirect in getStaticProps to avoid unecessary fetching * move Pagination component in a noscript tag * use hooks to refresh thematics and topics lists * complete Cypress tests --- src/utils/hooks/index.ts | 3 +- src/utils/hooks/use-articles-list/index.ts | 1 + .../use-articles-list/use-articles-list.test.tsx | 109 +++++++++++++++++++++ .../hooks/use-articles-list/use-articles-list.ts | 86 ++++++++++++++++ src/utils/hooks/use-is-mounted.tsx | 17 ---- src/utils/hooks/use-pagination/use-pagination.ts | 8 +- src/utils/hooks/use-posts-list/index.ts | 1 - .../hooks/use-posts-list/use-posts-list.test.tsx | 74 -------------- src/utils/hooks/use-posts-list/use-posts-list.ts | 83 ---------------- src/utils/hooks/use-redirection.tsx | 31 ------ src/utils/hooks/use-redirection/index.ts | 1 + .../hooks/use-redirection/use-redirection.test.ts | 80 +++++++++++++++ src/utils/hooks/use-redirection/use-redirection.ts | 41 ++++++++ 13 files changed, 323 insertions(+), 212 deletions(-) create mode 100644 src/utils/hooks/use-articles-list/index.ts create mode 100644 src/utils/hooks/use-articles-list/use-articles-list.test.tsx create mode 100644 src/utils/hooks/use-articles-list/use-articles-list.ts delete mode 100644 src/utils/hooks/use-is-mounted.tsx delete mode 100644 src/utils/hooks/use-posts-list/index.ts delete mode 100644 src/utils/hooks/use-posts-list/use-posts-list.test.tsx delete mode 100644 src/utils/hooks/use-posts-list/use-posts-list.ts delete mode 100644 src/utils/hooks/use-redirection.tsx create mode 100644 src/utils/hooks/use-redirection/index.ts create mode 100644 src/utils/hooks/use-redirection/use-redirection.test.ts create mode 100644 src/utils/hooks/use-redirection/use-redirection.ts (limited to 'src/utils/hooks') diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index da4ed9e..1e0bfe3 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -1,5 +1,6 @@ export * from './use-ackee'; export * from './use-article'; +export * from './use-articles-list'; export * from './use-boolean'; export * from './use-breadcrumb'; export * from './use-comments'; @@ -7,13 +8,11 @@ export * from './use-data-from-api'; export * from './use-form'; export * from './use-github-api'; export * from './use-headings-tree'; -export * from './use-is-mounted'; export * from './use-local-storage'; export * from './use-match-media'; export * from './use-on-click-outside'; export * from './use-on-route-change'; export * from './use-pagination'; -export * from './use-posts-list'; export * from './use-prism'; export * from './use-prism-theme'; export * from './use-redirection'; diff --git a/src/utils/hooks/use-articles-list/index.ts b/src/utils/hooks/use-articles-list/index.ts new file mode 100644 index 0000000..5f42aeb --- /dev/null +++ b/src/utils/hooks/use-articles-list/index.ts @@ -0,0 +1 @@ +export * from './use-articles-list'; diff --git a/src/utils/hooks/use-articles-list/use-articles-list.test.tsx b/src/utils/hooks/use-articles-list/use-articles-list.test.tsx new file mode 100644 index 0000000..6191ed6 --- /dev/null +++ b/src/utils/hooks/use-articles-list/use-articles-list.test.tsx @@ -0,0 +1,109 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { SWRConfig } from 'swr'; +import { wpPostsFixture } from '../../../../tests/fixtures'; +import { getConnection } from '../../../../tests/utils/graphql'; +import { convertPostPreviewToArticlePreview } from '../../../services/graphql'; +import { useArticlesList } from './use-articles-list'; + +const wrapper = ({ children }: { children?: ReactNode }) => { + const map = new Map(); + + return ( + map, + isOnline() { + return true; + }, + isVisible() { + return true; + }, + initFocus() { + /* nothing */ + }, + initReconnect() { + /* nothing */ + }, + }} + > + {children} + + ); +}; + +describe('useArticlesList', () => { + beforeEach(() => { + /* Not sure why it is needed, but without it Jest was complaining with `You + * are trying to import a file after the Jest environment has been torn + * down`... Maybe because of useSWR? */ + jest.useFakeTimers({ + doNotFake: ['queueMicrotask'], + }); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('can return the first new result index when loading more posts', async () => { + const perPage = 5; + const { result } = renderHook(() => useArticlesList({ perPage }), { + wrapper, + }); + + expect.assertions(2); + + expect(result.current.firstNewResultIndex).toBeUndefined(); + + await act(async () => { + await result.current.loadMore(); + }); + + // Assuming there is more than one page. + expect(result.current.firstNewResultIndex).toBe(perPage + 1); + }); + + it('converts a WordPress post connection to an article connection', async () => { + const perPage = 1; + const { result } = renderHook(() => useArticlesList({ perPage }), { + wrapper, + }); + const connection = getConnection({ + after: null, + data: wpPostsFixture, + first: perPage, + }); + + expect.hasAssertions(); + + await waitFor(() => { + expect(result.current.articles).toBeDefined(); + }); + + expect(result.current.articles).toStrictEqual([ + { + edges: connection.edges.map((edge) => { + return { + cursor: edge.cursor, + node: convertPostPreviewToArticlePreview(edge.node), + }; + }), + pageInfo: { + endCursor: connection.pageInfo.endCursor, + hasNextPage: connection.pageInfo.hasNextPage, + total: connection.pageInfo.total, + }, + }, + ]); + }); +}); diff --git a/src/utils/hooks/use-articles-list/use-articles-list.ts b/src/utils/hooks/use-articles-list/use-articles-list.ts new file mode 100644 index 0000000..8a52702 --- /dev/null +++ b/src/utils/hooks/use-articles-list/use-articles-list.ts @@ -0,0 +1,86 @@ +import { useCallback, useState } from 'react'; +import { + convertPostPreviewToArticlePreview, + fetchPostsList, +} from '../../../services/graphql'; +import type { + ArticlePreview, + GraphQLConnection, + GraphQLEdge, + Maybe, + WPPostPreview, +} from '../../../types'; +import { + type UsePaginationConfig, + usePagination, + type UsePaginationReturn, +} from '../use-pagination'; + +export type useArticlesListReturn = Omit< + UsePaginationReturn, + 'data' +> & { + /** + * The articles list. + */ + articles: Maybe[]>; + /** + * The index of the first new result when loading more posts. + */ + firstNewResultIndex: Maybe; +}; + +export const useArticlesList = ( + config: Omit, 'fetcher'> +): useArticlesListReturn => { + const { + data, + error, + hasNextPage, + isEmpty, + isError, + isLoading, + isLoadingMore, + isRefreshing, + isValidating, + loadMore, + size, + } = usePagination({ ...config, fetcher: fetchPostsList }); + const [firstNewResultIndex, setFirstNewResultIndex] = + useState>(undefined); + + const handleLoadMore = useCallback(async () => { + setFirstNewResultIndex(size * config.perPage + 1); + + await loadMore(); + }, [config.perPage, loadMore, size]); + + const articles: Maybe[]> = data?.map( + ({ edges, pageInfo }): GraphQLConnection => { + return { + edges: edges.map((edge): GraphQLEdge => { + return { + cursor: edge.cursor, + node: convertPostPreviewToArticlePreview(edge.node), + }; + }), + pageInfo, + }; + } + ); + + return { + articles, + error, + firstNewResultIndex, + hasNextPage, + isEmpty, + isError, + isLoading, + isLoadingMore, + isRefreshing, + isValidating, + loadMore: handleLoadMore, + size, + }; +}; diff --git a/src/utils/hooks/use-is-mounted.tsx b/src/utils/hooks/use-is-mounted.tsx deleted file mode 100644 index 4d85d45..0000000 --- a/src/utils/hooks/use-is-mounted.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { RefObject, useEffect, useState } from 'react'; - -/** - * Check if an HTML element is mounted. - * - * @param {RefObject} ref - A React reference to an HTML element. - * @returns {boolean} True if the HTML element is mounted. - */ -export const useIsMounted = (ref: RefObject): boolean => { - const [isMounted, setIsMounted] = useState(false); - - useEffect(() => { - if (ref.current) setIsMounted(true); - }, [ref]); - - return isMounted; -}; diff --git a/src/utils/hooks/use-pagination/use-pagination.ts b/src/utils/hooks/use-pagination/use-pagination.ts index 2a40aa4..29d5ba2 100644 --- a/src/utils/hooks/use-pagination/use-pagination.ts +++ b/src/utils/hooks/use-pagination/use-pagination.ts @@ -11,7 +11,7 @@ export type UsePaginationFetcherInput = GraphQLEdgesInput & { search?: string; }; -export type UsePaginationConfig = { +export type UsePaginationConfig = Pick & { /** * The initial data. */ @@ -86,6 +86,7 @@ export type UsePaginationReturn = { * @returns {UsePaginationReturn} An object with pagination data and helpers. */ export const usePagination = ({ + after, fallback, fetcher, perPage, @@ -97,12 +98,11 @@ export const usePagination = ({ return { first: perPage, - after: - pageIndex === 0 ? undefined : previousPageData?.pageInfo.endCursor, + after: pageIndex === 0 ? after : previousPageData?.pageInfo.endCursor, search: searchQuery, }; }, - [perPage, searchQuery] + [after, perPage, searchQuery] ); const { data, error, isLoading, isValidating, setSize, size } = diff --git a/src/utils/hooks/use-posts-list/index.ts b/src/utils/hooks/use-posts-list/index.ts deleted file mode 100644 index 664c142..0000000 --- a/src/utils/hooks/use-posts-list/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './use-posts-list'; diff --git a/src/utils/hooks/use-posts-list/use-posts-list.test.tsx b/src/utils/hooks/use-posts-list/use-posts-list.test.tsx deleted file mode 100644 index f23ddde..0000000 --- a/src/utils/hooks/use-posts-list/use-posts-list.test.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { - afterEach, - beforeEach, - describe, - expect, - it, - jest, -} from '@jest/globals'; -import { act, renderHook } from '@testing-library/react'; -import type { ReactNode } from 'react'; -import { SWRConfig } from 'swr'; -import { fetchPostsList } from '../../../services/graphql'; -import { usePostsList } from './use-posts-list'; - -const wrapper = ({ children }: { children?: ReactNode }) => { - const map = new Map(); - - return ( - map, - isOnline() { - return true; - }, - isVisible() { - return true; - }, - initFocus() { - /* nothing */ - }, - initReconnect() { - /* nothing */ - }, - }} - > - {children} - - ); -}; - -describe('usePostsList', () => { - beforeEach(() => { - /* Not sure why it is needed, but without it Jest was complaining with `You - * are trying to import a file after the Jest environment has been torn - * down`... Maybe because of useSWR? */ - jest.useFakeTimers({ - doNotFake: ['queueMicrotask'], - }); - }); - - afterEach(() => { - jest.runOnlyPendingTimers(); - jest.useRealTimers(); - }); - - it('can return the first new result index when loading more posts', async () => { - const perPage = 5; - const { result } = renderHook( - () => usePostsList({ fetcher: fetchPostsList, perPage }), - { wrapper } - ); - - expect.assertions(2); - - expect(result.current.firstNewResultIndex).toBeUndefined(); - - await act(async () => { - await result.current.loadMore(); - }); - - // Assuming there is more than one page. - expect(result.current.firstNewResultIndex).toBe(perPage + 1); - }); -}); diff --git a/src/utils/hooks/use-posts-list/use-posts-list.ts b/src/utils/hooks/use-posts-list/use-posts-list.ts deleted file mode 100644 index bb77f31..0000000 --- a/src/utils/hooks/use-posts-list/use-posts-list.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { useCallback, useState } from 'react'; -import type { - ArticlePreview, - GraphQLConnection, - GraphQLEdge, - Maybe, - WPPostPreview, -} from '../../../types'; -import { - type UsePaginationConfig, - usePagination, - type UsePaginationReturn, -} from '../use-pagination'; -import { convertPostPreviewToArticlePreview } from 'src/services/graphql'; - -export type usePostsListReturn = Omit< - UsePaginationReturn, - 'data' -> & { - /** - * The articles list. - */ - articles: Maybe[]>; - /** - * The index of the first new result when loading more posts. - */ - firstNewResultIndex: Maybe; -}; - -export const usePostsList = ( - config: UsePaginationConfig -): usePostsListReturn => { - const { - data, - error, - hasNextPage, - isEmpty, - isError, - isLoading, - isLoadingMore, - isRefreshing, - isValidating, - loadMore, - size, - } = usePagination(config); - const [firstNewResultIndex, setFirstNewResultIndex] = - useState>(undefined); - - const handleLoadMore = useCallback(async () => { - setFirstNewResultIndex(size * config.perPage + 1); - - await loadMore(); - }, [config.perPage, loadMore, size]); - - const articles: Maybe[]> = data?.map( - (page): GraphQLConnection => { - return { - edges: page.edges.map((edge): GraphQLEdge => { - return { - cursor: edge.cursor, - node: convertPostPreviewToArticlePreview(edge.node), - }; - }), - pageInfo: page.pageInfo, - }; - } - ); - - return { - articles, - error, - firstNewResultIndex, - hasNextPage, - isEmpty, - isError, - isLoading, - isLoadingMore, - isRefreshing, - isValidating, - loadMore: handleLoadMore, - size, - }; -}; diff --git a/src/utils/hooks/use-redirection.tsx b/src/utils/hooks/use-redirection.tsx deleted file mode 100644 index 5a677e2..0000000 --- a/src/utils/hooks/use-redirection.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { useRouter } from 'next/router'; -import { useEffect } from 'react'; - -export type RouterQuery = { - param: string; - value: string; -}; - -export type UseRedirectionProps = { - /** - * The router query. - */ - query: RouterQuery; - /** - * The redirection url. - */ - redirectTo: string; -}; - -/** - * Redirect to another url when router query match the given parameters. - * - * @param {UseRedirectionProps} props - The redirection parameters. - */ -export const useRedirection = ({ query, redirectTo }: UseRedirectionProps) => { - const router = useRouter(); - - useEffect(() => { - if (router.query[query.param] === query.value) router.push(redirectTo); - }, [query, redirectTo, router]); -}; diff --git a/src/utils/hooks/use-redirection/index.ts b/src/utils/hooks/use-redirection/index.ts new file mode 100644 index 0000000..c81c82c --- /dev/null +++ b/src/utils/hooks/use-redirection/index.ts @@ -0,0 +1 @@ +export * from './use-redirection'; diff --git a/src/utils/hooks/use-redirection/use-redirection.test.ts b/src/utils/hooks/use-redirection/use-redirection.test.ts new file mode 100644 index 0000000..c14ac4c --- /dev/null +++ b/src/utils/hooks/use-redirection/use-redirection.test.ts @@ -0,0 +1,80 @@ +import { describe, it } from '@jest/globals'; +import { renderHook } from '@testing-library/react'; +import nextRouterMock from 'next-router-mock'; +import { MemoryRouterProvider } from 'next-router-mock/MemoryRouterProvider'; +import { useRedirection } from './use-redirection'; + +describe('useRedirection', () => { + it('redirects to another page', async () => { + const initialPath = '/initial-path'; + const redirectPath = '/redirect-path'; + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + expect.assertions(2); + + await nextRouterMock.push('/initial-path'); + + expect(nextRouterMock.asPath).toBe(initialPath); + + renderHook(() => useRedirection({ to: redirectPath }), { + wrapper: MemoryRouterProvider, + }); + + expect(nextRouterMock.asPath).toBe(redirectPath); + }); + + it('can replace the url in the history', async () => { + const initialPath = '/initial-path'; + const redirectPath = '/redirect-path'; + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + expect.assertions(2); + + await nextRouterMock.push('/initial-path'); + + expect(nextRouterMock.asPath).toBe(initialPath); + + renderHook(() => useRedirection({ isReplacing: true, to: redirectPath }), { + wrapper: MemoryRouterProvider, + }); + + expect(nextRouterMock.asPath).toBe(redirectPath); + + /* Ideally we should check if when we use `back()` the current path is + * still the redirectPath but it is not yet implemented in the mock. */ + }); + + it('can conditionally redirect to another page', async () => { + const paths = { + initial: '/initial-path', + matching: '/matching-path', + redirect: '/redirect-path', + }; + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + expect.assertions(3); + + await nextRouterMock.push('/initial-path'); + + expect(nextRouterMock.asPath).toBe(paths.initial); + + const { rerender } = renderHook( + () => + useRedirection({ + to: paths.redirect, + whenPathMatches: (path) => path === paths.matching, + }), + { + wrapper: MemoryRouterProvider, + } + ); + + expect(nextRouterMock.asPath).toBe(paths.initial); + + await nextRouterMock.push(paths.matching); + + rerender(); + + expect(nextRouterMock.asPath).toBe(paths.redirect); + }); +}); diff --git a/src/utils/hooks/use-redirection/use-redirection.ts b/src/utils/hooks/use-redirection/use-redirection.ts new file mode 100644 index 0000000..1592a33 --- /dev/null +++ b/src/utils/hooks/use-redirection/use-redirection.ts @@ -0,0 +1,41 @@ +import { useRouter } from 'next/router'; +import { useEffect } from 'react'; + +export type UseRedirectionConfig = { + /** + * Should the url be replaced in the history? + * + * @default false + */ + isReplacing?: boolean; + /** + * The destination. + */ + to: string; + /** + * Redirect only when the current path matches the condition. + * + * @param {string} path - The current slug. + * @returns {boolean} True if the path matches. + */ + whenPathMatches?: (path: string) => boolean; +}; + +export const useRedirection = ({ + isReplacing = false, + to, + whenPathMatches, +}: UseRedirectionConfig) => { + const router = useRouter(); + + useEffect(() => { + const shouldRedirect = whenPathMatches + ? whenPathMatches(router.asPath) + : true; + + if (shouldRedirect) { + if (isReplacing) router.replace(to, undefined, { shallow: true }); + else router.push(to); + } + }, [isReplacing, router, to, whenPathMatches]); +}; -- cgit v1.2.3