Slightly improved AdminX pagination behaviour (#18331)

refs https://github.com/TryGhost/Product/issues/3832

---

### <samp>🤖 Generated by Copilot at e1d84b3</samp>

This pull request fixes pagination bugs and improves pagination features
in various components and hooks of the admin settings app. It uses the
`meta` object from the API responses to display and fetch the correct
number of items in the lists of newsletters, tiers, users and actions.
It also simplifies and refactors some of the code to avoid repetition
and unnecessary properties.
This commit is contained in:
Jono M 2023-09-25 15:09:35 +01:00 committed by GitHub
parent 0e35baaf01
commit aa8063d081
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 53 additions and 30 deletions

View File

@ -163,10 +163,10 @@ const Select: React.FC<SelectProps> = ({
components: {DropdownIndicator: dropdownIndicatorComponent, Option, ClearIndicator},
inputId: id,
isClearable: false,
options: options,
options,
placeholder: prompt ? prompt : '',
value: selectedOption,
unstyled,
unstyled: true,
onChange: onSelect
};

View File

@ -74,10 +74,12 @@ export const useBrowseActions = createInfiniteQuery<ActionsResponseType>({
}
});
const meta = pages.at(-1)!.meta;
return {
actions: actions.reverse(),
meta: pages.at(-1)!.meta,
isEnd: pages.at(-1)!.actions.length < pages.at(-1)!.meta.pagination.limit
meta,
isEnd: meta ? meta.pagination.pages === meta.pagination.page : true
};
}
});

View File

