diff --git a/packages/core/review-workflows/admin/src/routes/content-manager/model/id/components/AssigneeSelect.tsx b/packages/core/review-workflows/admin/src/routes/content-manager/model/id/components/AssigneeSelect.tsx index b282441939..ae4c0909dc 100644 --- a/packages/core/review-workflows/admin/src/routes/content-manager/model/id/components/AssigneeSelect.tsx +++ b/packages/core/review-workflows/admin/src/routes/content-manager/model/id/components/AssigneeSelect.tsx @@ -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) => { + 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 ( { 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 ( { 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();