From 8ce139e125032b4c1a8daf57f80bd61c939264b0 Mon Sep 17 00:00:00 2001 From: Johan Book <{ID}+{username}@users.noreply.github.com> Date: Sun, 10 Sep 2023 21:45:30 +0200 Subject: [PATCH 1/4] feat(web-ui): add infinite scroll hook --- .../web-ui/src/core/infinite-scroll/index.ts | 1 + .../core/infinite-scroll/useInfiniteScroll.ts | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 services/web-ui/src/core/infinite-scroll/index.ts create mode 100644 services/web-ui/src/core/infinite-scroll/useInfiniteScroll.ts diff --git a/services/web-ui/src/core/infinite-scroll/index.ts b/services/web-ui/src/core/infinite-scroll/index.ts new file mode 100644 index 00000000..995964b6 --- /dev/null +++ b/services/web-ui/src/core/infinite-scroll/index.ts @@ -0,0 +1 @@ +export { useInfiniteScroll } from "./useInfiniteScroll"; diff --git a/services/web-ui/src/core/infinite-scroll/useInfiniteScroll.ts b/services/web-ui/src/core/infinite-scroll/useInfiniteScroll.ts new file mode 100644 index 00000000..dce55623 --- /dev/null +++ b/services/web-ui/src/core/infinite-scroll/useInfiniteScroll.ts @@ -0,0 +1,35 @@ +import { useEffect, useRef } from "react"; + +interface useInfiniteScrollProps { + onNext: () => Promise; +} + +export function useInfiniteScroll({ onNext }: useInfiniteScrollProps) { + const observerTarget = useRef(null); + + useEffect(() => { + const current = observerTarget.current; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + onNext(); + } + }, + { threshold: 1 } + ); + + if (current) { + observer.observe(current); + } + + return () => { + if (current) { + observer.unobserve(current); + } + }; + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [observerTarget.current]); + + return { observerTarget }; +} From 45bd8dc1a1ee548ccb1f3d13aebfa79de5302721 Mon Sep 17 00:00:00 2001 From: Johan Book <{ID}+{username}@users.noreply.github.com> Date: Fri, 22 Sep 2023 22:04:10 +0200 Subject: [PATCH 2/4] fix(web-ui): fix error stringifier util --- services/web-ui/src/utils/error.utils.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/services/web-ui/src/utils/error.utils.ts b/services/web-ui/src/utils/error.utils.ts index de1a18fc..18e1c6ff 100644 --- a/services/web-ui/src/utils/error.utils.ts +++ b/services/web-ui/src/utils/error.utils.ts @@ -4,6 +4,10 @@ function reponseErrorToMessage(error: ResponseError): string | undefined { const response = error.response; if (response.errorMessage) { + if (Array.isArray(response.errorMessage)) { + return response.errorMessage.join(", "); + } + return response.errorMessage; } From 2cb3b959f7975e96a685402872c0fcefca009bd7 Mon Sep 17 00:00:00 2001 From: Johan Book <{ID}+{username}@users.noreply.github.com> Date: Fri, 22 Sep 2023 22:05:34 +0200 Subject: [PATCH 3/4] fix(api): add missing query transform --- .../src/core/query/application/contracts/queries/base.query.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/api/src/core/query/application/contracts/queries/base.query.ts b/services/api/src/core/query/application/contracts/queries/base.query.ts index dc42d609..b42c8625 100644 --- a/services/api/src/core/query/application/contracts/queries/base.query.ts +++ b/services/api/src/core/query/application/contracts/queries/base.query.ts @@ -1,11 +1,14 @@ +import { Type } from "class-transformer"; import { IsOptional, Min } from "class-validator"; export class BaseQuery { @IsOptional() + @Type(() => Number) @Min(0) skip?: number; @IsOptional() + @Type(() => Number) @Min(0) top?: number; } From e5d5380886510af4454e0d2fc43d2d7fe985acc6 Mon Sep 17 00:00:00 2001 From: Johan Book <{ID}+{username}@users.noreply.github.com> Date: Sat, 23 Sep 2023 09:35:13 +0200 Subject: [PATCH 4/4] feat(web-ui): implement infinite scrolling --- .../core/infinite-scroll/useInfiniteScroll.ts | 4 +- services/web-ui/src/core/query/index.ts | 2 +- .../BlogPostPage/BlogPostPage.component.tsx | 68 ++++++++++--------- .../BlogPostPage/BlogPostPage.container.tsx | 46 ++++++++++--- 4 files changed, 78 insertions(+), 42 deletions(-) diff --git a/services/web-ui/src/core/infinite-scroll/useInfiniteScroll.ts b/services/web-ui/src/core/infinite-scroll/useInfiniteScroll.ts index dce55623..2896bfe6 100644 --- a/services/web-ui/src/core/infinite-scroll/useInfiniteScroll.ts +++ b/services/web-ui/src/core/infinite-scroll/useInfiniteScroll.ts @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; interface useInfiniteScrollProps { - onNext: () => Promise; + onNext: () => void; } export function useInfiniteScroll({ onNext }: useInfiniteScrollProps) { @@ -16,7 +16,7 @@ export function useInfiniteScroll({ onNext }: useInfiniteScrollProps) { onNext(); } }, - { threshold: 1 } + { threshold: 0.1 } ); if (current) { diff --git a/services/web-ui/src/core/query/index.ts b/services/web-ui/src/core/query/index.ts index 4f099aab..54c3bf37 100644 --- a/services/web-ui/src/core/query/index.ts +++ b/services/web-ui/src/core/query/index.ts @@ -1,2 +1,2 @@ -export { useQuery } from "react-query"; +export { useInfiniteQuery, useQuery } from "react-query"; export { CacheKeysConstants } from "./cache-keys.constants"; diff --git a/services/web-ui/src/pages/BlogPostPage/BlogPostPage.component.tsx b/services/web-ui/src/pages/BlogPostPage/BlogPostPage.component.tsx index 19cdd208..9fe9a935 100644 --- a/services/web-ui/src/pages/BlogPostPage/BlogPostPage.component.tsx +++ b/services/web-ui/src/pages/BlogPostPage/BlogPostPage.component.tsx @@ -10,7 +10,7 @@ import { BlogPostForm } from "./components/BlogPostForm"; import { BlogPostMenu } from "./components/BlogPostMenu"; interface BlogPostPageComponentProps { - data: BlogPostDetails[]; + data: BlogPostDetails[][]; } export function BlogPostPageComponent({ @@ -24,41 +24,47 @@ export function BlogPostPageComponent({ - {data.map((post) => ( - - - + {data.map((group, groupIndex) => ( + + {group.map((post) => ( + + + - - - {post.profile.name} {t("published")} - - - {timeSince(post.createdAt)} - - + + + {post.profile.name} {t("published")} + + + {timeSince(post.createdAt)} + + - {post.ownedByCurrentUser && } - + {post.ownedByCurrentUser && } + - {post.content} + {post.content} - {post.photos.map((photo) => ( - {photo.description + {post.photos.map((photo) => ( + {photo.description + ))} + ))} - + ))} ); diff --git a/services/web-ui/src/pages/BlogPostPage/BlogPostPage.container.tsx b/services/web-ui/src/pages/BlogPostPage/BlogPostPage.container.tsx index 93926d68..79e9d9c1 100644 --- a/services/web-ui/src/pages/BlogPostPage/BlogPostPage.container.tsx +++ b/services/web-ui/src/pages/BlogPostPage/BlogPostPage.container.tsx @@ -1,24 +1,39 @@ import React from "react"; -import { Box, Typography } from "@mui/material"; +import { Box, CircularProgress, Typography } from "@mui/material"; import { blogsApi } from "src/apis"; import { ErrorMessage } from "src/components/ui/ErrorMessage"; import { useTranslation } from "src/core/i18n"; -import { CacheKeysConstants, useQuery } from "src/core/query"; +import { useInfiniteScroll } from "src/core/infinite-scroll"; +import { CacheKeysConstants, useInfiniteQuery } from "src/core/query"; import { BlogPostPageComponent } from "./BlogPostPage.component"; import { BlogPostPageHeader } from "./BlogPostPage.header"; import { BlogPostPageSkeleton } from "./BlogPostPage.skeleton"; import { BlogPostForm } from "./components/BlogPostForm"; +const ITEMS_PER_PAGE = 10; + export function BlogPostPageContainer(): React.ReactElement { const { t } = useTranslation("blog"); - const { error, data, isLoading } = useQuery( - CacheKeysConstants.BlogPosts, - () => blogsApi.getBlogPosts() - ); + const { error, data, isLoading, fetchNextPage, hasNextPage } = + useInfiniteQuery( + [CacheKeysConstants.BlogPosts], + ({ pageParam = 0 }) => + blogsApi.getBlogPosts({ + skip: pageParam * ITEMS_PER_PAGE, + top: (pageParam + 1) * ITEMS_PER_PAGE, + }), + { + getNextPageParam: (_, pages) => pages.length - 1, + } + ); + + const { observerTarget } = useInfiniteScroll({ + onNext: fetchNextPage, + }); if (error) { return ( @@ -38,7 +53,7 @@ export function BlogPostPageContainer(): React.ReactElement { ); } - if (!data || data.length === 0) { + if (!data || data.pages.length === 0) { return ( <> @@ -56,7 +71,22 @@ export function BlogPostPageContainer(): React.ReactElement { <> - + + + {hasNextPage && ( + + + + )} ); }