mirror of
https://github.com/strapi/strapi.git
synced 2026-05-03 16:22:30 +00:00
fix(review-workflows): implement incremental loading in assignee dropdown (#25967)
* fix(review-workflows): fetch all users in assignee dropdown The AssigneeSelect component passed no pagination params to useAdminUsers, which defaults to pageSize 10. Users with more than 10 admin accounts could not see or select all assignees. Pass pageSize: 100 so the dropdown lists all available users. Fixes #25945 * fix(review-workflows): incremental loading + preserve current assignee Replaces the fixed pageSize:100 fetch with the same incremental Combobox pattern used by the admin-users filter (Filters.tsx): grow pageSize on onLoadMore, debounce server-side search via _q, and reset both on close. Also keeps the currently assigned user in the option list when they fall outside the loaded page or active search, so the Combobox never loses its value. * fix(review-workflows): improve assignee dropdown pagination search --------- Co-authored-by: Bassel Kanso <basselkanso82@gmail.com>
This commit is contained in:
+91
-6
@@ -6,6 +6,7 @@ import {
|
||||
useRBAC,
|
||||
useAdminUsers,
|
||||
useQueryParams,
|
||||
useDebounce,
|
||||
} from '@strapi/admin/strapi-admin';
|
||||
import { unstable_useDocument } from '@strapi/content-manager/strapi-admin';
|
||||
import { Combobox, ComboboxOption, Field, VisuallyHidden } from '@strapi/design-system';
|
||||
@@ -19,6 +20,14 @@ import { getDisplayName } from '../../../../../utils/users';
|
||||
|
||||
import { ASSIGNEE_ATTRIBUTE_NAME } from './constants';
|
||||
|
||||
import type { Modules } from '@strapi/types';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
type AdminUserFilters = Modules.EntityService.Params.Pick<'admin::user', 'filters'>['filters'];
|
||||
|
||||
const contains = (value: string) => ({ $containsi: value });
|
||||
|
||||
const AssigneeSelect = ({ isCompact }: { isCompact?: boolean }) => {
|
||||
const {
|
||||
collectionType = '',
|
||||
@@ -35,13 +44,56 @@ const AssigneeSelect = ({ isCompact }: { isCompact?: boolean }) => {
|
||||
} = useRBAC(permissions.settings?.users);
|
||||
const [{ query }] = useQueryParams();
|
||||
const params = React.useMemo(() => buildValidParams(query), [query]);
|
||||
|
||||
const [pageSize, setPageSize] = React.useState(PAGE_SIZE);
|
||||
const [search, setSearch] = React.useState('');
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const searchFilters = React.useMemo(() => {
|
||||
const value = debouncedSearch.trim();
|
||||
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [firstTerm, ...restTerms] = value.split(/\s+/);
|
||||
const rest = restTerms.join(' ');
|
||||
const filters: AdminUserFilters = {
|
||||
$or: [
|
||||
{ firstname: contains(value) },
|
||||
{ lastname: contains(value) },
|
||||
{ username: contains(value) },
|
||||
{ email: contains(value) },
|
||||
],
|
||||
};
|
||||
|
||||
if (rest) {
|
||||
filters.$or = [
|
||||
...(filters.$or ?? []),
|
||||
{
|
||||
$and: [{ firstname: contains(firstTerm) }, { lastname: contains(rest) }],
|
||||
},
|
||||
{
|
||||
$and: [{ firstname: contains(rest) }, { lastname: contains(firstTerm) }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return filters;
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading: isLoadingUsers,
|
||||
isError,
|
||||
} = useAdminUsers(undefined, {
|
||||
skip: isLoadingPermissions || !canRead,
|
||||
});
|
||||
} = useAdminUsers(
|
||||
{
|
||||
pageSize,
|
||||
filters: searchFilters,
|
||||
},
|
||||
{
|
||||
skip: isLoadingPermissions || !canRead,
|
||||
}
|
||||
);
|
||||
const { document } = unstable_useDocument(
|
||||
{
|
||||
collectionType,
|
||||
@@ -54,10 +106,34 @@ const AssigneeSelect = ({ isCompact }: { isCompact?: boolean }) => {
|
||||
}
|
||||
);
|
||||
|
||||
const users = data?.users || [];
|
||||
const users = React.useMemo(() => data?.users ?? [], [data?.users]);
|
||||
const { pageCount = 1, page = 1 } = data?.pagination ?? {};
|
||||
|
||||
const currentAssignee = document ? document[ASSIGNEE_ATTRIBUTE_NAME] : null;
|
||||
|
||||
// Keep the currently assigned user in the options even when they fall outside
|
||||
// the loaded page or the active search — otherwise the Combobox loses its value.
|
||||
const options = React.useMemo(() => {
|
||||
if (!currentAssignee) return users;
|
||||
return users.some((u) => u.id === currentAssignee.id) ? users : [currentAssignee, ...users];
|
||||
}, [users, currentAssignee]);
|
||||
|
||||
const handleOpenChange = (isOpen?: boolean) => {
|
||||
if (!isOpen) {
|
||||
setPageSize(PAGE_SIZE);
|
||||
setSearch('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
setPageSize(pageSize + PAGE_SIZE);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearch(e.currentTarget.value);
|
||||
setPageSize(PAGE_SIZE);
|
||||
};
|
||||
|
||||
const [updateAssignee, { error, isLoading: isMutating }] = useUpdateAssigneeMutation();
|
||||
|
||||
if (!collectionType || !model || !document?.documentId) {
|
||||
@@ -101,6 +177,7 @@ const AssigneeSelect = ({ isCompact }: { isCompact?: boolean }) => {
|
||||
const isDisabled =
|
||||
(!isLoadingPermissions && !isLoadingUsers && users.length === 0) || !document.documentId;
|
||||
const isLoading = isLoadingUsers || isLoadingPermissions || isMutating;
|
||||
const hasMoreItems = page < pageCount;
|
||||
|
||||
const assigneeLabel = formatMessage({
|
||||
id: 'content-manager.reviewWorkflows.assignee.label',
|
||||
@@ -127,11 +204,15 @@ const AssigneeSelect = ({ isCompact }: { isCompact?: boolean }) => {
|
||||
value={currentAssignee ? currentAssignee.id.toString() : null}
|
||||
onChange={handleChange}
|
||||
onClear={() => handleChange(null)}
|
||||
onOpenChange={handleOpenChange}
|
||||
onLoadMore={handleLoadMore}
|
||||
hasMoreItems={hasMoreItems}
|
||||
onInputChange={handleInputChange}
|
||||
placeholder={assigneePlaceholder}
|
||||
loading={isLoading || isLoadingPermissions || isMutating}
|
||||
size="S"
|
||||
>
|
||||
{users.map((user) => {
|
||||
{options.map((user) => {
|
||||
return (
|
||||
<ComboboxOption
|
||||
key={user.id}
|
||||
@@ -171,10 +252,14 @@ const AssigneeSelect = ({ isCompact }: { isCompact?: boolean }) => {
|
||||
value={currentAssignee ? currentAssignee.id.toString() : null}
|
||||
onChange={handleChange}
|
||||
onClear={() => handleChange(null)}
|
||||
onOpenChange={handleOpenChange}
|
||||
onLoadMore={handleLoadMore}
|
||||
hasMoreItems={hasMoreItems}
|
||||
onInputChange={handleInputChange}
|
||||
placeholder={assigneePlaceholder}
|
||||
loading={isLoading || isLoadingPermissions || isMutating}
|
||||
>
|
||||
{users.map((user) => {
|
||||
{options.map((user) => {
|
||||
return (
|
||||
<ComboboxOption
|
||||
key={user.id}
|
||||
|
||||
+175
-1
@@ -1,5 +1,5 @@
|
||||
import { unstable_useDocument } from '@strapi/content-manager/strapi-admin';
|
||||
import { render as renderRTL, waitFor, server, screen, act } from '@tests/utils';
|
||||
import { render as renderRTL, waitFor, server, screen } from '@tests/utils';
|
||||
import { rest } from 'msw';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
|
||||
@@ -56,6 +56,180 @@ describe('AssigneeSelect', () => {
|
||||
await screen.findByText('John Doe');
|
||||
});
|
||||
|
||||
it('loads more users when the combobox reaches the end of the list', async () => {
|
||||
const originalIntersectionObserver = window.IntersectionObserver;
|
||||
const originalScrollHeight = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
'scrollHeight'
|
||||
);
|
||||
const originalClientHeight = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
'clientHeight'
|
||||
);
|
||||
const requestedPageSizes: string[] = [];
|
||||
let intersections = 0;
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
|
||||
configurable: true,
|
||||
value: 100,
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
|
||||
configurable: true,
|
||||
value: 10,
|
||||
});
|
||||
|
||||
window.IntersectionObserver = class MockIntersectionObserver implements IntersectionObserver {
|
||||
readonly root = null;
|
||||
readonly rootMargin = '';
|
||||
readonly scrollMargin = '';
|
||||
readonly thresholds = [];
|
||||
|
||||
constructor(private callback: IntersectionObserverCallback) {}
|
||||
|
||||
disconnect = jest.fn();
|
||||
takeRecords = jest.fn(() => []);
|
||||
unobserve = jest.fn();
|
||||
|
||||
observe = (element: Element) => {
|
||||
if (intersections === 0) {
|
||||
intersections += 1;
|
||||
this.callback(
|
||||
[{ isIntersecting: true, target: element } as IntersectionObserverEntry],
|
||||
this
|
||||
);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get('/admin/users', (req, res, ctx) => {
|
||||
requestedPageSizes.push(req.url.searchParams.get('pageSize') ?? '');
|
||||
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
results: [{ id: 1, firstname: 'John', lastname: 'Doe', roles: [] }],
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageCount: 2,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
const { user } = render();
|
||||
|
||||
await waitFor(() => expect(requestedPageSizes).toContain('10'));
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
await waitFor(() => expect(requestedPageSizes).toContain('20'));
|
||||
} finally {
|
||||
window.IntersectionObserver = originalIntersectionObserver;
|
||||
|
||||
if (originalScrollHeight) {
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', originalScrollHeight);
|
||||
}
|
||||
|
||||
if (originalClientHeight) {
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', originalClientHeight);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('searches admin users with filters that support split display names', async () => {
|
||||
let latestSearchParams: URLSearchParams | undefined;
|
||||
|
||||
jest.mocked(unstable_useDocument).mockReturnValue({
|
||||
components: {},
|
||||
isLoading: false,
|
||||
validate: jest.fn(),
|
||||
getInitialFormValues: jest.fn(),
|
||||
getTitle: jest.fn(),
|
||||
refetch: jest.fn(),
|
||||
document: {
|
||||
documentId: '12345',
|
||||
id: 12345,
|
||||
['strapi_assignee']: null,
|
||||
},
|
||||
});
|
||||
|
||||
server.use(
|
||||
rest.get('/admin/users', (req, res, ctx) => {
|
||||
latestSearchParams = req.url.searchParams;
|
||||
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
results: [{ id: 3, firstname: 'Assignee', lastname: '03', roles: [] }],
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageCount: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
const { user } = render();
|
||||
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
await user.type(screen.getByRole('combobox'), 'Assignee 3');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(latestSearchParams?.get('filters[$or][4][$and][0][firstname][$containsi]')).toBe(
|
||||
'Assignee'
|
||||
)
|
||||
);
|
||||
expect(latestSearchParams?.get('filters[$or][4][$and][1][lastname][$containsi]')).toBe('3');
|
||||
expect(latestSearchParams?.has('_q')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the current assignee in the options when they are not in the loaded users', async () => {
|
||||
jest.mocked(unstable_useDocument).mockReturnValue({
|
||||
document: {
|
||||
documentId: '12345',
|
||||
id: 12345,
|
||||
['strapi_assignee']: {
|
||||
id: 99,
|
||||
firstname: 'Assigned',
|
||||
lastname: 'Outside',
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
components: {},
|
||||
validate: jest.fn(),
|
||||
getInitialFormValues: jest.fn(),
|
||||
getTitle: jest.fn(),
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
server.use(
|
||||
rest.get('/admin/users', (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
results: [{ id: 1, firstname: 'John', lastname: 'Doe', roles: [] }],
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageCount: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
const { user } = render();
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Assigned Outside')).not.toBeInTheDocument());
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
|
||||
await screen.findByText('Assigned Outside');
|
||||
});
|
||||
|
||||
it.skip('renders a select with users, first user is selected', async () => {
|
||||
render();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user