diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-11-24 20:00:08 +0100 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-11-27 14:47:51 +0100 |
| commit | f111685c5886f3e77edfd3621c98d8ac1b9bcce4 (patch) | |
| tree | 62a541fe3afeb64bf745443706fbfb02e96c5230 /src/utils | |
| parent | bee515641cb144be9a855ff2cac258d2fedab21d (diff) | |
refactor(services, types): reorganize GraphQL fetchers and data types
The Typescript mapped types was useful for autocompletion in fetchers
but their are harder to maintain. I think it's better to keep each
query close to its fetcher to have a better understanding of the
fetched data. So I:
* colocate queries with their own fetcher
* colocate mutations with their own mutator
* remove Typescript mapped types for queries and mutations
* move data convertors inside graphql services
* rename most of data types and fetchers
Diffstat (limited to 'src/utils')
| -rw-r--r-- | src/utils/helpers/author.ts | 31 | ||||
| -rw-r--r-- | src/utils/helpers/graphql.ts | 64 | ||||
| -rw-r--r-- | src/utils/helpers/images.ts | 17 | ||||
| -rw-r--r-- | src/utils/helpers/index.ts | 3 | ||||
| -rw-r--r-- | src/utils/helpers/pages.tsx | 83 | ||||
| -rw-r--r-- | src/utils/helpers/rss.ts | 25 | ||||
| -rw-r--r-- | src/utils/helpers/server/projects.ts | 38 | ||||
| -rw-r--r-- | src/utils/hooks/use-article.ts | 28 | ||||
| -rw-r--r-- | src/utils/hooks/use-comments.ts | 21 | ||||
| -rw-r--r-- | src/utils/hooks/use-pagination/use-pagination.test.ts | 9 | ||||
| -rw-r--r-- | src/utils/hooks/use-pagination/use-pagination.ts | 17 | ||||
| -rw-r--r-- | src/utils/hooks/use-posts-list/use-posts-list.test.ts | 4 | ||||
| -rw-r--r-- | src/utils/hooks/use-posts-list/use-posts-list.ts | 50 |
13 files changed, 169 insertions, 221 deletions
diff --git a/src/utils/helpers/author.ts b/src/utils/helpers/author.ts deleted file mode 100644 index a5e9bc6..0000000 --- a/src/utils/helpers/author.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { type Author, type ContentKind, type RawAuthor } from '../../types'; - -/** - * Convert author raw data to regular data. - * - * @param {RawAuthor<ContentKind>} data - The author raw data. - * @param {ContentKind} kind - The author kind. Either `page` or `comment`. - * @param {number} [avatarSize] - The author avatar size. - * @returns {Author<ContentKind>} The author data. - */ -export const getAuthorFromRawData = ( - data: RawAuthor<typeof kind>, - kind: ContentKind, - avatarSize: number = 80 -): Author<typeof kind> => { - const { name, description, gravatarUrl, url } = data; - - return { - name, - avatar: gravatarUrl - ? { - alt: `${name} avatar`, - height: avatarSize, - src: gravatarUrl, - width: avatarSize, - } - : undefined, - description, - website: url, - }; -}; diff --git a/src/utils/helpers/graphql.ts b/src/utils/helpers/graphql.ts new file mode 100644 index 0000000..e07b151 --- /dev/null +++ b/src/utils/helpers/graphql.ts @@ -0,0 +1,64 @@ +import type { Nullable } from '../../types'; +import { CONFIG } from '../config'; + +/** + * Retrieve the API url from settings. + * + * @returns {string} The API url. + */ +export const getGraphQLUrl = (): string => { + if (!CONFIG.api.url) throw new Error('You forgot to define the API url.'); + + return CONFIG.api.url; +}; + +export type GraphQLData<T> = Record<string, Nullable<T>>; + +type GraphQLResponse<T extends GraphQLData<unknown>> = { + data: T; + errors?: { message: string }[]; +}; + +export type FetchGraphQLConfig = { + query: string; + url: string; + variables?: Record<string, unknown>; +}; + +/** + * Retrieve GraphQL data using fetch. + * + * @template T - The expected data type. + * @param {FetchGraphQLConfig} config - A configuration object. + * @returns {Promise<T>} The data. + */ +export const fetchGraphQL = async < + T extends GraphQLData<unknown> = GraphQLData<unknown>, +>({ + query, + url, + variables, +}: FetchGraphQLConfig): Promise<T> => { + const response = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json;charset=UTF-8', + }, + body: JSON.stringify({ + query, + variables, + }), + }); + + const { data, errors }: GraphQLResponse<T> = await response.json(); + + if (!response.ok) { + const error = new Error( + errors?.map((e) => e.message).join('\n') ?? 'Network response was not OK' + ); + + return Promise.reject(error); + } + + return data; +}; diff --git a/src/utils/helpers/images.ts b/src/utils/helpers/images.ts deleted file mode 100644 index 6e0c2c5..0000000 --- a/src/utils/helpers/images.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { type Image, type RawCover } from '../../types'; - -/** - * Retrieve an Image object from raw data. - * - * @param image - The cover raw data. - * @returns {Image} - An Image object. - */ -export const getImageFromRawData = (image: RawCover): Image => { - return { - alt: image.altText, - height: image.mediaDetails.height, - src: image.sourceUrl, - title: image.title, - width: image.mediaDetails.width, - }; -}; diff --git a/src/utils/helpers/index.ts b/src/utils/helpers/index.ts index 92f9424..94fde45 100644 --- a/src/utils/helpers/index.ts +++ b/src/utils/helpers/index.ts @@ -1,5 +1,4 @@ -export * from './author'; -export * from './images'; +export * from './graphql'; export * from './pages'; export * from './reading-time'; export * from './refs'; diff --git a/src/utils/helpers/pages.tsx b/src/utils/helpers/pages.tsx index 7b6bdca..9e015db 100644 --- a/src/utils/helpers/pages.tsx +++ b/src/utils/helpers/pages.tsx @@ -1,43 +1,7 @@ import NextImage from 'next/image'; import type { LinksWidgetItemData, PostData } from '../../components'; -import { getArticleFromRawData } from '../../services/graphql'; -import type { - Article, - EdgesResponse, - PageLink, - RawArticle, - RawThematicPreview, - RawTopicPreview, -} from '../../types'; +import type { ArticlePreview, PageLink } from '../../types'; import { ROUTES } from '../constants'; -import { getImageFromRawData } from './images'; - -/** - * Convert raw data to a Link object. - * - * @param data - An object. - * @param {number} data.databaseId - The data id. - * @param {number} [data.logo] - The data logo. - * @param {string} data.slug - The data slug. - * @param {string} data.title - The data name. - * @returns {PageLink} The link data (id, slug and title). - */ -export const getPageLinkFromRawData = ( - data: RawThematicPreview | RawTopicPreview, - kind: 'thematic' | 'topic' -): PageLink => { - const { databaseId, featuredImage, slug, title } = data; - const baseUrl = `${ - kind === 'thematic' ? ROUTES.THEMATICS.INDEX : ROUTES.TOPICS - }/`; - - return { - id: databaseId, - logo: featuredImage ? getImageFromRawData(featuredImage.node) : undefined, - name: title, - url: `${baseUrl}${slug}`, - }; -}; /** * Method to sort PageLink objects by name. @@ -73,55 +37,28 @@ export const getLinksItemData = (links: PageLink[]): LinksWidgetItemData[] => /** * Retrieve the posts list with the article URL. * - * @param {Article[]} posts - An array of articles. + * @param {ArticlePreview[]} posts - An array of articles. * @returns {PostData[]} An array of posts with full article URL. */ -export const getPostsWithUrl = (posts: Article[]): PostData[] => - posts.map(({ intro, meta, slug, title, ...post }) => { +export const getPostsWithUrl = (posts: ArticlePreview[]): PostData[] => + posts.map(({ id, intro, meta, slug, title, ...post }) => { return { ...post, cover: meta.cover ? <NextImage {...meta.cover} /> : undefined, excerpt: intro, heading: title, + id, meta: { publicationDate: meta.dates.publication, updateDate: meta.dates.update, wordsCount: meta.wordsCount, - author: meta.author?.name, thematics: meta.thematics, - topics: meta.topics, - comments: - meta.commentsCount === undefined - ? undefined - : { - count: meta.commentsCount, - postHeading: title, - url: `${ROUTES.ARTICLE}/${slug}#comments`, - }, + comments: { + count: meta.commentsCount ?? 0, + postHeading: title, + url: `${ROUTES.ARTICLE}/${slug}#comments`, + }, }, url: `${ROUTES.ARTICLE}/${slug}`, }; }); - -/** - * Retrieve the posts list from raw data. - * - * @param {EdgesResponse<RawArticle>[]} rawData - The raw data. - * @returns {PostData[]} An array of posts. - */ -export const getPostsList = async ( - rawData: EdgesResponse<RawArticle>[] -): Promise<PostData[]> => { - const articlesList: RawArticle[] = []; - rawData.forEach((articleData) => { - articleData.edges.forEach((edge) => { - articlesList.push(edge.node); - }); - }); - - return getPostsWithUrl( - await Promise.all( - articlesList.map(async (article) => getArticleFromRawData(article)) - ) - ); -}; diff --git a/src/utils/helpers/rss.ts b/src/utils/helpers/rss.ts index d9c3b1e..82fa1ee 100644 --- a/src/utils/helpers/rss.ts +++ b/src/utils/helpers/rss.ts @@ -1,28 +1,25 @@ import { Feed } from 'feed'; import { - getArticleFromRawData, - getArticles, - getTotalArticles, + convertPostPreviewToArticlePreview, + fetchPostsList, + fetchPostsCount, } from '../../services/graphql'; -import type { Article } from '../../types'; +import type { ArticlePreview } from '../../types'; import { CONFIG } from '../config'; import { ROUTES } from '../constants'; /** * Retrieve the data for all the articles. * - * @returns {Promise<Article[]>} - All the articles. + * @returns {Promise<ArticlePreview[]>} - All the articles. */ -const getAllArticles = async (): Promise<Article[]> => { - const totalArticles = await getTotalArticles(); - const rawArticles = await getArticles({ first: totalArticles }); - const articles: Article[] = []; +const getAllArticles = async (): Promise<ArticlePreview[]> => { + const totalPosts = await fetchPostsCount(); + const posts = await fetchPostsList({ first: totalPosts }); - rawArticles.edges.forEach(async (edge) => { - articles.push(await getArticleFromRawData(edge.node)); - }); - - return articles; + return posts.edges.map((edge) => + convertPostPreviewToArticlePreview(edge.node) + ); }; /** diff --git a/src/utils/helpers/server/projects.ts b/src/utils/helpers/server/projects.ts index ed73da8..c1a3d10 100644 --- a/src/utils/helpers/server/projects.ts +++ b/src/utils/helpers/server/projects.ts @@ -1,10 +1,6 @@ import { readdirSync } from 'fs'; import path from 'path'; -import { - type MDXProjectMeta, - type ProjectCard, - type ProjectPreview, -} from '../../../types'; +import type { MDXProjectMeta, Project, ProjectPreview } from '../../../types'; /** * Retrieve all the projects filename. @@ -24,9 +20,7 @@ export const getProjectFilenames = (): string[] => { * @param {string} filename - The project filename. * @returns {Promise<ProjectPreview>} */ -export const getProjectData = async ( - filename: string -): Promise<ProjectPreview> => { +export const getProjectData = async (filename: string): Promise<Project> => { try { const { meta, @@ -53,7 +47,7 @@ export const getProjectData = async ( }, }, slug: filename, - title: title, + title, }; } catch (err) { console.error(err); @@ -65,28 +59,24 @@ export const getProjectData = async ( * Retrieve all the projects data using filenames. * * @param {string[]} filenames - The filenames without extension. - * @returns {Promise<ProjectCard[]>} - An array of projects data. + * @returns {Promise<ProjectPreview[]>} - An array of projects data. */ export const getProjectsData = async ( filenames: string[] -): Promise<ProjectCard[]> => { - return Promise.all( - filenames.map(async (filename) => { - const { id, meta, slug, title } = await getProjectData(filename); - const { cover, dates, tagline, technologies } = meta; - return { id, meta: { cover, dates, tagline, technologies }, slug, title }; - }) - ); -}; +): Promise<ProjectPreview[]> => + Promise.all(filenames.map(async (filename) => getProjectData(filename))); /** * Method to sort an array of projects by publication date. * - * @param {ProjectCard} a - A single project. - * @param {ProjectCard} b - A single project. + * @param {ProjectPreview} a - A single project. + * @param {ProjectPreview} b - A single project. * @returns The result used by Array.sort() method: 1 || -1 || 0. */ -const sortProjectsByPublicationDate = (a: ProjectCard, b: ProjectCard) => { +const sortProjectsByPublicationDate = ( + a: ProjectPreview, + b: ProjectPreview +) => { if (a.meta.dates.publication < b.meta.dates.publication) return 1; if (a.meta.dates.publication > b.meta.dates.publication) return -1; return 0; @@ -95,9 +85,9 @@ const sortProjectsByPublicationDate = (a: ProjectCard, b: ProjectCard) => { /** * Retrieve all projects in content folder sorted by publication date. * - * @returns {Promise<ProjectCard[]>} An array of projects. + * @returns {Promise<ProjectPreview[]>} An array of projects. */ -export const getProjectsCard = async (): Promise<ProjectCard[]> => { +export const getProjectsCard = async (): Promise<ProjectPreview[]> => { const filenames = getProjectFilenames(); const projects = await getProjectsData(filenames); diff --git a/src/utils/hooks/use-article.ts b/src/utils/hooks/use-article.ts index f339f7f..5e54ee4 100644 --- a/src/utils/hooks/use-article.ts +++ b/src/utils/hooks/use-article.ts @@ -1,11 +1,7 @@ import { useEffect, useState } from 'react'; import useSWR from 'swr'; -import { - articleBySlugQuery, - fetchAPI, - getArticleFromRawData, -} from '../../services/graphql'; -import type { Article, Maybe, RawArticle } from '../../types'; +import { convertPostToArticle, fetchPost } from '../../services/graphql'; +import type { Article, Maybe } from '../../types'; export type UseArticleConfig = { /** @@ -28,24 +24,12 @@ export const useArticle = ({ slug, fallback, }: UseArticleConfig): Article | undefined => { - const { data } = useSWR( - slug ? { query: articleBySlugQuery, variables: { slug } } : null, - fetchAPI<RawArticle, typeof articleBySlugQuery>, - {} - ); - const [article, setArticle] = useState<Maybe<Article>>(); + const { data } = useSWR(slug, fetchPost, {}); + const [article, setArticle] = useState<Maybe<Article>>(fallback); useEffect(() => { - const getArticle = async () => { - if (data) { - setArticle(await getArticleFromRawData(data.post)); - } else { - setArticle(fallback); - } - }; - - getArticle(); - }, [data, fallback]); + if (data) convertPostToArticle(data).then((post) => setArticle(post)); + }, [data]); return article; }; diff --git a/src/utils/hooks/use-comments.ts b/src/utils/hooks/use-comments.ts index ac723e9..94a2d7e 100644 --- a/src/utils/hooks/use-comments.ts +++ b/src/utils/hooks/use-comments.ts @@ -1,9 +1,13 @@ import useSWR from 'swr'; -import { getAllComments } from '../../services/graphql'; +import { + type FetchCommentsListInput, + fetchCommentsList, + convertWPCommentToComment, + buildCommentsTree, +} from '../../services/graphql'; import type { SingleComment } from '../../types'; -export type UseCommentsConfig = { - contentId?: string | number; +export type UseCommentsConfig = FetchCommentsListInput & { fallback?: SingleComment[]; }; @@ -14,10 +18,15 @@ export type UseCommentsConfig = { * @returns {SingleComment[]|undefined} */ export const useComments = ({ - contentId, fallback, + ...input }: UseCommentsConfig): SingleComment[] | undefined => { - const { data } = useSWR(contentId ? { contentId } : null, getAllComments, {}); + const { data } = useSWR(input, fetchCommentsList, {}); - return data ?? fallback; + if (!data) return fallback; + + const comments = data.map(convertWPCommentToComment); + const commentsTree = buildCommentsTree(comments); + + return commentsTree; }; diff --git a/src/utils/hooks/use-pagination/use-pagination.test.ts b/src/utils/hooks/use-pagination/use-pagination.test.ts index 20cb37e..18f3ac5 100644 --- a/src/utils/hooks/use-pagination/use-pagination.test.ts +++ b/src/utils/hooks/use-pagination/use-pagination.test.ts @@ -1,8 +1,11 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals'; import { act, renderHook, waitFor } from '@testing-library/react'; import { getConnection } from '../../../../tests/utils/graphql'; -import type { EdgesResponse, GraphQLEdgesInput, Search } from '../../../types'; -import { usePagination } from './use-pagination'; +import type { GraphQLConnection } from '../../../types'; +import { + type UsePaginationFetcherInput, + usePagination, +} from './use-pagination'; type Data = { id: number; @@ -24,7 +27,7 @@ describe('usePagination', () => { after, first, search, - }: GraphQLEdgesInput & Search): Promise<EdgesResponse<Data>> => { + }: UsePaginationFetcherInput): Promise<GraphQLConnection<Data>> => { const filteredData = search ? data.filter((d) => d.title.includes(search)) : data; diff --git a/src/utils/hooks/use-pagination/use-pagination.ts b/src/utils/hooks/use-pagination/use-pagination.ts index 4df521b..2a40aa4 100644 --- a/src/utils/hooks/use-pagination/use-pagination.ts +++ b/src/utils/hooks/use-pagination/use-pagination.ts @@ -1,22 +1,25 @@ import { useCallback } from 'react'; import useSWRInfinite, { type SWRInfiniteKeyLoader } from 'swr/infinite'; import type { - EdgesResponse, + GraphQLConnection, GraphQLEdgesInput, Maybe, Nullable, - Search, } from '../../../types'; +export type UsePaginationFetcherInput = GraphQLEdgesInput & { + search?: string; +}; + export type UsePaginationConfig<T> = { /** * The initial data. */ - fallback?: EdgesResponse<T>[]; + fallback?: GraphQLConnection<T>[]; /** * A function to fetch more data. */ - fetcher: (props: GraphQLEdgesInput & Search) => Promise<EdgesResponse<T>>; + fetcher: (props: UsePaginationFetcherInput) => Promise<GraphQLConnection<T>>; /** * The number of results per page. */ @@ -31,7 +34,7 @@ export type UsePaginationReturn<T> = { /** * The data from the API. */ - data: Maybe<EdgesResponse<T>[]>; + data: Maybe<GraphQLConnection<T>[]>; /** * An error thrown by fetcher. */ @@ -88,8 +91,8 @@ export const usePagination = <T>({ perPage, searchQuery, }: UsePaginationConfig<T>): UsePaginationReturn<T> => { - const getKey: SWRInfiniteKeyLoader<EdgesResponse<T>> = useCallback( - (pageIndex, previousPageData): Nullable<GraphQLEdgesInput & Search> => { + const getKey: SWRInfiniteKeyLoader<GraphQLConnection<T>> = useCallback( + (pageIndex, previousPageData): Nullable<UsePaginationFetcherInput> => { if (previousPageData && !previousPageData.edges.length) return null; return { diff --git a/src/utils/hooks/use-posts-list/use-posts-list.test.ts b/src/utils/hooks/use-posts-list/use-posts-list.test.ts index 1d11111..ff69de2 100644 --- a/src/utils/hooks/use-posts-list/use-posts-list.test.ts +++ b/src/utils/hooks/use-posts-list/use-posts-list.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from '@jest/globals'; import { act, renderHook } from '@testing-library/react'; -import { getArticles } from '../../../services/graphql'; +import { fetchPostsList } from '../../../services/graphql'; import { usePostsList } from './use-posts-list'; describe('usePostsList', () => { it('can return the first new result index when loading more posts', async () => { const perPage = 5; const { result } = renderHook(() => - usePostsList({ fetcher: getArticles, perPage }) + usePostsList({ fetcher: fetchPostsList, perPage }) ); expect.assertions(2); diff --git a/src/utils/hooks/use-posts-list/use-posts-list.ts b/src/utils/hooks/use-posts-list/use-posts-list.ts index 980d531..bb77f31 100644 --- a/src/utils/hooks/use-posts-list/use-posts-list.ts +++ b/src/utils/hooks/use-posts-list/use-posts-list.ts @@ -1,29 +1,34 @@ -import { useCallback, useEffect, useState } from 'react'; -import type { PostData } from '../../../components'; -import type { Maybe, RawArticle } from '../../../types'; -import { getPostsList } from '../../helpers'; +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<RawArticle>, + UsePaginationReturn<WPPostPreview>, 'data' > & { /** - * The index of the first new result when loading more posts. + * The articles list. */ - firstNewResultIndex: Maybe<number>; + articles: Maybe<GraphQLConnection<ArticlePreview>[]>; /** - * The posts list. + * The index of the first new result when loading more posts. */ - posts: Maybe<PostData[]>; + firstNewResultIndex: Maybe<number>; }; export const usePostsList = ( - config: UsePaginationConfig<RawArticle> + config: UsePaginationConfig<WPPostPreview> ): usePostsListReturn => { const { data, @@ -40,15 +45,6 @@ export const usePostsList = ( } = usePagination(config); const [firstNewResultIndex, setFirstNewResultIndex] = useState<Maybe<number>>(undefined); - const [posts, setPosts] = useState<Maybe<PostData[]>>(undefined); - - useEffect(() => { - const getPosts = async () => { - if (data) setPosts(await getPostsList(data)); - }; - - getPosts(); - }, [data]); const handleLoadMore = useCallback(async () => { setFirstNewResultIndex(size * config.perPage + 1); @@ -56,7 +52,22 @@ export const usePostsList = ( await loadMore(); }, [config.perPage, loadMore, size]); + const articles: Maybe<GraphQLConnection<ArticlePreview>[]> = data?.map( + (page): GraphQLConnection<ArticlePreview> => { + return { + edges: page.edges.map((edge): GraphQLEdge<ArticlePreview> => { + return { + cursor: edge.cursor, + node: convertPostPreviewToArticlePreview(edge.node), + }; + }), + pageInfo: page.pageInfo, + }; + } + ); + return { + articles, error, firstNewResultIndex, hasNextPage, @@ -67,7 +78,6 @@ export const usePostsList = ( isRefreshing, isValidating, loadMore: handleLoadMore, - posts, size, }; }; |
