diff --git a/frontend/app/components/profile/profile.module.css b/frontend/app/components/profile/profile.module.css index 77b79835..67c8db9f 100644 --- a/frontend/app/components/profile/profile.module.css +++ b/frontend/app/components/profile/profile.module.css @@ -193,3 +193,11 @@ margin: auto; color: var(--color13); } + +.loadMoreWrapper { + display: flex; + justify-content: center; + align-items: center; + height: 35px; + flex-shrink: 0; +} diff --git a/frontend/app/components/profile/profile.spec.tsx b/frontend/app/components/profile/profile.spec.tsx index f2f6a9d5..923da6de 100644 --- a/frontend/app/components/profile/profile.spec.tsx +++ b/frontend/app/components/profile/profile.spec.tsx @@ -1,5 +1,7 @@ import { h } from 'preact'; import '@testing-library/jest-dom'; +import { waitFor } from '@testing-library/preact'; +import { fireEvent } from '@testing-library/dom'; import { render } from 'tests/utils'; import * as api from 'common/api'; @@ -7,7 +9,6 @@ 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', @@ -90,25 +91,24 @@ describe('', () => { .spyOn(api, 'getUserComments') .mockImplementation(async () => ({ comments: commentsStub, count: commentsStub.length })); - const { findByText, queryByLabelText, queryByRole, queryByTestId } = render(); + const { findByText, queryByRole, queryByTestId } = render(); expect(await findByText('Comments')).toBeInTheDocument(); 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 })); + jest + .spyOn(api, 'getUserComments') + .mockImplementation(async () => ({ comments: new Array(10).fill(commentStub), count: 15 })); - const { findByText, queryByLabelText, queryByRole, queryByTestId } = render(); + const { findByText, queryByRole, queryByTestId } = render(); expect(await findByText('Comments')).toBeInTheDocument(); - expect(queryByTestId('comments-counter')).toHaveTextContent(comments.length.toString()); - expect(queryByLabelText('Loading...')).not.toBeInTheDocument(); + expect(queryByTestId('comments-counter')).toHaveTextContent('15'); expect(queryByRole('button', { name: /retry/i })).not.toBeInTheDocument(); expect(queryByRole('button', { name: /load more/i })).toBeInTheDocument(); }); @@ -167,13 +167,16 @@ describe('', () => { }); 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 })); + jest + .spyOn(api, 'getUserComments') + .mockImplementation(async () => ({ comments: new Array(10).fill(commentStub), count: 15 })); - const { findByRole, queryByRole } = render(); + const { findByRole, getByRole, queryByRole } = render(); expect(await findByRole('button', { name: /load more/i })).toBeInTheDocument(); - fireEvent.click(await findByRole('button', { name: /load more/i })); + fireEvent.click(getByRole('button', { name: /load more/i })); + expect(getByRole('presentation')).toHaveClass('spinner'); + await waitFor(() => expect(queryByRole('presentation')).not.toBeInTheDocument()); 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 4cc8f1a7..20ee8f83 100644 --- a/frontend/app/components/profile/profile.tsx +++ b/frontend/app/components/profile/profile.tsx @@ -38,52 +38,31 @@ export function Profile() { 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 [commentsAmount, setCommentsAmount] = useState(0); + // store skip count in ref because it don't affect the view + const commentsSkipCountsRef = useRef(0); const [isSigningOut, setSigningOut] = useState(false); - const isLoadMoreVisible = commentsAmount && commentsAmount > commentsSkipCounts + COMMENTS_LIMIT; - const fetchUserComments = useCallback( - async (skip: number = 0) => { - const { comments, count } = await getUserComments(user.id, { skip, limit: COMMENTS_LIMIT }); - return { comments, count }; - }, - [user.id] - ); - - const fetchUserCommentsOnMount = useCallback(async () => { + const fetchComments = useCallback(async () => { setIsCommentsLoading(true); setError(false); - setComments(null); - setCommentsAmount(null); try { - const { comments, count } = await fetchUserComments(); + const { comments, count } = await getUserComments(user.id, { + skip: commentsSkipCountsRef.current, + limit: COMMENTS_LIMIT, + }); - setComments(comments); + // update skip count after successful fetch before rendering + commentsSkipCountsRef.current += COMMENTS_LIMIT; + setComments((c) => [...(c || []), ...comments]); setCommentsAmount(count); } catch (err) { setError(true); } 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; @@ -107,20 +86,9 @@ export function Profile() { await signout(); } - function handleLoadMore() { - const nextSkipCounts = commentsSkipCounts + COMMENTS_LIMIT; - - setCommentsSkipCounts(nextSkipCounts); - fetchMoreUserComments(nextSkipCounts); - } - - function handleClickRetryCommentsRequest() { - fetchMoreUserComments(commentsSkipCounts); - } - useEffect(() => { - fetchUserCommentsOnMount(); - }, [fetchUserCommentsOnMount]); + fetchComments(); + }, [fetchComments]); useEffect(() => { const styles = { height: '100%', padding: 0 }; @@ -151,6 +119,7 @@ export function Profile() { return null; } + const isLoadMoreVisible = commentsAmount > commentsSkipCountsRef.current; const isCurrent = user.current === '1'; const commentsJSX = comments?.length ? ( <> @@ -162,7 +131,7 @@ export function Profile() { )} - {!!commentsAmount && ( + {commentsAmount > 0 && (
{commentsAmount}
@@ -181,9 +150,15 @@ export function Profile() { /> ))} {isLoadMoreVisible && ( - +
+ {isCommentsLoading ? ( + + ) : ( + + )} +
)} ) : ( @@ -228,7 +203,7 @@ export function Profile() {

- diff --git a/frontend/app/components/spinner/spinner.module.css b/frontend/app/components/spinner/spinner.module.css index f5d5a863..364b8166 100644 --- a/frontend/app/components/spinner/spinner.module.css +++ b/frontend/app/components/spinner/spinner.module.css @@ -7,6 +7,16 @@ animation: spin 1s linear infinite; } +.dark { + border: 2px solid var(--color46); + border-right-color: var(--color37); +} + +:global(.dark) & { + border: 2px solid rgba(var(--white-color), 0.2); + border-right-color: rgb(var(--white-color)); +} + @keyframes spin { 0% { transform: rotate(0deg); diff --git a/frontend/app/components/spinner/spinner.tsx b/frontend/app/components/spinner/spinner.tsx index 5881dee8..f4450243 100644 --- a/frontend/app/components/spinner/spinner.tsx +++ b/frontend/app/components/spinner/spinner.tsx @@ -5,12 +5,16 @@ import { useIntl } from 'react-intl'; import styles from './spinner.module.css'; -export function Spinner() { +type Props = { + color?: 'white' | 'gray'; +}; + +export function Spinner({ color }: Props) { const intl = useIntl(); return (