diff --git a/.changeset/eleven-mugs-buy.md b/.changeset/eleven-mugs-buy.md new file mode 100644 index 00000000000..93b8d402377 --- /dev/null +++ b/.changeset/eleven-mugs-buy.md @@ -0,0 +1,5 @@ +--- +"@clerk/ui": patch +--- + +Add infinite loading to organization selection in ``. diff --git a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx index 6802e665807..c71d2d06bb6 100644 --- a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx +++ b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx @@ -35,10 +35,8 @@ function _OAuthConsent() { const { applicationName, logoImageUrl } = useEnvironment().displayConfig; const [isUriModalOpen, setIsUriModalOpen] = useState(false); const { isLoaded: isMembershipsLoaded, userMemberships } = useOrganizationList({ - // TODO(rob): Implement lazy loading in another PR - userMemberships: ctx.enableOrgSelection ? { infinite: true, pageSize: 50 } : undefined, + userMemberships: ctx.enableOrgSelection ? { infinite: true } : undefined, }); - const orgOptions: OrgOption[] = (userMemberships.data ?? []).map(m => ({ value: m.organization.id, label: m.organization.name, @@ -228,6 +226,8 @@ function _OAuthConsent() { options={orgOptions} value={effectiveOrg} onChange={setSelectedOrg} + hasMore={userMemberships.hasNextPage} + onLoadMore={userMemberships.fetchNext} /> )} diff --git a/packages/ui/src/components/OAuthConsent/OrgSelect.tsx b/packages/ui/src/components/OAuthConsent/OrgSelect.tsx index aac8314e78b..5579f3c3de0 100644 --- a/packages/ui/src/components/OAuthConsent/OrgSelect.tsx +++ b/packages/ui/src/components/OAuthConsent/OrgSelect.tsx @@ -1,7 +1,9 @@ import { useRef } from 'react'; +import { InfiniteListSpinner } from '@/ui/common/InfiniteListSpinner'; import { Box, Icon, Image, Text } from '@/ui/customizables'; import { Select, SelectButton, SelectOptionList } from '@/ui/elements/Select'; +import { useInView } from '@/ui/hooks/useInView'; import { Check } from '@/ui/icons'; import { common } from '@/ui/styledSystem'; @@ -15,11 +17,21 @@ type OrgSelectProps = { options: OrgOption[]; value: string | null; onChange: (value: string) => void; + hasMore?: boolean; + onLoadMore?: () => void; }; -export function OrgSelect({ options, value, onChange }: OrgSelectProps) { +export function OrgSelect({ options, value, onChange, hasMore, onLoadMore }: OrgSelectProps) { const buttonRef = useRef(null); const selected = options.find(option => option.value === value); + const { ref: loadMoreRef } = useInView({ + threshold: 0, + onChange: inView => { + if (inView && hasMore) { + onLoadMore?.(); + } + }, + }); return ( ); } diff --git a/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx b/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx index 643e74d1a1d..e871fd41fb1 100644 --- a/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx +++ b/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx @@ -1,12 +1,15 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - import { useOrganizationList } from '@clerk/shared/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render, waitFor } from '@/test/utils'; import { OAuthConsent } from '../OAuthConsent'; +// Captures the onChange injected into SelectOptionList's useInView so tests +// can simulate "user scrolled to the bottom of the org dropdown". +let capturedLoadMoreOnChange: ((inView: boolean) => void) | undefined; + // Default: useOrganizationList returns no memberships and is not loaded. // Individual tests override this mock to inject org data. vi.mock('@clerk/shared/react', async importOriginal => { @@ -15,11 +18,18 @@ vi.mock('@clerk/shared/react', async importOriginal => { ...actual, useOrganizationList: vi.fn().mockReturnValue({ isLoaded: false, - userMemberships: { data: [] }, + userMemberships: { data: [], hasNextPage: false, fetchNext: vi.fn(), isLoading: false }, }), }; }); +vi.mock('@/ui/hooks/useInView', () => ({ + useInView: vi.fn().mockImplementation(({ onChange }: { onChange?: (inView: boolean) => void }) => { + capturedLoadMoreOnChange = onChange; + return { ref: vi.fn(), inView: false }; + }), +})); + const { createFixtures } = bindCreateFixtures('OAuthConsent'); const fakeConsentInfo = { @@ -56,6 +66,7 @@ describe('OAuthConsent', () => { const originalLocation = window.location; beforeEach(() => { + capturedLoadMoreOnChange = undefined; Object.defineProperty(window, 'location', { configurable: true, writable: true, @@ -431,4 +442,54 @@ describe('OAuthConsent', () => { }); }); }); + + describe('org selection — infinite scroll', () => { + const twoOrgs = [ + { organization: { id: 'org_1', name: 'Acme Corp', imageUrl: 'https://img.clerk.com/static/clerk.png' } }, + { organization: { id: 'org_2', name: 'Beta Inc', imageUrl: 'https://img.clerk.com/static/beta.png' } }, + ]; + + it('calls fetchNext when the load-more sentinel enters view and more pages are available', async () => { + const fetchNext = vi.fn(); + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withUser({ email_addresses: ['jane@example.com'] }); + }); + + props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any); + mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) }); + + vi.mocked(useOrganizationList).mockReturnValue({ + isLoaded: true, + userMemberships: { data: twoOrgs, hasNextPage: true, fetchNext, isLoading: false }, + } as any); + + render(, { wrapper }); + + await waitFor(() => expect(capturedLoadMoreOnChange).toBeDefined()); + + capturedLoadMoreOnChange!(true); + expect(fetchNext).toHaveBeenCalledTimes(1); + }); + + it('does not call fetchNext when hasNextPage is false', async () => { + const fetchNext = vi.fn(); + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withUser({ email_addresses: ['jane@example.com'] }); + }); + + props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any); + mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) }); + + vi.mocked(useOrganizationList).mockReturnValue({ + isLoaded: true, + userMemberships: { data: twoOrgs, hasNextPage: false, fetchNext, isLoading: false }, + } as any); + + render(, { wrapper }); + + await waitFor(() => expect(capturedLoadMoreOnChange).toBeDefined()); + capturedLoadMoreOnChange!(true); + expect(fetchNext).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/ui/src/elements/Select.tsx b/packages/ui/src/elements/Select.tsx index 3a32ca5e9d5..1dfb229f32c 100644 --- a/packages/ui/src/elements/Select.tsx +++ b/packages/ui/src/elements/Select.tsx @@ -235,10 +235,12 @@ export const SelectNoResults = (props: PropsOfComponent) => { type SelectOptionListProps = PropsOfComponent & { containerSx?: ThemableCssProp; + footer?: React.ReactNode; + onReachEnd?: () => void; }; export const SelectOptionList = (props: SelectOptionListProps) => { - const { containerSx, sx, ...rest } = props; + const { containerSx, sx, footer, onReachEnd, ...rest } = props; const { popoverCtx, searchInputCtx, @@ -294,6 +296,10 @@ export const SelectOptionList = (props: SelectOptionListProps) => { if (e.key === 'ArrowDown') { e.preventDefault(); if (isOpen) { + if (onReachEnd && focusedIndex === options.length - 1) { + onReachEnd(); + return; + } return setFocusedIndex((i = 0) => (i === options.length - 1 ? 0 : i + 1)); } return onTriggerClick(); @@ -376,6 +382,7 @@ export const SelectOptionList = (props: SelectOptionListProps) => { ); })} {noResultsMessage && options.length === 0 && {noResultsMessage}} + {footer}