@ -51,7 +51,7 @@ const dataType = 'NewslettersResponseType';
export const useBrowseNewsletters = createInfiniteQuery<NewslettersResponseType & {isEnd: boolean}>({
dataType,
path: '/newsletters/',
defaultSearchParams: {include: 'count.active_members,count.posts', limit: '20'},
defaultSearchParams: {include: 'count.active_members,count.posts', limit: '50'},
defaultNextPageParams: (lastPage, otherParams) => ({
...otherParams,
page: (lastPage.meta?.pagination.next || 1).toString()
@ -59,11 +59,12 @@ export const useBrowseNewsletters = createInfiniteQuery<NewslettersResponseType
returnData: (originalData) => {
const {pages} = originalData as InfiniteData<NewslettersResponseType>;
const newsletters = pages.flatMap(page => page.newsletters);
const meta = pages.at(-1)!.meta;
return {
newsletters: newsletters,
meta: pages.at(-1)!.meta,
isEnd: pages.at(-1)!.newsletters.length < (pages.at(-1)!.meta?.pagination.limit || 0)
meta,
isEnd: meta ? meta.pagination.pages === meta.pagination.page : true
};
}
});

View File

@ -34,7 +34,6 @@ const dataType = 'TiersResponseType';
export const useBrowseTiers = createInfiniteQuery<TiersResponseType & {isEnd: boolean}>({
dataType,
path: '/tiers/',
defaultSearchParams: {limit: '20'},
defaultNextPageParams: (lastPage, otherParams) => ({
...otherParams,
page: (lastPage.meta?.pagination.next || 1).toString()
@ -42,11 +41,12 @@ export const useBrowseTiers = createInfiniteQuery<TiersResponseType & {isEnd: bo
returnData: (originalData) => {
const {pages} = originalData as InfiniteData<TiersResponseType>;
const tiers = pages.flatMap(page => page.tiers);
const meta = pages.at(-1)!.meta;
return {
tiers,
meta: pages.at(-1)!.meta,
isEnd: pages.at(-1)!.tiers.length < (pages.at(-1)!.meta?.pagination.limit || 0)
meta,
isEnd: meta ? meta.pagination.pages === meta.pagination.page : true
};
}
});

View File

@ -75,11 +75,12 @@ export const useBrowseUsers = createInfiniteQuery<UsersResponseType & {isEnd: bo
returnData: (originalData) => {
const {pages} = originalData as InfiniteData<UsersResponseType>;
const users = pages.flatMap(page => page.users);
const meta = pages.at(-1)!.meta;
return {
users: users,
meta: pages.at(-1)!.meta,
isEnd: pages.at(-1)!.users.length < (pages.at(-1)!.meta?.pagination.limit || 0)
meta,
isEnd: meta ? meta.pagination.pages === meta.pagination.page : true
};
}
});

View File

@ -13,7 +13,7 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
updateRoute('newsletters/add');
};
const [selectedTab, setSelectedTab] = useState('active-newsletters');
const {data: {newsletters, isEnd} = {}, fetchNextPage} = useBrowseNewsletters();
const {data: {newsletters, meta, isEnd} = {}, fetchNextPage} = useBrowseNewsletters();
const buttons = (
<Button color='green' label='Add newsletter' link linkWithPadding onClick={() => {
@ -43,7 +43,11 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
title='Newsletters'
>
<TabView selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} />
{isEnd === false && <Button label='Load more' link onClick={() => fetchNextPage()} />}
{isEnd === false && <Button
label={`Load more (showing ${newsletters?.length || 0}/${meta?.pagination.total || 0} newsletters)`}
link
onClick={() => fetchNextPage()}
/>}
</SettingGroup>
);
};

View File

@ -206,12 +206,16 @@ const InvitesUserList: React.FC<InviteListProps> = ({users}) => {
const Users: React.FC<{ keywords: string[], highlight?: boolean }> = ({keywords, highlight = true}) => {
const {
totalUsers,
users,
ownerUser,
adminUsers,
editorUsers,
authorUsers,
contributorUsers,
invites
invites,
hasNextPage,
fetchNextPage
} = useStaffUsers();
const {updateRoute} = useRouting();
@ -266,6 +270,11 @@ const Users: React.FC<{ keywords: string[], highlight?: boolean }> = ({keywords,
>
<Owner user={ownerUser} />
<TabView selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} />
{hasNextPage && <Button
label={`Load more (showing ${users.length}/${totalUsers} users)`}
link
onClick={() => fetchNextPage()}
/>}
</SettingGroup>
);
};

View File

@ -27,7 +27,7 @@ const StripeConnectedButton: React.FC<{className?: string; onClick: () => void;}
const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => {
const [selectedTab, setSelectedTab] = useState('active-tiers');
const {settings, config} = useGlobalData();
const {data: {tiers, isEnd} = {}, fetchNextPage} = useBrowseTiers();
const {data: {tiers, meta, isEnd} = {}, fetchNextPage} = useBrowseTiers();
const activeTiers = getActiveTiers(tiers || []);
const archivedTiers = getArchivedTiers(tiers || []);
const {updateRoute} = useRouting();
@ -81,7 +81,11 @@ const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => {
</div>
{content}
{isEnd === false && <Button label='Load more' link onClick={() => fetchNextPage()} />}
{isEnd === false && <Button
label={`Load more (showing ${tiers?.length || 0}/${meta?.pagination.total || 0} tiers)`}
link
onClick={() => fetchNextPage()}
/>}
</SettingGroup>
);
};

View File

@ -5,6 +5,7 @@ import {useGlobalData} from '../components/providers/GlobalDataProvider';
import {useMemo} from 'react';
export type UsersHook = {
totalUsers: number;
users: User[];
invites: UserInvite[];
ownerUser: User;
@ -32,7 +33,7 @@ function getOwnerUser(users: User[]): User {
const useStaffUsers = (): UsersHook => {
const {currentUser} = useGlobalData();
const {data: {users, isEnd} = {users: []}, isLoading: usersLoading, fetchNextPage} = useBrowseUsers();
const {data: {users, meta, isEnd} = {users: []}, isLoading: usersLoading, fetchNextPage} = useBrowseUsers();
const {data: {invites} = {invites: []}, isLoading: invitesLoading} = useBrowseInvites();
const {data: {roles} = {}, isLoading: rolesLoading} = useBrowseRoles();
@ -52,6 +53,7 @@ const useStaffUsers = (): UsersHook => {
}), [invites, roles]);
return {
totalUsers: meta?.pagination.total || 0,
users,
ownerUser,
adminUsers,
@ -61,7 +63,7 @@ const useStaffUsers = (): UsersHook => {
currentUser,
invites: mappedInvites,
isLoading: usersLoading || invitesLoading || rolesLoading,
hasNextPage: isEnd,
hasNextPage: isEnd === false,
fetchNextPage
};
};

View File

@ -5,7 +5,7 @@ test.describe('Newsletter settings', async () => {
test('Supports creating a new newsletter', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=20', response: responseFixtures.newsletters},
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=50', response: responseFixtures.newsletters},
addNewsletter: {method: 'POST', path: '/newsletters/?opt_in_existing=true&include=count.active_members%2Ccount.posts', response: {newsletters: [{
id: 'new-newsletter',
name: 'New newsletter',
@ -48,7 +48,7 @@ test.describe('Newsletter settings', async () => {
test('Supports updating a newsletter', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=20', response: responseFixtures.newsletters},
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=50', response: responseFixtures.newsletters},
editNewsletter: {method: 'PUT', path: `/newsletters/${responseFixtures.newsletters.newsletters[0].id}/?include=count.active_members%2Ccount.posts`, response: {
newsletters: [{
...responseFixtures.newsletters.newsletters[0],
@ -93,7 +93,7 @@ test.describe('Newsletter settings', async () => {
test('Displays a prompt when email verification is required', async ({page}) => {
await mockApi({page, requests: {
...globalDataRequests,
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=20', response: responseFixtures.newsletters},
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=50', response: responseFixtures.newsletters},
editNewsletter: {method: 'PUT', path: `/newsletters/${responseFixtures.newsletters.newsletters[0].id}/?include=count.active_members%2Ccount.posts`, response: {
newsletters: [responseFixtures.newsletters.newsletters[0]],
meta: {
@ -120,7 +120,7 @@ test.describe('Newsletter settings', async () => {
test('Supports archiving newsletters', async ({page}) => {
const activate = await mockApi({page, requests: {
...globalDataRequests,
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=20', response: responseFixtures.newsletters},
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=50', response: responseFixtures.newsletters},
editNewsletter: {method: 'PUT', path: `/newsletters/${responseFixtures.newsletters.newsletters[1].id}/?include=count.active_members%2Ccount.posts`, response: {
newsletters: [{
...responseFixtures.newsletters.newsletters[1],
@ -157,7 +157,7 @@ test.describe('Newsletter settings', async () => {
const archive = await mockApi({page, requests: {
...globalDataRequests,
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=20', response: responseFixtures.newsletters},
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=50', response: responseFixtures.newsletters},
editNewsletter: {method: 'PUT', path: `/newsletters/${responseFixtures.newsletters.newsletters[0].id}/?include=count.active_members%2Ccount.posts`, response: {
newsletters: [{
...responseFixtures.newsletters.newsletters[0],
@ -206,7 +206,7 @@ test.describe('Newsletter settings', async () => {
}
}
},
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=20', response: responseFixtures.newsletters}
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=50', response: responseFixtures.newsletters}
}});
await page.goto('/');

View File

@ -46,7 +46,7 @@ test.describe('Access settings', async () => {
test('Supports selecting specific tiers', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
browseTiers: {method: 'GET', path: '/tiers/?limit=20', response: responseFixtures.tiers},
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
editSettings: {method: 'PUT', path: '/settings/', response: updatedSettingsResponse([
{key: 'default_content_visibility', value: 'tiers'},
{key: 'default_content_visibility_tiers', value: JSON.stringify(responseFixtures.tiers.tiers.map(tier => tier.id))}

View File

@ -13,7 +13,7 @@ test.describe('Tier settings', async () => {
await mockApi({page, requests: {
...globalDataRequests,
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
browseTiers: {method: 'GET', path: '/tiers/?limit=20', response: responseFixtures.tiers}
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers}
}});
await page.goto('/');
@ -55,7 +55,7 @@ test.describe('Tier settings', async () => {
...globalDataRequests,
addTier: {method: 'POST', path: '/tiers/', response: {tiers: [newTier]}},
// This request will be reloaded after the new tier is added
browseTiers: {method: 'GET', path: '/tiers/?limit=20', response: {tiers: [...responseFixtures.tiers.tiers, newTier]}}
browseTiers: {method: 'GET', path: '/tiers/', response: {tiers: [...responseFixtures.tiers.tiers, newTier]}}
}});
await modal.getByRole('button', {name: 'Save & close'}).click();
@ -77,7 +77,7 @@ test.describe('Tier settings', async () => {
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
browseTiers: {method: 'GET', path: '/tiers/?limit=20', response: responseFixtures.tiers},
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
editTier: {method: 'PUT', path: `/tiers/${responseFixtures.tiers.tiers[1].id}/`, response: {
tiers: [{
...responseFixtures.tiers.tiers[1],
@ -140,7 +140,7 @@ test.describe('Tier settings', async () => {
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
browseTiers: {method: 'GET', path: '/tiers/?limit=20', response: responseFixtures.tiers},
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
editTier: {method: 'PUT', path: `/tiers/${responseFixtures.tiers.tiers[0].id}/`, response: {
tiers: [{
...responseFixtures.tiers.tiers[0],