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 | |
| 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
87 files changed, 2600 insertions, 2220 deletions
diff --git a/jest.setup.js b/jest.setup.js index 8124620..92c6c3b 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -5,3 +5,13 @@ import './tests/jest/__mocks__/matchMedia.mock'; jest.mock('next/router', () => nextRouterMock); jest.mock('next/dynamic', () => () => 'dynamic-import'); + +/* Jest complains about "Must use import to load ES Module" when importing + * unified and rehype modules. Maybe it is not the right way to avoid those + * errors but for now it is the only things that work. */ +jest.mock('src/utils/helpers/rehype.ts', () => { + return { + __esModule: true, + updateContentTree: jest.fn((str) => str), + }; +}); diff --git a/src/components/templates/page/page-comments.tsx b/src/components/templates/page/page-comments.tsx index 170d6b7..5f5208f 100644 --- a/src/components/templates/page/page-comments.tsx +++ b/src/components/templates/page/page-comments.tsx @@ -6,8 +6,10 @@ import { useCallback, } from 'react'; import { useIntl } from 'react-intl'; -import { sendComment } from '../../../services/graphql'; -import type { SendCommentInput } from '../../../types'; +import { + createComment, + type CreateCommentInput, +} from '../../../services/graphql'; import { Heading, Link, Section } from '../../atoms'; import { Card, CardBody } from '../../molecules'; import { @@ -99,7 +101,7 @@ const PageCommentsWithRef: ForwardRefRenderFunction< const saveComment: CommentFormSubmit = useCallback( async (data) => { - const commentData: SendCommentInput = { + const commentData: CreateCommentInput = { author: data.author, authorEmail: data.email, authorUrl: data.website ?? '', @@ -108,7 +110,7 @@ const PageCommentsWithRef: ForwardRefRenderFunction< content: data.comment, parent: data.parentId, }; - const { comment, success } = await sendComment(commentData); + const { comment, success } = await createComment(commentData); const successPrefix = intl.formatMessage({ defaultMessage: 'Thanks, your comment was successfully sent.', description: 'PageComments: comment form success message', diff --git a/src/pages/404.tsx b/src/pages/404.tsx index d6785b6..5f4f89d 100644 --- a/src/pages/404.tsx +++ b/src/pages/404.tsx @@ -18,25 +18,26 @@ import { type SearchFormSubmit, } from '../components'; import { - getThematicsPreview, - getTopicsPreview, - getTotalThematics, - getTotalTopics, + convertTaxonomyToPageLink, + fetchThematicsCount, + fetchThematicsList, + fetchTopicsCount, + fetchTopicsList, } from '../services/graphql'; import type { NextPageWithLayout, - RawThematicPreview, - RawTopicPreview, + WPThematicPreview, + WPTopicPreview, } from '../types'; import { CONFIG } from '../utils/config'; import { ROUTES } from '../utils/constants'; -import { getLinksItemData, getPageLinkFromRawData } from '../utils/helpers'; +import { getLinksItemData } from '../utils/helpers'; import { loadTranslation, type Messages } from '../utils/helpers/server'; import { useBreadcrumb } from '../utils/hooks'; type Error404PageProps = { - thematicsList: RawThematicPreview[]; - topicsList: RawTopicPreview[]; + thematicsList: WPThematicPreview[]; + topicsList: WPTopicPreview[]; translation: Messages; }; @@ -146,11 +147,7 @@ const Error404Page: NextPageWithLayout<Error404PageProps> = ({ {thematicsListTitle} </Heading> } - items={getLinksItemData( - thematicsList.map((thematic) => - getPageLinkFromRawData(thematic, 'thematic') - ) - )} + items={getLinksItemData(thematicsList.map(convertTaxonomyToPageLink))} /> <LinksWidget heading={ @@ -158,9 +155,7 @@ const Error404Page: NextPageWithLayout<Error404PageProps> = ({ {topicsListTitle} </Heading> } - items={getLinksItemData( - topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic')) - )} + items={getLinksItemData(topicsList.map(convertTaxonomyToPageLink))} /> </PageSidebar> </Page> @@ -172,10 +167,10 @@ Error404Page.getLayout = (page) => getLayout(page); export const getStaticProps: GetStaticProps<Error404PageProps> = async ({ locale, }) => { - const totalThematics = await getTotalThematics(); - const thematics = await getThematicsPreview({ first: totalThematics }); - const totalTopics = await getTotalTopics(); - const topics = await getTopicsPreview({ first: totalTopics }); + const totalThematics = await fetchThematicsCount(); + const thematics = await fetchThematicsList({ first: totalThematics }); + const totalTopics = await fetchTopicsCount(); + const topics = await fetchTopicsList({ first: totalTopics }); const translation = await loadTranslation(locale); return { diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx index 224b1c5..f228ff0 100644 --- a/src/pages/article/[slug].tsx +++ b/src/pages/article/[slug].tsx @@ -21,9 +21,11 @@ import { TocWidget, } from '../../components'; import { - getAllArticlesSlugs, - getAllComments, - getArticleBySlug, + convertPostToArticle, + convertWPCommentToComment, + fetchAllPostsSlugs, + fetchCommentsList, + fetchPost, } from '../../services/graphql'; import styles from '../../styles/pages/article.module.scss'; import type { Article, NextPageWithLayout, SingleComment } from '../../types'; @@ -63,8 +65,11 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ const intl = useIntl(); const article = useArticle({ slug, fallback: post }); const commentsData = useComments({ - contentId: article?.id, fallback: comments, + first: article?.meta.commentsCount, + where: { + contentId: article?.id ?? post.id, + }, }); const getComments = (data?: SingleComment[]) => @@ -73,7 +78,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ author: comment.meta.author, content: comment.content, id: comment.id, - isApproved: comment.approved, + isApproved: comment.isApproved, publicationDate: comment.meta.date, replies: getComments(comment.replies), }; @@ -255,7 +260,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ heading={title} intro={intro} meta={{ - author: author?.name, + author, publicationDate: dates.publication, thematics, updateDate: dates.update, @@ -292,11 +297,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({ ]} /> </PageSidebar> - <PageComments - comments={articleComments ?? []} - depth={2} - pageId={id as number} - /> + <PageComments comments={articleComments ?? []} depth={2} pageId={id} /> </Page> ); }; @@ -311,14 +312,20 @@ export const getStaticProps: GetStaticProps<ArticlePageProps> = async ({ locale, params, }) => { - const post = await getArticleBySlug((params as PostParams).slug); - const comments = await getAllComments({ contentId: post.id as number }); + const post = await fetchPost((params as PostParams).slug); + const article = await convertPostToArticle(post); + const comments = await fetchCommentsList({ + first: post.commentCount ?? 1, + where: { contentId: post.databaseId }, + }); const translation = await loadTranslation(locale); return { props: { - comments: JSON.parse(JSON.stringify(comments)), - post: JSON.parse(JSON.stringify(post)), + comments: JSON.parse( + JSON.stringify(comments.map(convertWPCommentToComment)) + ), + post: JSON.parse(JSON.stringify(article)), slug: post.slug, translation, }, @@ -326,7 +333,7 @@ export const getStaticProps: GetStaticProps<ArticlePageProps> = async ({ }; export const getStaticPaths: GetStaticPaths = async () => { - const slugs = await getAllArticlesSlugs(); + const slugs = await fetchAllPostsSlugs(); const paths = slugs.map((slug) => { return { params: { slug } }; }); diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx index 0de5523..56cbb02 100644 --- a/src/pages/blog/index.tsx +++ b/src/pages/blog/index.tsx @@ -20,27 +20,28 @@ import { PageSidebar, } from '../../components'; import { - getArticles, - getThematicsPreview, - getTopicsPreview, - getTotalArticles, - getTotalThematics, - getTotalTopics, + convertTaxonomyToPageLink, + fetchPostsCount, + fetchPostsList, + fetchThematicsCount, + fetchThematicsList, + fetchTopicsCount, + fetchTopicsList, } from '../../services/graphql'; import styles from '../../styles/pages/blog.module.scss'; import type { - EdgesResponse, + GraphQLConnection, NextPageWithLayout, - RawArticle, - RawThematicPreview, - RawTopicPreview, + WPPostPreview, + WPThematicPreview, + WPTopicPreview, } from '../../types'; import { CONFIG } from '../../utils/config'; import { ROUTES } from '../../utils/constants'; import { getBlogSchema, getLinksItemData, - getPageLinkFromRawData, + getPostsWithUrl, getSchemaJson, getWebPageSchema, } from '../../utils/helpers'; @@ -48,9 +49,9 @@ import { loadTranslation, type Messages } from '../../utils/helpers/server'; import { useBreadcrumb, useIsMounted, usePostsList } from '../../utils/hooks'; type BlogPageProps = { - articles: EdgesResponse<RawArticle>; - thematicsList: RawThematicPreview[]; - topicsList: RawTopicPreview[]; + posts: GraphQLConnection<WPPostPreview>; + thematicsList: WPThematicPreview[]; + topicsList: WPTopicPreview[]; totalArticles: number; translation: Messages; }; @@ -59,7 +60,7 @@ type BlogPageProps = { * Blog index page. */ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ - articles, + posts, thematicsList, topicsList, totalArticles, @@ -111,6 +112,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]); const { + articles, error, firstNewResultIndex, isLoading, @@ -118,10 +120,9 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ isRefreshing, hasNextPage, loadMore, - posts, } = usePostsList({ - fallback: [articles], - fetcher: getArticles, + fallback: [posts], + fetcher: fetchPostsList, perPage: CONFIG.postsPerPage, }); @@ -191,6 +192,10 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ id: 'AXe1Iz', }); + const blogArticles = articles?.flatMap((p) => + p.edges.map((edge) => edge.node) + ); + return ( <Page breadcrumbs={breadcrumbItems} isBodyLastChild> <Head> @@ -218,13 +223,13 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ /> <PageHeader heading={title} meta={{ total: totalArticles }} /> <PageBody className={styles.body}> - {posts ? ( + {blogArticles ? ( <PostsList className={styles.list} firstNewResult={firstNewResultIndex} isLoading={isLoading || isLoadingMore || isRefreshing} onLoadMore={hasNextPage && isMounted ? loadMore : undefined} - posts={posts} + posts={getPostsWithUrl(blogArticles)} ref={postsListRef} sortByYear total={isMounted ? totalArticles : undefined} @@ -260,11 +265,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ {thematicsListTitle} </Heading> } - items={getLinksItemData( - thematicsList.map((thematic) => - getPageLinkFromRawData(thematic, 'thematic') - ) - )} + items={getLinksItemData(thematicsList.map(convertTaxonomyToPageLink))} /> <LinksWidget heading={ @@ -272,9 +273,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ {topicsListTitle} </Heading> } - items={getLinksItemData( - topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic')) - )} + items={getLinksItemData(topicsList.map(convertTaxonomyToPageLink))} /> </PageSidebar> </Page> @@ -286,17 +285,17 @@ BlogPage.getLayout = (page) => getLayout(page); export const getStaticProps: GetStaticProps<BlogPageProps> = async ({ locale, }) => { - const articles = await getArticles({ first: CONFIG.postsPerPage }); - const totalArticles = await getTotalArticles(); - const totalThematics = await getTotalThematics(); - const thematics = await getThematicsPreview({ first: totalThematics }); - const totalTopics = await getTotalTopics(); - const topics = await getTopicsPreview({ first: totalTopics }); + const posts = await fetchPostsList({ first: CONFIG.postsPerPage }); + const totalArticles = await fetchPostsCount(); + const totalThematics = await fetchThematicsCount(); + const thematics = await fetchThematicsList({ first: totalThematics }); + const totalTopics = await fetchTopicsCount(); + const topics = await fetchTopicsList({ first: totalTopics }); const translation = await loadTranslation(locale); return { props: { - articles: JSON.parse(JSON.stringify(articles)), + posts: JSON.parse(JSON.stringify(posts)), thematicsList: thematics.edges.map((edge) => edge.node), topicsList: topics.edges.map((edge) => edge.node), totalArticles, diff --git a/src/pages/blog/page/[number].tsx b/src/pages/blog/page/[number].tsx index b254603..d6071d1 100644 --- a/src/pages/blog/page/[number].tsx +++ b/src/pages/blog/page/[number].tsx @@ -20,27 +20,28 @@ import { PageSidebar, } from '../../../components'; import { - getArticles, - getArticlesEndCursor, - getThematicsPreview, - getTopicsPreview, - getTotalArticles, - getTotalThematics, - getTotalTopics, + convertTaxonomyToPageLink, + fetchLastPostCursor, + fetchPostsCount, + fetchPostsList, + fetchThematicsCount, + fetchThematicsList, + fetchTopicsCount, + fetchTopicsList, } from '../../../services/graphql'; import type { - EdgesResponse, + GraphQLConnection, NextPageWithLayout, - RawArticle, - RawThematicPreview, - RawTopicPreview, + WPPostPreview, + WPThematicPreview, + WPTopicPreview, } from '../../../types'; import { CONFIG } from '../../../utils/config'; import { ROUTES } from '../../../utils/constants'; import { getBlogSchema, getLinksItemData, - getPageLinkFromRawData, + getPostsWithUrl, getSchemaJson, getWebPageSchema, } from '../../../utils/helpers'; @@ -52,10 +53,10 @@ import { } from '../../../utils/hooks'; type BlogPageProps = { - articles: EdgesResponse<RawArticle>; pageNumber: number; - thematicsList: RawThematicPreview[]; - topicsList: RawTopicPreview[]; + posts: GraphQLConnection<WPPostPreview>; + thematicsList: WPThematicPreview[]; + topicsList: WPTopicPreview[]; totalArticles: number; translation: Messages; }; @@ -64,8 +65,8 @@ type BlogPageProps = { * Blog index page. */ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ - articles, pageNumber, + posts, thematicsList, topicsList, totalArticles, @@ -75,9 +76,9 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ redirectTo: ROUTES.BLOG, }); - const { posts } = usePostsList({ - fallback: [articles], - fetcher: getArticles, + const { articles } = usePostsList({ + fallback: [posts], + fetcher: fetchPostsList, perPage: CONFIG.postsPerPage, }); const intl = useIntl(); @@ -195,6 +196,10 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ id: 'AXe1Iz', }); + const blogPageArticles = articles?.flatMap((p) => + p.edges.map((edge) => edge.node) + ); + return ( <Page breadcrumbs={breadcrumbItems} isBodyLastChild> <Head> @@ -225,7 +230,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ meta={{ total: totalArticles }} /> <PageBody> - <PostsList posts={posts ?? []} sortByYear /> + <PostsList posts={getPostsWithUrl(blogPageArticles ?? [])} sortByYear /> <Pagination aria-label={paginationAriaLabel} current={pageNumber} @@ -242,11 +247,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ {thematicsListTitle} </Heading> } - items={getLinksItemData( - thematicsList.map((thematic) => - getPageLinkFromRawData(thematic, 'thematic') - ) - )} + items={getLinksItemData(thematicsList.map(convertTaxonomyToPageLink))} /> <LinksWidget heading={ @@ -254,9 +255,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ {topicsListTitle} </Heading> } - items={getLinksItemData( - topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic')) - )} + items={getLinksItemData(topicsList.map(convertTaxonomyToPageLink))} /> </PageSidebar> </Page> @@ -274,23 +273,23 @@ export const getStaticProps: GetStaticProps<BlogPageProps> = async ({ params, }) => { const pageNumber = Number((params as BlogPageParams).number); - const lastCursor = await getArticlesEndCursor({ - first: CONFIG.postsPerPage * pageNumber, - }); - const articles = await getArticles({ + const lastCursor = await fetchLastPostCursor( + CONFIG.postsPerPage * pageNumber + ); + const posts = await fetchPostsList({ first: CONFIG.postsPerPage, after: lastCursor, }); - const totalArticles = await getTotalArticles(); - const totalThematics = await getTotalThematics(); - const thematics = await getThematicsPreview({ first: totalThematics }); - const totalTopics = await getTotalTopics(); - const topics = await getTopicsPreview({ first: totalTopics }); + const totalArticles = await fetchPostsCount(); + const totalThematics = await fetchThematicsCount(); + const thematics = await fetchThematicsList({ first: totalThematics }); + const totalTopics = await fetchTopicsCount(); + const topics = await fetchTopicsList({ first: totalTopics }); const translation = await loadTranslation(locale); return { props: { - articles: JSON.parse(JSON.stringify(articles)), + posts: JSON.parse(JSON.stringify(posts)), pageNumber, thematicsList: thematics.edges.map((edge) => edge.node), topicsList: topics.edges.map((edge) => edge.node), @@ -301,7 +300,7 @@ export const getStaticProps: GetStaticProps<BlogPageProps> = async ({ }; export const getStaticPaths: GetStaticPaths = async () => { - const totalArticles = await getTotalArticles(); + const totalArticles = await fetchPostsCount(); const totalPages = Math.ceil(totalArticles / CONFIG.postsPerPage); const pagesArray = Array.from( { length: totalPages }, diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 56de5b5..7bd8aec 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -26,9 +26,12 @@ import { } from '../components'; import { mdxComponents } from '../components/mdx'; import HomePageContent from '../content/pages/homepage.mdx'; -import { getArticlesCard } from '../services/graphql'; +import { + convertRecentPostToRecentArticle, + fetchRecentPosts, +} from '../services/graphql'; import styles from '../styles/pages/home.module.scss'; -import type { ArticleCard, NextPageWithLayout } from '../types'; +import type { NextPageWithLayout, RecentArticle } from '../types'; import { CONFIG } from '../utils/config'; import { PERSONAL_LINKS, ROUTES } from '../utils/constants'; import { getSchemaJson, getWebPageSchema } from '../utils/helpers'; @@ -229,7 +232,7 @@ const HomePageSection: FC<PageSectionProps> = ({ ); type HomeProps = { - recentPosts: ArticleCard[]; + recentPosts: RecentArticle[]; translation?: Messages; }; @@ -277,7 +280,7 @@ const HomePage: NextPageWithLayout<HomeProps> = ({ recentPosts }) => { hasBorderedValues isCentered label={publicationDate} - value={<Time date={post.dates.publication} />} + value={<Time date={post.publicationDate} />} /> </CardMeta> } @@ -365,11 +368,13 @@ HomePage.getLayout = (page) => getLayout(page, { isHome: true }); export const getStaticProps: GetStaticProps<HomeProps> = async ({ locale }) => { const translation = await loadTranslation(locale); - const recentPosts = await getArticlesCard({ first: 3 }); + const recentPosts = await fetchRecentPosts({ first: 3 }); return { props: { - recentPosts, + recentPosts: recentPosts.edges.map((edge) => + convertRecentPostToRecentArticle(edge.node) + ), translation, }, }; diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx index 2911951..ee88638 100644 --- a/src/pages/projets/[slug].tsx +++ b/src/pages/projets/[slug].tsx @@ -24,7 +24,7 @@ import { } from '../../components'; import { mdxComponents } from '../../components/mdx'; import styles from '../../styles/pages/project.module.scss'; -import type { NextPageWithLayout, ProjectPreview, Repos } from '../../types'; +import type { NextPageWithLayout, Project, Repos } from '../../types'; import { CONFIG } from '../../utils/config'; import { ROUTES } from '../../utils/constants'; import { @@ -45,7 +45,7 @@ import { } from '../../utils/hooks'; type ProjectPageProps = { - project: ProjectPreview; + project: Project; translation: Messages; }; diff --git a/src/pages/projets/index.tsx b/src/pages/projets/index.tsx index 00c5a70..4e0bf92 100644 --- a/src/pages/projets/index.tsx +++ b/src/pages/projets/index.tsx @@ -22,7 +22,7 @@ import { import { mdxComponents } from '../../components/mdx'; import PageContent, { meta } from '../../content/pages/projects.mdx'; import styles from '../../styles/pages/projects.module.scss'; -import type { NextPageWithLayout, ProjectCard } from '../../types'; +import type { NextPageWithLayout, ProjectPreview } from '../../types'; import { CONFIG } from '../../utils/config'; import { ROUTES } from '../../utils/constants'; import { @@ -38,7 +38,7 @@ import { import { useBreadcrumb } from '../../utils/hooks'; type ProjectsPageProps = { - projects: ProjectCard[]; + projects: ProjectPreview[]; translation?: Messages; }; diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx index 2a18aa3..293df0e 100644 --- a/src/pages/recherche/index.tsx +++ b/src/pages/recherche/index.tsx @@ -20,25 +20,26 @@ import { PageBody, } from '../../components'; import { - getArticles, - getThematicsPreview, - getTopicsPreview, - getTotalArticles, - getTotalThematics, - getTotalTopics, + convertTaxonomyToPageLink, + fetchPostsCount, + fetchPostsList, + fetchThematicsCount, + fetchThematicsList, + fetchTopicsCount, + fetchTopicsList, } from '../../services/graphql'; import styles from '../../styles/pages/blog.module.scss'; import type { NextPageWithLayout, - RawThematicPreview, - RawTopicPreview, + WPThematicPreview, + WPTopicPreview, } from '../../types'; import { CONFIG } from '../../utils/config'; import { ROUTES } from '../../utils/constants'; import { getBlogSchema, getLinksItemData, - getPageLinkFromRawData, + getPostsWithUrl, getSchemaJson, getWebPageSchema, } from '../../utils/helpers'; @@ -46,8 +47,8 @@ import { loadTranslation, type Messages } from '../../utils/helpers/server'; import { useBreadcrumb, useDataFromAPI, usePostsList } from '../../utils/hooks'; type SearchPageProps = { - thematicsList: RawThematicPreview[]; - topicsList: RawTopicPreview[]; + thematicsList: WPThematicPreview[]; + topicsList: WPTopicPreview[]; translation: Messages; }; @@ -115,6 +116,7 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]); const { + articles, error, firstNewResultIndex, isLoading, @@ -122,16 +124,15 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ isRefreshing, hasNextPage, loadMore, - posts, } = usePostsList({ fallback: [], - fetcher: getArticles, + fetcher: fetchPostsList, perPage: CONFIG.postsPerPage, searchQuery: query.s as string, }); const totalArticles = useDataFromAPI<number>(async () => - getTotalArticles(query.s as string) + fetchPostsCount({ search: query.s as string }) ); const thematicsListTitle = intl.formatMessage({ @@ -172,6 +173,10 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ [intl, routerPush] ); + const foundArticles = articles?.flatMap((p) => + p.edges.map((edge) => edge.node) + ); + return ( <Page breadcrumbs={breadcrumbItems} isBodyLastChild> <Head> @@ -199,14 +204,14 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ /> <PageHeader heading={title} meta={{ total: totalArticles }} /> <PageBody className={styles.body}> - {posts ? null : <Spinner>{loadingResults}</Spinner>} - {posts?.length ? ( + {foundArticles ? null : <Spinner>{loadingResults}</Spinner>} + {foundArticles?.length ? ( <PostsList className={styles.list} firstNewResult={firstNewResultIndex} isLoading={isLoading || isLoadingMore || isRefreshing} onLoadMore={hasNextPage ? loadMore : undefined} - posts={posts} + posts={getPostsWithUrl(foundArticles)} sortByYear /> ) : ( @@ -248,11 +253,7 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ {thematicsListTitle} </Heading> } - items={getLinksItemData( - thematicsList.map((thematic) => - getPageLinkFromRawData(thematic, 'thematic') - ) - )} + items={getLinksItemData(thematicsList.map(convertTaxonomyToPageLink))} /> <LinksWidget heading={ @@ -260,9 +261,7 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ {topicsListTitle} </Heading> } - items={getLinksItemData( - topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic')) - )} + items={getLinksItemData(topicsList.map(convertTaxonomyToPageLink))} /> </PageSidebar> </Page> @@ -274,10 +273,10 @@ SearchPage.getLayout = (page) => getLayout(page); export const getStaticProps: GetStaticProps<SearchPageProps> = async ({ locale, }) => { - const totalThematics = await getTotalThematics(); - const thematics = await getThematicsPreview({ first: totalThematics }); - const totalTopics = await getTotalTopics(); - const topics = await getTopicsPreview({ first: totalTopics }); + const totalThematics = await fetchThematicsCount(); + const thematics = await fetchThematicsList({ first: totalThematics }); + const totalTopics = await fetchTopicsCount(); + const topics = await fetchTopicsList({ first: totalTopics }); const translation = await loadTranslation(locale); return { diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx index 30adec3..aed7ea9 100644 --- a/src/pages/sujet/[slug].tsx +++ b/src/pages/sujet/[slug].tsx @@ -18,10 +18,11 @@ import { PageBody, } from '../../components'; import { - getAllTopicsSlugs, - getTopicBySlug, - getTopicsPreview, - getTotalTopics, + convertTaxonomyToPageLink, + fetchAllTopicsSlugs, + fetchTopic, + fetchTopicsCount, + fetchTopicsList, } from '../../services/graphql'; import styles from '../../styles/pages/blog.module.scss'; import type { NextPageWithLayout, PageLink, Topic } from '../../types'; @@ -29,7 +30,6 @@ import { CONFIG } from '../../utils/config'; import { ROUTES } from '../../utils/constants'; import { getLinksItemData, - getPageLinkFromRawData, getPostsWithUrl, getSchemaJson, getSinglePageSchema, @@ -208,13 +208,13 @@ export const getStaticProps: GetStaticProps<TopicPageProps> = async ({ locale, params, }) => { - const currentTopic = await getTopicBySlug((params as TopicParams).slug); - const totalTopics = await getTotalTopics(); - const allTopicsEdges = await getTopicsPreview({ + const currentTopic = await fetchTopic((params as TopicParams).slug); + const totalTopics = await fetchTopicsCount(); + const allTopicsEdges = await fetchTopicsList({ first: totalTopics, }); const allTopics = allTopicsEdges.edges.map((edge) => - getPageLinkFromRawData(edge.node, 'topic') + convertTaxonomyToPageLink(edge.node) ); const topicsLinks = allTopics.filter( (topic) => topic.url !== `${ROUTES.TOPICS}/${(params as TopicParams).slug}` @@ -231,7 +231,7 @@ export const getStaticProps: GetStaticProps<TopicPageProps> = async ({ }; export const getStaticPaths: GetStaticPaths = async () => { - const slugs = await getAllTopicsSlugs(); + const slugs = await fetchAllTopicsSlugs(); const paths = slugs.map((slug) => { return { params: { slug } }; }); diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx index b8518c5..a44c98b 100644 --- a/src/pages/thematique/[slug].tsx +++ b/src/pages/thematique/[slug].tsx @@ -17,10 +17,11 @@ import { PageBody, } from '../../components'; import { - getAllThematicsSlugs, - getThematicBySlug, - getThematicsPreview, - getTotalThematics, + convertTaxonomyToPageLink, + fetchAllThematicsSlugs, + fetchThematic, + fetchThematicsCount, + fetchThematicsList, } from '../../services/graphql'; import styles from '../../styles/pages/blog.module.scss'; import type { NextPageWithLayout, PageLink, Thematic } from '../../types'; @@ -28,7 +29,6 @@ import { CONFIG } from '../../utils/config'; import { ROUTES } from '../../utils/constants'; import { getLinksItemData, - getPageLinkFromRawData, getPostsWithUrl, getSchemaJson, getSinglePageSchema, @@ -191,15 +191,13 @@ export const getStaticProps: GetStaticProps<ThematicPageProps> = async ({ locale, params, }) => { - const currentThematic = await getThematicBySlug( - (params as ThematicParams).slug - ); - const totalThematics = await getTotalThematics(); - const allThematicsEdges = await getThematicsPreview({ + const currentThematic = await fetchThematic((params as ThematicParams).slug); + const totalThematics = await fetchThematicsCount(); + const allThematicsEdges = await fetchThematicsList({ first: totalThematics, }); const allThematics = allThematicsEdges.edges.map((edge) => - getPageLinkFromRawData(edge.node, 'thematic') + convertTaxonomyToPageLink(edge.node) ); const allThematicsLinks = allThematics.filter( (thematic) => @@ -218,7 +216,7 @@ export const getStaticProps: GetStaticProps<ThematicPageProps> = async ({ }; export const getStaticPaths: GetStaticPaths = async () => { - const slugs = await getAllThematicsSlugs(); + const slugs = await fetchAllThematicsSlugs(); const paths = slugs.map((slug) => { return { params: { slug } }; }); 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); -}; diff --git a/src/types/app.ts b/src/types/app.ts index b613e6e..218d63b 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -28,108 +28,6 @@ export type AppPropsWithLayout = AppProps<CustomPageProps> & { Component: NextPageWithLayout; }; -export type ContentKind = - | 'article' - | 'comment' - | 'page' - | 'project' - | 'thematic' - | 'topic'; - -export type Author<T extends ContentKind> = { - avatar?: Image; - description?: T extends 'comment' ? never : string; - name: string; - website?: string; -}; - -export type CommentMeta = { - author: Author<'comment'>; - date: string; -}; - -export type SingleComment = { - approved: boolean; - content: string; - id: number; - meta: CommentMeta; - parentId?: number; - replies: SingleComment[]; -}; - -export type Dates = { - publication: string; - update?: string; -}; - -export type Image = { - alt: string; - height: number; - src: string; - title?: string; - width: number; -}; - -export type Repos = { - github?: string; - gitlab?: string; -}; - -export type SEO = { - description: string; - title: string; -}; - -export type PageKind = Exclude<ContentKind, 'comment'>; - -export type Meta<T extends PageKind> = { - articles?: T extends 'thematic' | 'topic' ? Article[] : never; - author?: T extends 'article' | 'page' ? Author<T> : never; - commentsCount?: T extends 'article' ? number : never; - cover?: Image; - dates: Dates; - license?: T extends 'project' ? string : never; - repos?: T extends 'project' ? Repos : never; - seo: SEO; - tagline?: T extends 'project' ? string : never; - technologies?: T extends 'project' ? string[] : never; - thematics?: T extends 'article' | 'topic' ? PageLink[] : never; - topics?: T extends 'article' | 'thematic' ? PageLink[] : never; - website?: T extends 'topic' ? string : never; - wordsCount: number; -}; - -export type Page<T extends PageKind> = { - content: string; - id: number | string; - intro: string; - meta: Meta<T>; - slug: string; - title: string; -}; - -export type PageLink = { - id: number; - logo?: Image; - name: string; - url: string; -}; - -export type Article = Page<'article'>; -export type ArticleCard = Pick<Article, 'id' | 'slug' | 'title'> & - Pick<Meta<'article'>, 'cover' | 'dates'>; -export type Project = Page<'project'>; -export type ProjectPreview = Omit<Page<'project'>, 'content'>; -export type ProjectCard = Pick<Page<'project'>, 'id' | 'slug' | 'title'> & { - meta: Pick<Meta<'project'>, 'cover' | 'dates' | 'tagline' | 'technologies'>; -}; -export type Thematic = Page<'thematic'>; -export type Topic = Page<'topic'>; - -export type Slug = { - slug: string; -}; - export type Position = 'bottom' | 'center' | 'left' | 'right' | 'top'; /** Spacing keys defined has CSS variables */ diff --git a/src/types/data.ts b/src/types/data.ts new file mode 100644 index 0000000..9a6d674 --- /dev/null +++ b/src/types/data.ts @@ -0,0 +1,289 @@ +import type { StaticImageData } from 'next/image'; +import type { Nullable } from './generics'; +import type { GraphQLNode } from './gql'; + +export type SlugNode = { + slug: string; +}; + +//=========================================================================== +// Data from WordPress +//=========================================================================== + +type WPSeo = { + metaDesc: string; + title: string; +}; + +type WPCommentAuthorAvatar = { + height: number; + url: string; + width: number; +}; + +type WPCommentAuthor = { + avatar: Nullable<WPCommentAuthorAvatar>; + name: string; + url: Nullable<string>; +}; + +export type WPCommentStatus = 'APPROVE' | 'HOLD' | 'SPAM' | 'TRASH'; + +export type WPComment = { + approved: boolean; + author: GraphQLNode<WPCommentAuthor>; + content: string; + databaseId: number; + date: string; + parentDatabaseId: number; + status: WPCommentStatus; +}; + +type WPContentParts = { + afterMore: string; + beforeMore: string; +}; + +export type WPImage = { + altText: Nullable<string>; + mediaDetails: { + height: number; + width: number; + }; + sourceUrl: string; + title: Nullable<string>; +}; + +type WPInfo = { wordsCount: number }; + +type WPContent = { + date: string; + featuredImage: Nullable<GraphQLNode<WPImage>>; + modified: string; + seo: WPSeo; + slug: string; + title: string; +}; + +export type WPPage = WPContent & { + contentParts: WPContentParts; + info: WPInfo; +}; + +type WPPostAuthor = { name: string }; + +type WPAcfPosts = { + postsInThematic: Nullable<WPThematicPreview[]>; + postsInTopic: Nullable<WPTopicPreview[]>; +}; + +export type WPPost = WPContent & { + acfPosts: Nullable<Partial<WPAcfPosts>>; + author: GraphQLNode<WPPostAuthor>; + commentCount: Nullable<number>; + contentParts: WPContentParts; + databaseId: number; + info: WPInfo; +}; + +export type WPPostPreview = Pick< + WPPost, + | 'commentCount' + | 'databaseId' + | 'date' + | 'featuredImage' + | 'info' + | 'modified' + | 'slug' + | 'title' +> & { + acfPosts: + | Nullable<Pick<WPAcfPosts, 'postsInThematic'>> + | Nullable<Pick<WPAcfPosts, 'postsInTopic'>>; + contentParts: Pick<WPContentParts, 'beforeMore'>; +}; + +export type RecentWPPost = Pick< + WPPost, + 'date' | 'featuredImage' | 'slug' | 'title' +> & { + databaseId: number; +}; + +type WPAcfThematics = { + postsInThematic: Nullable<WPPostPreview[]>; +}; + +export type WPThematic = WPContent & { + acfThematics: Nullable<WPAcfThematics>; +}; + +export type WPThematicPreview = Pick<WPThematic, 'slug' | 'title'> & { + databaseId: number; +}; + +type WPAcfTopics = { + officialWebsite: string; + postsInTopic: Nullable<WPPostPreview[]>; +}; + +export type WPTopic = WPContent & { + acfTopics: Nullable<WPAcfTopics>; +}; + +export type WPTopicPreview = Pick< + WPTopic, + 'featuredImage' | 'slug' | 'title' +> & { + databaseId: number; +}; + +//=========================================================================== +// Data from MDX files +//=========================================================================== + +export type MDXData = { + file: string; + image: MDXImage; +}; + +export type MDXImage = StaticImageData & { + alt: string; + title?: string; +}; + +export type MDXPageMeta = Omit<PageMeta, 'wordsCount'> & { + intro: string; + title: string; +}; + +export type MDXProjectMeta = Omit<ProjectMeta, 'wordsCount'> & { + intro: string; + title: string; +}; + +//=========================================================================== +// Data used in this application +//=========================================================================== + +export type Dates = { + publication: string; + update?: string; +}; + +export type SEO = { + description: string; + title: string; +}; + +export type Img = { + alt: string; + height: number; + src: string; + title?: string; + width: number; +}; + +export type CommentAuthor = { + avatar?: Omit<Img, 'title'>; + name: string; + website?: string; +}; + +export type CommentMeta = { + author: CommentAuthor; + date: string; +}; + +export type SingleComment = { + content: string; + id: number; + isApproved: boolean; + meta: CommentMeta; + parentId?: number; + replies: SingleComment[]; +}; + +export type PageMeta = { + cover?: Img; + dates: Dates; + seo: SEO; + wordsCount: number; +}; + +export type Page = { + content: string; + intro: string; + slug: string; + title: string; +}; + +export type PageLink = { + id: number; + logo?: Img; + name: string; + url: string; +}; + +type ArticleMeta = PageMeta & { + author?: string; + commentsCount?: number; + thematics?: PageLink[]; + topics?: PageLink[]; +}; + +export type Article = Page & { + id: number; + meta: ArticleMeta; +}; + +export type ArticlePreview = Pick<Article, 'intro' | 'slug' | 'title'> & { + id: number; + meta: Omit<ArticleMeta, 'author' | 'seo' | 'topics'>; +}; + +export type RecentArticle = Pick<Article, 'slug' | 'title'> & + Pick<ArticleMeta, 'cover'> & { + id: number; + publicationDate: string; + }; + +export type Repos = { + github?: string; + gitlab?: string; +}; + +export type ProjectMeta = Omit<PageMeta, 'wordsCount'> & { + license?: string; + repos?: Repos; + tagline?: string; + technologies?: string[]; +}; + +export type Project = Omit<Page, 'content'> & { + id: string; + meta: ProjectMeta; +}; + +export type ProjectPreview = Omit<Project, 'meta'> & { + meta: Omit<ProjectMeta, 'license' | 'repos'>; +}; + +export type ThematicMeta = PageMeta & { + articles?: ArticlePreview[]; + topics?: PageLink[]; +}; + +export type Thematic = Page & { + meta: ThematicMeta; +}; + +export type TopicMeta = PageMeta & { + articles?: ArticlePreview[]; + thematics?: PageLink[]; + website?: string; +}; + +export type Topic = Page & { + meta: TopicMeta; +}; diff --git a/src/types/gql.ts b/src/types/gql.ts new file mode 100644 index 0000000..cec66c6 --- /dev/null +++ b/src/types/gql.ts @@ -0,0 +1,73 @@ +import type { WPCommentStatus } from './data'; +import type { Nullable } from './generics'; + +export type GraphQLNode<T> = { + node: T; +}; + +export type GraphQLNodes<T> = { + nodes: T[]; +}; + +export type GraphQLPageInfo = { + endCursor: Nullable<string>; + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: Nullable<string>; + total: number; +}; + +export type GraphQLEdge<T> = GraphQLNode<T> & { + cursor: string; +}; + +export type GraphQLConnection<T> = { + edges: GraphQLEdge<T>[]; + pageInfo: GraphQLPageInfo; +}; + +export type GraphQLEdgesInput = { + after?: Nullable<string>; + before?: Nullable<string>; + first?: number; + last?: number; +}; + +export type GraphQLOrder = 'ASC' | 'DESC'; + +export type GraphQLCommentWhere = { + contentId?: number; + contentName?: string; + status?: WPCommentStatus; +}; + +type GraphQLPostFieldOrder = + | 'AUTHOR' + | 'COMMENT_COUNT' + | 'DATE' + | 'MODIFIED' + | 'SLUG' + | 'TITLE'; + +export type GraphQLPostOrderBy = { + field: GraphQLPostFieldOrder; + order: GraphQLOrder; +}; + +export type GraphQLPostWhere = { + authorName?: string; + search?: string; + title?: string; +}; + +export type GraphQLTaxonomyFieldOrder = 'DATE' | 'MODIFIED' | 'SLUG' | 'TITLE'; + +export type GraphQLTaxonomyOrderBy = { + field: GraphQLTaxonomyFieldOrder; + order: GraphQLOrder; +}; + +export type GraphQLTaxonomyWhere = { + search?: string; + title?: string; +}; diff --git a/src/types/graphql/generics.ts b/src/types/graphql/generics.ts deleted file mode 100644 index dec5f10..0000000 --- a/src/types/graphql/generics.ts +++ /dev/null @@ -1,25 +0,0 @@ -export type GraphQLPageInfo = { - endCursor: string; - hasNextPage: boolean; - total: number; -}; - -export type GraphQLEdges<T> = { - cursor: string; - node: T; -}; - -export type GraphQLEdgesInput = { - after?: string; - before?: string; - first?: number; - last?: number; -}; - -export type GraphQLNode<T> = { - node: T; -}; - -export type GraphQLNodes<T> = { - nodes: T[]; -}; diff --git a/src/types/graphql/index.ts b/src/types/graphql/index.ts deleted file mode 100644 index 79eb05e..0000000 --- a/src/types/graphql/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './generics'; -export * from './mutations'; -export * from './queries'; diff --git a/src/types/graphql/mutations.ts b/src/types/graphql/mutations.ts deleted file mode 100644 index 6ff066c..0000000 --- a/src/types/graphql/mutations.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { sendCommentMutation, sendMailMutation } from '../../services/graphql'; - -//=========================================================================== -// Existing mutations list -//=========================================================================== - -export type Mutations = typeof sendMailMutation | typeof sendCommentMutation; - -//=========================================================================== -// Mutations response types -//=========================================================================== - -export type SendCommentResponse<T> = { - createComment: T; -}; - -export type SendMailResponse<T> = { - sendEmail: T; -}; - -export type MutationsResponseMap<T> = { - [sendCommentMutation]: SendCommentResponse<T>; - [sendMailMutation]: SendMailResponse<T>; -}; - -export type Approved = { - approved: boolean; -}; - -export type SentComment = { - clientMutationId: string; - success: boolean; - comment: Approved | null; -}; - -//=========================================================================== -// Mutations input types -//=========================================================================== - -export type SendCommentInput = { - author: string; - authorEmail: string; - authorUrl: string; - clientMutationId: string; - commentOn: number; - content: string; - parent?: number; -}; - -export type SendMailInput = { - body: string; - clientMutationId: string; - replyTo: string; - subject: string; -}; - -export type MutationsInputMap = { - [sendCommentMutation]: SendCommentInput; - [sendMailMutation]: SendMailInput; -}; diff --git a/src/types/graphql/queries.ts b/src/types/graphql/queries.ts deleted file mode 100644 index 83e9c67..0000000 --- a/src/types/graphql/queries.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { - articleBySlugQuery, - articlesCardQuery, - articlesEndCursorQuery, - articlesQuery, - articlesSlugQuery, - commentsQuery, - thematicBySlugQuery, - thematicsListQuery, - thematicsSlugQuery, - topicBySlugQuery, - topicsListQuery, - topicsSlugQuery, - totalArticlesQuery, - totalThematicsQuery, - totalTopicsQuery, -} from '../../services/graphql'; -import { Slug } from '../app'; -import { RawComment } from '../raw-data'; -import { - GraphQLEdges, - GraphQLEdgesInput, - GraphQLNodes, - GraphQLPageInfo, -} from './generics'; - -//=========================================================================== -// Existing queries list -//=========================================================================== - -export type Queries = - | typeof articlesQuery - | typeof articleBySlugQuery - | typeof articlesCardQuery - | typeof articlesEndCursorQuery - | typeof articlesSlugQuery - | typeof commentsQuery - | typeof thematicBySlugQuery - | typeof thematicsListQuery - | typeof thematicsSlugQuery - | typeof topicBySlugQuery - | typeof topicsListQuery - | typeof topicsSlugQuery - | typeof totalArticlesQuery - | typeof totalThematicsQuery - | typeof totalTopicsQuery; - -//=========================================================================== -// Queries response types -//=========================================================================== - -export type ArticleResponse<T> = { - post: T; -}; - -export type ArticlesResponse<T> = { - posts: T; -}; - -export type CommentsResponse<T> = { - comments: T; -}; - -export type ThematicResponse<T> = { - thematic: T; -}; - -export type ThematicsResponse<T> = { - thematics: T; -}; - -export type TopicResponse<T> = { - topic: T; -}; - -export type TopicsResponse<T> = { - topics: T; -}; - -export type EdgesResponse<T> = { - edges: GraphQLEdges<T>[]; - pageInfo: GraphQLPageInfo; -}; - -export type EndCursorResponse = { - pageInfo: Pick<GraphQLPageInfo, 'endCursor'>; -}; - -export type QueriesResponseMap<T> = { - [articleBySlugQuery]: ArticleResponse<T>; - [articlesCardQuery]: ArticlesResponse<GraphQLNodes<T>>; - [articlesEndCursorQuery]: ArticlesResponse<EndCursorResponse>; - [articlesQuery]: ArticlesResponse<EdgesResponse<T>>; - [articlesSlugQuery]: ArticlesResponse<EdgesResponse<T>>; - [commentsQuery]: CommentsResponse<EdgesResponse<T>>; - [thematicBySlugQuery]: ThematicResponse<T>; - [thematicsListQuery]: ThematicsResponse<EdgesResponse<T>>; - [thematicsSlugQuery]: ThematicsResponse<EdgesResponse<T>>; - [topicBySlugQuery]: TopicResponse<T>; - [topicsListQuery]: TopicsResponse<EdgesResponse<T>>; - [topicsSlugQuery]: TopicsResponse<EdgesResponse<T>>; - [totalArticlesQuery]: ArticlesResponse<T>; - [totalThematicsQuery]: ThematicsResponse<T>; - [totalTopicsQuery]: TopicsResponse<T>; -}; - -//=========================================================================== -// Queries input types -//=========================================================================== - -export type QueryEdges = Pick<GraphQLEdgesInput, 'after' | 'first'>; - -export type ContentId = { - contentId: number; -}; - -export type Search = { - search?: string; -}; - -export type QueriesInputMap = { - [articleBySlugQuery]: Slug; - [articlesCardQuery]: QueryEdges & Search; - [articlesEndCursorQuery]: QueryEdges & Search; - [articlesQuery]: QueryEdges & Search; - [articlesSlugQuery]: QueryEdges & Search; - [commentsQuery]: ContentId & QueryEdges; - [thematicBySlugQuery]: Slug; - [thematicsListQuery]: QueryEdges & Search; - [thematicsSlugQuery]: QueryEdges & Search; - [topicBySlugQuery]: Slug; - [topicsListQuery]: QueryEdges & Search; - [topicsSlugQuery]: QueryEdges & Search; - [totalArticlesQuery]: Search; - [totalThematicsQuery]: null; - [totalTopicsQuery]: null; -}; - -export type CommentPage = { - comments: RawComment[]; - hasNextPage: boolean; - endCursor: string; -}; diff --git a/src/types/index.ts b/src/types/index.ts index e2f0f55..d6e4a6a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,5 @@ export * from './app'; +export * from './data'; export * from './generics'; -export * from './graphql'; -export * from './mdx'; -export * from './raw-data'; +export * from './gql'; export * from './swr'; diff --git a/src/types/mdx.ts b/src/types/mdx.ts deleted file mode 100644 index 7645ce6..0000000 --- a/src/types/mdx.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { StaticImageData } from 'next/image'; -import { Meta } from './app'; - -export type MDXData = { - file: string; - image: MDXImage; -}; - -export type MDXImage = StaticImageData & { - alt: string; - title?: string; -}; - -export type MDXPageMeta = Pick<Meta<'page'>, 'cover' | 'dates' | 'seo'> & { - intro: string; - title: string; -}; - -export type MDXProjectMeta = Exclude<Meta<'project'>, 'wordsCount'> & { - intro: string; - title: string; -}; diff --git a/src/types/raw-data.ts b/src/types/raw-data.ts deleted file mode 100644 index 022016e..0000000 --- a/src/types/raw-data.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Types for raw data coming from GraphQL API. - */ - -import { ContentKind } from './app'; -import { GraphQLNode, GraphQLPageInfo } from './graphql/generics'; - -export type ACFPosts = { - postsInThematic?: RawThematicPreview[]; - postsInTopic?: RawTopicPreview[]; -}; - -export type ACFThematics = { - postsInThematic: RawArticle[]; -}; - -export type ACFTopics = { - officialWebsite: string; - postsInTopic: RawArticle[]; -}; - -export type ContentParts = { - afterMore: string; - beforeMore: string; -}; - -export type Info = { - wordsCount: number; -}; - -export type RawAuthor<T extends ContentKind> = { - description?: T extends 'comment' ? never : string; - gravatarUrl?: string; - name: string; - url?: string; -}; - -export type RawComment = { - approved: boolean; - author: GraphQLNode<RawAuthor<'comment'>>; - content: string; - databaseId: number; - date: string; - parentDatabaseId: number; -}; - -export type RawCommentsPage = { - comments: RawComment[]; - hasNextPage: boolean; - endCursor: string; -}; - -export type RawCover = { - altText: string; - mediaDetails: { - width: number; - height: number; - }; - sourceUrl: string; - title?: string; -}; - -export type RawArticle = RawPage & { - acfPosts: ACFPosts; - commentCount: number | null; -}; - -export type RawArticlePreview = Pick< - RawArticle, - 'databaseId' | 'date' | 'featuredImage' | 'slug' | 'title' ->; - -export type RawPage = { - author?: GraphQLNode<RawAuthor<'page'>>; - contentParts: ContentParts; - databaseId: number; - date: string; - featuredImage: GraphQLNode<RawCover> | null; - info: Info; - modified: string; - seo?: RawSEO; - slug: string; - title: string; -}; - -export type RawSEO = { - metaDesc: string; - title: string; -}; - -export type RawThematic = RawPage & { - acfThematics: ACFThematics; -}; - -export type RawThematicPreview = Pick< - RawThematic, - 'databaseId' | 'featuredImage' | 'slug' | 'title' ->; - -export type RawTopic = RawPage & { - acfTopics: ACFTopics; -}; - -export type RawTopicPreview = Pick< - RawTopic, - 'databaseId' | 'featuredImage' | 'slug' | 'title' ->; - -export type TotalItems = { - pageInfo: Pick<GraphQLPageInfo, 'total'>; -}; 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, }; }; diff --git a/tests/utils/graphql/connections.ts b/tests/utils/graphql/connections.ts index f38fa59..dc4bfc6 100644 --- a/tests/utils/graphql/connections.ts +++ b/tests/utils/graphql/connections.ts @@ -1,4 +1,9 @@ -import type { EdgesResponse, GraphQLEdges, Maybe } from '../../../src/types'; +import type { + GraphQLConnection, + GraphQLEdge, + Maybe, + Nullable, +} from '../../../src/types'; import { CONFIG } from '../../../src/utils/config'; /** @@ -8,7 +13,7 @@ import { CONFIG } from '../../../src/utils/config'; * @param {number} offset - The offset. * @returns {Array<Edge<T>>} The edges. */ -export const getEdges = <T>(data: T[], offset: number): GraphQLEdges<T>[] => +export const getEdges = <T>(data: T[], offset: number): GraphQLEdge<T>[] => data.map((singleData, index) => { const currentItemNumber = index + 1; @@ -21,7 +26,7 @@ export const getEdges = <T>(data: T[], offset: number): GraphQLEdges<T>[] => type GetConnectionProps<T> = { data: Maybe<T[]>; first: Maybe<number>; - after: Maybe<string>; + after: Maybe<Nullable<string>>; }; /** @@ -37,7 +42,7 @@ export const getConnection = <T>({ after, data = [], first = CONFIG.postsPerPage, -}: GetConnectionProps<T>): EdgesResponse<T> => { +}: GetConnectionProps<T>): GraphQLConnection<T> => { const afterInt = after ? Number(after.replace('cursor', '')) : 0; const edges = getEdges(data.slice(afterInt, afterInt + first), afterInt); const endCursor = @@ -47,7 +52,9 @@ export const getConnection = <T>({ edges, pageInfo: { endCursor, + hasPreviousPage: typeof after !== 'undefined', hasNextPage: data.length - afterInt > first, + startCursor: after ?? 'cursor1', total: data.length, }, }; |
