0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-11 02:12:21 -05:00

Added loading state when ordering comments (#21788)

PLG-280

- Added a loading state implementation when changing the ordering of
comments.
- This improves the overall UX particularly with slower connections.
- Due to the nature of how comments and ordering are handled, we
approached it with a simple state that determines whether it's done
loading or not around the API query.

---------

Co-authored-by: Sanne de Vries <sannedv@protonmail.com>
This commit is contained in:
Ronald Langeveld 2024-12-04 10:16:58 +08:00 committed by GitHub
parent 0757f270eb
commit 55dc9d997f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 107 additions and 32 deletions

View file

@ -29,7 +29,8 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
popup: null, popup: null,
labs: {}, labs: {},
order: 'count__likes desc, created_at desc', order: 'count__likes desc, created_at desc',
adminApi: null adminApi: null,
commentsIsLoading: false
}); });
const iframeRef = React.createRef<HTMLIFrameElement>(); const iframeRef = React.createRef<HTMLIFrameElement>();
@ -173,7 +174,8 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
pagination, pagination,
commentCount: count, commentCount: count,
order, order,
labs: labs labs: labs,
commentsIsLoading: false
}; };
setState(state); setState(state);

View file

@ -81,7 +81,8 @@ export type EditableAppContext = {
popup: Page | null, popup: Page | null,
labs: LabsContextType, labs: LabsContextType,
order: string, order: string,
adminApi: AdminApi | null adminApi: AdminApi | null,
commentsIsLoading?: boolean
} }
export type TranslationFunction = (key: string, replacements?: Record<string, string | number>) => string; export type TranslationFunction = (key: string, replacements?: Record<string, string | number>) => string;

View file

