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/services | |
| 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/services')
51 files changed, 1866 insertions, 1350 deletions
diff --git a/src/services/graphql/api.ts b/src/services/graphql/api.ts deleted file mode 100644 index 003f92d..0000000 --- a/src/services/graphql/api.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { - Mutations, - MutationsInputMap, - MutationsResponseMap, - Queries, - QueriesInputMap, - QueriesResponseMap, -} from '../../types'; -import { CONFIG } from '../../utils/config'; - -/** - * Retrieve the API url from settings. - * - * @returns {string} The API url. - */ -export const getAPIUrl = (): string => { - const { url } = CONFIG.api; - - if (!url) { - throw new Error('API url is not defined.'); - } - - return url; -}; - -export type ResponseMap<T, K extends Mutations | Queries> = K extends Mutations - ? MutationsResponseMap<T> - : QueriesResponseMap<T>; - -export type InputMap<T extends Mutations | Queries> = T extends Mutations - ? MutationsInputMap - : QueriesInputMap; - -type FetchAPIVariables<T> = T extends Queries - ? QueriesInputMap[T] - : T extends Mutations - ? MutationsInputMap[T] - : never; - -type FetchAPIProps<Q extends Queries | Mutations, V = FetchAPIVariables<Q>> = { - query: Q; - variables?: V; -}; - -type FetchAPIResponse<T, K extends Queries | Mutations> = K extends Queries - ? QueriesResponseMap<T>[K] - : K extends Mutations - ? MutationsResponseMap<T>[K] - : never; - -export const fetchAPI = async <T, K extends Queries | Mutations>({ - query, - variables, -}: FetchAPIProps<K>): Promise<FetchAPIResponse<T, K>> => { - const response = await fetch(getAPIUrl(), { - method: 'POST', - headers: { - 'content-type': 'application/json;charset=UTF-8', - }, - body: JSON.stringify({ - query, - variables, - }), - }); - - type JSONResponse = { - data?: FetchAPIResponse<T, K>; - errors?: { message: string }[]; - }; - - const { data, errors }: JSONResponse = await response.json(); - - if (response.ok) { - if (!data) return Promise.reject(new Error(`No data found"`)); - - return data; - } - console.error('Failed to fetch API'); - const error = new Error( - errors?.map((e) => e.message).join('\n') ?? 'unknown' - ); - return Promise.reject(error); -}; diff --git a/src/services/graphql/articles.query.ts b/src/services/graphql/articles.query.ts deleted file mode 100644 index 46e3df6..0000000 --- a/src/services/graphql/articles.query.ts +++ /dev/null @@ -1,191 +0,0 @@ -/** - * Query the full article data using its slug. - */ -export const articleBySlugQuery = `query PostBy($slug: ID!) { - post(id: $slug, idType: SLUG) { - acfPosts { - postsInThematic { - ... on Thematic { - databaseId - slug - title - } - } - postsInTopic { - ... on Topic { - databaseId - featuredImage { - node { - altText - mediaDetails { - height - width - } - sourceUrl - title - } - } - slug - title - } - } - } - author { - node { - gravatarUrl - name - url - } - } - commentCount - contentParts { - afterMore - beforeMore - } - databaseId - date - featuredImage { - node { - altText - mediaDetails { - height - width - } - sourceUrl - title - } - } - info { - wordsCount - } - modified - seo { - metaDesc - title - } - slug - title - } -}`; - -/** - * Query an array of partial articles. - */ -export const articlesQuery = `query Articles($after: String = "", $first: Int = 10, $search: String = "") { - posts( - after: $after - first: $first - where: {orderby: {field: DATE, order: DESC}, search: $search, status: PUBLISH} - ) { - edges { - cursor - node { - acfPosts { - postsInThematic { - ... on Thematic { - databaseId - slug - title - } - } - } - commentCount - contentParts { - beforeMore - } - databaseId - date - featuredImage { - node { - altText - mediaDetails { - height - width - } - sourceUrl - title - } - } - info { - wordsCount - } - modified - slug - title - } - } - pageInfo { - endCursor - hasNextPage - total - } - } -}`; - -/** - * Query an array of articles with only the minimal data. - */ -export const articlesCardQuery = `query ArticlesCard($first: Int = 10) { - posts( - first: $first - where: {orderby: {field: DATE, order: DESC}, status: PUBLISH} - ) { - nodes { - databaseId - date - featuredImage { - node { - altText - mediaDetails { - height - width - } - sourceUrl - title - } - } - slug - title - } - } -}`; - -/** - * Query an array of articles slug. - */ -export const articlesSlugQuery = `query ArticlesSlug($first: Int = 10, $after: String = "") { - posts(after: $after, first: $first) { - edges { - cursor - node { - slug - } - } - pageInfo { - total - } - } -}`; - -/** - * Query the total number of articles. - */ -export const totalArticlesQuery = `query PostsTotal($search: String = "") { - posts(where: {search: $search}) { - pageInfo { - total - } - } -}`; - -/** - * Query the end cursor based on the queried posts number. - */ -export const articlesEndCursorQuery = `query EndCursorAfter($first: Int) { - posts(first: $first) { - pageInfo { - hasNextPage - endCursor - } - } -}`; diff --git a/src/services/graphql/articles.ts b/src/services/graphql/articles.ts deleted file mode 100644 index 82bde41..0000000 --- a/src/services/graphql/articles.ts +++ /dev/null @@ -1,201 +0,0 @@ -import type { - Article, - ArticleCard, - EdgesResponse, - EndCursorResponse, - GraphQLEdgesInput, - GraphQLPageInfo, - RawArticle, - RawArticlePreview, - Slug, - TotalItems, -} from '../../types'; -import { - getAuthorFromRawData, - getImageFromRawData, - getPageLinkFromRawData, - updateContentTree, -} from '../../utils/helpers'; -import { fetchAPI } from './api'; -import { - articleBySlugQuery, - articlesCardQuery, - articlesEndCursorQuery, - articlesQuery, - articlesSlugQuery, - totalArticlesQuery, -} from './articles.query'; - -/** - * Retrieve the total number of articles. - * - * @returns {Promise<number>} - The articles total number. - */ -export const getTotalArticles = async (search?: string): Promise<number> => { - const response = await fetchAPI<TotalItems, typeof totalArticlesQuery>({ - query: totalArticlesQuery, - variables: { search }, - }); - - return response.posts.pageInfo.total; -}; - -export type GetArticlesReturn = { - articles: Article[]; - pageInfo: GraphQLPageInfo; -}; - -/** - * Convert raw data to an Article object. - * - * @param {RawArticle} data - The page raw data. - * @returns {Article} The page data. - */ -export const getArticleFromRawData = async ( - data: RawArticle -): Promise<Article> => { - const { - acfPosts, - author, - commentCount, - contentParts, - databaseId, - date, - featuredImage, - info, - modified, - slug, - title, - seo, - } = data; - - return { - content: await updateContentTree(contentParts.afterMore), - id: databaseId, - intro: contentParts.beforeMore, - meta: { - author: author && getAuthorFromRawData(author.node, 'page'), - commentsCount: commentCount ?? 0, - cover: featuredImage?.node - ? getImageFromRawData(featuredImage.node) - : undefined, - dates: { publication: date, update: modified }, - seo: { - description: seo?.metaDesc ?? '', - title: seo?.title ?? '', - }, - thematics: acfPosts.postsInThematic?.map((thematic) => - getPageLinkFromRawData(thematic, 'thematic') - ), - topics: acfPosts.postsInTopic?.map((topic) => - getPageLinkFromRawData(topic, 'topic') - ), - wordsCount: info.wordsCount, - }, - slug, - title, - }; -}; - -/** - * Retrieve the given number of articles from API. - * - * @param {GraphQLEdgesInput} props - An object of GraphQL variables. - * @returns {Promise<EdgesResponse<RawArticle>>} The articles data. - */ -export const getArticles = async ( - props: GraphQLEdgesInput -): Promise<EdgesResponse<RawArticle>> => { - const response = await fetchAPI<RawArticle, typeof articlesQuery>({ - query: articlesQuery, - variables: { ...props }, - }); - - return response.posts; -}; - -/** - * Convert a raw article preview to an article card. - * - * @param {RawArticlePreview} data - A raw article preview. - * @returns {ArticleCard} An article card. - */ -const getArticleCardFromRawData = (data: RawArticlePreview): ArticleCard => { - const { databaseId, date, featuredImage, slug, title } = data; - - return { - cover: featuredImage ? getImageFromRawData(featuredImage.node) : undefined, - dates: { publication: date }, - id: databaseId, - slug, - title, - }; -}; - -/** - * Retrieve the given number of article cards from API. - * - * @param {GraphQLEdgesInput} obj - An object. - * @param {number} obj.first - The number of articles. - * @returns {Promise<ArticleCard[]>} - The article cards data. - */ -export const getArticlesCard = async ({ - first, -}: GraphQLEdgesInput): Promise<ArticleCard[]> => { - const response = await fetchAPI<RawArticlePreview, typeof articlesCardQuery>({ - query: articlesCardQuery, - variables: { first }, - }); - - return response.posts.nodes.map((node) => getArticleCardFromRawData(node)); -}; - -/** - * Retrieve an Article object by slug. - * - * @param {string} slug - The article slug. - * @returns {Promise<Article>} The requested article. - */ -export const getArticleBySlug = async (slug: string): Promise<Article> => { - const response = await fetchAPI<RawArticle, typeof articleBySlugQuery>({ - query: articleBySlugQuery, - variables: { slug }, - }); - - return getArticleFromRawData(response.post); -}; - -/** - * Retrieve all the articles slugs. - * - * @returns {Promise<string[]>} - An array of articles slugs. - */ -export const getAllArticlesSlugs = async (): Promise<string[]> => { - const totalArticles = await getTotalArticles(); - const response = await fetchAPI<Slug, typeof articlesSlugQuery>({ - query: articlesSlugQuery, - variables: { first: totalArticles }, - }); - - return response.posts.edges.map((edge) => edge.node.slug); -}; - -/** - * Retrieve the last cursor. - * - * @param {GraphQLEdgesInput} props - An object of GraphQL variables. - * @returns {Promise<string>} - The end cursor. - */ -export const getArticlesEndCursor = async ( - props: GraphQLEdgesInput -): Promise<string> => { - const response = await fetchAPI< - EndCursorResponse, - typeof articlesEndCursorQuery - >({ - query: articlesEndCursorQuery, - variables: { ...props }, - }); - - return response.posts.pageInfo.endCursor; -}; diff --git a/src/services/graphql/comments.mutation.ts b/src/services/graphql/comments.mutation.ts deleted file mode 100644 index f52c7e9..0000000 --- a/src/services/graphql/comments.mutation.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Send comment mutation. - */ -export const sendCommentMutation = `mutation CreateComment( - $author: String! - $authorEmail: String! - $authorUrl: String! - $content: String! - $parent: ID = null - $commentOn: Int! - $clientMutationId: String! -) { - createComment( - input: { - author: $author - authorEmail: $authorEmail - authorUrl: $authorUrl - content: $content - parent: $parent - commentOn: $commentOn - clientMutationId: $clientMutationId - } - ) { - clientMutationId - success - comment { - approved - } - } -}`; diff --git a/src/services/graphql/comments.query.ts b/src/services/graphql/comments.query.ts deleted file mode 100644 index 5110db3..0000000 --- a/src/services/graphql/comments.query.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Query the comments data by post id. - */ -export const commentsQuery = `query CommentsByPostId($contentId: ID!, $first: Int = 10, $after: String = "") { - comments( - where: {contentId: $contentId} - first: $first - after: $after - ) { - edges { - cursor - node { - approved - author { - node { - gravatarUrl - name - url - } - } - content - databaseId - date - parentDatabaseId - } - } - pageInfo { - hasNextPage - endCursor - } - } -}`; diff --git a/src/services/graphql/comments.ts b/src/services/graphql/comments.ts deleted file mode 100644 index 4eaeac7..0000000 --- a/src/services/graphql/comments.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { - type ContentId, - type GraphQLEdgesInput, - type RawComment, - type RawCommentsPage, - type SendCommentInput, - type SentComment, - type SingleComment, -} from '../../types'; -import { getAuthorFromRawData } from '../../utils/helpers'; -import { fetchAPI } from './api'; -import { sendCommentMutation } from './comments.mutation'; -import { commentsQuery } from './comments.query'; - -/** - * Convert a comment from RawComment type to SingleComment type. - * - * @param {RawComment} comment - A raw comment. - * @returns {SingleComment} A formatted comment. - */ -export const getCommentFromRawData = (comment: RawComment): SingleComment => { - const { author, databaseId, date, parentDatabaseId, ...data } = comment; - - return { - id: databaseId, - meta: { - author: getAuthorFromRawData(author.node, 'comment'), - date, - }, - parentId: parentDatabaseId === 0 ? undefined : parentDatabaseId, - replies: [], - ...data, - }; -}; - -/** - * Convert an array of RawComment type to an array of SingleComment type. - * - * @param {RawComment[]} comments - The raw comments. - * @returns {SingleComment[]} The formatted comments. - */ -export const getCommentsFromRawData = ( - comments: RawComment[] -): SingleComment[] => { - return comments.map((comment) => getCommentFromRawData(comment)); -}; - -/** - * Create a comments tree with replies. - * - * @param {SingleComment[]} comments - A flatten comments list. - * @returns {SingleComment[]} An array of comments with replies. - */ -export const buildCommentsTree = ( - comments: SingleComment[] -): SingleComment[] => { - type CommentsHashTable = { - [key: string]: SingleComment; - }; - - const hashTable: CommentsHashTable = Object.create(null); - const commentsTree: SingleComment[] = []; - - comments.forEach( - (comment) => (hashTable[comment.id] = { ...comment, replies: [] }) - ); - - comments.forEach((comment) => { - if (!comment.parentId) { - commentsTree.push(hashTable[comment.id]); - } else { - hashTable[comment.parentId].replies.push(hashTable[comment.id]); - } - }); - - return commentsTree; -}; - -type FetchCommentsInput = ContentId & - Pick<GraphQLEdgesInput, 'after' | 'first'>; - -/** - * Retrieve a raw comments page from GraphQL. - * - * @param {FetchCommentsInput} variables - An object of variables. - * @returns {Promise<RawCommentsPage>} A raw comments page. - */ -export const fetchRawComments = async ( - variables: FetchCommentsInput -): Promise<RawCommentsPage> => { - const response = await fetchAPI<RawComment, typeof commentsQuery>({ - query: commentsQuery, - variables, - }); - - return { - comments: response.comments.edges.map((edge) => edge.node), - hasNextPage: response.comments.pageInfo.hasNextPage, - endCursor: response.comments.pageInfo.endCursor, - }; -}; - -/** - * Fetch recursively all the comments on a post. - * - * @param {FetchCommentsInput} variables - An object of query variables. - * @param {RawCommentsPage[]} pages - An accumulator to keep track of pages. - * @returns {Promise<RawCommentsPage[]>} The raw comments pages. - */ -export const fetchAllRawCommentsPages = async ( - variables: FetchCommentsInput, - pages: RawCommentsPage[] = [] -): Promise<RawCommentsPage[]> => { - return fetchRawComments(variables).then((page) => { - pages.push(page); - - if (page.hasNextPage) { - return fetchAllRawCommentsPages( - { ...variables, after: page.endCursor }, - pages - ); - } else { - return pages; - } - }); -}; - -/** - * Method to compare two comments dates and sort them from older to newest. - * - * @param {SingleComment} a - A comment. - * @param {SingleComment} b - Another comment. - * @returns {number} The difference between dates. - */ -export const compareCommentsDate = ( - a: SingleComment, - b: SingleComment -): number => { - return +new Date(a.meta.date) - +new Date(b.meta.date); -}; - -/** - * Retrieve all the comments on a post. - * - * @param {number} id - A post id. - * @returns {Promise<SingleComment[]>} The comments list. - */ -export const getAllComments = async ({ - contentId, -}: { - contentId: number; -}): Promise<SingleComment[]> => { - const pages = await fetchAllRawCommentsPages({ contentId }); - const comments = pages - .map((page) => getCommentsFromRawData(page.comments)) - .flat() - .sort(compareCommentsDate); - - return buildCommentsTree(comments); -}; - -/** - * Send a comment using GraphQL API. - * - * @param {SendCommentVars} data - The comment data. - * @returns {Promise<SentComment>} The mutation response. - */ -export const sendComment = async ( - data: SendCommentInput -): Promise<SentComment> => { - const response = await fetchAPI<SentComment, typeof sendCommentMutation>({ - query: sendCommentMutation, - variables: { ...data }, - }); - - return response.createComment; -}; diff --git a/src/services/graphql/contact.mutation.ts b/src/services/graphql/contact.mutation.ts deleted file mode 100644 index b82fc07..0000000 --- a/src/services/graphql/contact.mutation.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Send mail mutation. - */ -export const sendMailMutation = `mutation SendEmail( - $subject: String! - $body: String! - $replyTo: String! - $clientMutationId: String! -) { - sendEmail( - input: { - clientMutationId: $clientMutationId - body: $body - replyTo: $replyTo - subject: $subject - } - ) { - clientMutationId - message - sent - origin - replyTo - to - } -}`; diff --git a/src/services/graphql/contact.ts b/src/services/graphql/contact.ts deleted file mode 100644 index 3098374..0000000 --- a/src/services/graphql/contact.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { SendMailInput } from '../../types'; -import { fetchAPI } from './api'; -import { sendMailMutation } from './contact.mutation'; - -export type SentEmail = { - clientMutationId: string; - message: string; - origin: string; - replyTo: string; - sent: boolean; -}; - -/** - * Send an email using GraphQL API. - * - * @param {SendMailInput} data - The mail data. - * @returns {Promise<SentEmail>} The mutation response. - */ -export const sendMail = async (data: SendMailInput): Promise<SentEmail> => { - const response = await fetchAPI<SentEmail, typeof sendMailMutation>({ - query: sendMailMutation, - variables: { ...data }, - }); - - return response.sendEmail; -}; diff --git a/src/services/graphql/fetchers/comments/fetch-comments.ts b/src/services/graphql/fetchers/comments/fetch-comments.ts new file mode 100644 index 0000000..85ae6c1 --- /dev/null +++ b/src/services/graphql/fetchers/comments/fetch-comments.ts @@ -0,0 +1,65 @@ +import type { + GraphQLCommentWhere, + GraphQLEdgesInput, + GraphQLNodes, + Nullable, + WPComment, +} from '../../../../types'; +import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers'; + +type CommentsListResponse = { + comments: Nullable<GraphQLNodes<WPComment>>; +}; + +const commentsListQuery = `query CommentsList($first: Int, $contentId: ID, $contentName: String, $status: String) { + comments( + first: $first + where: {contentId: $contentId, contentName: $contentName, order: ASC, orderby: COMMENT_DATE, status: $status} + ) { + nodes { + approved + author { + node { + avatar { + height + url + width + } + name + url + } + } + content + databaseId + date + parentDatabaseId + status + } + } +}`; + +export type FetchCommentsListInput = Pick<GraphQLEdgesInput, 'first'> & { + where?: GraphQLCommentWhere; +}; + +/** + * Retrieve the comments list. + * + * @param {FetchCommentsListInput} input - The input to retrieve comments. + * @returns {Promise<WPComment[]>} An array of comments. + */ +export const fetchCommentsList = async ({ + where, + ...vars +}: FetchCommentsListInput): Promise<WPComment[]> => { + const response = await fetchGraphQL<CommentsListResponse>({ + query: commentsListQuery, + url: getGraphQLUrl(), + variables: { ...vars, ...where }, + }); + + if (!response.comments) + return Promise.reject(new Error('No comments found.')); + + return response.comments.nodes; +}; diff --git a/src/services/graphql/fetchers/comments/index.ts b/src/services/graphql/fetchers/comments/index.ts new file mode 100644 index 0000000..6a15970 --- /dev/null +++ b/src/services/graphql/fetchers/comments/index.ts @@ -0,0 +1 @@ +export * from './fetch-comments'; diff --git a/src/services/graphql/fetchers/index.ts b/src/services/graphql/fetchers/index.ts new file mode 100644 index 0000000..f45b1c0 --- /dev/null +++ b/src/services/graphql/fetchers/index.ts @@ -0,0 +1,4 @@ +export * from './comments'; +export * from './posts'; +export * from './thematics'; +export * from './topics'; diff --git a/src/services/graphql/fetchers/posts/fetch-all-posts-slugs.ts b/src/services/graphql/fetchers/posts/fetch-all-posts-slugs.ts new file mode 100644 index 0000000..28f2bbf --- /dev/null +++ b/src/services/graphql/fetchers/posts/fetch-all-posts-slugs.ts @@ -0,0 +1,34 @@ +import type { GraphQLNodes, Nullable, SlugNode } from '../../../../types'; +import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers'; +import { fetchPostsCount } from './fetch-posts-count'; + +type PostsSlugsResponse = { + posts: Nullable<GraphQLNodes<SlugNode>>; +}; + +const postsSlugsQuery = `query PostsSlugs($first: Int) { + posts(first: $first) { + nodes { + slug + } + } +}`; + +/** + * Retrieve the WordPress posts slugs. + * + * @returns {Promise<string[]>} The posts slugs. + */ +export const fetchAllPostsSlugs = async (): Promise<string[]> => { + const postsCount = await fetchPostsCount(); + const response = await fetchGraphQL<PostsSlugsResponse>({ + query: postsSlugsQuery, + url: getGraphQLUrl(), + variables: { first: postsCount }, + }); + + if (!response.posts) + return Promise.reject(new Error('Unable to find the posts slugs.')); + + return response.posts.nodes.map((node) => node.slug); +}; diff --git a/src/services/graphql/fetchers/posts/fetch-last-post-cursor.ts b/src/services/graphql/fetchers/posts/fetch-last-post-cursor.ts new file mode 100644 index 0000000..d5ed174 --- /dev/null +++ b/src/services/graphql/fetchers/posts/fetch-last-post-cursor.ts @@ -0,0 +1,37 @@ +import type { GraphQLPageInfo, Nullable } from '../../../../types'; +import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers'; + +type LastPostCursorResponse = { + posts: Nullable<{ + pageInfo: Pick<GraphQLPageInfo, 'endCursor'>; + }>; +}; + +const lastPostCursorQuery = `query LastPostCursor($first: Int) { + posts(first: $first) { + pageInfo { + endCursor + } + } +}`; + +/** + * Retrieve the cursor of the last post for a given number of posts. + * + * @param {number} count - The number of posts to fetch. + * @returns {Promise<string>} The cursor of the last post. + */ +export const fetchLastPostCursor = async (count: number): Promise<string> => { + const response = await fetchGraphQL<LastPostCursorResponse>({ + url: getGraphQLUrl(), + query: lastPostCursorQuery, + variables: { first: count }, + }); + + if (!response.posts?.pageInfo.endCursor) + return Promise.reject( + new Error('Unable to find the cursor of the last post.') + ); + + return response.posts.pageInfo.endCursor; +}; diff --git a/src/services/graphql/fetchers/posts/fetch-post.ts b/src/services/graphql/fetchers/posts/fetch-post.ts new file mode 100644 index 0000000..53c6bc3 --- /dev/null +++ b/src/services/graphql/fetchers/posts/fetch-post.ts @@ -0,0 +1,92 @@ +import type { Nullable, WPPost } from '../../../../types'; +import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers'; + +type PostResponse = { + post: Nullable<WPPost>; +}; + +const postQuery = `query Post($slug: ID!) { + post(id: $slug, idType: SLUG) { + acfPosts { + postsInThematic { + ... on Thematic { + databaseId + slug + title + } + } + postsInTopic { + ... on Topic { + databaseId + featuredImage { + node { + altText + mediaDetails { + height + width + } + sourceUrl + title + } + } + slug + title + } + } + } + author { + node { + name + } + } + commentCount + contentParts { + afterMore + beforeMore + } + databaseId + date + featuredImage { + node { + altText + mediaDetails { + height + width + } + sourceUrl + title + } + } + info { + wordsCount + } + modified + seo { + metaDesc + title + } + slug + title + } +}`; + +/** + * Retrieve a WordPress post by slug. + * + * @param {string} slug - The post slug. + * @returns {Promise<WPPost>} The requested post. + */ +export const fetchPost = async (slug: string): Promise<WPPost> => { + const response = await fetchGraphQL<PostResponse>({ + query: postQuery, + url: getGraphQLUrl(), + variables: { slug }, + }); + + if (!response.post) + return Promise.reject( + new Error(`No post found for the following slug ${slug}.`) + ); + + return response.post; +}; diff --git a/src/services/graphql/fetchers/posts/fetch-posts-count.ts b/src/services/graphql/fetchers/posts/fetch-posts-count.ts new file mode 100644 index 0000000..a72af52 --- /dev/null +++ b/src/services/graphql/fetchers/posts/fetch-posts-count.ts @@ -0,0 +1,43 @@ +import type { + GraphQLPageInfo, + GraphQLPostWhere, + Nullable, +} from '../../../../types'; +import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers'; + +type PostsCountResponse = { + posts: Nullable<{ + pageInfo: Pick<GraphQLPageInfo, 'total'>; + }>; +}; + +const postsCountQuery = `query PostsCount($authorName: String, $search: String, $title: String) { + posts(where: {authorName: $authorName, search: $search, title: $title}) { + pageInfo { + total + } + } +}`; + +/** + * Retrieve the total of WordPress posts. + * + * @param {GraphQLPostWhere} [input] - The input to filter the posts. + * @returns {Promise<number>} The total number of posts. + */ +export const fetchPostsCount = async ( + input?: GraphQLPostWhere +): Promise<number> => { + const response = await fetchGraphQL<PostsCountResponse>({ + query: postsCountQuery, + url: getGraphQLUrl(), + variables: { ...input }, + }); + + if (!response.posts) + return Promise.reject( + new Error('Unable to find the total number of posts.') + ); + + return response.posts.pageInfo.total; +}; diff --git a/src/services/graphql/fetchers/posts/fetch-posts-list.ts b/src/services/graphql/fetchers/posts/fetch-posts-list.ts new file mode 100644 index 0000000..452892b --- /dev/null +++ b/src/services/graphql/fetchers/posts/fetch-posts-list.ts @@ -0,0 +1,97 @@ +import type { + GraphQLConnection, + GraphQLEdgesInput, + GraphQLPostOrderBy, + GraphQLPostWhere, + Nullable, + WPPostPreview, +} from '../../../../types'; +import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers'; + +type PostsListResponse = { + posts: Nullable<GraphQLConnection<WPPostPreview>>; +}; + +const postsListQuery = `query PostsList($after: String, $before: String, $first: Int, $last: Int, $authorName: String, $orderby: [PostObjectsConnectionOrderbyInput], $search: String, $title: String) { + posts( + after: $after + before: $before + first: $first + last: $last + where: {authorName: $authorName, orderby: $orderby, search: $search, title: $title} + ) { + edges { + cursor + node { + acfPosts { + postsInThematic { + ... on Thematic { + databaseId + slug + title + } + } + } + commentCount + contentParts { + beforeMore + } + databaseId + date + featuredImage { + node { + altText + mediaDetails { + height + width + } + sourceUrl + title + } + } + info { + wordsCount + } + modified + slug + title + } + } + pageInfo { + endCursor + hasNextPage + total + } + } +}`; + +export type FetchPostsListInput = GraphQLEdgesInput & { + orderBy?: GraphQLPostOrderBy; + where?: GraphQLPostWhere; +}; + +/** + * Retrieve a paginated list of WordPress posts. + * + * @param {FetchPostsListInput} input - The input to retrieve posts. + * @returns {Promise<GraphQLConnection<WPPostPreview>>} The paginated posts. + */ +export const fetchPostsList = async ({ + orderBy, + where, + ...vars +}: FetchPostsListInput): Promise<GraphQLConnection<WPPostPreview>> => { + const response = await fetchGraphQL<PostsListResponse>({ + query: postsListQuery, + url: getGraphQLUrl(), + variables: { + ...vars, + ...where, + orderBy: orderBy ? [orderBy] : undefined, + }, + }); + + if (!response.posts) return Promise.reject(new Error('No posts found.')); + + return response.posts; +}; diff --git a/src/services/graphql/fetchers/posts/fetch-recent-posts.ts b/src/services/graphql/fetchers/posts/fetch-recent-posts.ts new file mode 100644 index 0000000..12785d6 --- /dev/null +++ b/src/services/graphql/fetchers/posts/fetch-recent-posts.ts @@ -0,0 +1,76 @@ +import type { + GraphQLConnection, + GraphQLEdgesInput, + GraphQLPostWhere, + Nullable, + RecentWPPost, +} from '../../../../types'; +import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers'; + +type RecentPostsResponse = { + posts: Nullable<GraphQLConnection<RecentWPPost>>; +}; + +const recentPostsQuery = `query RecentPosts($after: String, $before: String, $first: Int, $last: Int, $authorName: String, $search: String, $title: String) { + posts( + after: $after + before: $before + first: $first + last: $last + where: {authorName: $authorName, search: $search, title: $title, orderby: {field: DATE, order: DESC}} + ) { + edges { + cursor + node { + databaseId + date + featuredImage { + node { + altText + mediaDetails { + height + width + } + sourceUrl + title + } + } + slug + title + } + } + pageInfo { + endCursor + hasNextPage + hasPreviousPage + startCursor + total + } + } +}`; + +export type FetchRecentPostsInput = GraphQLEdgesInput & { + where?: GraphQLPostWhere; +}; + +/** + * Retrieve a paginated list of recent WordPress posts. + * + * @param {FetchRecentPostsInput} input - The input to retrieve recent posts. + * @returns {Promise<GraphQLConnection<RecentWPPost>>} The recent posts. + */ +export const fetchRecentPosts = async ({ + where, + ...vars +}: FetchRecentPostsInput): Promise<GraphQLConnection<RecentWPPost>> => { + const response = await fetchGraphQL<RecentPostsResponse>({ + query: recentPostsQuery, + url: getGraphQLUrl(), + variables: { ...vars, ...where }, + }); + + if (!response.posts) + return Promise.reject(new Error('No recent posts found.')); + + return response.posts; +}; diff --git a/src/services/graphql/fetchers/posts/index.ts b/src/services/graphql/fetchers/posts/index.ts new file mode 100644 index 0000000..fd725cd --- /dev/null +++ b/src/services/graphql/fetchers/posts/index.ts @@ -0,0 +1,6 @@ +export * from './fetch-all-posts-slugs'; +export * from './fetch-last-post-cursor'; +export * from './fetch-post'; +export * from './fetch-posts-count'; +export * from './fetch-posts-list'; +export * from './fetch-recent-posts'; diff --git a/src/services/graphql/fetchers/thematics/fetch-all-thematics-slugs.ts b/src/services/graphql/fetchers/thematics/fetch-all-thematics-slugs.ts new file mode 100644 index 0000000..739c009 --- /dev/null +++ b/src/services/graphql/fetchers/thematics/fetch-all-thematics-slugs.ts @@ -0,0 +1,34 @@ +import type { GraphQLNodes, Nullable, SlugNode } from '../../../../types'; +import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers'; +import { fetchThematicsCount } from './fetch-thematics-count'; + +type ThematicsSlugsResponse = { + thematics: Nullable<GraphQLNodes<SlugNode>>; +}; + +const thematicsSlugsQuery = `query ThematicsSlugs($first: Int) { + thematics(first: $first) { + nodes { + slug + } + } +}`; + +/** + * Retrieve the WordPress thematics slugs. + * + * @returns {Promise<string[]>} The thematics slugs. + */ +export const fetchAllThematicsSlugs = async (): Promise<string[]> => { + const thematicsCount = await fetchThematicsCount(); + const response = await fetchGraphQL<ThematicsSlugsResponse>({ + query: thematicsSlugsQuery, + url: getGraphQLUrl(), + variables: { first: thematicsCount }, + }); + + if (!response.thematics) + return Promise.reject(new Error('Unable to find the thematics slugs.')); + + return response.thematics.nodes.map((node) => node.slug); +}; diff --git a/src/services/graphql/fetchers/thematics/fetch-thematic.ts b/src/services/graphql/fetchers/thematics/fetch-thematic.ts new file mode 100644 index 0000000..a9958bc --- /dev/null +++ b/src/services/graphql/fetchers/thematics/fetch-thematic.ts @@ -0,0 +1,96 @@ +import type { Nullable, WPThematic } from '../../../../types'; +import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers'; + +type ThematicResponse = { + thematic: Nullable<WPThematic>; +}; + +const thematicQuery = `query Thematic($slug: ID!) { + thematic(id: $slug, idType: SLUG) { + acfThematics { + postsInThematic { + ... on Post { + acfPosts { + postsInTopic { + ... on Topic { + databaseId + slug + title + } + } + } + author { + node { + name + } + } + commentCount + contentParts { + beforeMore + } + databaseId + date + featuredImage { + node { + altText + mediaDetails { + height + width + } + sourceUrl + title + } + } + info { + wordsCount + } + modified + slug + title + } + } + } + contentParts { + afterMore + beforeMore + } + featuredImage { + node { + altText + mediaDetails { + height + width + } + sourceUrl + title + } + } + seo { + metaDesc + title + } + slug + title + } +}`; + +/** + * Retrieve a WordPress thematic by slug. + * + * @param {string} slug - The thematic slug. + * @returns {Promise<WPThematic>} The requested thematic. + */ +export const fetchThematic = async (slug: string): Promise<WPThematic> => { + const response = await fetchGraphQL<ThematicResponse>({ + query: thematicQuery, + url: getGraphQLUrl(), + variables: { slug }, + }); + + if (!response.thematic) + return Promise.reject( + new Error(`No thematic found for the following slug ${slug}.`) + ); + + return response.thematic; +}; diff --git a/src/services/graphql/fetchers/thematics/fetch-thematics-count.ts b/src/services/graphql/fetchers/thematics/fetch-thematics-count.ts new file mode 100644 index 0000000..29a3b17 --- /dev/null +++ b/src/services/graphql/fetchers/thematics/fetch-thematics-count.ts @@ -0,0 +1,43 @@ +import type { + GraphQLPageInfo, + GraphQLTaxonomyWhere, + Nullable, +} from '../../../../types'; +import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers'; + +type ThematicsCountResponse = { + thematics: Nullable<{ + pageInfo: Pick<GraphQLPageInfo, 'total'>; + }>; +}; + +const thematicsCountQuery = `query ThematicsCount($search: String, $title: String) { + thematics(where: {search: $search, title: $title}) { + pageInfo { + total + } + } +}`; + +/** + * Retrieve the total of WordPress thematics. + * + * @param {GraphQLTaxonomyWhere} [input] - The input to filter the thematics. + * @returns {Promise<number>} The total number of thematics. + */ +export const fetchThematicsCount = async ( + input?: GraphQLTaxonomyWhere +): Promise<number> => { + const response = await fetchGraphQL<ThematicsCountResponse>({ + query: thematicsCountQuery, + url: getGraphQLUrl(), + variables: { ...input }, + }); + + if (!response.thematics) + return Promise.reject( + new Error('Unable to find the total number of thematics.') + ); + + return response.thematics.pageInfo.total; +}; diff --git a/src/services/graphql/fetchers/thematics/fetch-thematics-list.ts b/src/services/graphql/fetchers/thematics/fetch-thematics-list.ts new file mode 100644 index 0000000..f4d22c6 --- /dev/null +++ b/src/services/graphql/fetchers/thematics/fetch-thematics-list.ts @@ -0,0 +1,78 @@ +import type { + GraphQLConnection, + GraphQLEdgesInput, + GraphQLTaxonomyOrderBy, + GraphQLTaxonomyWhere, + Nullable, + WPThematicPreview, +} from '../../../../types'; +import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers'; + +type ThematicsListResponse = { + thematics: Nullable<GraphQLConnection<WPThematicPreview>>; +}; + +const thematicsListQuery = `query ThematicsList($after: String, $before: String, $first: Int, $last: Int, $orderby: [PostObjectsConnectionOrderbyInput], $search: String, $title: String) { + thematics( + after: $after + before: $before + first: $first + last: $last + where: {orderby: $orderby, search: $search, title: $title} + ) { + edges { + cursor + node { + contentParts { + beforeMore + } + databaseId + featuredImage { + node { + altText + mediaDetails { + height + width + } + sourceUrl + title + } + } + slug + title + } + } + } +}`; + +export type FetchThematicsListInput = GraphQLEdgesInput & { + orderBy?: GraphQLTaxonomyOrderBy; + where?: GraphQLTaxonomyWhere; +}; + +/** + * Retrieve a paginated list of WordPress thematics. + * + * @param {FetchThematicsListInput} input - The input to retrieve thematics. + * @returns {Promise<GraphQLConnection<WPThematicPreview>>} The paginated thematics. + */ +export const fetchThematicsList = async ({ + orderBy, + where, + ...vars +}: FetchThematicsListInput): Promise<GraphQLConnection<WPThematicPreview>> => { + const response = await fetchGraphQL<ThematicsListResponse>({ + query: thematicsListQuery, + url: getGraphQLUrl(), + variables: { + ...vars, + ...where, + orderBy: orderBy ? [orderBy] : undefined, + }, + }); + + if (!response.thematics) + return Promise.reject(new Error('No thematics found.')); + + return response.thematics; +}; diff --git a/src/services/graphql/fetchers/thematics/index.ts b/src/services/graphql/fetchers/thematics/index.ts new file mode 100644 index 0000000..c002793 --- /dev/null +++ b/src/services/graphql/fetchers/thematics/index.ts @@ -0,0 +1,4 @@ +export * from './fetch-all-thematics-slugs'; +export * from './fetch-thematic'; +export * from './fetch-thematics-count'; +export * from './fetch-thematics-list'; diff --git a/src/services/graphql/fetchers/topics/fetch-all-topics-slugs.ts b/src/services/graphql/fetchers/topics/fetch-all-topics-slugs.ts new file mode 100644 index 0000000..eab4a7c --- /dev/null +++ b/src/services/graphql/fetchers/topics/fetch-all-topics-slugs.ts @@ -0,0 +1,34 @@ +import type { GraphQLNodes, Nullable, SlugNode } from '../../../../types'; +import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers'; +import { fetchTopicsCount } from './fetch-topics-count'; + +type TopicsSlugsResponse = { + topics: Nullable<GraphQLNodes<SlugNode>>; +}; + +const topicsSlugsQuery = `query TopicsSlugs($first: Int) { + topics(first: $first) { + nodes { + slug + } + } +}`; + +/** + * Retrieve the WordPress topics slugs. + * + * @returns {Promise<string[]>} The topics slugs. + */ +export const fetchAllTopicsSlugs = async (): Promise<string[]> => { + const topicsCount = await fetchTopicsCount(); + const response = await fetchGraphQL<TopicsSlugsResponse>({ + query: topicsSlugsQuery, + url: getGraphQLUrl(), + variables: { first: topicsCount }, + }); + + if (!response.topics) + return Promise.reject(new Error('Unable to find the topics slugs.')); + + return response.topics.nodes.map((node) => node.slug); +}; diff --git a/src/services/graphql/fetchers/topics/fetch-topic.ts b/src/services/graphql/fetchers/topics/fetch-topic.ts new file mode 100644 index 0000000..efc1d9e --- /dev/null +++ b/src/services/graphql/fetchers/topics/fetch-topic.ts @@ -0,0 +1,97 @@ +import type { Nullable, WPTopic } from '../../../../types'; +import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers'; + +type TopicResponse = { + topic: Nullable<WPTopic>; +}; + +const topicQuery = `query Topic($slug: ID!) { + topic(id: $slug, idType: SLUG) { + acfTopics { + officialWebsite + postsInTopic { + ... on Post { + acfPosts { + postsInThematic { + ... on Thematic { + databaseId + slug + title + } + } + } + author { + node { + name + } + } + commentCount + contentParts { + beforeMore + } + databaseId + date + featuredImage { + node { + altText + mediaDetails { + height + width + } + sourceUrl + title + } + } + info { + wordsCount + } + modified + slug + title + } + } + } + contentParts { + afterMore + beforeMore + } + featuredImage { + node { + altText + mediaDetails { + height + width + } + sourceUrl + title + } + } + seo { + metaDesc + title + } + slug + title + } +}`; + +/** + * Retrieve a WordPress topic by slug. + * + * @param {string} slug - The topic slug. + * @returns {Promise<WPTopic>} The requested topic. + */ +export const fetchTopic = async (slug: string): Promise<WPTopic> => { + const response = await fetchGraphQL<TopicResponse>({ + query: topicQuery, + url: getGraphQLUrl(), + variables: { slug }, + }); + + if (!response.topic) + return Promise.reject( + new Error(`No topic found for the following slug ${slug}.`) + ); + + return response.topic; +}; diff --git a/src/services/graphql/fetchers/topics/fetch-topics-count.ts b/src/services/graphql/fetchers/topics/fetch-topics-count.ts new file mode 100644 index 0000000..868b01e --- /dev/null +++ b/src/services/graphql/fetchers/topics/fetch-topics-count.ts @@ -0,0 +1,43 @@ +import type { + GraphQLPageInfo, + GraphQLTaxonomyWhere, + Nullable, +} from '../../../../types'; +import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers'; + +type TopicsCountResponse = { + topics: Nullable<{ + pageInfo: Pick<GraphQLPageInfo, 'total'>; + }>; +}; + +const topicsCountQuery = `query TopicsCount($search: String, $title: String) { + topics(where: {search: $search, title: $title}) { + pageInfo { + total + } + } +}`; + +/** + * Retrieve the total of WordPress topics. + * + * @param {GraphQLTaxonomyWhere} [input] - The input to filter the topics. + * @returns {Promise<number>} The total number of topics. + */ +export const fetchTopicsCount = async ( + input?: GraphQLTaxonomyWhere +): Promise<number> => { + const response = await fetchGraphQL<TopicsCountResponse>({ + query: topicsCountQuery, + url: getGraphQLUrl(), + variables: { ...input }, + }); + + if (!response.topics) + return Promise.reject( + new Error('Unable to find the total number of topics.') + ); + + return response.topics.pageInfo.total; +}; diff --git a/src/services/graphql/fetchers/topics/fetch-topics-list.ts b/src/services/graphql/fetchers/topics/fetch-topics-list.ts new file mode 100644 index 0000000..1bc2e38 --- /dev/null +++ b/src/services/graphql/fetchers/topics/fetch-topics-list.ts @@ -0,0 +1,84 @@ +import type { + GraphQLConnection, + GraphQLEdgesInput, + GraphQLTaxonomyOrderBy, + GraphQLTaxonomyWhere, + Nullable, + WPTopicPreview, +} from '../../../../types'; +import { fetchGraphQL, getGraphQLUrl } from '../../../../utils/helpers'; + +type TopicsListResponse = { + topics: Nullable<GraphQLConnection<WPTopicPreview>>; +}; + +const topicsListQuery = `query TopicsList($after: String, $before: String, $first: Int, $last: Int, $orderby: [PostObjectsConnectionOrderbyInput], $search: String, $title: String) { + topics( + after: $after + before: $before + first: $first + last: $last + where: {orderby: $orderby, search: $search, title: $title} + ) { + edges { + cursor + node { + contentParts { + beforeMore + } + databaseId + featuredImage { + node { + altText + mediaDetails { + height + width + } + slug + title + } + } + slug + title + } + } + pageInfo { + endCursor + hasNextPage + hasPreviousPage + startCursor + total + } + } +}`; + +export type FetchTopicsListInput = GraphQLEdgesInput & { + orderBy?: GraphQLTaxonomyOrderBy; + where?: GraphQLTaxonomyWhere; +}; + +/** + * Retrieve a paginated list of WordPress topics. + * + * @param {FetchTopicsListInput} input - The input to retrieve topics. + * @returns {Promise<GraphQLConnection<WPTopicPreview>>} The paginated topics. + */ +export const fetchTopicsList = async ({ + orderBy, + where, + ...vars +}: FetchTopicsListInput): Promise<GraphQLConnection<WPTopicPreview>> => { + const response = await fetchGraphQL<TopicsListResponse>({ + query: topicsListQuery, + url: getGraphQLUrl(), + variables: { + ...vars, + ...where, + orderBy: orderBy ? [orderBy] : undefined, + }, + }); + + if (!response.topics) return Promise.reject(new Error('No topics found.')); + + return response.topics; +}; diff --git a/src/services/graphql/fetchers/topics/index.ts b/src/services/graphql/fetchers/topics/index.ts new file mode 100644 index 0000000..e381883 --- /dev/null +++ b/src/services/graphql/fetchers/topics/index.ts @@ -0,0 +1,4 @@ +export * from './fetch-all-topics-slugs'; +export * from './fetch-topic'; +export * from './fetch-topics-count'; +export * from './fetch-topics-list'; diff --git a/src/services/graphql/helpers/build-comments-tree.test.ts b/src/services/graphql/helpers/build-comments-tree.test.ts new file mode 100644 index 0000000..cd9fa40 --- /dev/null +++ b/src/services/graphql/helpers/build-comments-tree.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from '@jest/globals'; +import type { SingleComment } from '../../../types'; +import { buildCommentsTree } from './build-comments-tree'; + +describe('build-comments-tree', () => { + it('transforms a flat comments array to a comments tree', () => { + const firstComment = { + content: 'Non non provident mollitia a.', + id: 1, + isApproved: true, + meta: { author: { name: 'Emma_Muller' }, date: '2022-11-02' }, + replies: [], + } satisfies SingleComment; + const firstCommentReplies = [ + { + content: 'Et omnis voluptatem est atque.', + id: 3, + isApproved: true, + meta: { author: { name: 'Patrick.Goodwin44' }, date: '2022-11-05' }, + replies: [], + parentId: 1, + }, + ] satisfies SingleComment[]; + const secondComment = { + content: 'Vero iure architecto modi iusto qui.', + id: 2, + isApproved: true, + meta: { author: { name: 'Dominique13' }, date: '2022-11-04' }, + replies: [], + } satisfies SingleComment; + const secondCommentReplies = [ + { + content: 'Qui quaerat quas quia praesentium quasi.', + id: 4, + isApproved: true, + meta: { author: { name: 'Patrick.Goodwin44' }, date: '2022-11-05' }, + replies: [], + parentId: 2, + }, + { + content: 'Ut officia aliquid harum voluptas molestiae quo.', + id: 5, + isApproved: true, + meta: { author: { name: 'Ariel.Braun6' }, date: '2022-11-06' }, + replies: [], + parentId: 2, + }, + ] satisfies SingleComment[]; + const comments: SingleComment[] = [ + firstComment, + secondComment, + ...firstCommentReplies, + ...secondCommentReplies, + ]; + const result = buildCommentsTree(comments); + + expect(result).toHaveLength(2); + expect(result[0]).toStrictEqual({ + ...firstComment, + replies: firstCommentReplies, + }); + expect(result[1]).toStrictEqual({ + ...secondComment, + replies: secondCommentReplies, + }); + }); +}); diff --git a/src/services/graphql/helpers/build-comments-tree.ts b/src/services/graphql/helpers/build-comments-tree.ts new file mode 100644 index 0000000..1534cfe --- /dev/null +++ b/src/services/graphql/helpers/build-comments-tree.ts @@ -0,0 +1,30 @@ +import type { SingleComment } from '../../../types'; + +/** + * Create a comments tree with replies. + * + * @param {SingleComment[]} comments - A flatten comments list. + * @returns {SingleComment[]} An array of comments with replies. + */ +export const buildCommentsTree = ( + comments: SingleComment[] +): SingleComment[] => { + type CommentsHashTable = Record<string, SingleComment>; + + const hashTable: CommentsHashTable = Object.create(null); + const commentsTree: SingleComment[] = []; + + comments.forEach((comment) => { + hashTable[comment.id] = { ...comment, replies: [] }; + }); + + comments.forEach((comment) => { + if (comment.parentId) { + hashTable[comment.parentId].replies.push(hashTable[comment.id]); + } else { + commentsTree.push(hashTable[comment.id]); + } + }); + + return commentsTree; +}; diff --git a/src/services/graphql/helpers/convert-post-preview-to-article-preview.test.ts b/src/services/graphql/helpers/convert-post-preview-to-article-preview.test.ts new file mode 100644 index 0000000..c13684f --- /dev/null +++ b/src/services/graphql/helpers/convert-post-preview-to-article-preview.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from '@jest/globals'; +import type { WPPostPreview, WPThematicPreview } from '../../../types'; +import { convertPostPreviewToArticlePreview } from './convert-post-preview-to-article-preview'; +import { convertTaxonomyToPageLink } from './convert-taxonomy-to-page-link'; +import { convertWPImgToImg } from './convert-wp-image-to-img'; + +describe('convert-post-preview-to-article-preview', () => { + /* eslint-disable max-statements */ + it('converts a RecentWPPost object to a RecentArticle object', () => { + const post: WPPostPreview = { + acfPosts: null, + commentCount: 6, + contentParts: { + beforeMore: + 'Et quos fuga molestias. Voluptatum nobis mollitia eaque dolorem sunt. Dolores dignissimos consequuntur mollitia. Enim molestias quibusdam sequi. Dolore ut quo. Libero iure non vel reprehenderit.', + }, + databaseId: 5, + date: '2021-04-28', + featuredImage: null, + info: { + wordsCount: 450, + }, + modified: '2021-04-29', + slug: '/the-post-slug', + title: 'et tempore sint', + }; + const result = convertPostPreviewToArticlePreview(post); + + expect(result.id).toBe(post.databaseId); + expect(result.intro).toBe(post.contentParts.beforeMore); + expect(result.meta.commentsCount).toBe(post.commentCount); + expect(result.meta.cover).toBeUndefined(); + expect(result.meta.dates.publication).toBe(post.date); + expect(result.meta.dates.update).toBe(post.modified); + expect(result.meta.thematics).toBeUndefined(); + expect(result.meta.wordsCount).toBe(post.info.wordsCount); + expect(result.slug).toBe(post.slug); + expect(result.title).toBe(post.title); + }); + /* eslint-enable max-statements */ + + it('can return 0 as comment count if not defined', () => { + const post: WPPostPreview = { + acfPosts: null, + commentCount: null, + contentParts: { + beforeMore: + 'Et quos fuga molestias. Voluptatum nobis mollitia eaque dolorem sunt. Dolores dignissimos consequuntur mollitia. Enim molestias quibusdam sequi. Dolore ut quo. Libero iure non vel reprehenderit.', + }, + databaseId: 5, + date: '2021-04-28', + featuredImage: null, + info: { + wordsCount: 450, + }, + modified: '2021-04-29', + slug: '/the-post-slug', + title: 'et tempore sint', + }; + const result = convertPostPreviewToArticlePreview(post); + + expect(result.meta.commentsCount).toBe(0); + }); + + it('can convert the cover', () => { + const post = { + acfPosts: null, + commentCount: null, + contentParts: { + beforeMore: + 'Et quos fuga molestias. Voluptatum nobis mollitia eaque dolorem sunt. Dolores dignissimos consequuntur mollitia. Enim molestias quibusdam sequi. Dolore ut quo. Libero iure non vel reprehenderit.', + }, + databaseId: 5, + date: '2021-04-28', + featuredImage: { + node: { + altText: 'molestiae praesentium animi', + mediaDetails: { + height: 480, + width: 640, + }, + sourceUrl: 'https://picsum.photos/640/480', + title: 'ullam deserunt perspiciatis', + }, + }, + info: { + wordsCount: 450, + }, + modified: '2021-04-29', + slug: '/the-post-slug', + title: 'et tempore sint', + } satisfies WPPostPreview; + const result = convertPostPreviewToArticlePreview(post); + + expect(result.meta.cover).toStrictEqual( + convertWPImgToImg(post.featuredImage.node) + ); + }); + + it('can convert the thematics', () => { + const thematics: WPThematicPreview[] = [ + { databaseId: 2, slug: '/thematic1', title: 'aut quis vel' }, + { databaseId: 8, slug: '/thematic2', title: 'vel sint autem' }, + ]; + const post: WPPostPreview = { + acfPosts: { + postsInThematic: thematics, + }, + commentCount: 6, + contentParts: { + beforeMore: + 'Et quos fuga molestias. Voluptatum nobis mollitia eaque dolorem sunt. Dolores dignissimos consequuntur mollitia. Enim molestias quibusdam sequi. Dolore ut quo. Libero iure non vel reprehenderit.', + }, + databaseId: 5, + date: '2021-04-28', + featuredImage: null, + info: { + wordsCount: 450, + }, + modified: '2021-04-29', + slug: '/the-post-slug', + title: 'et tempore sint', + }; + const result = convertPostPreviewToArticlePreview(post); + + expect(result.meta.thematics).toStrictEqual( + thematics.map(convertTaxonomyToPageLink) + ); + }); +}); diff --git a/src/services/graphql/helpers/convert-post-preview-to-article-preview.ts b/src/services/graphql/helpers/convert-post-preview-to-article-preview.ts new file mode 100644 index 0000000..78777eb --- /dev/null +++ b/src/services/graphql/helpers/convert-post-preview-to-article-preview.ts @@ -0,0 +1,36 @@ +import type { ArticlePreview, WPPostPreview } from '../../../types'; +import { convertTaxonomyToPageLink } from './convert-taxonomy-to-page-link'; +import { convertWPImgToImg } from './convert-wp-image-to-img'; + +export const convertPostPreviewToArticlePreview = ({ + acfPosts, + commentCount, + contentParts, + databaseId, + date, + featuredImage, + info, + modified, + slug, + title, +}: WPPostPreview): ArticlePreview => { + return { + id: databaseId, + intro: contentParts.beforeMore, + meta: { + commentsCount: typeof commentCount === 'number' ? commentCount : 0, + cover: featuredImage ? convertWPImgToImg(featuredImage.node) : undefined, + dates: { + publication: date, + update: modified, + }, + thematics: + acfPosts && 'postsInThematic' in acfPosts + ? acfPosts.postsInThematic?.map(convertTaxonomyToPageLink) + : undefined, + wordsCount: info.wordsCount, + }, + slug, + title, + }; +}; diff --git a/src/services/graphql/helpers/convert-post-to-article.test.ts b/src/services/graphql/helpers/convert-post-to-article.test.ts new file mode 100644 index 0000000..0a1c359 --- /dev/null +++ b/src/services/graphql/helpers/convert-post-to-article.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from '@jest/globals'; +import type { WPPost } from '../../../types'; +import { convertPostToArticle } from './convert-post-to-article'; +import { convertWPImgToImg } from './convert-wp-image-to-img'; + +describe('convert-post-to-article', () => { + /* eslint-disable max-statements */ + it('converts a WPPost object to an Article object', async () => { + const post: WPPost = { + acfPosts: null, + author: { node: { name: 'Vince5' } }, + commentCount: 10, + contentParts: { + afterMore: + 'Eum est rerum neque placeat iure veniam enim consequatur assumenda. Quos eos placeat ea et vel sit ratione fugit. Modi qui sint iure beatae illo voluptas.', + beforeMore: + 'Omnis ab qui dolorem praesentium voluptas asperiores officiis. Id nostrum minus quae ducimus tenetur eum a rem eum. Aut odio libero sit soluta ullam odit.', + }, + databaseId: 8, + date: '2022-05-04', + featuredImage: null, + info: { wordsCount: 300 }, + modified: '2022-06-01', + seo: { + metaDesc: 'Est non debitis quas harum quasi voluptatem qui.', + title: 'consequuntur molestiae amet', + }, + slug: '/the-post-slug', + title: 'ea vero repellat', + }; + const result = await convertPostToArticle(post); + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + expect.assertions(15); + + expect(result.content).toBe(post.contentParts.afterMore); + expect(result.id).toBe(post.databaseId); + expect(result.intro).toBe(post.contentParts.beforeMore); + expect(result.meta.author).toBe(post.author.node.name); + expect(result.meta.commentsCount).toBe(post.commentCount); + expect(result.meta.cover).toBeUndefined(); + expect(result.meta.dates.publication).toBe(post.date); + expect(result.meta.dates.update).toBe(post.modified); + expect(result.meta.seo.description).toBe(post.seo.metaDesc); + expect(result.meta.seo.title).toBe(post.seo.title); + expect(result.meta.thematics).toBeUndefined(); + expect(result.meta.topics).toBeUndefined(); + expect(result.meta.wordsCount).toBe(post.info.wordsCount); + expect(result.slug).toBe(post.slug); + expect(result.title).toBe(post.title); + }); + /* eslint-enable max-statements */ + + it('can convert the cover', async () => { + const post = { + acfPosts: null, + author: { node: { name: 'Vince5' } }, + commentCount: null, + contentParts: { + afterMore: + 'Eum est rerum neque placeat iure veniam enim consequatur assumenda. Quos eos placeat ea et vel sit ratione fugit. Modi qui sint iure beatae illo voluptas.', + beforeMore: + 'Omnis ab qui dolorem praesentium voluptas asperiores officiis. Id nostrum minus quae ducimus tenetur eum a rem eum. Aut odio libero sit soluta ullam odit.', + }, + databaseId: 8, + date: '2022-05-04', + featuredImage: { + node: { + altText: 'molestiae praesentium animi', + mediaDetails: { + height: 480, + width: 640, + }, + sourceUrl: 'https://picsum.photos/640/480', + title: 'ullam deserunt perspiciatis', + }, + }, + info: { wordsCount: 300 }, + modified: '2022-06-01', + seo: { + metaDesc: 'Est non debitis quas harum quasi voluptatem qui.', + title: 'consequuntur molestiae amet', + }, + slug: '/the-post-slug', + title: 'ea vero repellat', + } satisfies WPPost; + const result = await convertPostToArticle(post); + + expect.assertions(1); + + expect(result.meta.cover).toStrictEqual( + convertWPImgToImg(post.featuredImage.node) + ); + }); + + it('can return 0 as comment count when not defined', async () => { + const post: WPPost = { + acfPosts: null, + author: { node: { name: 'Vince5' } }, + commentCount: null, + contentParts: { + afterMore: + 'Eum est rerum neque placeat iure veniam enim consequatur assumenda. Quos eos placeat ea et vel sit ratione fugit. Modi qui sint iure beatae illo voluptas.', + beforeMore: + 'Omnis ab qui dolorem praesentium voluptas asperiores officiis. Id nostrum minus quae ducimus tenetur eum a rem eum. Aut odio libero sit soluta ullam odit.', + }, + databaseId: 8, + date: '2022-05-04', + featuredImage: null, + info: { wordsCount: 300 }, + modified: '2022-06-01', + seo: { + metaDesc: 'Est non debitis quas harum quasi voluptatem qui.', + title: 'consequuntur molestiae amet', + }, + slug: '/the-post-slug', + title: 'ea vero repellat', + }; + const result = await convertPostToArticle(post); + + expect.assertions(1); + + expect(result.meta.commentsCount).toBe(0); + }); +}); diff --git a/src/services/graphql/helpers/convert-post-to-article.ts b/src/services/graphql/helpers/convert-post-to-article.ts new file mode 100644 index 0000000..b540a77 --- /dev/null +++ b/src/services/graphql/helpers/convert-post-to-article.ts @@ -0,0 +1,43 @@ +import type { Article, WPPost } from '../../../types'; +import { updateContentTree } from '../../../utils/helpers'; +import { convertTaxonomyToPageLink } from './convert-taxonomy-to-page-link'; +import { convertWPImgToImg } from './convert-wp-image-to-img'; + +export const convertPostToArticle = async ({ + acfPosts, + author, + commentCount, + contentParts, + databaseId, + date, + featuredImage, + info, + modified, + seo, + slug, + title, +}: WPPost): Promise<Article> => { + return { + content: await updateContentTree(contentParts.afterMore), + id: databaseId, + intro: contentParts.beforeMore, + meta: { + author: author.node.name, + commentsCount: commentCount ?? 0, + cover: featuredImage ? convertWPImgToImg(featuredImage.node) : undefined, + dates: { + publication: date, + update: modified, + }, + seo: { + description: seo.metaDesc, + title: seo.title, + }, + thematics: acfPosts?.postsInThematic?.map(convertTaxonomyToPageLink), + topics: acfPosts?.postsInTopic?.map(convertTaxonomyToPageLink), + wordsCount: info.wordsCount, + }, + slug, + title, + }; +}; diff --git a/src/services/graphql/helpers/convert-recent-post-to-recent-article.test.ts b/src/services/graphql/helpers/convert-recent-post-to-recent-article.test.ts new file mode 100644 index 0000000..8acf753 --- /dev/null +++ b/src/services/graphql/helpers/convert-recent-post-to-recent-article.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from '@jest/globals'; +import type { RecentWPPost } from '../../../types'; +import { convertRecentPostToRecentArticle } from './convert-recent-post-to-recent-article'; +import { convertWPImgToImg } from './convert-wp-image-to-img'; + +describe('convert-recent-post-to-recent-article', () => { + it('converts a RecentWPPost object to a RecentArticle object', () => { + const post: RecentWPPost = { + databaseId: 5, + date: '2022-03-20', + featuredImage: null, + slug: '/the-post-slug', + title: 'veritatis ex autem', + }; + const result = convertRecentPostToRecentArticle(post); + + expect(result.cover).toBeUndefined(); + expect(result.id).toBe(post.databaseId); + expect(result.publicationDate).toBe(post.date); + expect(result.slug).toBe(post.slug); + expect(result.title).toBe(post.title); + }); + + it('can convert the cover', () => { + const post = { + databaseId: 5, + date: '2022-03-20', + featuredImage: { + node: { + altText: 'molestiae praesentium animi', + mediaDetails: { + height: 480, + width: 640, + }, + sourceUrl: 'https://picsum.photos/640/480', + title: 'ullam deserunt perspiciatis', + }, + }, + slug: '/the-post-slug', + title: 'veritatis ex autem', + } satisfies RecentWPPost; + const result = convertRecentPostToRecentArticle(post); + + expect(result.cover).toStrictEqual( + convertWPImgToImg(post.featuredImage.node) + ); + }); +}); diff --git a/src/services/graphql/helpers/convert-recent-post-to-recent-article.ts b/src/services/graphql/helpers/convert-recent-post-to-recent-article.ts new file mode 100644 index 0000000..ff5eb67 --- /dev/null +++ b/src/services/graphql/helpers/convert-recent-post-to-recent-article.ts @@ -0,0 +1,24 @@ +import type { RecentArticle, RecentWPPost } from '../../../types'; +import { convertWPImgToImg } from './convert-wp-image-to-img'; + +/** + * Convert a WordPress post to an article. + * + * @param {RecentWPPost} post - A post. + * @returns {RecentArticle} An article. + */ +export const convertRecentPostToRecentArticle = ({ + databaseId, + date, + featuredImage, + slug, + title, +}: RecentWPPost): RecentArticle => { + return { + cover: featuredImage ? convertWPImgToImg(featuredImage.node) : undefined, + id: databaseId, + publicationDate: date, + slug, + title, + }; +}; diff --git a/src/services/graphql/helpers/convert-taxonomy-to-page-link.test.ts b/src/services/graphql/helpers/convert-taxonomy-to-page-link.test.ts new file mode 100644 index 0000000..b687ccb --- /dev/null +++ b/src/services/graphql/helpers/convert-taxonomy-to-page-link.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from '@jest/globals'; +import type { WPThematicPreview, WPTopicPreview } from '../../../types'; +import { convertTaxonomyToPageLink } from './convert-taxonomy-to-page-link'; + +describe('convert-taxonomy-to-page-link', () => { + it('can convert a WPThematicPreview object to a Thematic object', () => { + const thematic: WPThematicPreview = { + databaseId: 42, + slug: '/the-thematic-slug', + title: 'et ut alias', + }; + const result = convertTaxonomyToPageLink(thematic); + + expect(result.id).toBe(thematic.databaseId); + expect(result.logo).toBeUndefined(); + expect(result.name).toBe(thematic.title); + expect(result.url).toBe(thematic.slug); + }); + + it('can convert a WPTopicPreview object to a Topic object', () => { + const topic: WPTopicPreview = { + databaseId: 42, + featuredImage: { + node: { + altText: 'dolorem quia possimus', + mediaDetails: { + height: 480, + width: 640, + }, + sourceUrl: 'https://picsum.photos/640/480', + title: 'eos', + }, + }, + slug: '/the-topic-slug', + title: 'et ut alias', + }; + const result = convertTaxonomyToPageLink(topic); + + expect(result.id).toBe(topic.databaseId); + expect(result.logo?.alt).toBe(topic.featuredImage?.node.altText); + expect(result.logo?.height).toBe( + topic.featuredImage?.node.mediaDetails.height + ); + expect(result.logo?.src).toBe(topic.featuredImage?.node.sourceUrl); + expect(result.logo?.title).toBe(topic.featuredImage?.node.title); + expect(result.logo?.width).toBe( + topic.featuredImage?.node.mediaDetails.width + ); + expect(result.name).toBe(topic.title); + expect(result.url).toBe(topic.slug); + }); +}); diff --git a/src/services/graphql/helpers/convert-taxonomy-to-page-link.ts b/src/services/graphql/helpers/convert-taxonomy-to-page-link.ts new file mode 100644 index 0000000..2294fb7 --- /dev/null +++ b/src/services/graphql/helpers/convert-taxonomy-to-page-link.ts @@ -0,0 +1,23 @@ +import type { + PageLink, + WPThematicPreview, + WPTopicPreview, +} from '../../../types'; +import { convertWPImgToImg } from './convert-wp-image-to-img'; + +export const convertTaxonomyToPageLink = ({ + databaseId, + slug, + title, + ...props +}: WPThematicPreview | WPTopicPreview): PageLink => { + return { + id: databaseId, + logo: + 'featuredImage' in props && props.featuredImage + ? convertWPImgToImg(props.featuredImage.node) + : undefined, + name: title, + url: slug, + }; +}; diff --git a/src/services/graphql/helpers/convert-wp-comment-to-comment.test.ts b/src/services/graphql/helpers/convert-wp-comment-to-comment.test.ts new file mode 100644 index 0000000..4b385b4 --- /dev/null +++ b/src/services/graphql/helpers/convert-wp-comment-to-comment.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from '@jest/globals'; +import type { WPComment } from '../../../types'; +import { convertWPCommentToComment } from './convert-wp-comment-to-comment'; + +describe('convert-wp-comment-to-comment', () => { + it('converts a WPComment object to a Comment object', () => { + const comment: WPComment = { + approved: true, + author: { + node: { + avatar: null, + name: 'Maribel.Roberts', + url: null, + }, + }, + content: 'Aliquam qui et facere consequatur quia.', + databaseId: 4, + date: '2023-10-15', + parentDatabaseId: 1, + status: 'HOLD', + }; + + const transformedComment = convertWPCommentToComment(comment); + + expect(transformedComment.content).toBe(comment.content); + expect(transformedComment.id).toBe(comment.databaseId); + expect(transformedComment.isApproved).toBe(comment.approved); + expect(transformedComment.meta.author.avatar).toBeUndefined(); + expect(transformedComment.meta.author.name).toBe(comment.author.node.name); + expect(transformedComment.meta.author.website).toBeUndefined(); + expect(transformedComment.parentId).toBe(comment.parentDatabaseId); + expect(transformedComment.replies).toStrictEqual([]); + }); + + it('can convert the avatar', () => { + const comment: WPComment = { + approved: true, + author: { + node: { + avatar: { + height: 80, + url: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/426.jpg', + width: 80, + }, + name: 'Maribel.Roberts', + url: null, + }, + }, + content: 'Aliquam qui et facere consequatur quia.', + databaseId: 4, + date: '2023-10-15', + parentDatabaseId: 1, + status: 'HOLD', + }; + + const transformedComment = convertWPCommentToComment(comment); + + expect(transformedComment.meta.author.avatar?.alt).toBe( + `${comment.author.node.name} avatar` + ); + expect(transformedComment.meta.author.avatar?.height).toBe( + comment.author.node.avatar?.height + ); + expect(transformedComment.meta.author.avatar?.src).toBe( + comment.author.node.avatar?.url + ); + expect(transformedComment.meta.author.avatar?.width).toBe( + comment.author.node.avatar?.width + ); + }); + + it('can remove the parentId when not meaningful', () => { + const comment: WPComment = { + approved: true, + author: { + node: { + avatar: null, + name: 'Maribel.Roberts', + url: null, + }, + }, + content: 'Aliquam qui et facere consequatur quia.', + databaseId: 4, + date: '2023-10-15', + parentDatabaseId: 0, + status: 'HOLD', + }; + + const transformedComment = convertWPCommentToComment(comment); + + expect(transformedComment.parentId).toBeUndefined(); + }); +}); diff --git a/src/services/graphql/helpers/convert-wp-comment-to-comment.ts b/src/services/graphql/helpers/convert-wp-comment-to-comment.ts new file mode 100644 index 0000000..7a7e2ca --- /dev/null +++ b/src/services/graphql/helpers/convert-wp-comment-to-comment.ts @@ -0,0 +1,35 @@ +import type { SingleComment, WPComment } from '../../../types'; + +/** + * Convert a comment from WordPress type to SingleComment. + * + * @param {WPComment} comment - A raw comment from WordPress. + * @returns {SingleComment} A comment. + */ +export const convertWPCommentToComment = ( + comment: WPComment +): SingleComment => { + return { + content: comment.content, + isApproved: comment.approved, + id: comment.databaseId, + meta: { + author: { + name: comment.author.node.name, + avatar: comment.author.node.avatar + ? { + alt: `${comment.author.node.name} avatar`, + height: comment.author.node.avatar.height, + src: comment.author.node.avatar.url, + width: comment.author.node.avatar.width, + } + : undefined, + website: comment.author.node.url ?? undefined, + }, + date: comment.date, + }, + parentId: + comment.parentDatabaseId === 0 ? undefined : comment.parentDatabaseId, + replies: [], + }; +}; diff --git a/src/services/graphql/helpers/convert-wp-image-to-img.test.ts b/src/services/graphql/helpers/convert-wp-image-to-img.test.ts new file mode 100644 index 0000000..ca58a4f --- /dev/null +++ b/src/services/graphql/helpers/convert-wp-image-to-img.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from '@jest/globals'; +import type { WPImage } from '../../../types'; +import { convertWPImgToImg } from './convert-wp-image-to-img'; + +describe('convert-wp-image-to-img', () => { + it('converts a WPImage object to an Img object', () => { + const img: WPImage = { + altText: 'molestiae praesentium animi', + mediaDetails: { + height: 480, + width: 640, + }, + sourceUrl: 'https://picsum.photos/640/480', + title: null, + }; + + const transformedImg = convertWPImgToImg(img); + + expect(transformedImg.alt).toBe(img.altText); + expect(transformedImg.height).toBe(img.mediaDetails.height); + expect(transformedImg.src).toBe(img.sourceUrl); + expect(transformedImg.title).toBeUndefined(); + expect(transformedImg.width).toBe(img.mediaDetails.width); + }); + + it('can return an empty string if altText is missing', () => { + const img: WPImage = { + altText: null, + mediaDetails: { + height: 480, + width: 640, + }, + sourceUrl: 'https://picsum.photos/640/480', + title: null, + }; + + const transformedImg = convertWPImgToImg(img); + + expect(transformedImg.alt).toBe(''); + }); +}); diff --git a/src/services/graphql/helpers/convert-wp-image-to-img.ts b/src/services/graphql/helpers/convert-wp-image-to-img.ts new file mode 100644 index 0000000..392aaf9 --- /dev/null +++ b/src/services/graphql/helpers/convert-wp-image-to-img.ts @@ -0,0 +1,16 @@ +import type { Img, WPImage } from '../../../types'; + +export const convertWPImgToImg = ({ + altText, + mediaDetails, + sourceUrl, + title, +}: WPImage): Img => { + return { + alt: altText ?? '', + height: mediaDetails.height, + src: sourceUrl, + title: title ?? undefined, + width: mediaDetails.width, + }; +}; diff --git a/src/services/graphql/helpers/index.ts b/src/services/graphql/helpers/index.ts new file mode 100644 index 0000000..16e93d2 --- /dev/null +++ b/src/services/graphql/helpers/index.ts @@ -0,0 +1,7 @@ +export * from './build-comments-tree'; +export * from './convert-post-preview-to-article-preview'; +export * from './convert-post-to-article'; +export * from './convert-recent-post-to-recent-article'; +export * from './convert-taxonomy-to-page-link'; +export * from './convert-wp-comment-to-comment'; +export * from './convert-wp-image-to-img'; diff --git a/src/services/graphql/index.ts b/src/services/graphql/index.ts index c1eac16..53afbc7 100644 --- a/src/services/graphql/index.ts +++ b/src/services/graphql/index.ts @@ -1,12 +1,3 @@ -export * from './api'; -export * from './articles'; -export * from './articles.query'; -export * from './comments'; -export * from './comments.mutation'; -export * from './comments.query'; -export * from './contact'; -export * from './contact.mutation'; -export * from './thematics'; -export * from './thematics.query'; -export * from './topics'; -export * from './topics.query'; +export * from './fetchers'; +export * from './helpers'; +export * from './mutators'; diff --git a/src/services/graphql/mutators/create-comment.ts b/src/services/graphql/mutators/create-comment.ts new file mode 100644 index 0000000..d9d177d --- /dev/null +++ b/src/services/graphql/mutators/create-comment.ts @@ -0,0 +1,70 @@ +import type { Nullable } from '../../../types'; +import { fetchGraphQL, getGraphQLUrl } from '../../../utils/helpers'; + +type CreatedComment = { + clientMutationId: string; + success: boolean; + comment: Nullable<{ + approved: boolean; + }>; +}; + +type CreateCommentResponse = { + createComment: CreatedComment; +}; + +export const createCommentMutation = `mutation CreateComment( + $author: String! + $authorEmail: String! + $authorUrl: String! + $content: String! + $parent: ID = null + $commentOn: Int! + $clientMutationId: String! +) { + createComment( + input: { + author: $author + authorEmail: $authorEmail + authorUrl: $authorUrl + content: $content + parent: $parent + commentOn: $commentOn + clientMutationId: $clientMutationId + } + ) { + clientMutationId + success + comment { + approved + } + } +}`; + +export type CreateCommentInput = { + author: string; + authorEmail: string; + authorUrl: string; + clientMutationId: string; + commentOn: number; + content: string; + parent?: number; +}; + +/** + * Create a new comment using GraphQL API. + * + * @param {CreateCommentInput} input - The comment data. + * @returns {Promise<CreatedComment>} The created comment. + */ +export const createComment = async ( + input: CreateCommentInput +): Promise<CreatedComment> => { + const response = await fetchGraphQL<CreateCommentResponse>({ + query: createCommentMutation, + url: getGraphQLUrl(), + variables: { ...input }, + }); + + return response.createComment; +}; diff --git a/src/services/graphql/mutators/index.ts b/src/services/graphql/mutators/index.ts new file mode 100644 index 0000000..dfdd511 --- /dev/null +++ b/src/services/graphql/mutators/index.ts @@ -0,0 +1,2 @@ +export * from './create-comment'; +export * from './send-email'; diff --git a/src/services/graphql/mutators/send-email.ts b/src/services/graphql/mutators/send-email.ts new file mode 100644 index 0000000..45b6fca --- /dev/null +++ b/src/services/graphql/mutators/send-email.ts @@ -0,0 +1,49 @@ +import { fetchGraphQL, getGraphQLUrl } from 'src/utils/helpers'; + +type SentEmail = { + clientMutationId: string; + message: string; + origin: string; + replyTo: string; + sent: boolean; +}; + +type SendEmailResponse = { + sendEmail: SentEmail; +}; + +const sendMailMutation = `mutation SendEmail($body: String, $clientMutationId: String, $replyTo: String, $subject: String) { + sendEmail( + input: {body: $body, clientMutationId: $clientMutationId, replyTo: $replyTo, subject: $subject} + ) { + clientMutationId + message + origin + replyTo + sent + to + } +}`; + +export type SendMailInput = { + body: string; + clientMutationId: string; + replyTo: string; + subject: string; +}; + +/** + * Send an email using GraphQL API. + * + * @param {SendMailInput} data - The mail data. + * @returns {Promise<SentEmail>} The mutation response. + */ +export const sendMail = async (data: SendMailInput): Promise<SentEmail> => { + const response = await fetchGraphQL<SendEmailResponse>({ + query: sendMailMutation, + url: getGraphQLUrl(), + variables: { ...data }, + }); + + return response.sendEmail; +}; diff --git a/src/services/graphql/thematics.query.ts b/src/services/graphql/thematics.query.ts deleted file mode 100644 index 5a82133..0000000 --- a/src/services/graphql/thematics.query.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * Query the full thematic data using its slug. - */ -export const thematicBySlugQuery = `query ThematicBy($slug: ID!) { - thematic(id: $slug, idType: SLUG) { - acfThematics { - postsInThematic { - ... on Post { - acfPosts { - postsInTopic { - ... on Topic { - databaseId - slug - title - } - } - } - commentCount - contentParts { - beforeMore - } - databaseId - date - featuredImage { - node { - altText - mediaDetails { - height - width - } - sourceUrl - title - } - } - info { - wordsCount - } - modified - slug - title - } - } - } - contentParts { - afterMore - beforeMore - } - databaseId - date - featuredImage { - node { - altText - mediaDetails { - height - width - } - sourceUrl - title - } - } - info { - wordsCount - } - modified - seo { - metaDesc - title - } - slug - title - } -}`; - -/** - * Query an array of partial thematics. - */ -export const thematicsListQuery = `query ThematicsList($after: String = "", $first: Int = 10) { - thematics( - after: $after - first: $first - where: {orderby: {field: TITLE, order: ASC}, status: PUBLISH} - ) { - edges { - cursor - node { - databaseId - slug - title - } - } - pageInfo { - endCursor - hasNextPage - total - } - } -}`; - -/** - * Query an array of thematics slug. - */ -export const thematicsSlugQuery = `query ThematicsSlug($first: Int = 10, $after: String = "") { - thematics(after: $after, first: $first) { - edges { - cursor - node { - slug - } - } - pageInfo { - total - } - } -}`; - -/** - * Query the total number of thematics. - */ -export const totalThematicsQuery = `query ThematicsTotal { - thematics { - pageInfo { - total - } - } -}`; diff --git a/src/services/graphql/thematics.ts b/src/services/graphql/thematics.ts deleted file mode 100644 index c02a42c..0000000 --- a/src/services/graphql/thematics.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { - EdgesResponse, - GraphQLEdgesInput, - PageLink, - RawArticle, - RawThematic, - RawThematicPreview, - Slug, - Thematic, - TotalItems, -} from '../../types'; -import { - getImageFromRawData, - getPageLinkFromRawData, - sortPageLinksByName, -} from '../../utils/helpers'; -import { fetchAPI } from './api'; -import { getArticleFromRawData } from './articles'; -import { - thematicBySlugQuery, - thematicsListQuery, - thematicsSlugQuery, - totalThematicsQuery, -} from './thematics.query'; - -/** - * Retrieve the total number of thematics. - * - * @returns {Promise<number>} - The thematics total number. - */ -export const getTotalThematics = async (): Promise<number> => { - const response = await fetchAPI<TotalItems, typeof totalThematicsQuery>({ - query: totalThematicsQuery, - }); - - return response.thematics.pageInfo.total; -}; - -/** - * Retrieve the given number of thematics from API. - * - * @param {GraphQLEdgesInput} props - An object of GraphQL variables. - * @returns {Promise<EdgesResponse<RawThematicPreview>>} The thematics data. - */ -export const getThematicsPreview = async ( - props: GraphQLEdgesInput -): Promise<EdgesResponse<RawThematicPreview>> => { - const response = await fetchAPI< - RawThematicPreview, - typeof thematicsListQuery - >({ query: thematicsListQuery, variables: props }); - - return response.thematics; -}; - -/** - * Convert raw data to an Thematic object. - * - * @param {RawThematic} data - The page raw data. - * @returns {Thematic} The page data. - */ -export const getThematicFromRawData = async ( - data: RawThematic -): Promise<Thematic> => { - const { - acfThematics, - contentParts, - databaseId, - date, - featuredImage, - info, - modified, - slug, - title, - seo, - } = data; - - /** - * Retrieve an array of related topics. - * - * @param posts - The thematic posts. - * @returns {PageLink[]} An array of topics links. - */ - const getRelatedTopics = (posts: RawArticle[]): PageLink[] => { - const topics: PageLink[] = []; - - posts.forEach((post) => { - if (post.acfPosts.postsInTopic) { - for (const topic of post.acfPosts.postsInTopic) { - topics.push(getPageLinkFromRawData(topic, 'topic')); - } - } - }); - - const topicsIds = topics.map((topic) => topic.id); - const uniqueTopics = topics.filter( - ({ id }, index) => !topicsIds.includes(id, index + 1) - ); - - return uniqueTopics.sort(sortPageLinksByName); - }; - - return { - content: contentParts.afterMore, - id: databaseId, - intro: contentParts.beforeMore, - meta: { - articles: await Promise.all( - acfThematics.postsInThematic.map(async (post) => - getArticleFromRawData(post) - ) - ), - cover: featuredImage?.node - ? getImageFromRawData(featuredImage.node) - : undefined, - dates: { publication: date, update: modified }, - seo: { - description: seo?.metaDesc ?? '', - title: seo?.title ?? '', - }, - topics: getRelatedTopics(acfThematics.postsInThematic), - wordsCount: info.wordsCount, - }, - slug, - title, - }; -}; - -/** - * Retrieve a Thematic object by slug. - * - * @param {string} slug - The thematic slug. - * @returns {Promise<Article>} The requested thematic. - */ -export const getThematicBySlug = async (slug: string): Promise<Thematic> => { - const response = await fetchAPI<RawThematic, typeof thematicBySlugQuery>({ - query: thematicBySlugQuery, - variables: { slug }, - }); - - return getThematicFromRawData(response.thematic); -}; - -/** - * Retrieve all the thematics slugs. - * - * @returns {Promise<string[]>} - An array of thematics slugs. - */ -export const getAllThematicsSlugs = async (): Promise<string[]> => { - const totalThematics = await getTotalThematics(); - const response = await fetchAPI<Slug, typeof thematicsSlugQuery>({ - query: thematicsSlugQuery, - variables: { first: totalThematics }, - }); - - return response.thematics.edges.map((edge) => edge.node.slug); -}; diff --git a/src/services/graphql/topics.query.ts b/src/services/graphql/topics.query.ts deleted file mode 100644 index 57b2569..0000000 --- a/src/services/graphql/topics.query.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Query the full topic data using its slug. - */ -export const topicBySlugQuery = `query TopicBy($slug: ID!) { - topic(id: $slug, idType: SLUG) { - acfTopics { - officialWebsite - postsInTopic { - ... on Post { - acfPosts { - postsInThematic { - ... on Thematic { - databaseId - slug - title - } - } - } - commentCount - contentParts { - beforeMore - } - databaseId - date - featuredImage { - node { - altText - mediaDetails { - height - width - } - sourceUrl - title - } - } - info { - wordsCount - } - modified - slug - title - } - } - } - contentParts { - afterMore - beforeMore - } - databaseId - date - featuredImage { - node { - altText - mediaDetails { - height - width - } - sourceUrl - title - } - } - info { - wordsCount - } - modified - seo { - metaDesc - title - } - slug - title - } -}`; - -/** - * Query an array of partial topics. - */ -export const topicsListQuery = `query TopicsList($after: String = "", $first: Int = 10) { - topics( - after: $after - first: $first - where: {orderby: {field: TITLE, order: ASC}, status: PUBLISH} - ) { - edges { - cursor - node { - databaseId - featuredImage { - node { - altText - mediaDetails { - height - width - } - sourceUrl - title - } - } - slug - title - } - } - pageInfo { - endCursor - hasNextPage - total - } - } -}`; - -/** - * Query an array of topics slug. - */ -export const topicsSlugQuery = `query TopicsSlug($first: Int = 10, $after: String = "") { - topics(after: $after, first: $first) { - edges { - cursor - node { - slug - } - } - pageInfo { - total - } - } -}`; - -/** - * Query the total number of topics. - */ -export const totalTopicsQuery = `query TopicsTotal { - topics { - pageInfo { - total - } - } -}`; diff --git a/src/services/graphql/topics.ts b/src/services/graphql/topics.ts deleted file mode 100644 index d8a9b6a..0000000 --- a/src/services/graphql/topics.ts +++ /dev/null @@ -1,154 +0,0 @@ -import type { - EdgesResponse, - GraphQLEdgesInput, - PageLink, - RawArticle, - RawTopic, - RawTopicPreview, - Slug, - Topic, - TotalItems, -} from '../../types'; -import { - getImageFromRawData, - getPageLinkFromRawData, - sortPageLinksByName, -} from '../../utils/helpers'; -import { fetchAPI } from './api'; -import { getArticleFromRawData } from './articles'; -import { - topicBySlugQuery, - topicsListQuery, - topicsSlugQuery, - totalTopicsQuery, -} from './topics.query'; - -/** - * Retrieve the total number of topics. - * - * @returns {Promise<number>} - The topics total number. - */ -export const getTotalTopics = async (): Promise<number> => { - const response = await fetchAPI<TotalItems, typeof totalTopicsQuery>({ - query: totalTopicsQuery, - }); - - return response.topics.pageInfo.total; -}; - -/** - * Retrieve the given number of topics from API. - * - * @param {GraphQLEdgesInput} props - An object of GraphQL variables. - * @returns {Promise<EdgesResponse<RawTopicPreview>>} The topics data. - */ -export const getTopicsPreview = async ( - props: GraphQLEdgesInput -): Promise<EdgesResponse<RawTopicPreview>> => { - const response = await fetchAPI<RawTopicPreview, typeof topicsListQuery>({ - query: topicsListQuery, - variables: props, - }); - - return response.topics; -}; - -/** - * Convert raw data to a Topic object. - * - * @param {RawTopic} data - The page raw data. - * @returns {Topic} The page data. - */ -export const getTopicFromRawData = async (data: RawTopic): Promise<Topic> => { - const { - acfTopics, - contentParts, - databaseId, - date, - featuredImage, - info, - modified, - slug, - title, - seo, - } = data; - - /** - * Retrieve an array of related topics. - * - * @param posts - The topic posts. - * @returns {PageLink[]} An array of topics links. - */ - const getRelatedThematics = (posts: RawArticle[]): PageLink[] => { - const thematics: PageLink[] = []; - - posts.forEach((post) => { - if (post.acfPosts.postsInThematic) { - for (const thematic of post.acfPosts.postsInThematic) { - thematics.push(getPageLinkFromRawData(thematic, 'thematic')); - } - } - }); - - const thematicsIds = thematics.map((thematic) => thematic.id); - const uniqueThematics = thematics.filter( - ({ id }, index) => !thematicsIds.includes(id, index + 1) - ); - - return uniqueThematics.sort(sortPageLinksByName); - }; - - return { - content: contentParts.afterMore, - id: databaseId, - intro: contentParts.beforeMore, - meta: { - articles: await Promise.all( - acfTopics.postsInTopic.map(async (post) => getArticleFromRawData(post)) - ), - cover: featuredImage?.node - ? getImageFromRawData(featuredImage.node) - : undefined, - dates: { publication: date, update: modified }, - website: acfTopics.officialWebsite, - seo: { - description: seo?.metaDesc ?? '', - title: seo?.title ?? '', - }, - thematics: getRelatedThematics(acfTopics.postsInTopic), - wordsCount: info.wordsCount, - }, - slug, - title, - }; -}; - -/** - * Retrieve a Topic object by slug. - * - * @param {string} slug - The topic slug. - * @returns {Promise<Article>} The requested topic. - */ -export const getTopicBySlug = async (slug: string): Promise<Topic> => { - const response = await fetchAPI<RawTopic, typeof topicBySlugQuery>({ - query: topicBySlugQuery, - variables: { slug }, - }); - - return getTopicFromRawData(response.topic); -}; - -/** - * Retrieve all the topics slugs. - * - * @returns {Promise<string[]>} - An array of topics slugs. - */ -export const getAllTopicsSlugs = async (): Promise<string[]> => { - const totalTopics = await getTotalTopics(); - const response = await fetchAPI<Slug, typeof topicsSlugQuery>({ - query: topicsSlugQuery, - variables: { first: totalTopics }, - }); - - return response.topics.edges.map((edge) => edge.node.slug); -}; |
