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:
parent
0757f270eb
commit
55dc9d997f
8 changed files with 107 additions and 32 deletions
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
if (state.admin && state.adminApi && state.labs.commentImprovements) {
|
try {
|
||||||
data = await state.adminApi.browse({page: 1, postId: options.postId, order});
|
let data;
|
||||||
|
if (state.admin && state.adminApi && state.labs.commentImprovements) {
|
||||||
|
data = await state.adminApi.browse({page: 1, postId: options.postId, order});
|
||||||
|
} else {
|
||||||
|
data = await api.comments.browse({page: 1, postId: options.postId, order});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
comments: [...data.comments],
|
||||||
|
pagination: data.meta.pagination,
|
||||||
|
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
|
||||||
}
|
}
|
||||||
data = await api.comments.browse({page: 1, postId: options.postId, order: order});
|
|
||||||
|
|
||||||
return {
|
|
||||||
comments: [...data.comments],
|
|
||||||
pagination: data.meta.pagination,
|
|
||||||
order
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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>> {
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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: [
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
Loading…
Add table
Reference in a new issue