diff --git a/frontend/app/common/api.ts b/frontend/app/common/api.ts index 0abea9a7..771809f8 100644 --- a/frontend/app/common/api.ts +++ b/frontend/app/common/api.ts @@ -11,8 +11,10 @@ export const getPostComments = (sort: Sorting) => apiFetcher.get('/find', export const getComment = (id: Comment['id']): Promise => apiFetcher.get(`/id/${id}`, { url }); -export const getUserComments = (userId: User['id']): Promise<{ comments: Comment[]; count: number }> => - apiFetcher.get('/comments', { user: userId, limit: 10 }); +export const getUserComments = ( + userId: User['id'], + config: { skip?: number; limit?: number } = { skip: 0, limit: 10 } +): Promise<{ comments: Comment[]; count: number }> => apiFetcher.get('/comments', { user: userId, ...config }); export const putCommentVote = ({ id, value }: { id: Comment['id']; value: number }): Promise => apiFetcher.put(`/vote/${id}`, { url, vote: value }); diff --git a/frontend/app/components/profile/profile.spec.tsx b/frontend/app/components/profile/profile.spec.tsx index 3dcdbe9c..f2f6a9d5 100644 --- a/frontend/app/components/profile/profile.spec.tsx +++ b/frontend/app/components/profile/profile.spec.tsx @@ -7,6 +7,7 @@ import * as pq from 'utils/parse-query'; import type { Comment, User } from 'common/types'; import { Profile } from './profile'; +import { fireEvent } from '@testing-library/dom'; const userParamsStub = { id: '1', @@ -49,11 +50,12 @@ describe('', () => { expect(queryByRole('heading', { name: /my comments/i })).not.toBeInTheDocument(); expect(queryByRole('heading', { name: /comments/i })).not.toBeInTheDocument(); expect(queryByTestId('comments-counter')).not.toBeInTheDocument(); + expect(queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); }); it('should render error', async () => { jest.spyOn(pq, 'parseQuery').mockImplementation(() => ({ ...userParamsStub })); - jest.spyOn(api, 'getUserComments').mockImplementation(async () => { + jest.spyOn(api, 'getUserComments').mockImplementation(() => { throw new Error('error'); }); const { queryByLabelText, queryByRole, findByRole, queryByTestId } = render(); @@ -63,6 +65,7 @@ describe('', () => { expect(queryByRole('heading', { name: /my comments/i })).not.toBeInTheDocument(); expect(queryByRole('heading', { name: /comments/i })).not.toBeInTheDocument(); expect(queryByTestId('comments-counter')).not.toBeInTheDocument(); + expect(queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); }); it('should render user without comments', async () => { @@ -73,14 +76,15 @@ describe('', () => { const { findByText, queryByLabelText, queryByRole, queryByTestId } = render(); - expect(getUserComments).toHaveBeenCalledWith('1'); + expect(getUserComments).toHaveBeenCalledWith('1', { limit: 10, skip: 0 }); expect(await findByText("Don't have comments yet")).toBeInTheDocument(); expect(queryByTestId('comments-counter')).not.toBeInTheDocument(); expect(queryByLabelText('Loading...')).not.toBeInTheDocument(); expect(queryByRole('button', { name: /retry/i })).not.toBeInTheDocument(); + expect(queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); }); - it('should render user with comments', async () => { + it('should render user with comments without load more button', async () => { jest.spyOn(pq, 'parseQuery').mockImplementation(() => userParamsStub); jest .spyOn(api, 'getUserComments') @@ -92,32 +96,63 @@ describe('', () => { expect(queryByTestId('comments-counter')).toHaveTextContent(commentsStub.length.toString()); expect(queryByLabelText('Loading...')).not.toBeInTheDocument(); expect(queryByRole('button', { name: /retry/i })).not.toBeInTheDocument(); + expect(queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); + }); + + it('should render user with comments with load more button', async () => { + const comments = new Array(15).fill(commentStub); + jest.spyOn(pq, 'parseQuery').mockImplementation(() => userParamsStub); + jest.spyOn(api, 'getUserComments').mockImplementation(async () => ({ comments, count: comments.length })); + + const { findByText, queryByLabelText, queryByRole, queryByTestId } = render(); + + expect(await findByText('Comments')).toBeInTheDocument(); + expect(queryByTestId('comments-counter')).toHaveTextContent(comments.length.toString()); + expect(queryByLabelText('Loading...')).not.toBeInTheDocument(); + expect(queryByRole('button', { name: /retry/i })).not.toBeInTheDocument(); + expect(queryByRole('button', { name: /load more/i })).toBeInTheDocument(); }); it('should render current user without comments', async () => { jest.spyOn(pq, 'parseQuery').mockImplementation(() => ({ ...userParamsStub, current: '1' })); jest.spyOn(api, 'getUserComments').mockImplementation(async () => ({ comments: [], count: 0 })); - const { getByText, getByTitle, findByText, queryByTestId } = render(); + const { getByText, getByTitle, findByText, queryByTestId, queryByRole } = render(); expect(getByTitle('Sign Out')).toBeInTheDocument(); expect(getByText('Request my data removal')).toBeInTheDocument(); expect(await findByText("Don't have comments yet")).toBeInTheDocument(); expect(queryByTestId('comments-counter')).not.toBeInTheDocument(); + expect(queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); }); - it('should render current user with comments', async () => { + it('should render current user with comments without load more button', async () => { jest.spyOn(pq, 'parseQuery').mockImplementation(() => ({ ...userParamsStub, current: '1' })); jest .spyOn(api, 'getUserComments') .mockImplementation(async () => ({ comments: commentsStub, count: commentsStub.length })); - const { findByText, queryByTitle, queryByText, queryByTestId } = render(); + const { findByText, queryByTitle, queryByText, queryByTestId, queryByRole } = render(); expect(await findByText('My comments')).toBeInTheDocument(); expect(queryByTestId('comments-counter')).toHaveTextContent(commentsStub.length.toString()); expect(queryByTitle('Sign Out')).toBeInTheDocument(); expect(queryByText('Request my data removal')).toBeInTheDocument(); + expect(queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); + }); + + it('should render current user with comments with load more button', async () => { + const comments = Array(15).fill(commentStub); + jest.spyOn(pq, 'parseQuery').mockImplementation(() => ({ ...userParamsStub, current: '1' })); + jest.spyOn(api, 'getUserComments').mockImplementation(async () => ({ comments, count: comments.length })); + + const { findByText, queryByTitle, queryByText, queryByTestId, queryByRole } = render(); + + expect(await findByText('My comments')).toBeInTheDocument(); + expect(queryByTestId('comments-counter')).toHaveTextContent(comments.length.toString()); + expect(queryByTitle('Sign Out')).toBeInTheDocument(); + expect(queryByText('Request my data removal')).toBeInTheDocument(); + expect(queryByRole('button', { name: /load more/i })).toBeInTheDocument(); }); it('should render user without footer', async () => { @@ -130,4 +165,15 @@ describe('', () => { expect(container.querySelector('profile-footer')).not.toBeInTheDocument(); }); + + it('load more button should dissapear if there no more comments to fetch', async () => { + const comments = Array(20).fill(commentStub); + jest.spyOn(api, 'getUserComments').mockImplementation(async () => ({ comments, count: comments.length })); + + const { findByRole, queryByRole } = render(); + + expect(await findByRole('button', { name: /load more/i })).toBeInTheDocument(); + fireEvent.click(await findByRole('button', { name: /load more/i })); + expect(queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); + }); }); diff --git a/frontend/app/components/profile/profile.tsx b/frontend/app/components/profile/profile.tsx index f1d58241..fb62fd82 100644 --- a/frontend/app/components/profile/profile.tsx +++ b/frontend/app/components/profile/profile.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; import { h, Fragment } from 'preact'; -import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useIntl, FormattedMessage } from 'react-intl'; import { getUserComments } from 'common/api'; @@ -32,21 +32,32 @@ async function signout() { export function Profile() { const intl = useIntl(); const rootRef = useRef(null); + const commentsLimit = useMemo(() => 10, []); const user = useMemo(() => parseQuery(), []); const [isCommentsLoading, setIsCommentsLoading] = useState(false); const [error, setError] = useState(false); const [comments, setComments] = useState(null); const [commentsAmount, setCommentsAmount] = useState(null); + const [commentsSkipCounts, setCommentsSkipCounts] = useState(0); const [isSigningOut, setSigningOut] = useState(false); + const isLoadMoreVisible = commentsAmount && commentsAmount > commentsSkipCounts + commentsLimit; - async function fetchUserComments(userId: string) { + const fetchUserComments = useCallback( + async (skip: number = 0) => { + const { comments, count } = await getUserComments(user.id, { skip, limit: commentsLimit }); + return { comments, count }; + }, + [user.id, commentsLimit] + ); + + const fetchUserCommentsOnMount = useCallback(async () => { setIsCommentsLoading(true); setError(false); setComments(null); setCommentsAmount(null); try { - const { comments, count } = await getUserComments(userId); + const { comments, count } = await fetchUserComments(); setComments(comments); setCommentsAmount(count); @@ -55,7 +66,23 @@ export function Profile() { } finally { setIsCommentsLoading(false); } - } + }, [fetchUserComments]); + + const fetchMoreUserComments = useCallback( + async (skipCounts: number) => { + setError(false); + + try { + const { comments: nextComments, count } = await fetchUserComments(skipCounts); + + setComments([...(comments || []), ...nextComments]); + setCommentsAmount(count); + } catch (err) { + setError(true); + } + }, + [comments, fetchUserComments] + ); function handleClickClose() { const rootElement = rootRef.current; @@ -79,13 +106,20 @@ export function Profile() { await signout(); } + function handleLoadMore() { + const nextSkipCounts = commentsSkipCounts + commentsLimit; + + setCommentsSkipCounts(nextSkipCounts); + fetchMoreUserComments(nextSkipCounts); + } + function handleClickRetryCommentsRequest() { - fetchUserComments(user.id); + fetchMoreUserComments(commentsSkipCounts); } useEffect(() => { - fetchUserComments(user.id); - }, [user.id]); + fetchUserCommentsOnMount(); + }, [fetchUserCommentsOnMount]); useEffect(() => { const styles = { height: '100%', padding: 0 }; @@ -145,6 +179,11 @@ export function Profile() { theme={(user.theme as Theme) || 'light'} /> ))} + {isLoadMoreVisible && ( + + )} ) : (

diff --git a/frontend/app/locales/be.json b/frontend/app/locales/be.json index 713d91e0..9c999869 100644 --- a/frontend/app/locales/be.json +++ b/frontend/app/locales/be.json @@ -159,6 +159,7 @@ "toolbar.unordered-list": "Ненумараваны спіс", "user.my-comments": "My comments", "user.comments": "Comments", + "user.load-more": "Load more", "vote.anonymous": "Ананімныя карыстальнікі не могуць галасаваць", "vote.deleted": "Нельга галасаваць за выдалены каментар", "vote.guest": "Увайдзіце ў сістэму, каб галасаваць", diff --git a/frontend/app/locales/bg.json b/frontend/app/locales/bg.json index cd37ed0b..7685c329 100644 --- a/frontend/app/locales/bg.json +++ b/frontend/app/locales/bg.json @@ -159,6 +159,7 @@ "toolbar.unordered-list": "Добави обозначен списък", "user.my-comments": "My comments", "user.comments": "Comments", + "user.load-more": "Load more", "vote.anonymous": "Анонимни потребители не могат да гласуват", "vote.deleted": "Не може да гласъвате за изтрит коментарт", "vote.guest": "Влезте за да гласувате", diff --git a/frontend/app/locales/bp.json b/frontend/app/locales/bp.json index e4071ee7..9e41d8e6 100644 --- a/frontend/app/locales/bp.json +++ b/frontend/app/locales/bp.json @@ -159,6 +159,7 @@ "toolbar.unordered-list": "Adicionar lista com marcadores", "user.my-comments": "My comments", "user.comments": "Comments", + "user.load-more": "Load more", "vote.anonymous": "Usuários anônimos não podem votar", "vote.deleted": "Não posso votar em um comentário excluído", "vote.guest": "Faça login para votar", diff --git a/frontend/app/locales/de.json b/frontend/app/locales/de.json index db8edf0f..d641f528 100644 --- a/frontend/app/locales/de.json +++ b/frontend/app/locales/de.json @@ -159,6 +159,7 @@ "toolbar.unordered-list": "Aufzählungsliste einfügen", "user.my-comments": "My comments", "user.comments": "Comments", + "user.load-more": "Load more", "vote.anonymous": "Anonyme Benutzer können leider nicht abstimmen", "vote.deleted": "Sie können nicht für einen gelöschten Kommentar abstimmen", "vote.guest": "Melden Sie sich an, um abzustimmen", diff --git a/frontend/app/locales/en.json b/frontend/app/locales/en.json index 7bfc46b5..9d7a76aa 100644 --- a/frontend/app/locales/en.json +++ b/frontend/app/locales/en.json @@ -159,6 +159,7 @@ "toolbar.unordered-list": "Add a bulleted list", "user.my-comments": "My comments", "user.comments": "Comments", + "user.load-more": "Load more", "vote.anonymous": "Anonymous users can't vote", "vote.deleted": "Can't vote for deleted comment", "vote.guest": "Sign in to vote", diff --git a/frontend/app/locales/es.json b/frontend/app/locales/es.json index f36dd967..7d72e06e 100644 --- a/frontend/app/locales/es.json +++ b/frontend/app/locales/es.json @@ -159,6 +159,7 @@ "toolbar.unordered-list": "Agrega una lista sin numerar", "user.my-comments": "My comments", "user.comments": "Comments", + "user.load-more": "Load more", "vote.anonymous": "Los usuarios anónimos no puede votar", "vote.deleted": "No se puede votar un comentario eliminado", "vote.guest": "Accede para votar", diff --git a/frontend/app/locales/fi.json b/frontend/app/locales/fi.json index a2446bd5..88944c13 100644 --- a/frontend/app/locales/fi.json +++ b/frontend/app/locales/fi.json @@ -159,6 +159,7 @@ "toolbar.unordered-list": "Järjestämätön luettelo", "user.my-comments": "My comments", "user.comments": "Comments", + "user.load-more": "Load more", "vote.anonymous": "Anonyymit käyttäjät eivät voi äänestää", "vote.deleted": "Poistettua kommenttia ei voi äänestää", "vote.guest": "Kirjaudu sisään äänestääksesi", diff --git a/frontend/app/locales/fr.json b/frontend/app/locales/fr.json index 1c71b4d8..c0080a1b 100644 --- a/frontend/app/locales/fr.json +++ b/frontend/app/locales/fr.json @@ -159,6 +159,7 @@ "toolbar.unordered-list": "Ajouter une liste à puces", "user.my-comments": "My comments", "user.comments": "Comments", + "user.load-more": "Load more", "vote.anonymous": "Les utilisateurs anonymes ne peuvent pas voter", "vote.deleted": "Vous ne pouvez pas voter pour un commentaire supprimé", "vote.guest": "Connectez-vous pour voter", diff --git a/frontend/app/locales/ja.json b/frontend/app/locales/ja.json index f402f605..1c58f916 100644 --- a/frontend/app/locales/ja.json +++ b/frontend/app/locales/ja.json @@ -159,6 +159,7 @@ "toolbar.unordered-list": "記号付きリストの追加", "user.my-comments": "My comments", "user.comments": "Comments", + "user.load-more": "Load more", "vote.anonymous": "匿名ユーザーは投稿できません", "vote.deleted": "削除済みコメントには投票できません", "vote.guest": "投票するにはサインインしてください", diff --git a/frontend/app/locales/ko.json b/frontend/app/locales/ko.json index c5fe2a15..82714a0a 100644 --- a/frontend/app/locales/ko.json +++ b/frontend/app/locales/ko.json @@ -159,6 +159,7 @@ "toolbar.unordered-list": "글머리 기호가 지정된 목록 추가", "user.my-comments": "My comments", "user.comments": "Comments", + "user.load-more": "Load more", "vote.anonymous": "익명의 사용자는 투표할 수 없습니다", "vote.deleted": "삭제된 댓글에는 투표할 수 없습니다", "vote.guest": "투표하려면 로그인하세요", diff --git a/frontend/app/locales/pl.json b/frontend/app/locales/pl.json index edddcba0..ad459bb3 100644 --- a/frontend/app/locales/pl.json +++ b/frontend/app/locales/pl.json @@ -159,6 +159,7 @@ "toolbar.unordered-list": "Dodaj listę punktowaną", "user.my-comments": "Moje komentarze", "user.comments": "Komentarze", + "user.load-more": "Load more", "vote.anonymous": "Anonimowi użytkownicy nie mogą głosować", "vote.deleted": "Nie można głosować na usunięte komentarze", "vote.guest": "Zaloguj się żeby głosować", diff --git a/frontend/app/locales/ru.json b/frontend/app/locales/ru.json index 0f510bc2..b88b8718 100644 --- a/frontend/app/locales/ru.json +++ b/frontend/app/locales/ru.json @@ -159,6 +159,7 @@ "toolbar.unordered-list": "Добавить маркированный список", "user.my-comments": "Мои комментарии", "user.comments": "Комментарии", + "user.load-more": "Load more", "vote.anonymous": "Анонимные пользователи не могут голосовать", "vote.deleted": "Нельзя голосовать за удаленный комментарий", "vote.guest": "Войдите, чтобы проголосовать", diff --git a/frontend/app/locales/tr.json b/frontend/app/locales/tr.json index baab859d..a52c0cb4 100644 --- a/frontend/app/locales/tr.json +++ b/frontend/app/locales/tr.json @@ -159,6 +159,7 @@ "toolbar.unordered-list": "Liste ekle", "user.my-comments": "My comments", "user.comments": "Comments", + "user.load-more": "Load more", "vote.anonymous": "Anonim kullanıcılar oy kullanamaz", "vote.deleted": "Silinmiş yorum oylanamaz", "vote.guest": "Oy vermek için giriş yapın", diff --git a/frontend/app/locales/ua.json b/frontend/app/locales/ua.json index 46927477..29417178 100644 --- a/frontend/app/locales/ua.json +++ b/frontend/app/locales/ua.json @@ -159,6 +159,7 @@ "toolbar.unordered-list": "Неупорядкованний список", "user.my-comments": "Мої коментарі", "user.comments": "Коментарі", + "user.load-more": "Load more", "vote.anonymous": "Не можна голосувати анонімному користувачу", "vote.deleted": "Не можна голосувати за видалений коментар", "vote.guest": "Увійдіть в систему для голосування", diff --git a/frontend/app/locales/vi.json b/frontend/app/locales/vi.json index 14a1641c..e5dc6782 100644 --- a/frontend/app/locales/vi.json +++ b/frontend/app/locales/vi.json @@ -159,6 +159,7 @@ "toolbar.unordered-list": "Thêm danh sách", "user.my-comments": "My comments", "user.comments": "Comments", + "user.load-more": "Load more", "vote.anonymous": "Người dùng ẩn danh không thể vote", "vote.deleted": "Không thể vote bình luận đã xoá", "vote.guest": "Đăng nhập để vote", diff --git a/frontend/app/locales/zh.json b/frontend/app/locales/zh.json index 34bb6c67..8b3e7891 100644 --- a/frontend/app/locales/zh.json +++ b/frontend/app/locales/zh.json @@ -159,6 +159,7 @@ "toolbar.unordered-list": "添加项目符号列表", "user.my-comments": "My comments", "user.comments": "Comments", + "user.load-more": "Load more", "vote.anonymous": "匿名用户无法投票", "vote.deleted": "无法为已删除的评论投票", "vote.guest": "登录以投票",