@ -23,18 +23,27 @@ async function loadMoreComments({state, api, options, order}: {state: EditableAp
} }
async function setOrder({state, data: {order}, options, api}: {state: EditableAppContext, data: {order: string}, options: CommentsOptions, api: GhostApi}) { async function setOrder({state, data: {order}, options, api}: {state: EditableAppContext, data: {order: string}, options: CommentsOptions, api: GhostApi}) {
let data; state.commentsIsLoading = true;
try {
let data;
if (state.admin && state.adminApi && state.labs.commentImprovements) { if (state.admin && state.adminApi && state.labs.commentImprovements) {
data = await state.adminApi.browse({page: 1, postId: options.postId, order}); data = await state.adminApi.browse({page: 1, postId: options.postId, order});
} else {
data = await api.comments.browse({page: 1, postId: options.postId, order});
} }
data = await api.comments.browse({page: 1, postId: options.postId, order: order});
return { return {
comments: [...data.comments], comments: [...data.comments],
pagination: data.meta.pagination, pagination: data.meta.pagination,
order order,
commentsIsLoading: false
}; };
} catch (error) {
console.error('Failed to set order:', error); // eslint-disable-line no-console
state.commentsIsLoading = false;
throw error; // Rethrow the error to allow upstream handling
}
} }
async function loadMoreReplies({state, api, data: {comment, limit}, isReply}: {state: EditableAppContext, api: GhostApi, data: {comment: any, limit?: number | 'all'}, isReply: boolean}): Promise<Partial<EditableAppContext>> { async function loadMoreReplies({state, api, data: {comment, limit}, isReply}: {state: EditableAppContext, api: GhostApi, data: {comment: any, limit?: number | 'all'}, isReply: boolean}): Promise<Partial<EditableAppContext>> {

View file

@ -17,8 +17,10 @@ type AnimatedCommentProps = {
}; };
const AnimatedComment: React.FC<AnimatedCommentProps> = ({comment, parent}) => { const AnimatedComment: React.FC<AnimatedCommentProps> = ({comment, parent}) => {
const {commentsIsLoading} = useAppContext();
return ( return (
<Transition <Transition
className={`${commentsIsLoading ? 'animate-pulse' : ''}`}
data-testid="animated-comment" data-testid="animated-comment"
enter="transition-opacity duration-300 ease-out" enter="transition-opacity duration-300 ease-out"
enterFrom="opacity-0" enterFrom="opacity-0"

View file

@ -10,7 +10,7 @@ import {useEffect} from 'react';
const Content = () => { const Content = () => {
const labs = useLabs(); const labs = useLabs();
const {pagination, member, comments, commentCount, commentsEnabled, title, showCount, openFormCount, t} = useAppContext(); const {pagination, member, comments, commentCount, commentsEnabled, title, showCount, openFormCount, commentsIsLoading, t} = useAppContext();
let commentsElements; let commentsElements;
const commentsDataset = comments; const commentsDataset = comments;
@ -64,7 +64,7 @@ const Content = () => {
</span> </span>
</div> </div>
)} )}
<div className="z-10" data-test="comment-elements"> <div className={`z-10 transition-opacity duration-100 ${commentsIsLoading ? 'opacity-50' : ''}`} data-testid="comment-elements">
{commentsElements} {commentsElements}
</div> </div>
<Pagination /> <Pagination />
@ -76,7 +76,7 @@ const Content = () => {
<> <>
<ContentTitle count={commentCount} showCount={showCount} title={title}/> <ContentTitle count={commentCount} showCount={showCount} title={title}/>
<Pagination /> <Pagination />
<div className={!pagination ? 'mt-4' : ''} data-test="comment-elements"> <div className={!pagination ? 'mt-4' : ''} data-testid="comment-elements">
{commentsElements} {commentsElements}
</div> </div>
<div> <div>
@ -92,7 +92,7 @@ const Content = () => {
} }
</div> </div>
{ {
labs?.testFlag ? <div data-testid="this-comes-from-a-flag" style={{display: 'none'}}></div> : null labs?.testFlag ? <div data-testid="this-comes-from-a-flag" style={{display: 'none'}}></div> : null // do not remove
} }
</> </>
) )

View file

@ -1,6 +1,18 @@
module.exports = { module.exports = {
darkMode: 'class', darkMode: 'class',
theme: { theme: {
extend: {
animation: {
heartbeat: 'heartbeat 0.35s ease-in-out forwards',
pulse: 'pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite'
},
keyframes: {
heartbeat: {
'0%, 100%': {transform: 'scale(1)'},
'50%': {transform: 'scale(1.3)'}
}
}
},
screens: { screens: {
sm: '481px', sm: '481px',
md: '768px', md: '768px',
@ -156,15 +168,6 @@ module.exports = {
'0 57.7px 33.4px rgba(0, 0, 0, 0.072)', '0 57.7px 33.4px rgba(0, 0, 0, 0.072)',
'0 138px 80px rgba(0, 0, 0, 0.1)' '0 138px 80px rgba(0, 0, 0, 0.1)'
] ]
},
animation: {
heartbeat: 'heartbeat 0.35s ease-in-out forwards'
},
keyframes: {
heartbeat: {
'0%, 100%': {transform: 'scale(1)'},
'50%': {transform: 'scale(1.3)'}
}
} }
}, },
content: [ content: [

View file

@ -414,15 +414,65 @@ test.describe('Actions', async () => {
'comments-sorting-form-dropdown' 'comments-sorting-form-dropdown'
); );
const newestOption = await sortingDropdown.getByText('Newest'); const optionSelect = await sortingDropdown.getByText('Newest');
await newestOption.click(); mockedApi.setDelay(100);
await optionSelect.click();
const commentsElement = await frame.getByTestId('comment-elements');
const hasOpacity50 = await commentsElement.evaluate(el => el.classList.contains('opacity-50'));
expect(hasOpacity50).toBe(true);
const comments = await frame.getByTestId('comment-component'); const comments = await frame.getByTestId('comment-component');
await expect(comments.nth(0)).toContainText('This is the newest comment'); await expect(comments.nth(0)).toContainText('This is the newest comment');
const hasNoOpacity50 = await commentsElement.evaluate(el => el.classList.contains('opacity-50'));
expect(hasNoOpacity50).toBe(false);
}); });
test('Sorts by oldest', async ({page}) => { test('Sorts by oldest', async ({page}) => {
mockedApi.addComment({
html: '<p>This is comment 2</p>',
created_at: new Date('2024-03-02T00:00:00Z'),
liked: true,
count: {
likes: 52
}
});
mockedApi.addComment({
html: '<p>This is the oldest</p>',
created_at: new Date('2024-02-01T00:00:00Z')
});
mockedApi.addComment({
html: '<p>This is the newest comment</p>',
created_at: new Date('2024-04-03T00:00:00Z')
});
const {frame} = await initializeTest(page, {labs: true});
const sortingForm = await frame.getByTestId('comments-sorting-form');
await sortingForm.click();
const sortingDropdown = await frame.getByTestId(
'comments-sorting-form-dropdown'
);
const optionSelect = await sortingDropdown.getByText('Oldest');
mockedApi.setDelay(100);
await optionSelect.click();
const commentsElement = await frame.getByTestId('comment-elements');
const hasOpacity50 = await commentsElement.evaluate(el => el.classList.contains('opacity-50'));
expect(hasOpacity50).toBe(true);
const comments = await frame.getByTestId('comment-component');
await expect(comments.nth(0)).toContainText('This is the oldest');
const hasNoOpacity50 = await commentsElement.evaluate(el => el.classList.contains('opacity-50'));
expect(hasNoOpacity50).toBe(false);
});
test('has loading state when changing sorting', async ({page}) => {
mockedApi.addComment({ mockedApi.addComment({
html: '<p>This is the oldest</p>', html: '<p>This is the oldest</p>',
created_at: new Date('2024-02-01T00:00:00Z') created_at: new Date('2024-02-01T00:00:00Z')
@ -446,12 +496,19 @@ test.describe('Actions', async () => {
'comments-sorting-form-dropdown' 'comments-sorting-form-dropdown'
); );
const newestOption = await sortingDropdown.getByText('Oldest'); const optionSelect = await sortingDropdown.getByText('Newest');
await newestOption.click(); mockedApi.setDelay(100);
await optionSelect.click();
const commentsElement = await frame.getByTestId('comment-elements');
const hasOpacity50 = await commentsElement.evaluate(el => el.classList.contains('opacity-50'));
expect(hasOpacity50).toBe(true);
const comments = await frame.getByTestId('comment-component'); const comments = await frame.getByTestId('comment-component');
await expect(comments.nth(0)).toContainText('This is the oldest'); await expect(comments.nth(0)).toContainText('This is the newest comment');
const hasNoOpacity50 = await commentsElement.evaluate(el => el.classList.contains('opacity-50'));
expect(hasNoOpacity50).toBe(false);
}); });
}); });

View file

@ -10,7 +10,8 @@ const setup = async () => {
brandColor: site.accent_color, brandColor: site.accent_color,
page: 'signup', page: 'signup',
initStatus: 'success', initStatus: 'success',
showPopup: true showPopup: true,
commentsIsLoading: false
}; };
const {...utils} = render( const {...utils} = render(
<App testState={testState} /> <App testState={testState} />