From 3f3994a73e8198038bcda84c1f8a4e852c02a72d Mon Sep 17 00:00:00 2001 From: Dilusha-Madushan Date: Mon, 28 Sep 2026 10:24:46 +0530 Subject: [PATCH] Implement USER_SELECT flow input in the JavaScript core and React SDK Signed-off-by: Dilusha-Madushan # Conflicts: # packages/javascript/src/index.ts # packages/javascript/src/utils/resolveResourceEndpoint.ts --- packages/browser/src/FetchHttpClient.ts | 5 +- .../src/__tests__/FetchHttpClient.test.ts | 57 +++ .../__tests__/createHttpClientFetcher.test.ts | 17 + .../src/utils/createHttpClientFetcher.ts | 1 + .../src/constants/UserSelectConstants.ts | 16 + packages/javascript/src/i18n/models/i18n.ts | 13 + .../javascript/src/i18n/translations/en-US.ts | 13 + .../javascript/src/i18n/translations/fr-FR.ts | 13 + .../javascript/src/i18n/translations/hi-IN.ts | 13 + .../javascript/src/i18n/translations/ja-JP.ts | 13 + .../javascript/src/i18n/translations/pt-BR.ts | 13 + .../javascript/src/i18n/translations/pt-PT.ts | 13 + .../javascript/src/i18n/translations/si-LK.ts | 13 + .../javascript/src/i18n/translations/ta-IN.ts | 13 + .../javascript/src/i18n/translations/te-IN.ts | 13 + packages/javascript/src/index.ts | 19 + .../javascript/src/models/embedded-flow.ts | 3 + .../javascript/src/models/paged-select.ts | 62 +++ packages/javascript/src/models/user-select.ts | 9 + .../__tests__/computeNextPageOffset.test.ts | 30 ++ .../dedupePagedSelectOptions.test.ts | 47 +++ .../__tests__/isAdvancingPageOffset.test.ts | 21 + .../__tests__/mapPagedSelectError.test.ts | 40 ++ .../__tests__/resolveResourceEndpoint.test.ts | 2 + .../__tests__/toPagedSelectOption.test.ts | 122 ++++++ .../__tests__/toPagedSelectOptions.test.ts | 30 ++ .../__tests__/toUserSelectOption.test.ts | 53 +++ .../utils/__tests__/toUserSelectPage.test.ts | 53 +++ .../src/utils/computeNextPageOffset.ts | 18 + .../src/utils/dedupePagedSelectOptions.ts | 31 ++ .../src/utils/isAdvancingPageOffset.ts | 11 + .../src/utils/mapPagedSelectError.ts | 49 +++ .../src/utils/toPagedSelectOption.ts | 117 ++++++ .../src/utils/toPagedSelectOptions.ts | 16 + .../src/utils/toUserSelectOption.ts | 20 + .../javascript/src/utils/toUserSelectPage.ts | 25 ++ packages/nextjs/src/ThunderIDNextClient.ts | 15 + .../__tests__/fetchUsersFromServer.test.ts | 57 +++ .../presentation/UserSelect/UserSelect.tsx | 21 + .../nextjs/src/client/fetchUsersFromServer.ts | 31 ++ packages/nextjs/src/client/index.ts | 3 + .../actions/__tests__/getUsersAction.test.ts | 88 ++++ .../src/server/actions/getUsersAction.ts | 45 ++ packages/node/src/ThunderIDNodeClient.ts | 24 ++ .../src/__tests__/ThunderIDNodeClient.test.ts | 45 ++ packages/nuxt/src/module.ts | 3 + .../src/runtime/components/user/UserSelect.ts | 28 ++ .../src/runtime/constants/NuxtAPIRoutes.ts | 3 + .../server/routes/auth/user/users.get.ts | 69 ++++ packages/nuxt/src/runtime/types.ts | 9 + .../src/runtime/utils/fetchUsersFromServer.ts | 36 ++ .../unit/fetch-users-from-server.test.ts | 61 +++ packages/nuxt/tests/unit/users-get.test.ts | 149 +++++++ .../presentation/auth/AuthOptionFactory.tsx | 36 ++ .../auth/InviteUser/BaseInviteUser.tsx | 13 +- .../auth/InviteUser/InviteUser.tsx | 5 + .../__tests__/BaseInviteUser.test.tsx | 111 +++++ .../InviteUser/__tests__/InviteUser.test.tsx | 111 +++++ .../auth/__tests__/AuthOptionFactory.test.tsx | 108 ++++- .../__tests__/UserSelectFlowWiring.test.tsx | 120 ++++++ .../PagedSelect/PagedSelect.styles.ts | 159 ++++++++ .../primitives/PagedSelect/PagedSelect.tsx | 328 +++++++++++++++ .../__tests__/PagedSelect.test.tsx | 304 ++++++++++++++ .../primitives/UserSelect/BaseUserSelect.tsx | 64 +++ .../primitives/UserSelect/UserSelect.tsx | 19 + .../__tests__/BaseUserSelect.test.tsx | 98 +++++ .../UserSelect/__tests__/UserSelect.test.tsx | 71 ++++ .../hooks/__tests__/usePagedSelect.test.tsx | 247 +++++++++++ packages/react/src/hooks/useFetchUsers.ts | 25 ++ packages/react/src/hooks/usePagedSelect.ts | 193 +++++++++ packages/react/src/index.ts | 27 ++ .../src/utils/createAuthenticatedFetcher.ts | 1 + .../vue/src/__tests__/api/get-users.test.ts | 78 ++++ .../components/base-user-select.test.ts | 250 ++++++++++++ .../invite-user-users-source.test.ts | 109 +++++ .../user-select-flow-wiring.test.ts | 148 +++++++ .../__tests__/components/user-select.test.ts | 84 ++++ .../composables/use-paged-select.test.ts | 227 ++++++++++ packages/vue/src/api/getUsers.ts | 51 +++ .../auth/sign-in/AuthOptionFactoryCore.ts | 33 ++ .../invite-user/BaseInviteUser.ts | 13 +- .../presentation/invite-user/InviteUser.ts | 5 + .../primitives/PagedSelect/PagedSelect.css.ts | 192 +++++++++ .../primitives/PagedSelect/PagedSelect.ts | 386 ++++++++++++++++++ .../primitives/UserSelect/BaseUserSelect.ts | 88 ++++ .../primitives/UserSelect/UserSelect.ts | 61 +++ packages/vue/src/composables/useFetchUsers.ts | 18 + .../vue/src/composables/usePagedSelect.ts | 181 ++++++++ packages/vue/src/index.ts | 7 + packages/vue/src/styles/injectStyles.ts | 2 + 90 files changed, 5657 insertions(+), 7 deletions(-) create mode 100644 packages/browser/src/__tests__/FetchHttpClient.test.ts create mode 100644 packages/javascript/src/constants/UserSelectConstants.ts create mode 100644 packages/javascript/src/models/paged-select.ts create mode 100644 packages/javascript/src/models/user-select.ts create mode 100644 packages/javascript/src/utils/__tests__/computeNextPageOffset.test.ts create mode 100644 packages/javascript/src/utils/__tests__/dedupePagedSelectOptions.test.ts create mode 100644 packages/javascript/src/utils/__tests__/isAdvancingPageOffset.test.ts create mode 100644 packages/javascript/src/utils/__tests__/mapPagedSelectError.test.ts create mode 100644 packages/javascript/src/utils/__tests__/toPagedSelectOption.test.ts create mode 100644 packages/javascript/src/utils/__tests__/toPagedSelectOptions.test.ts create mode 100644 packages/javascript/src/utils/__tests__/toUserSelectOption.test.ts create mode 100644 packages/javascript/src/utils/__tests__/toUserSelectPage.test.ts create mode 100644 packages/javascript/src/utils/computeNextPageOffset.ts create mode 100644 packages/javascript/src/utils/dedupePagedSelectOptions.ts create mode 100644 packages/javascript/src/utils/isAdvancingPageOffset.ts create mode 100644 packages/javascript/src/utils/mapPagedSelectError.ts create mode 100644 packages/javascript/src/utils/toPagedSelectOption.ts create mode 100644 packages/javascript/src/utils/toPagedSelectOptions.ts create mode 100644 packages/javascript/src/utils/toUserSelectOption.ts create mode 100644 packages/javascript/src/utils/toUserSelectPage.ts create mode 100644 packages/nextjs/src/client/__tests__/fetchUsersFromServer.test.ts create mode 100644 packages/nextjs/src/client/components/presentation/UserSelect/UserSelect.tsx create mode 100644 packages/nextjs/src/client/fetchUsersFromServer.ts create mode 100644 packages/nextjs/src/server/actions/__tests__/getUsersAction.test.ts create mode 100644 packages/nextjs/src/server/actions/getUsersAction.ts create mode 100644 packages/nuxt/src/runtime/components/user/UserSelect.ts create mode 100644 packages/nuxt/src/runtime/server/routes/auth/user/users.get.ts create mode 100644 packages/nuxt/src/runtime/utils/fetchUsersFromServer.ts create mode 100644 packages/nuxt/tests/unit/fetch-users-from-server.test.ts create mode 100644 packages/nuxt/tests/unit/users-get.test.ts create mode 100644 packages/react/src/components/presentation/auth/InviteUser/__tests__/BaseInviteUser.test.tsx create mode 100644 packages/react/src/components/presentation/auth/InviteUser/__tests__/InviteUser.test.tsx create mode 100644 packages/react/src/components/presentation/auth/__tests__/UserSelectFlowWiring.test.tsx create mode 100644 packages/react/src/components/primitives/PagedSelect/PagedSelect.styles.ts create mode 100644 packages/react/src/components/primitives/PagedSelect/PagedSelect.tsx create mode 100644 packages/react/src/components/primitives/PagedSelect/__tests__/PagedSelect.test.tsx create mode 100644 packages/react/src/components/primitives/UserSelect/BaseUserSelect.tsx create mode 100644 packages/react/src/components/primitives/UserSelect/UserSelect.tsx create mode 100644 packages/react/src/components/primitives/UserSelect/__tests__/BaseUserSelect.test.tsx create mode 100644 packages/react/src/components/primitives/UserSelect/__tests__/UserSelect.test.tsx create mode 100644 packages/react/src/hooks/__tests__/usePagedSelect.test.tsx create mode 100644 packages/react/src/hooks/useFetchUsers.ts create mode 100644 packages/react/src/hooks/usePagedSelect.ts create mode 100644 packages/vue/src/__tests__/api/get-users.test.ts create mode 100644 packages/vue/src/__tests__/components/base-user-select.test.ts create mode 100644 packages/vue/src/__tests__/components/invite-user-users-source.test.ts create mode 100644 packages/vue/src/__tests__/components/user-select-flow-wiring.test.ts create mode 100644 packages/vue/src/__tests__/components/user-select.test.ts create mode 100644 packages/vue/src/__tests__/composables/use-paged-select.test.ts create mode 100644 packages/vue/src/api/getUsers.ts create mode 100644 packages/vue/src/components/primitives/PagedSelect/PagedSelect.css.ts create mode 100644 packages/vue/src/components/primitives/PagedSelect/PagedSelect.ts create mode 100644 packages/vue/src/components/primitives/UserSelect/BaseUserSelect.ts create mode 100644 packages/vue/src/components/primitives/UserSelect/UserSelect.ts create mode 100644 packages/vue/src/composables/useFetchUsers.ts create mode 100644 packages/vue/src/composables/usePagedSelect.ts diff --git a/packages/browser/src/FetchHttpClient.ts b/packages/browser/src/FetchHttpClient.ts index 5c31fad5..06deb11a 100644 --- a/packages/browser/src/FetchHttpClient.ts +++ b/packages/browser/src/FetchHttpClient.ts @@ -77,9 +77,12 @@ export class FetchHttpClient extends HttpClient { method: (method ?? 'GET').toUpperCase(), }); } catch (networkError: any) { + // A caller-aborted request rejects with an 'AbortError'; keep it distinguishable from a network failure. + const isAbort: boolean = (networkError as Error | undefined)?.name === 'AbortError'; throw Object.assign(new Error(networkError.message), { - code: 'NETWORK_ERROR', + code: isAbort ? 'ABORT_ERROR' : 'NETWORK_ERROR', config, + name: isAbort ? 'AbortError' : 'Error', } as Partial); } diff --git a/packages/browser/src/__tests__/FetchHttpClient.test.ts b/packages/browser/src/__tests__/FetchHttpClient.test.ts new file mode 100644 index 00000000..95ad8c95 --- /dev/null +++ b/packages/browser/src/__tests__/FetchHttpClient.test.ts @@ -0,0 +1,57 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import type {HttpRequestConfig} from '@thunderid/javascript'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import FetchHttpClient from '../FetchHttpClient'; + +/** `transport` is protected on purpose; these tests exercise it directly, below the request pipeline. */ +const transport = (client: FetchHttpClient, config: HttpRequestConfig): Promise => + (client as unknown as {transport: (c: HttpRequestConfig) => Promise}).transport(config); + +describe('FetchHttpClient', () => { + beforeEach(() => { + FetchHttpClient.destroyInstance(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('forwards a caller-supplied AbortSignal to the underlying fetch call', async () => { + const controller = new AbortController(); + const fetchMock = vi.fn().mockResolvedValue({ + headers: new Headers({'content-type': 'application/json'}), + json: () => Promise.resolve({ok: true}), + ok: true, + status: 200, + statusText: 'OK', + }); + vi.stubGlobal('fetch', fetchMock); + + const client: FetchHttpClient = FetchHttpClient.getInstance(); + await transport(client, {method: 'GET', signal: controller.signal, url: 'https://localhost:8090/x'}); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://localhost:8090/x', + expect.objectContaining({signal: controller.signal}), + ); + }); + + it('surfaces an aborted request as a distinguishable AbortError, not a generic NETWORK_ERROR', async () => { + const controller = new AbortController(); + const abortError = new DOMException('The operation was aborted.', 'AbortError'); + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => Promise.reject(abortError)), + ); + + const client: FetchHttpClient = FetchHttpClient.getInstance(); + controller.abort(); + + await expect( + transport(client, {method: 'GET', signal: controller.signal, url: 'https://localhost:8090/x'}), + ).rejects.toMatchObject({code: 'ABORT_ERROR', name: 'AbortError'}); + }); +}); diff --git a/packages/browser/src/utils/__tests__/createHttpClientFetcher.test.ts b/packages/browser/src/utils/__tests__/createHttpClientFetcher.test.ts index c9661a8d..fc5a826d 100644 --- a/packages/browser/src/utils/__tests__/createHttpClientFetcher.test.ts +++ b/packages/browser/src/utils/__tests__/createHttpClientFetcher.test.ts @@ -62,4 +62,21 @@ describe('createHttpClientFetcher', () => { expect(getInstanceSpy).toHaveBeenCalledWith(3); }); + + it('forwards the caller-supplied AbortSignal so cancellation reaches the underlying request', async () => { + mockRequest.mockResolvedValueOnce({data: {ok: true}, status: 200, statusText: 'OK'}); + const controller = new AbortController(); + + await createHttpClientFetcher()('https://localhost:8090/x', {method: 'POST', signal: controller.signal}); + + expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({signal: controller.signal})); + }); + + it('does not attach a signal when the caller did not supply one', async () => { + mockRequest.mockResolvedValueOnce({data: {ok: true}, status: 200, statusText: 'OK'}); + + await createHttpClientFetcher()('https://localhost:8090/x', {method: 'POST'}); + + expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({signal: undefined})); + }); }); diff --git a/packages/browser/src/utils/createHttpClientFetcher.ts b/packages/browser/src/utils/createHttpClientFetcher.ts index d5901603..37c71330 100644 --- a/packages/browser/src/utils/createHttpClientFetcher.ts +++ b/packages/browser/src/utils/createHttpClientFetcher.ts @@ -43,6 +43,7 @@ const createHttpClientFetcher = (instanceId = 0): ((url: string, config: Request data: config.body ? JSON.parse(config.body as string) : undefined, headers: config.headers as Record, method: config.method || 'POST', + signal: config.signal, url, } as HttpRequestConfig); diff --git a/packages/javascript/src/constants/UserSelectConstants.ts b/packages/javascript/src/constants/UserSelectConstants.ts new file mode 100644 index 00000000..17fc3380 --- /dev/null +++ b/packages/javascript/src/constants/UserSelectConstants.ts @@ -0,0 +1,16 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Defaults for turning directory users into USER_SELECT options. + */ +const UserSelectConstants: { + LABEL_ATTRIBUTES: readonly string[]; +} = { + /** + * Fields tried, in order, for a user's visible label before falling back to the user's ID. + */ + LABEL_ATTRIBUTES: ['display', 'username', 'email'], +}; + +export default UserSelectConstants; diff --git a/packages/javascript/src/i18n/models/i18n.ts b/packages/javascript/src/i18n/models/i18n.ts index cae903df..9d25a62b 100644 --- a/packages/javascript/src/i18n/models/i18n.ts +++ b/packages/javascript/src/i18n/models/i18n.ts @@ -46,6 +46,19 @@ export interface I18nTranslations { 'elements.fields.organization.select.label': string; 'elements.fields.organization.select.placeholder': string; + /* Paged Select */ + 'elements.fields.paged_select.aria_label': string; + 'elements.fields.paged_select.empty': string; + 'elements.fields.paged_select.load_error': string; + 'elements.fields.paged_select.load_more': string; + 'elements.fields.paged_select.loaded': string; + 'elements.fields.paged_select.loading': string; + 'elements.fields.paged_select.loading_more': string; + 'elements.fields.paged_select.retry': string; + 'elements.fields.user_select.aria_label': string; + 'elements.fields.user_select.empty': string; + 'elements.fields.user_select.loading': string; + /* Validation */ 'validations.required.field.error': string; 'validation.pattern.invalid': string; diff --git a/packages/javascript/src/i18n/translations/en-US.ts b/packages/javascript/src/i18n/translations/en-US.ts index 54328b31..de1c4ef9 100644 --- a/packages/javascript/src/i18n/translations/en-US.ts +++ b/packages/javascript/src/i18n/translations/en-US.ts @@ -46,6 +46,19 @@ const translations: I18nTranslations = { 'elements.fields.organization.select.label': 'Select Organization', 'elements.fields.organization.select.placeholder': 'Choose an organization', + /* Paged Select */ + 'elements.fields.paged_select.aria_label': 'Select an option', + 'elements.fields.paged_select.empty': 'No results found.', + 'elements.fields.paged_select.load_error': 'Failed to load options.', + 'elements.fields.paged_select.load_more': 'Load more', + 'elements.fields.paged_select.loaded': '{count} loaded.', + 'elements.fields.paged_select.loading': 'Loading…', + 'elements.fields.paged_select.loading_more': 'Loading more…', + 'elements.fields.paged_select.retry': 'Retry', + 'elements.fields.user_select.aria_label': 'Select a user', + 'elements.fields.user_select.empty': 'No users found.', + 'elements.fields.user_select.loading': 'Loading users…', + /* Validation */ 'validations.required.field.error': 'This field is required', 'validation.pattern.invalid': 'This value does not match the required format.', diff --git a/packages/javascript/src/i18n/translations/fr-FR.ts b/packages/javascript/src/i18n/translations/fr-FR.ts index 5178f2f7..45916856 100644 --- a/packages/javascript/src/i18n/translations/fr-FR.ts +++ b/packages/javascript/src/i18n/translations/fr-FR.ts @@ -46,6 +46,19 @@ const translations: I18nTranslations = { 'elements.fields.organization.select.label': "Sélectionner l'organisation", 'elements.fields.organization.select.placeholder': 'Choisissez une organisation', + /* Paged Select */ + 'elements.fields.paged_select.aria_label': 'Sélectionner une option', + 'elements.fields.paged_select.empty': 'Aucun résultat trouvé.', + 'elements.fields.paged_select.load_error': 'Échec du chargement des options.', + 'elements.fields.paged_select.load_more': 'Charger plus', + 'elements.fields.paged_select.loaded': '{count} chargé(s).', + 'elements.fields.paged_select.loading': 'Chargement…', + 'elements.fields.paged_select.loading_more': 'Chargement supplémentaire…', + 'elements.fields.paged_select.retry': 'Réessayer', + 'elements.fields.user_select.aria_label': 'Sélectionner un utilisateur', + 'elements.fields.user_select.empty': 'Aucun utilisateur trouvé.', + 'elements.fields.user_select.loading': 'Chargement des utilisateurs…', + /* Validation */ 'validations.required.field.error': 'Ce champ est obligatoire', 'validation.pattern.invalid': 'Cette valeur ne correspond pas au format requis.', diff --git a/packages/javascript/src/i18n/translations/hi-IN.ts b/packages/javascript/src/i18n/translations/hi-IN.ts index 0afc7fec..c95e0472 100644 --- a/packages/javascript/src/i18n/translations/hi-IN.ts +++ b/packages/javascript/src/i18n/translations/hi-IN.ts @@ -46,6 +46,19 @@ const translations: I18nTranslations = { 'elements.fields.organization.select.label': 'संगठन चुनें', 'elements.fields.organization.select.placeholder': 'एक संगठन चुनें', + /* Paged Select */ + 'elements.fields.paged_select.aria_label': 'एक विकल्प चुनें', + 'elements.fields.paged_select.empty': 'कोई परिणाम नहीं मिला।', + 'elements.fields.paged_select.load_error': 'विकल्प लोड करने में विफल।', + 'elements.fields.paged_select.load_more': 'और लोड करें', + 'elements.fields.paged_select.loaded': '{count} लोड हुए।', + 'elements.fields.paged_select.loading': 'लोड हो रहा है…', + 'elements.fields.paged_select.loading_more': 'और लोड हो रहा है…', + 'elements.fields.paged_select.retry': 'पुनः प्रयास करें', + 'elements.fields.user_select.aria_label': 'एक उपयोगकर्ता चुनें', + 'elements.fields.user_select.empty': 'कोई उपयोगकर्ता नहीं मिला।', + 'elements.fields.user_select.loading': 'उपयोगकर्ता लोड हो रहे हैं…', + /* Validation */ 'validations.required.field.error': 'यह फील्ड आवश्यक है', 'validation.pattern.invalid': 'यह मान आवश्यक प्रारूप से मेल नहीं खाता।', diff --git a/packages/javascript/src/i18n/translations/ja-JP.ts b/packages/javascript/src/i18n/translations/ja-JP.ts index b26ee3a1..258aa351 100644 --- a/packages/javascript/src/i18n/translations/ja-JP.ts +++ b/packages/javascript/src/i18n/translations/ja-JP.ts @@ -46,6 +46,19 @@ const translations: I18nTranslations = { 'elements.fields.organization.select.label': '組織を選択', 'elements.fields.organization.select.placeholder': '組織を選択してください', + /* Paged Select */ + 'elements.fields.paged_select.aria_label': 'オプションを選択', + 'elements.fields.paged_select.empty': '該当する結果がありません。', + 'elements.fields.paged_select.load_error': 'オプションの読み込みに失敗しました。', + 'elements.fields.paged_select.load_more': 'さらに読み込む', + 'elements.fields.paged_select.loaded': '{count} 件読み込みました。', + 'elements.fields.paged_select.loading': '読み込み中…', + 'elements.fields.paged_select.loading_more': 'さらに読み込み中…', + 'elements.fields.paged_select.retry': '再試行', + 'elements.fields.user_select.aria_label': 'ユーザーを選択', + 'elements.fields.user_select.empty': '該当するユーザーが見つかりません。', + 'elements.fields.user_select.loading': 'ユーザーを読み込み中…', + /* Validation */ 'validations.required.field.error': 'この項目は必須です', 'validation.pattern.invalid': 'この値は必要な形式と一致しません。', diff --git a/packages/javascript/src/i18n/translations/pt-BR.ts b/packages/javascript/src/i18n/translations/pt-BR.ts index de2c4a76..08e799b9 100644 --- a/packages/javascript/src/i18n/translations/pt-BR.ts +++ b/packages/javascript/src/i18n/translations/pt-BR.ts @@ -46,6 +46,19 @@ const translations: I18nTranslations = { 'elements.fields.organization.select.label': 'Selecionar Organização', 'elements.fields.organization.select.placeholder': 'Selecione uma organização', + /* Paged Select */ + 'elements.fields.paged_select.aria_label': 'Selecionar uma opção', + 'elements.fields.paged_select.empty': 'Nenhum resultado encontrado.', + 'elements.fields.paged_select.load_error': 'Falha ao carregar as opções.', + 'elements.fields.paged_select.load_more': 'Carregar mais', + 'elements.fields.paged_select.loaded': '{count} carregado(s).', + 'elements.fields.paged_select.loading': 'Carregando…', + 'elements.fields.paged_select.loading_more': 'Carregando mais…', + 'elements.fields.paged_select.retry': 'Tentar novamente', + 'elements.fields.user_select.aria_label': 'Selecionar um usuário', + 'elements.fields.user_select.empty': 'Nenhum usuário encontrado.', + 'elements.fields.user_select.loading': 'Carregando usuários…', + /* Validation */ 'validations.required.field.error': 'Este campo é obrigatório', 'validation.pattern.invalid': 'Este valor não corresponde ao formato necessário.', diff --git a/packages/javascript/src/i18n/translations/pt-PT.ts b/packages/javascript/src/i18n/translations/pt-PT.ts index 0c1e495a..b1724753 100644 --- a/packages/javascript/src/i18n/translations/pt-PT.ts +++ b/packages/javascript/src/i18n/translations/pt-PT.ts @@ -46,6 +46,19 @@ const translations: I18nTranslations = { 'elements.fields.organization.select.label': 'Selecionar Organização', 'elements.fields.organization.select.placeholder': 'Selecione uma organização', + /* Paged Select */ + 'elements.fields.paged_select.aria_label': 'Selecionar uma opção', + 'elements.fields.paged_select.empty': 'Nenhum resultado encontrado.', + 'elements.fields.paged_select.load_error': 'Falha ao carregar as opções.', + 'elements.fields.paged_select.load_more': 'Carregar mais', + 'elements.fields.paged_select.loaded': '{count} carregado(s).', + 'elements.fields.paged_select.loading': 'A carregar…', + 'elements.fields.paged_select.loading_more': 'A carregar mais…', + 'elements.fields.paged_select.retry': 'Tentar novamente', + 'elements.fields.user_select.aria_label': 'Selecionar um utilizador', + 'elements.fields.user_select.empty': 'Nenhum utilizador encontrado.', + 'elements.fields.user_select.loading': 'A carregar utilizadores…', + /* Validation */ 'validations.required.field.error': 'Este campo é obrigatório', 'validation.pattern.invalid': 'Este valor não corresponde ao formato necessário.', diff --git a/packages/javascript/src/i18n/translations/si-LK.ts b/packages/javascript/src/i18n/translations/si-LK.ts index 562d9023..f760a936 100644 --- a/packages/javascript/src/i18n/translations/si-LK.ts +++ b/packages/javascript/src/i18n/translations/si-LK.ts @@ -46,6 +46,19 @@ const translations: I18nTranslations = { 'elements.fields.organization.select.label': 'සංවිධානය තෝරන්න', 'elements.fields.organization.select.placeholder': 'සංවිධානයක් සැළුම් කරන්න', + /* Paged Select */ + 'elements.fields.paged_select.aria_label': 'විකල්පයක් තෝරන්න', + 'elements.fields.paged_select.empty': 'ප්‍රතිඵල හමු නොවීය.', + 'elements.fields.paged_select.load_error': 'විකල්ප පූරණය කිරීම අසාර්ථක විය.', + 'elements.fields.paged_select.load_more': 'තවත් පූරණය කරන්න', + 'elements.fields.paged_select.loaded': '{count} පූරණය විය.', + 'elements.fields.paged_select.loading': 'පූරණය වේ…', + 'elements.fields.paged_select.loading_more': 'තවත් පූරණය වේ…', + 'elements.fields.paged_select.retry': 'නැවත උත්සාහ කරන්න', + 'elements.fields.user_select.aria_label': 'පරිශීලකයෙකු තෝරන්න', + 'elements.fields.user_select.empty': 'පරිශීලකයන් හමු නොවීය.', + 'elements.fields.user_select.loading': 'පරිශීලකයන් පූරණය වේ…', + /* Validation */ 'validations.required.field.error': 'මෙම ක්ෂේත්‍රය අවශ්‍යයි', 'validation.pattern.invalid': 'මෙම අගය අවශ්‍ය ආකෘතියට නොගැලපේ.', diff --git a/packages/javascript/src/i18n/translations/ta-IN.ts b/packages/javascript/src/i18n/translations/ta-IN.ts index 758db99e..bc3a76a8 100644 --- a/packages/javascript/src/i18n/translations/ta-IN.ts +++ b/packages/javascript/src/i18n/translations/ta-IN.ts @@ -46,6 +46,19 @@ const translations: I18nTranslations = { 'elements.fields.organization.select.label': 'அமைப்பை தேர்ந்தெடு', 'elements.fields.organization.select.placeholder': 'அமைப்பை தெரிந்தெடுக்கவும்', + /* Paged Select */ + 'elements.fields.paged_select.aria_label': 'ஒரு விருப்பத்தைத் தேர்ந்தெடுக்கவும்', + 'elements.fields.paged_select.empty': 'முடிவுகள் எதுவும் கிடைக்கவில்லை.', + 'elements.fields.paged_select.load_error': 'விருப்பங்களை ஏற்ற முடியவில்லை.', + 'elements.fields.paged_select.load_more': 'மேலும் ஏற்றவும்', + 'elements.fields.paged_select.loaded': '{count} ஏற்றப்பட்டது.', + 'elements.fields.paged_select.loading': 'ஏற்றுகிறது…', + 'elements.fields.paged_select.loading_more': 'மேலும் ஏற்றுகிறது…', + 'elements.fields.paged_select.retry': 'மீண்டும் முயற்சிக்கவும்', + 'elements.fields.user_select.aria_label': 'ஒரு பயனரைத் தேர்ந்தெடுக்கவும்', + 'elements.fields.user_select.empty': 'பயனர்கள் எவரும் கிடைக்கவில்லை.', + 'elements.fields.user_select.loading': 'பயனர்களை ஏற்றுகிறது…', + /* Validation */ 'validations.required.field.error': 'இந்த புலம் தேவை', 'validation.pattern.invalid': 'இந்த மதிப்பு தேவையான வடிவத்துடன் பொருந்தவில்லை.', diff --git a/packages/javascript/src/i18n/translations/te-IN.ts b/packages/javascript/src/i18n/translations/te-IN.ts index c23be0ca..af4c481b 100644 --- a/packages/javascript/src/i18n/translations/te-IN.ts +++ b/packages/javascript/src/i18n/translations/te-IN.ts @@ -46,6 +46,19 @@ const translations: I18nTranslations = { 'elements.fields.organization.select.label': 'ఆర్గనైజేషన్‌ను ఎంచుకోండి', 'elements.fields.organization.select.placeholder': 'సంస్థను ఎంచుకోండి', + /* Paged Select */ + 'elements.fields.paged_select.aria_label': 'ఒక ఎంపికను ఎంచుకోండి', + 'elements.fields.paged_select.empty': 'ఫలితాలు కనుగొనబడలేదు.', + 'elements.fields.paged_select.load_error': 'ఎంపికలను లోడ్ చేయడంలో విఫలమైంది.', + 'elements.fields.paged_select.load_more': 'మరిన్ని లోడ్ చేయండి', + 'elements.fields.paged_select.loaded': '{count} లోడ్ అయ్యాయి.', + 'elements.fields.paged_select.loading': 'లోడ్ అవుతోంది…', + 'elements.fields.paged_select.loading_more': 'మరిన్ని లోడ్ అవుతోంది…', + 'elements.fields.paged_select.retry': 'మళ్లీ ప్రయత్నించండి', + 'elements.fields.user_select.aria_label': 'ఒక వినియోగదారుని ఎంచుకోండి', + 'elements.fields.user_select.empty': 'వినియోగదారులు కనుగొనబడలేదు.', + 'elements.fields.user_select.loading': 'వినియోగదారులను లోడ్ చేస్తోంది…', + /* Validation */ 'validations.required.field.error': 'ఈ ఫీల్డ్ అవసరం', 'validation.pattern.invalid': 'ఈ విలువ అవసరమైన ఆకృతికి సరిపోలడం లేదు.', diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 2793971d..70acd8e8 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -65,6 +65,7 @@ export {default as ConsentConstants} from './constants/ConsentConstants'; export {default as ApplicationQueryKeys} from './constants/ApplicationQueryKeys'; export {default as UserQueryKeys} from './constants/UserQueryKeys'; export {default as AgentQueryKeys} from './constants/AgentQueryKeys'; +export {default as UserSelectConstants} from './constants/UserSelectConstants'; export {default as ThunderIDError} from './errors/ThunderIDError'; export {default as ThunderIDAPIError} from './errors/ThunderIDAPIError'; @@ -97,6 +98,15 @@ export type { FieldError, PrefixOption, } from './models/embedded-flow'; +export type { + FetchPagedOptions, + PagedSelectOption, + PagedSelectOptionDefaults, + PagedSelectOptionMapping, + PagedSelectPage, + PagedSelectRequest, +} from './models/paged-select'; +export type {FetchUsers, UserSelectOptionMapping} from './models/user-select'; export {EmbeddedSignInFlowStatus, EmbeddedSignInFlowType} from './models/embedded-signin-flow'; export type { ExtendedEmbeddedSignInFlowResponse, @@ -265,6 +275,13 @@ export {default as removeTrailingSlash} from './utils/removeTrailingSlash'; export {default as resolveFieldName} from './utils/resolveFieldName'; export {default as resolveResourceEndpoint} from './utils/resolveResourceEndpoint'; export type {ResourceEndpointKey, ResourceEndpointConfig} from './utils/resolveResourceEndpoint'; +export {default as computeNextPageOffset} from './utils/computeNextPageOffset'; +export {default as isAdvancingPageOffset} from './utils/isAdvancingPageOffset'; +export {default as dedupePagedSelectOptions} from './utils/dedupePagedSelectOptions'; +export {default as toPagedSelectOption} from './utils/toPagedSelectOption'; +export {default as toPagedSelectOptions} from './utils/toPagedSelectOptions'; +export {default as toUserSelectOption} from './utils/toUserSelectOption'; +export {default as toUserSelectPage} from './utils/toUserSelectPage'; export {default as evaluatePasswordPolicy} from './utils/evaluatePasswordPolicy'; export type {PasswordPolicy, PasswordRuleResult} from './utils/evaluatePasswordPolicy'; export {default as evaluateChangePasswordForm} from './utils/evaluateChangePasswordForm'; @@ -273,6 +290,8 @@ export {default as resolveChangeCredentialPolicy} from './utils/resolveChangeCre export {default as supportsCredential} from './utils/supportsCredential'; export {default as mapCredentialUpdateError} from './utils/mapCredentialUpdateError'; export type {CredentialUpdateErrorField, CredentialUpdateErrorResult} from './utils/mapCredentialUpdateError'; +export {default as mapPagedSelectError} from './utils/mapPagedSelectError'; +export type {PagedSelectErrorResult} from './utils/mapPagedSelectError'; export {default as resolveMeta} from './utils/resolveMeta'; export {default as resolveFlowTemplateLiterals} from './utils/resolveFlowTemplateLiterals'; export {default as countryCodeToFlagEmoji} from './utils/countryCodeToFlagEmoji'; diff --git a/packages/javascript/src/models/embedded-flow.ts b/packages/javascript/src/models/embedded-flow.ts index 765cb1f1..b3759123 100644 --- a/packages/javascript/src/models/embedded-flow.ts +++ b/packages/javascript/src/models/embedded-flow.ts @@ -132,6 +132,9 @@ export enum EmbeddedFlowComponentType { /** QR code display component for wallet-based flows (e.g. OpenID4VP) */ QrCode = 'QR_CODE', + + /** Single-user picker with incremental, paginated directory loading */ + UserSelect = 'USER_SELECT', } /** diff --git a/packages/javascript/src/models/paged-select.ts b/packages/javascript/src/models/paged-select.ts new file mode 100644 index 00000000..f6bb6874 --- /dev/null +++ b/packages/javascript/src/models/paged-select.ts @@ -0,0 +1,62 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import type {EmbeddedFlowComponent} from './embedded-flow'; + +/** + * A selectable option: `label` is shown, `value` is submitted. + */ +export interface PagedSelectOption { + disabled?: boolean; + label: string; + value: string; +} + +/** + * One page request sent to a {@link FetchPagedOptions} loader. + */ +export interface PagedSelectRequest { + component: EmbeddedFlowComponent; + filter?: string; + limit: number; + offset: number; + signal: AbortSignal; +} + +/** + * One page returned by a loader. Return ready-made `options`, or raw `items` for the picker to + * convert with its `mapping`. `nextOffset` is `null` on the last page, otherwise it must be greater + * than the request's `offset`. + */ +export interface PagedSelectPage { + items?: unknown[]; + nextOffset: number | null; + options?: PagedSelectOption[]; + totalResults?: number; +} + +export type FetchPagedOptions = (request: PagedSelectRequest) => Promise; + +/** + * Chooses which field of each raw item is the label and which is the value. + * + * A field is a key of the item, or a dotted path into it (`'name.given'`); keys inside an + * `attributes` object are found too. Plain strings and numbers need no mapping. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- the item shape belongs to the consumer's loader +export interface PagedSelectOptionMapping { + getLabel?: (item: T) => string | undefined; + getValue?: (item: T) => string | undefined; + /** Fields tried in order for the label; the first non-empty one that is not the item's `id` wins. */ + labelAttributes?: string[]; + /** Field submitted as the value. When set, items without it are left out. */ + valueAttribute?: string; +} + +/** + * What a specialised picker (users, emails, ...) assumes about its items when none is configured. + */ +export interface PagedSelectOptionDefaults { + /** Label fields tried for items that have an `id`. */ + labelAttributes?: readonly string[]; +} diff --git a/packages/javascript/src/models/user-select.ts b/packages/javascript/src/models/user-select.ts new file mode 100644 index 00000000..7c1ef789 --- /dev/null +++ b/packages/javascript/src/models/user-select.ts @@ -0,0 +1,9 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import type {ManagedUser} from './managed-user'; +import type {FetchPagedOptions, PagedSelectOptionMapping} from './paged-select'; + +export type FetchUsers = FetchPagedOptions; + +export type UserSelectOptionMapping = PagedSelectOptionMapping; diff --git a/packages/javascript/src/utils/__tests__/computeNextPageOffset.test.ts b/packages/javascript/src/utils/__tests__/computeNextPageOffset.test.ts new file mode 100644 index 00000000..5f12e877 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/computeNextPageOffset.test.ts @@ -0,0 +1,30 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import computeNextPageOffset from '../computeNextPageOffset'; + +describe('computeNextPageOffset', () => { + it('advances by the returned count', () => { + expect(computeNextPageOffset(0, 30)).toBe(30); + expect(computeNextPageOffset(30, 30, 75)).toBe(60); + }); + + it('stops when the page is empty', () => { + expect(computeNextPageOffset(60, 0, 75)).toBeNull(); + }); + + it('stops when the next offset reaches totalResults', () => { + expect(computeNextPageOffset(60, 15, 75)).toBeNull(); + }); + + it('continues when totalResults is unknown', () => { + expect(computeNextPageOffset(60, 15)).toBe(75); + }); + + it('treats malformed input as exhausted', () => { + expect(computeNextPageOffset(-1, 30)).toBeNull(); + expect(computeNextPageOffset(0, -1)).toBeNull(); + expect(computeNextPageOffset(1.5, 30)).toBeNull(); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/dedupePagedSelectOptions.test.ts b/packages/javascript/src/utils/__tests__/dedupePagedSelectOptions.test.ts new file mode 100644 index 00000000..82cd8ef6 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/dedupePagedSelectOptions.test.ts @@ -0,0 +1,47 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import type {PagedSelectOption} from '../../models/paged-select'; +import dedupePagedSelectOptions from '../dedupePagedSelectOptions'; + +describe('dedupePagedSelectOptions', () => { + it('appends new options after existing ones', () => { + const existing: PagedSelectOption[] = [{label: 'Alice', value: 'user-1'}]; + const incoming: PagedSelectOption[] = [{label: 'Bob', value: 'user-2'}]; + + expect(dedupePagedSelectOptions(existing, incoming)).toEqual([ + {label: 'Alice', value: 'user-1'}, + {label: 'Bob', value: 'user-2'}, + ]); + }); + + it('keeps a repeated value at its first position with the newest data', () => { + const existing: PagedSelectOption[] = [ + {label: 'Alice', value: 'user-1'}, + {label: 'Bob', value: 'user-2'}, + ]; + const incoming: PagedSelectOption[] = [ + {label: 'Bob (updated)', value: 'user-2'}, + {label: 'Carol', value: 'user-3'}, + ]; + + expect(dedupePagedSelectOptions(existing, incoming)).toEqual([ + {label: 'Alice', value: 'user-1'}, + {label: 'Bob (updated)', value: 'user-2'}, + {label: 'Carol', value: 'user-3'}, + ]); + }); + + it('removes repeats within a single page', () => { + expect( + dedupePagedSelectOptions( + [], + [ + {label: 'A', value: 'a'}, + {label: 'A again', value: 'a'}, + ], + ), + ).toEqual([{label: 'A again', value: 'a'}]); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/isAdvancingPageOffset.test.ts b/packages/javascript/src/utils/__tests__/isAdvancingPageOffset.test.ts new file mode 100644 index 00000000..c946fad6 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/isAdvancingPageOffset.test.ts @@ -0,0 +1,21 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import isAdvancingPageOffset from '../isAdvancingPageOffset'; + +describe('isAdvancingPageOffset', () => { + it('accepts null as the end of the list', () => { + expect(isAdvancingPageOffset(30, null)).toBe(true); + }); + + it('accepts an integer past the request offset', () => { + expect(isAdvancingPageOffset(30, 60)).toBe(true); + }); + + it('rejects an offset that would loop, go backwards, or is not an integer', () => { + expect(isAdvancingPageOffset(30, 30)).toBe(false); + expect(isAdvancingPageOffset(30, 10)).toBe(false); + expect(isAdvancingPageOffset(30, 30.5)).toBe(false); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/mapPagedSelectError.test.ts b/packages/javascript/src/utils/__tests__/mapPagedSelectError.test.ts new file mode 100644 index 00000000..a8caeab6 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/mapPagedSelectError.test.ts @@ -0,0 +1,40 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; +import ThunderIDError from '../../errors/ThunderIDError'; +import mapPagedSelectError from '../mapPagedSelectError'; + +describe('mapPagedSelectError', (): void => { + it('should surface a ThunderIDError message', (): void => { + const result = mapPagedSelectError(new ThunderIDError('Network down', 'test-code', 'test-origin')); + + expect(result.message).toBe('Network down'); + }); + + it('should surface a ThunderIDAPIError message', (): void => { + const result = mapPagedSelectError(new ThunderIDAPIError('Upstream exploded', 'test-code', 'test-origin', 500)); + + expect(result.message).toBe('Upstream exploded'); + }); + + it.each([[new Error('plain')], ['a string'], [undefined], [null]])( + 'should fall back to the generic key without trusting an unrecognized throw (%s)', + (thrown: unknown): void => { + const result = mapPagedSelectError(thrown); + + expect(result.message).toBeUndefined(); + expect(result.messageKey).toBe('elements.fields.paged_select.load_error'); + }, + ); + + it.each([ + [new ThunderIDError('offline', 'test-code', 'test-origin')], + [new ThunderIDAPIError('boom', 'test-code', 'test-origin', 500)], + [new Error('plain')], + [undefined], + ])('should always supply a messageKey so callers need no non-null assertion (%s)', (thrown: unknown): void => { + expect(typeof mapPagedSelectError(thrown).messageKey).toBe('string'); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts b/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts index f2907eb3..7ea8eace 100644 --- a/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts +++ b/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts @@ -22,6 +22,7 @@ describe('resolveResourceEndpoint', (): void => { endpoints: { flowExecute: 'https://rs.example.com/flow/execute', flowMeta: 'https://rs.example.com/flow/meta', + users: 'https://rs.example.com/users', usersMe: 'https://rs.example.com/users/me', usersMeMeta: 'https://rs.example.com/users/me/meta', }, @@ -29,6 +30,7 @@ describe('resolveResourceEndpoint', (): void => { expect(resolveResourceEndpoint('flowExecute', config)).toBe('https://rs.example.com/flow/execute'); expect(resolveResourceEndpoint('flowMeta', config)).toBe('https://rs.example.com/flow/meta'); + expect(resolveResourceEndpoint('users', config)).toBe('https://rs.example.com/users'); expect(resolveResourceEndpoint('usersMe', config)).toBe('https://rs.example.com/users/me'); expect(resolveResourceEndpoint('usersMeMeta', config)).toBe('https://rs.example.com/users/me/meta'); }); diff --git a/packages/javascript/src/utils/__tests__/toPagedSelectOption.test.ts b/packages/javascript/src/utils/__tests__/toPagedSelectOption.test.ts new file mode 100644 index 00000000..411cf0ef --- /dev/null +++ b/packages/javascript/src/utils/__tests__/toPagedSelectOption.test.ts @@ -0,0 +1,122 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import toPagedSelectOption from '../toPagedSelectOption'; + +describe('toPagedSelectOption', () => { + describe('plain text', () => { + it('is used for both label and value', () => { + expect(toPagedSelectOption('alice@example.com')).toEqual({ + label: 'alice@example.com', + value: 'alice@example.com', + }); + expect(toPagedSelectOption(7)).toEqual({label: '7', value: '7'}); + }); + + it('is dropped when blank', () => { + expect(toPagedSelectOption(' ')).toBeNull(); + }); + }); + + describe('objects with a configured mapping', () => { + const item = {email: 'a@example.com', fullName: 'Alice Example', id: 'u1'}; + + it('uses the configured label and value fields', () => { + expect(toPagedSelectOption(item, {labelAttributes: ['fullName'], valueAttribute: 'email'})).toEqual({ + label: 'Alice Example', + value: 'a@example.com', + }); + }); + + it('resolves nested paths, on the item or inside its attributes', () => { + const nested = {attributes: {name: {given: 'Alice'}}, id: 'u1'}; + + expect(toPagedSelectOption(nested, {labelAttributes: ['name.given']})?.label).toBe('Alice'); + }); + + it('tries label fields in order and falls back to the value', () => { + expect(toPagedSelectOption(item, {labelAttributes: ['missing', 'email']})?.label).toBe('a@example.com'); + expect(toPagedSelectOption(item, {labelAttributes: ['missing']})?.label).toBe('u1'); + }); + + it('prefers getLabel and getValue, falling through when they return nothing', () => { + expect(toPagedSelectOption(item, {getLabel: () => 'Custom', getValue: () => 'custom-value'})).toEqual({ + label: 'Custom', + value: 'custom-value', + }); + expect(toPagedSelectOption(item, {getLabel: () => '', getValue: () => undefined})).toEqual({ + label: 'u1', + value: 'u1', + }); + }); + + it('types the callbacks to the consumer’s item shape', () => { + interface Member { + fullName: string; + handle: string; + } + const member: Member = {fullName: 'Alice Example', handle: 'alice'}; + + expect(toPagedSelectOption(member, {getLabel: (m) => m.fullName, getValue: (m) => m.handle})).toEqual({ + label: 'Alice Example', + value: 'alice', + }); + }); + + it('drops an item with nothing at the configured value field, with no silent fallback', () => { + expect(toPagedSelectOption({id: 'u1'}, {valueAttribute: 'email'})).toBeNull(); + }); + + it('follows the shape rules for the label when only the value is configured', () => { + expect(toPagedSelectOption({code: 'X1', name: 'Alice'}, {valueAttribute: 'code'})).toEqual({ + label: 'X1', + value: 'X1', + }); + }); + }); + + describe('objects with no mapping', () => { + it('uses an {label, value} object as is', () => { + expect(toPagedSelectOption({label: 'Alice', value: 'a1'})).toEqual({label: 'Alice', value: 'a1'}); + }); + + it('submits the id of an object that has one, labelling it from the default fields', () => { + expect(toPagedSelectOption({id: 'u1', name: 'Alice'})).toEqual({label: 'Alice', value: 'u1'}); + expect(toPagedSelectOption({id: 'u1'})).toEqual({label: 'u1', value: 'u1'}); + }); + + it('labels an object with an id from the defaults passed in', () => { + const item = {attributes: {email: 'a@example.com'}, id: 'u1'}; + + expect(toPagedSelectOption(item, undefined, {labelAttributes: ['email']})).toEqual({ + label: 'a@example.com', + value: 'u1', + }); + }); + + it('skips a label candidate that merely repeats the id', () => { + const item = {display: 'u1', id: 'u1', username: 'alice'}; + + expect(toPagedSelectOption(item, undefined, {labelAttributes: ['display', 'username']})?.label).toBe('alice'); + }); + + it('uses the first text field for both label and value otherwise', () => { + expect(toPagedSelectOption({name: 'Alice', email: 'a@example.com'})).toEqual({label: 'Alice', value: 'Alice'}); + expect(toPagedSelectOption({active: true, meta: {x: 1}, city: 'Colombo', zip: 10100})).toEqual({ + label: 'Colombo', + value: 'Colombo', + }); + }); + + it('drops an object with no text fields', () => { + expect(toPagedSelectOption({active: true, meta: {x: 1}})).toBeNull(); + }); + }); + + it('ignores values that are not text, numbers, or objects', () => { + expect(toPagedSelectOption(null)).toBeNull(); + expect(toPagedSelectOption(undefined)).toBeNull(); + expect(toPagedSelectOption(true)).toBeNull(); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/toPagedSelectOptions.test.ts b/packages/javascript/src/utils/__tests__/toPagedSelectOptions.test.ts new file mode 100644 index 00000000..f524da28 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/toPagedSelectOptions.test.ts @@ -0,0 +1,30 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import toPagedSelectOptions from '../toPagedSelectOptions'; + +describe('toPagedSelectOptions', () => { + it('converts a plain list', () => { + expect(toPagedSelectOptions(['a', 'b'])).toEqual([ + {label: 'a', value: 'a'}, + {label: 'b', value: 'b'}, + ]); + }); + + it('leaves out items with nothing to submit', () => { + expect(toPagedSelectOptions(['a', '', 'b'])).toHaveLength(2); + }); + + it('applies the mapping to every object', () => { + const items = [ + {code: 'x', title: 'X'}, + {code: 'y', title: 'Y'}, + ]; + + expect(toPagedSelectOptions(items, {labelAttributes: ['title'], valueAttribute: 'code'})).toEqual([ + {label: 'X', value: 'x'}, + {label: 'Y', value: 'y'}, + ]); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/toUserSelectOption.test.ts b/packages/javascript/src/utils/__tests__/toUserSelectOption.test.ts new file mode 100644 index 00000000..809d0985 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/toUserSelectOption.test.ts @@ -0,0 +1,53 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import type {ManagedUser} from '../../models/managed-user'; +import toUserSelectOption from '../toUserSelectOption'; + +const managedUser = (user: Partial & {id: string}): ManagedUser => ({ + ouId: 'ou-1', + type: 'person', + ...user, +}); + +describe('toUserSelectOption', () => { + it('submits the ID and labels the option with the display name', () => { + const user: ManagedUser = managedUser({display: 'Alice Example', id: 'user-1'}); + + expect(toUserSelectOption(user)).toEqual({label: 'Alice Example', value: 'user-1'}); + }); + + it('falls back to username, then email, then the ID', () => { + expect( + toUserSelectOption(managedUser({attributes: {email: 'a@example.com', username: 'alice'}, id: 'user-1'}))?.label, + ).toBe('alice'); + expect(toUserSelectOption(managedUser({attributes: {email: 'a@example.com'}, id: 'user-1'}))?.label).toBe( + 'a@example.com', + ); + expect(toUserSelectOption(managedUser({id: 'user-1'}))?.label).toBe('user-1'); + }); + + it('treats a display that merely equals the ID as unset', () => { + const user: ManagedUser = managedUser({attributes: {username: 'alice'}, display: 'user-1', id: 'user-1'}); + + expect(toUserSelectOption(user)?.label).toBe('alice'); + }); + + it('honours a configured mapping', () => { + const user: ManagedUser = managedUser({attributes: {email: 'a@example.com', name: {given: 'Alice'}}, id: 'user-1'}); + + expect(toUserSelectOption(user, {labelAttributes: ['name.given'], valueAttribute: 'email'})).toEqual({ + label: 'Alice', + value: 'a@example.com', + }); + expect(toUserSelectOption(user, {getLabel: (u) => `${u.id}!`})?.label).toBe('user-1!'); + }); + + it('accepts a plain string for both label and value', () => { + expect(toUserSelectOption('alice@example.com')).toEqual({ + label: 'alice@example.com', + value: 'alice@example.com', + }); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/toUserSelectPage.test.ts b/packages/javascript/src/utils/__tests__/toUserSelectPage.test.ts new file mode 100644 index 00000000..3dcdeb1d --- /dev/null +++ b/packages/javascript/src/utils/__tests__/toUserSelectPage.test.ts @@ -0,0 +1,53 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it} from 'vitest'; +import type {ManagedUser, ManagedUserListResponse} from '../../models/managed-user'; +import toUserSelectPage from '../toUserSelectPage'; + +const managedUser = (user: Partial & {id: string}): ManagedUser => ({ + ouId: 'ou-1', + type: 'person', + ...user, +}); + +const response = (users: ManagedUser[], startIndex: number, totalResults: number): ManagedUserListResponse => ({ + count: users.length, + links: [], + startIndex, + totalResults, + users, +}); + +describe('toUserSelectPage', () => { + it('maps the users and computes the next offset from the request offset and count', () => { + const users: ManagedUser[] = [ + managedUser({display: 'Alice', id: 'user-1'}), + managedUser({display: 'Bob', id: 'user-2'}), + ]; + + expect(toUserSelectPage(response(users, 31, 75), 30)).toEqual({ + nextOffset: 32, + options: [ + {label: 'Alice', value: 'user-1'}, + {label: 'Bob', value: 'user-2'}, + ], + totalResults: 75, + }); + }); + + it('ends the list on the last page', () => { + expect(toUserSelectPage(response([managedUser({id: 'user-75'})], 75, 75), 74).nextOffset).toBeNull(); + }); + + it('advances by the backend count even when the mapping drops users', () => { + const users: ManagedUser[] = [ + managedUser({attributes: {email: 'a@example.com'}, id: 'user-1'}), + managedUser({id: 'user-2'}), + ]; + const page = toUserSelectPage(response(users, 1, 10), 0, {valueAttribute: 'email'}); + + expect(page.options).toEqual([{label: 'a@example.com', value: 'a@example.com'}]); + expect(page.nextOffset).toBe(2); + }); +}); diff --git a/packages/javascript/src/utils/computeNextPageOffset.ts b/packages/javascript/src/utils/computeNextPageOffset.ts new file mode 100644 index 00000000..0d1a78bf --- /dev/null +++ b/packages/javascript/src/utils/computeNextPageOffset.ts @@ -0,0 +1,18 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Offset of the next page: the request offset plus the number of items the backend returned, or + * `null` once the list is exhausted. + */ +const computeNextPageOffset = (offset: number, count: number, totalResults?: number): number | null => { + if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(count) || count <= 0) { + return null; + } + + const next: number = offset + count; + + return totalResults !== undefined && next >= totalResults ? null : next; +}; + +export default computeNextPageOffset; diff --git a/packages/javascript/src/utils/dedupePagedSelectOptions.ts b/packages/javascript/src/utils/dedupePagedSelectOptions.ts new file mode 100644 index 00000000..a042ac98 --- /dev/null +++ b/packages/javascript/src/utils/dedupePagedSelectOptions.ts @@ -0,0 +1,31 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import type {PagedSelectOption} from '../models/paged-select'; + +/** + * Appends `incoming` to `existing`, keeping each value at its first position and taking the newest + * data when a value repeats (offset paging can return the same item on two pages). + */ +const dedupePagedSelectOptions = ( + existing: PagedSelectOption[], + incoming: PagedSelectOption[], +): PagedSelectOption[] => { + const merged: PagedSelectOption[] = [...existing]; + const indexByValue = new Map(merged.map((option, index) => [option.value, index])); + + incoming.forEach((option: PagedSelectOption) => { + const existingIndex: number | undefined = indexByValue.get(option.value); + + if (existingIndex === undefined) { + indexByValue.set(option.value, merged.length); + merged.push(option); + } else { + merged[existingIndex] = option; + } + }); + + return merged; +}; + +export default dedupePagedSelectOptions; diff --git a/packages/javascript/src/utils/isAdvancingPageOffset.ts b/packages/javascript/src/utils/isAdvancingPageOffset.ts new file mode 100644 index 00000000..867cc22d --- /dev/null +++ b/packages/javascript/src/utils/isAdvancingPageOffset.ts @@ -0,0 +1,11 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Whether a loader's reported next offset is safe to follow: `null` (exhausted) or an integer past + * the request offset. Anything else would loop or go backwards. + */ +const isAdvancingPageOffset = (requestOffset: number, nextOffset: number | null): nextOffset is number | null => + nextOffset === null || (Number.isInteger(nextOffset) && nextOffset > requestOffset); + +export default isAdvancingPageOffset; diff --git a/packages/javascript/src/utils/mapPagedSelectError.ts b/packages/javascript/src/utils/mapPagedSelectError.ts new file mode 100644 index 00000000..696a31e7 --- /dev/null +++ b/packages/javascript/src/utils/mapPagedSelectError.ts @@ -0,0 +1,49 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import ThunderIDError from '../errors/ThunderIDError'; + +/** + * Where a failed page load should get its display text from. + * + * `messageKey` is always set, so the caller can resolve text with a plain + * `message ?? t(messageKey)` and never needs a non-null assertion. `message` is present + * only when the loader threw a {@link ThunderIDError}, and takes precedence when it is. + * Translation is left to the caller, which keeps this module free of i18n concerns in the + * same way {@link mapCredentialUpdateError} is. + */ +export interface PagedSelectErrorResult { + /** + * The failed loader's own message, already human-readable. Shown as-is when present. + */ + message?: string; + /** + * The i18n key to fall back to when `message` is absent. Always set. + */ + messageKey: string; +} + +const GENERIC_MESSAGE_KEY = 'elements.fields.paged_select.load_error'; + +/** + * Maps a `fetchOptions`/`fetchUsers` loader failure onto what a picker should show. + * + * `fetchOptions` is consumer-supplied, so a thrown value is not guaranteed to be a + * {@link ThunderIDError} the way a core API call's failure is. Only a {@link ThunderIDError}'s + * message is trusted for direct display; anything else — a bare `Error`, a rejected string, an + * upstream exception with an unreviewed message — falls back to the generic, translatable one. + * + * @param error - The value thrown by the page loader. + * @returns Where to show the failure and what to show. + * @example + * ```typescript + * const {message, messageKey} = mapPagedSelectError(caughtError); + * const text = message ?? t(messageKey); + * ``` + */ +const mapPagedSelectError = (error: unknown): PagedSelectErrorResult => + error instanceof ThunderIDError + ? {message: error.message, messageKey: GENERIC_MESSAGE_KEY} + : {messageKey: GENERIC_MESSAGE_KEY}; + +export default mapPagedSelectError; diff --git a/packages/javascript/src/utils/toPagedSelectOption.ts b/packages/javascript/src/utils/toPagedSelectOption.ts new file mode 100644 index 00000000..0cd8eed5 --- /dev/null +++ b/packages/javascript/src/utils/toPagedSelectOption.ts @@ -0,0 +1,117 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import type {PagedSelectOption, PagedSelectOptionDefaults, PagedSelectOptionMapping} from '../models/paged-select'; + +const DEFAULT_LABEL_ATTRIBUTES: readonly string[] = ['label', 'name', 'title']; + +const asText = (raw: unknown): string | undefined => { + if (typeof raw === 'string') { + return raw.trim() === '' ? undefined : raw; + } + + return typeof raw === 'number' && Number.isFinite(raw) ? String(raw) : undefined; +}; + +const walkPath = (source: unknown, path: string): unknown => + path + .split('.') + .reduce( + (current: unknown, key: string) => + typeof current === 'object' && current !== null ? (current as Record)[key] : undefined, + source, + ); + +const readText = (record: Record, path: string): string | undefined => + asText(walkPath(record, path)) ?? asText(walkPath(record['attributes'], path)); + +const firstText = (record: Record): string | undefined => { + for (const raw of Object.values(record)) { + const text: string | undefined = asText(raw); + + if (text !== undefined) { + return text; + } + } + + return undefined; +}; + +const resolveValue = ( + item: T, + record: Record, + mapping?: PagedSelectOptionMapping, +): string | undefined => { + const custom: string | undefined = asText(mapping?.getValue?.(item)); + + if (custom !== undefined) { + return custom; + } + + if (mapping?.valueAttribute !== undefined) { + return readText(record, mapping.valueAttribute); + } + + return readText(record, 'id') ?? readText(record, 'value') ?? firstText(record); +}; + +const resolveLabel = ( + item: T, + record: Record, + value: string, + mapping?: PagedSelectOptionMapping, + defaults?: PagedSelectOptionDefaults, +): string => { + const custom: string | undefined = asText(mapping?.getLabel?.(item)); + + if (custom !== undefined) { + return custom; + } + + const id: string | undefined = readText(record, 'id'); + const paths: readonly string[] = + mapping?.labelAttributes ?? + (id === undefined ? ['label'] : (defaults?.labelAttributes ?? DEFAULT_LABEL_ATTRIBUTES)); + + for (const path of paths) { + const text: string | undefined = readText(record, path); + + if (text !== undefined && text !== id) { + return text; + } + } + + return value; +}; + +/** + * Converts one raw item into an option. + * + * Plain text is its own label and value. An object uses the configured `mapping`; with none, an + * object with an `id` submits it and labels it from `defaults.labelAttributes`, an `{label, value}` + * object is used as is, and anything else uses its first text field for both. + * + * Returns `null` when there is nothing to submit. + */ +const toPagedSelectOption = ( + item: T | string | number, + mapping?: PagedSelectOptionMapping, + defaults?: PagedSelectOptionDefaults, +): PagedSelectOption | null => { + if (typeof item === 'string' || typeof item === 'number') { + const text: string | undefined = asText(item); + + return text === undefined ? null : {label: text, value: text}; + } + + if (typeof item !== 'object' || item === null) { + return null; + } + + const record = item as Record; + const value: string | undefined = resolveValue(item, record, mapping); + + return value === undefined ? null : {label: resolveLabel(item, record, value, mapping, defaults), value}; +}; + +export default toPagedSelectOption; diff --git a/packages/javascript/src/utils/toPagedSelectOptions.ts b/packages/javascript/src/utils/toPagedSelectOptions.ts new file mode 100644 index 00000000..70afea41 --- /dev/null +++ b/packages/javascript/src/utils/toPagedSelectOptions.ts @@ -0,0 +1,16 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import toPagedSelectOption from './toPagedSelectOption'; +import type {PagedSelectOption, PagedSelectOptionDefaults, PagedSelectOptionMapping} from '../models/paged-select'; + +const toPagedSelectOptions = ( + items: (T | string | number)[], + mapping?: PagedSelectOptionMapping, + defaults?: PagedSelectOptionDefaults, +): PagedSelectOption[] => + items + .map((item: T | string | number) => toPagedSelectOption(item, mapping, defaults)) + .filter((option: PagedSelectOption | null): option is PagedSelectOption => option !== null); + +export default toPagedSelectOptions; diff --git a/packages/javascript/src/utils/toUserSelectOption.ts b/packages/javascript/src/utils/toUserSelectOption.ts new file mode 100644 index 00000000..0f5969f6 --- /dev/null +++ b/packages/javascript/src/utils/toUserSelectOption.ts @@ -0,0 +1,20 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import toPagedSelectOption from './toPagedSelectOption'; +import UserSelectConstants from '../constants/UserSelectConstants'; +import type {ManagedUser} from '../models/managed-user'; +import type {PagedSelectOption} from '../models/paged-select'; +import type {UserSelectOptionMapping} from '../models/user-select'; + +/** + * Converts one directory user into an option: submits the user's ID and labels it from `display`, + * then `username`, then `email`, unless `mapping` says otherwise. + */ +const toUserSelectOption = ( + user: ManagedUser | string | number, + mapping?: UserSelectOptionMapping, +): PagedSelectOption | null => + toPagedSelectOption(user, mapping, {labelAttributes: UserSelectConstants.LABEL_ATTRIBUTES}); + +export default toUserSelectOption; diff --git a/packages/javascript/src/utils/toUserSelectPage.ts b/packages/javascript/src/utils/toUserSelectPage.ts new file mode 100644 index 00000000..ca7eb315 --- /dev/null +++ b/packages/javascript/src/utils/toUserSelectPage.ts @@ -0,0 +1,25 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import computeNextPageOffset from './computeNextPageOffset'; +import toPagedSelectOptions from './toPagedSelectOptions'; +import UserSelectConstants from '../constants/UserSelectConstants'; +import type {ManagedUserListResponse} from '../models/managed-user'; +import type {PagedSelectOption, PagedSelectPage} from '../models/paged-select'; +import type {UserSelectOptionMapping} from '../models/user-select'; + +/** + * Converts a `getUsers` response into a loader page. The next offset comes from the backend's own + * `count`, so users dropped by the mapping never shift the paging. + */ +const toUserSelectPage = ( + response: ManagedUserListResponse, + requestOffset: number, + mapping?: UserSelectOptionMapping, +): PagedSelectPage & {options: PagedSelectOption[]} => ({ + nextOffset: computeNextPageOffset(requestOffset, response.count, response.totalResults), + options: toPagedSelectOptions(response.users, mapping, {labelAttributes: UserSelectConstants.LABEL_ATTRIBUTES}), + totalResults: response.totalResults, +}); + +export default toUserSelectPage; diff --git a/packages/nextjs/src/ThunderIDNextClient.ts b/packages/nextjs/src/ThunderIDNextClient.ts index 3aab4c10..9f8a98fd 100644 --- a/packages/nextjs/src/ThunderIDNextClient.ts +++ b/packages/nextjs/src/ThunderIDNextClient.ts @@ -15,6 +15,7 @@ import { TokenExchangeRequestConfig, TokenResponse, User, + ManagedUserListResponse, UserProfile, executeEmbeddedSignInFlow, extractUserClaimsFromIdToken, @@ -234,6 +235,20 @@ class ThunderIDNextClient e }); } + /** + * Retrieves one page of the user directory for the session identified by `userId`. + * + * Ensures the client is initialized first, like `updateUserCredentials`. Errors are not caught so the calling server action can map the real `ThunderIDAPIError`. + */ + override async getUsers( + options: {filter?: string; limit?: number; offset?: number; signal?: AbortSignal} = {}, + userId?: string, + ): Promise { + await this.ensureInitialized(); + + return super.getUsers(options, userId); + } + override isLoading(): boolean { return false; } diff --git a/packages/nextjs/src/client/__tests__/fetchUsersFromServer.test.ts b/packages/nextjs/src/client/__tests__/fetchUsersFromServer.test.ts new file mode 100644 index 00000000..d9902c6b --- /dev/null +++ b/packages/nextjs/src/client/__tests__/fetchUsersFromServer.test.ts @@ -0,0 +1,57 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ThunderIDError, mapPagedSelectError} from '@thunderid/node'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import getUsersAction from '../../server/actions/getUsersAction'; +import fetchUsersFromServer from '../fetchUsersFromServer'; + +vi.mock('../../server/actions/getUsersAction', () => ({default: vi.fn()})); + +const request = {component: {} as never, limit: 30, offset: 60, signal: new AbortController().signal}; + +describe('fetchUsersFromServer', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('returns the normalized page the server action produced', async () => { + const page = {nextOffset: null, options: [{label: 'Jane Doe', value: 'user-1'}], totalResults: 1}; + vi.mocked(getUsersAction).mockResolvedValue({page, success: true}); + + await expect(fetchUsersFromServer(request)).resolves.toEqual(page); + }); + + it('forwards only paging and filter — no signal, session, or URL crosses to the server', async () => { + vi.mocked(getUsersAction).mockResolvedValue({page: {nextOffset: null, options: []}, success: true}); + + await fetchUsersFromServer({...request, filter: 'department eq "Engineering"'}); + + expect(getUsersAction).toHaveBeenCalledWith({filter: 'department eq "Engineering"', limit: 30, offset: 60}); + }); + + it('re-throws a server-vouched message as an SDK error, so the picker shows it', async () => { + vi.mocked(getUsersAction).mockResolvedValue({ + message: 'Forbidden', + messageKey: 'elements.fields.paged_select.load_error', + success: false, + }); + + const thrown = await fetchUsersFromServer(request).catch((error: unknown) => error); + + expect(thrown).toBeInstanceOf(ThunderIDError); + expect(mapPagedSelectError(thrown).message).toBe('Forbidden'); + }); + + it('throws a bare Error when the server gave no message, so the picker falls back to the generic text', async () => { + vi.mocked(getUsersAction).mockResolvedValue({ + messageKey: 'elements.fields.paged_select.load_error', + success: false, + }); + + const thrown = await fetchUsersFromServer(request).catch((error: unknown) => error); + + expect(thrown).not.toBeInstanceOf(ThunderIDError); + expect(mapPagedSelectError(thrown).message).toBeUndefined(); + }); +}); diff --git a/packages/nextjs/src/client/components/presentation/UserSelect/UserSelect.tsx b/packages/nextjs/src/client/components/presentation/UserSelect/UserSelect.tsx new file mode 100644 index 00000000..861c9d7e --- /dev/null +++ b/packages/nextjs/src/client/components/presentation/UserSelect/UserSelect.tsx @@ -0,0 +1,21 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import {BaseUserSelect, BaseUserSelectProps} from '@thunderid/react'; +import {FC} from 'react'; +import fetchUsersFromServer from '../../../fetchUsersFromServer'; + +export type UserSelectProps = Omit; + +/** + * Single-user picker for signed-in screens. Users load through a server action that uses the + * signed-in user's server-held session, so the access token never reaches the browser. It takes no + * data source; for a custom one use `PagedSelect`. + */ +const UserSelect: FC = (props: UserSelectProps) => ( + +); + +export default UserSelect; diff --git a/packages/nextjs/src/client/fetchUsersFromServer.ts b/packages/nextjs/src/client/fetchUsersFromServer.ts new file mode 100644 index 00000000..bc8f0503 --- /dev/null +++ b/packages/nextjs/src/client/fetchUsersFromServer.ts @@ -0,0 +1,31 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ThunderIDRuntimeError} from '@thunderid/node'; +import type {FetchUsers, PagedSelectPage, PagedSelectRequest} from '@thunderid/node'; +import getUsersAction, {GetUsersActionResult} from '../server/actions/getUsersAction'; + +/** + * The loader behind `UserSelect`: loads users through {@link getUsersAction} with the signed-in user's server-held session. + * Requires an existing session. `request.signal` is not forwarded because an `AbortSignal` cannot cross the server action boundary. + */ +const fetchUsersFromServer: FetchUsers = async ({ + filter, + limit, + offset, +}: PagedSelectRequest): Promise => { + const result: GetUsersActionResult = await getUsersAction({filter, limit, offset}); + + if (result.success) { + return result.page; + } + + // Only a server-vouched message becomes an SDK error; anything else falls back to the generic text. + if (result.message) { + throw new ThunderIDRuntimeError(result.message, 'getUsersAction-Error-001', 'nextjs'); + } + + throw new Error(result.messageKey); +}; + +export default fetchUsersFromServer; diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index e844cb37..0ab5926d 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -38,3 +38,6 @@ export type {UserProfileProps} from './components/presentation/UserProfile/UserP export {default as ChangeCredential} from './components/presentation/ChangeCredential/ChangeCredential'; export type {ChangeCredentialProps} from './components/presentation/ChangeCredential/ChangeCredential'; + +export {default as UserSelect} from './components/presentation/UserSelect/UserSelect'; +export type {UserSelectProps} from './components/presentation/UserSelect/UserSelect'; diff --git a/packages/nextjs/src/server/actions/__tests__/getUsersAction.test.ts b/packages/nextjs/src/server/actions/__tests__/getUsersAction.test.ts new file mode 100644 index 00000000..aebfcf16 --- /dev/null +++ b/packages/nextjs/src/server/actions/__tests__/getUsersAction.test.ts @@ -0,0 +1,88 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ThunderIDAPIError} from '@thunderid/node'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import getClient from '../../getClient'; +import getSessionId from '../getSessionId'; +import getUsersAction from '../getUsersAction'; + +vi.mock('../getSessionId', () => ({default: vi.fn()})); +vi.mock('../../getClient', () => ({default: vi.fn()})); + +const mockGetUsers = vi.fn(); + +const usersPage = { + count: 2, + links: [], + startIndex: 0, + totalResults: 5, + users: [ + {display: 'Jane Doe', id: 'user-1'}, + {display: 'John Roe', id: 'user-2'}, + ], +}; + +describe('getUsersAction', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getClient).mockReturnValue({getUsers: mockGetUsers} as unknown as ReturnType); + vi.mocked(getSessionId).mockResolvedValue('session-1'); + }); + + it('refuses without a session and never reaches the upstream directory', async () => { + vi.mocked(getSessionId).mockResolvedValue(undefined); + + const result = await getUsersAction({limit: 30, offset: 0}); + + expect(result).toEqual({messageKey: 'elements.fields.paged_select.load_error', success: false}); + expect(mockGetUsers).not.toHaveBeenCalled(); + }); + + it('loads the page with the server-resolved session and returns only the normalized options', async () => { + mockGetUsers.mockResolvedValue(usersPage); + + const result = await getUsersAction({filter: 'department eq "Engineering"', limit: 2, offset: 0}); + + expect(mockGetUsers).toHaveBeenCalledWith( + {filter: 'department eq "Engineering"', limit: 2, offset: 0}, + 'session-1', + ); + expect(result).toEqual({ + page: { + nextOffset: 2, + options: [ + {label: 'Jane Doe', value: 'user-1'}, + {label: 'John Roe', value: 'user-2'}, + ], + totalResults: 5, + }, + success: true, + }); + }); + + it('does not let the caller choose the session: extra request fields are ignored', async () => { + mockGetUsers.mockResolvedValue(usersPage); + + await getUsersAction({limit: 2, offset: 0, sessionId: 'someone-elses-session'} as never); + + expect(mockGetUsers).toHaveBeenCalledWith({filter: undefined, limit: 2, offset: 0}, 'session-1'); + }); + + it("carries a ThunderIDAPIError's message across the boundary instead of throwing", async () => { + mockGetUsers.mockRejectedValue(new ThunderIDAPIError('Forbidden', 'getUsers-ResponseError-001', 'javascript', 403)); + + const result = await getUsersAction({limit: 30, offset: 0}); + + expect(result).toMatchObject({success: false, messageKey: 'elements.fields.paged_select.load_error'}); + expect((result as {message?: string}).message).toBeDefined(); + }); + + it('does not leak the text of an unexpected error', async () => { + mockGetUsers.mockRejectedValue(new Error('connect ECONNREFUSED 10.0.0.5:8090')); + + const result = await getUsersAction({limit: 30, offset: 0}); + + expect(result).toEqual({messageKey: 'elements.fields.paged_select.load_error', success: false}); + }); +}); diff --git a/packages/nextjs/src/server/actions/getUsersAction.ts b/packages/nextjs/src/server/actions/getUsersAction.ts new file mode 100644 index 00000000..aef627bb --- /dev/null +++ b/packages/nextjs/src/server/actions/getUsersAction.ts @@ -0,0 +1,45 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +'use server'; + +import {mapPagedSelectError, toUserSelectPage} from '@thunderid/node'; +import type {ManagedUserListResponse, PagedSelectErrorResult, PagedSelectPage} from '@thunderid/node'; +import getSessionId from './getSessionId'; +import getClient from '../getClient'; + +/** + * Request from the browser. Carries no session ID, token, or URL; the session comes from the request cookie. + */ +export interface GetUsersActionRequest { + filter?: string; + limit?: number; + offset?: number; +} + +export type GetUsersActionResult = {page: PagedSelectPage; success: true} | ({success: false} & PagedSelectErrorResult); + +const NO_SESSION_MESSAGE_KEY = 'elements.fields.paged_select.load_error'; + +/** + * Loads one page of users for a `USER_SELECT` picker using the signed-in user's server-held session. + * + * Never throws across the server action boundary; failures are mapped to a plain result. + */ +const getUsersAction = async ({filter, limit, offset}: GetUsersActionRequest = {}): Promise => { + const sessionId: string | undefined = await getSessionId(); + + if (!sessionId) { + return {messageKey: NO_SESSION_MESSAGE_KEY, success: false}; + } + + try { + const response: ManagedUserListResponse = await getClient().getUsers({filter, limit, offset}, sessionId); + + return {page: toUserSelectPage(response, offset ?? 0), success: true}; + } catch (error) { + return {...mapPagedSelectError(error), success: false}; + } +}; + +export default getUsersAction; diff --git a/packages/node/src/ThunderIDNodeClient.ts b/packages/node/src/ThunderIDNodeClient.ts index 27622803..651c9dc7 100644 --- a/packages/node/src/ThunderIDNodeClient.ts +++ b/packages/node/src/ThunderIDNodeClient.ts @@ -4,7 +4,9 @@ import { AuthClientConfig, ExtendedAuthorizeRequestUrlParams, + getUsers, IdToken, + ManagedUserListResponse, OIDCEndpoints, resolveResourceEndpoint, SessionData, @@ -224,6 +226,28 @@ class ThunderIDNodeClient e url: resolveResourceEndpoint('usersMeCredentials', configData), }); } + + /** + * Retrieves one page of the user directory on behalf of the session identified by `userId`. + * + * Makes a single request and does not follow `links`. The access token stays on the server. + */ + public async getUsers( + options: {filter?: string; limit?: number; offset?: number; signal?: AbortSignal} = {}, + userId?: string, + ): Promise { + const configData: AuthClientConfig = await this.getStorageManager().getConfigData(); + const baseUrl: string | undefined = configData?.baseUrl; + + return getUsers({ + ...options, + baseUrl, + headers: { + Authorization: `Bearer ${await this.getAccessToken(userId)}`, + }, + url: resolveResourceEndpoint('users', configData), + }); + } } export default ThunderIDNodeClient; diff --git a/packages/node/src/__tests__/ThunderIDNodeClient.test.ts b/packages/node/src/__tests__/ThunderIDNodeClient.test.ts index a119dcce..37fb6165 100644 --- a/packages/node/src/__tests__/ThunderIDNodeClient.test.ts +++ b/packages/node/src/__tests__/ThunderIDNodeClient.test.ts @@ -9,6 +9,7 @@ vi.mock('@thunderid/javascript', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + getUsers: vi.fn().mockResolvedValue({count: 1, links: [], startIndex: 0, totalResults: 1, users: [{id: 'u1'}]}), updateMeCredentials: vi.fn().mockResolvedValue(undefined), }; }); @@ -37,4 +38,48 @@ describe('ThunderIDNodeClient', () => { }); }); }); + + describe('getUsers', () => { + it('requests one page with the session bearer token and returns the response untouched', async () => { + const client = new ThunderIDNodeClient(); + await client.initialize({ + baseUrl: 'https://auth.example.com', + clientId: 'test-client', + }); + + const getAccessToken = vi.spyOn(client, 'getAccessToken').mockResolvedValue('test-access-token'); + + const page = await client.getUsers({filter: 'department eq "Engineering"', limit: 30, offset: 60}, 'session-1'); + + expect(getAccessToken).toHaveBeenCalledWith('session-1'); + expect(thunderJS.getUsers).toHaveBeenCalledWith({ + baseUrl: 'https://auth.example.com', + filter: 'department eq "Engineering"', + headers: { + Authorization: 'Bearer test-access-token', + }, + limit: 30, + offset: 60, + url: undefined, + }); + expect(page.users).toEqual([{id: 'u1'}]); + }); + + it('uses the configured users endpoint override when one is set', async () => { + const client = new ThunderIDNodeClient(); + await client.initialize({ + baseUrl: 'https://auth.example.com', + clientId: 'test-client', + endpoints: {users: 'https://rs.example.com/users'}, + }); + + vi.spyOn(client, 'getAccessToken').mockResolvedValue('test-access-token'); + + await client.getUsers({}, 'session-1'); + + expect(thunderJS.getUsers).toHaveBeenLastCalledWith( + expect.objectContaining({url: 'https://rs.example.com/users'}), + ); + }); + }); }); diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index f2b90987..8be7ff6b 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -181,6 +181,7 @@ export default defineNuxtModule({ method: 'patch' as const, route: NuxtAPIRoutes.USER_CREDENTIALS, }, + {handler: resolve('./runtime/server/routes/auth/user/users.get'), route: NuxtAPIRoutes.USERS}, ]; serverRoutes.forEach((sr: ServerRoute): void => { @@ -255,6 +256,8 @@ export default defineNuxtModule({ addComponent({filePath: resolve('./runtime/components/user/UserProfile'), name: 'UserProfile'}); addComponent({filePath: resolve('./runtime/components/user/UserDropdown'), name: 'UserDropdown'}); addComponent({filePath: resolve('./runtime/components/user/ChangeCredential'), name: 'ChangeCredential'}); + addComponent({filePath: resolve('./runtime/components/user/UserSelect'), name: 'UserSelect'}); + addComponent({export: 'PagedSelect', filePath: '@thunderid/vue', name: 'PagedSelect'}); // ── Auth callback ──────────────────────────────────────────────────────── addComponent({filePath: resolve('./runtime/components/auth/Callback'), name: 'Callback'}); diff --git a/packages/nuxt/src/runtime/components/user/UserSelect.ts b/packages/nuxt/src/runtime/components/user/UserSelect.ts new file mode 100644 index 00000000..6705dede --- /dev/null +++ b/packages/nuxt/src/runtime/components/user/UserSelect.ts @@ -0,0 +1,28 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {BaseUserSelect} from '@thunderid/vue'; +import {type Component, type SetupContext, type VNode, defineComponent, h} from 'vue'; +import fetchUsersFromServer from '../../utils/fetchUsersFromServer'; + +/** + * Nuxt-specific UserSelect container. + * + * Loads users through `GET /api/auth/users` with the signed-in user's server-held session, so the + * access token never reaches the browser. It takes no data source; for a custom one use + * `PagedSelect`. All other props (`v-model`, `label`, `mapping`, ...) go to the Vue component. + * + * @example + * ```vue + * + * ``` + */ +const UserSelect: Component = defineComponent({ + name: 'UserSelect', + inheritAttrs: false, + setup(_props: Record, {attrs}: SetupContext): () => VNode { + return (): VNode => h(BaseUserSelect, {...attrs, fetchUsers: fetchUsersFromServer}); + }, +}); + +export default UserSelect; diff --git a/packages/nuxt/src/runtime/constants/NuxtAPIRoutes.ts b/packages/nuxt/src/runtime/constants/NuxtAPIRoutes.ts index 3df666de..12b47eee 100644 --- a/packages/nuxt/src/runtime/constants/NuxtAPIRoutes.ts +++ b/packages/nuxt/src/runtime/constants/NuxtAPIRoutes.ts @@ -27,6 +27,7 @@ const NuxtAPIRoutes: { USER: string; USER_CREDENTIALS: string; USER_PROFILE: string; + USERS: string; } = { /** Resolves the OAuth callback and completes sign-in. */ CALLBACK: '/api/auth/callback', @@ -48,6 +49,8 @@ const NuxtAPIRoutes: { USER_CREDENTIALS: '/api/auth/user/credentials', /** Fetches (GET) and updates (PATCH) the user profile. */ USER_PROFILE: '/api/auth/user/profile', + /** Loads one page of the user directory for a `USER_SELECT` picker, using the signed-in session. */ + USERS: '/api/auth/users', } as const; export default NuxtAPIRoutes; diff --git a/packages/nuxt/src/runtime/server/routes/auth/user/users.get.ts b/packages/nuxt/src/runtime/server/routes/auth/user/users.get.ts new file mode 100644 index 00000000..1928dd5d --- /dev/null +++ b/packages/nuxt/src/runtime/server/routes/auth/user/users.get.ts @@ -0,0 +1,69 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {mapPagedSelectError, toUserSelectPage} from '@thunderid/node'; +import type {ManagedUserListResponse} from '@thunderid/node'; +import {defineEventHandler, getQuery, createError} from 'h3'; +import type {H3Event} from 'h3'; +import type {ThunderIDGetUsersResult} from '../../../../types'; +import ThunderIDNuxtClient from '../../../ThunderIDNuxtClient'; +import {verifyAndRehydrateSession} from '../../../utils/serverSession'; +import {useRuntimeConfig} from '#imports'; + +/** Upper bound on the `filter` expression the browser may send; the directory only takes a single equality expression. */ +const MAX_FILTER_LENGTH = 512; + +/** + * Reads an optional non-negative integer query parameter; throws `400` if it is not a single plain integer. + */ +const readIntegerParam = (raw: unknown, name: string): number | undefined => { + if (raw === undefined) { + return undefined; + } + + if (typeof raw !== 'string' || !/^\d+$/.test(raw)) { + throw createError({statusCode: 400, statusMessage: `Invalid \`${name}\` parameter.`}); + } + + return Number(raw); +}; + +/** + * GET /api/auth/users + * + * Loads one page of users for a `USER_SELECT` picker using the signed-in user's server-held session. + * + * Query: optional `limit`, `offset` and `filter`. No session ID, token, or URL is accepted from the browser. + * + * Response: `{success: true, page}` or `{success: false, message?, messageKey}`. A failed directory call returns `200` with `success: false`; `401` means an invalid session and `400` a malformed query. + */ +export default defineEventHandler(async (event: H3Event): Promise => { + const config: ReturnType = useRuntimeConfig(); + const sessionSecret: string | undefined = config.thunderid?.sessionSecret; + + const session: Awaited> = await verifyAndRehydrateSession( + event, + sessionSecret, + ); + if (!session) { + throw createError({statusCode: 401, statusMessage: 'Unauthorized: Invalid or expired session.'}); + } + + const {filter: rawFilter, limit: rawLimit, offset: rawOffset}: Record = getQuery(event); + const limit: number | undefined = readIntegerParam(rawLimit, 'limit'); + const offset: number | undefined = readIntegerParam(rawOffset, 'offset'); + + if (rawFilter !== undefined && (typeof rawFilter !== 'string' || rawFilter.length > MAX_FILTER_LENGTH)) { + throw createError({statusCode: 400, statusMessage: 'Invalid `filter` parameter.'}); + } + const filter: string | undefined = rawFilter === undefined || rawFilter === '' ? undefined : rawFilter; + + try { + const client: ThunderIDNuxtClient = ThunderIDNuxtClient.getInstance(); + const response: ManagedUserListResponse = await client.getUsers({filter, limit, offset}, session.sessionId); + + return {page: toUserSelectPage(response, offset ?? 0), success: true}; + } catch (error) { + return {...mapPagedSelectError(error), success: false}; + } +}); diff --git a/packages/nuxt/src/runtime/types.ts b/packages/nuxt/src/runtime/types.ts index b156d3a7..c7356c36 100644 --- a/packages/nuxt/src/runtime/types.ts +++ b/packages/nuxt/src/runtime/types.ts @@ -6,6 +6,8 @@ import type { AuthClientConfig, FlowMetadataResponse, I18nPreferences, + PagedSelectErrorResult, + PagedSelectPage, TokenEndpointAuthMethod, User, UserProfile, @@ -100,3 +102,10 @@ export interface ThunderIDAuthState { isSignedIn: boolean; user: User | null; } + +/** + * Result of `GET /api/auth/users`: one normalized `USER_SELECT` page, or why it could not be loaded. + */ +export type ThunderIDGetUsersResult = + | {page: PagedSelectPage; success: true} + | ({success: false} & PagedSelectErrorResult); diff --git a/packages/nuxt/src/runtime/utils/fetchUsersFromServer.ts b/packages/nuxt/src/runtime/utils/fetchUsersFromServer.ts new file mode 100644 index 00000000..fb62ceb8 --- /dev/null +++ b/packages/nuxt/src/runtime/utils/fetchUsersFromServer.ts @@ -0,0 +1,36 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ThunderIDRuntimeError} from '@thunderid/browser'; +import type {FetchUsers, PagedSelectPage, PagedSelectRequest} from '@thunderid/browser'; +import NuxtAPIRoutes from '../constants/NuxtAPIRoutes'; +import type {ThunderIDGetUsersResult} from '../types'; + +/** + * The loader behind `UserSelect`: loads users through `GET /api/auth/users` with the signed-in user's server-held session. + * Requires an existing session. + */ +export const fetchUsersFromServer: FetchUsers = async ({ + filter, + limit, + offset, + signal, +}: PagedSelectRequest): Promise => { + const result: ThunderIDGetUsersResult = await $fetch(NuxtAPIRoutes.USERS, { + query: {filter, limit, offset}, + signal, + }); + + if (result.success) { + return result.page; + } + + // Only a server-vouched message becomes an SDK error; anything else falls back to the generic text. + if (result.message) { + throw new ThunderIDRuntimeError(result.message, 'users.get-Error-001', 'nuxt'); + } + + throw new Error(result.messageKey); +}; + +export default fetchUsersFromServer; diff --git a/packages/nuxt/tests/unit/fetch-users-from-server.test.ts b/packages/nuxt/tests/unit/fetch-users-from-server.test.ts new file mode 100644 index 00000000..417a66b2 --- /dev/null +++ b/packages/nuxt/tests/unit/fetch-users-from-server.test.ts @@ -0,0 +1,61 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable @typescript-eslint/typedef, sort-keys, @typescript-eslint/explicit-function-return-type */ + +import {ThunderIDError, mapPagedSelectError} from '@thunderid/browser'; +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; + +import NuxtAPIRoutes from '../../src/runtime/constants/NuxtAPIRoutes'; +import {fetchUsersFromServer} from '../../src/runtime/utils/fetchUsersFromServer'; + +const $fetch = vi.fn(); +const request = {component: {} as never, limit: 30, offset: 60, signal: new AbortController().signal}; + +describe('fetchUsersFromServer', () => { + beforeEach(() => { + vi.stubGlobal('$fetch', $fetch); + $fetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('calls the users route with paging, filter and the abort signal, and returns the page', async () => { + const page = {nextOffset: null, options: [{label: 'Jane Doe', value: 'user-1'}], totalResults: 1}; + $fetch.mockResolvedValue({page, success: true}); + + const controller = new AbortController(); + await expect( + fetchUsersFromServer({...request, filter: 'department eq "Engineering"', signal: controller.signal}), + ).resolves.toEqual(page); + + expect($fetch).toHaveBeenCalledWith(NuxtAPIRoutes.USERS, { + query: {filter: 'department eq "Engineering"', limit: 30, offset: 60}, + signal: controller.signal, + }); + }); + + it('re-throws a server-vouched message as an SDK error, so the picker shows it', async () => { + $fetch.mockResolvedValue({ + message: 'Forbidden', + messageKey: 'elements.fields.paged_select.load_error', + success: false, + }); + + const thrown = await fetchUsersFromServer(request).catch((error: unknown) => error); + + expect(thrown).toBeInstanceOf(ThunderIDError); + expect(mapPagedSelectError(thrown).message).toBe('Forbidden'); + }); + + it('throws a bare Error when the server gave no message, so the picker falls back to the generic text', async () => { + $fetch.mockResolvedValue({messageKey: 'elements.fields.paged_select.load_error', success: false}); + + const thrown = await fetchUsersFromServer(request).catch((error: unknown) => error); + + expect(thrown).not.toBeInstanceOf(ThunderIDError); + expect(mapPagedSelectError(thrown).message).toBeUndefined(); + }); +}); diff --git a/packages/nuxt/tests/unit/users-get.test.ts b/packages/nuxt/tests/unit/users-get.test.ts new file mode 100644 index 00000000..af4f98ba --- /dev/null +++ b/packages/nuxt/tests/unit/users-get.test.ts @@ -0,0 +1,149 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/* eslint-disable @typescript-eslint/typedef, sort-keys, @typescript-eslint/explicit-function-return-type */ + +import {ThunderIDAPIError} from '@thunderid/node'; +import {getQuery} from 'h3'; +import {describe, it, expect, vi, beforeEach} from 'vitest'; + +import usersHandler from '../../src/runtime/server/routes/auth/user/users.get'; +import {verifyAndRehydrateSession} from '../../src/runtime/server/utils/serverSession'; + +// ── vi.hoisted ──────────────────────────────────────────────────────────────── +const mockClientInstance = vi.hoisted(() => ({ + getUsers: vi.fn<(...args: any[]) => Promise>(), +})); + +// ── Module mocks ────────────────────────────────────────────────────────────── + +vi.mock('h3', () => ({ + defineEventHandler: (fn: Function) => fn, + getQuery: vi.fn(), + createError: vi.fn((opts: any) => Object.assign(new Error(opts.statusMessage), opts)), +})); + +vi.mock('#imports', () => ({ + useRuntimeConfig: vi.fn(() => ({thunderid: {sessionSecret: 'test-secret'}})), +})); + +vi.mock('../../src/runtime/server/ThunderIDNuxtClient', () => ({ + default: {getInstance: () => mockClientInstance}, +})); + +vi.mock('../../src/runtime/server/utils/serverSession', () => ({ + verifyAndRehydrateSession: vi.fn(), +})); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const mockEvent = {}; + +const usersPage = { + count: 2, + links: [], + startIndex: 0, + totalResults: 5, + users: [ + {display: 'Jane Doe', id: 'user-1'}, + {display: 'John Roe', id: 'user-2'}, + ], +}; + +const GENERIC_KEY = 'elements.fields.paged_select.load_error'; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('GET /api/auth/users', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(verifyAndRehydrateSession).mockResolvedValue({sessionId: 'session-1'} as any); + vi.mocked(getQuery).mockReturnValue({}); + }); + + it('rejects an invalid or missing session with 401 and never calls the directory', async () => { + vi.mocked(verifyAndRehydrateSession).mockResolvedValue(null as any); + + await expect((usersHandler as any)(mockEvent)).rejects.toMatchObject({statusCode: 401}); + expect(mockClientInstance.getUsers).not.toHaveBeenCalled(); + }); + + it('loads a page with the server-resolved session and returns only the normalized options', async () => { + vi.mocked(getQuery).mockReturnValue({filter: 'department eq "Engineering"', limit: '2', offset: '0'}); + mockClientInstance.getUsers.mockResolvedValue(usersPage); + + const result = await (usersHandler as any)(mockEvent); + + expect(mockClientInstance.getUsers).toHaveBeenCalledWith( + {filter: 'department eq "Engineering"', limit: 2, offset: 0}, + 'session-1', + ); + expect(result).toEqual({ + page: { + nextOffset: 2, + options: [ + {label: 'Jane Doe', value: 'user-1'}, + {label: 'John Roe', value: 'user-2'}, + ], + totalResults: 5, + }, + success: true, + }); + }); + + it('treats every query parameter as optional', async () => { + mockClientInstance.getUsers.mockResolvedValue(usersPage); + + await (usersHandler as any)(mockEvent); + + expect(mockClientInstance.getUsers).toHaveBeenCalledWith( + {filter: undefined, limit: undefined, offset: undefined}, + 'session-1', + ); + }); + + it('ignores a session or URL smuggled in through the query string', async () => { + vi.mocked(getQuery).mockReturnValue({sessionId: 'someone-elses-session', url: 'https://evil.example/users'}); + mockClientInstance.getUsers.mockResolvedValue(usersPage); + + await (usersHandler as any)(mockEvent); + + expect(mockClientInstance.getUsers).toHaveBeenCalledWith( + {filter: undefined, limit: undefined, offset: undefined}, + 'session-1', + ); + }); + + it.each([ + ['a non-numeric limit', {limit: 'abc'}], + ['a negative offset', {offset: '-1'}], + ['a fractional limit', {limit: '1.5'}], + ['a repeated limit', {limit: ['1', '2']}], + ['a repeated filter', {filter: ['a', 'b']}], + ['an oversized filter', {filter: 'x'.repeat(513)}], + ])('rejects %s with 400 before reaching the directory', async (_label, query) => { + vi.mocked(getQuery).mockReturnValue(query); + + await expect((usersHandler as any)(mockEvent)).rejects.toMatchObject({statusCode: 400}); + expect(mockClientInstance.getUsers).not.toHaveBeenCalled(); + }); + + it("carries a ThunderIDAPIError's message in the body of a 200 instead of throwing", async () => { + mockClientInstance.getUsers.mockRejectedValue( + new ThunderIDAPIError('Forbidden', 'getUsers-ResponseError-001', 'javascript', 403), + ); + + const result = await (usersHandler as any)(mockEvent); + + expect(result).toMatchObject({success: false, messageKey: GENERIC_KEY}); + expect(result.message).toBeDefined(); + }); + + it('does not leak the text of an unexpected error', async () => { + mockClientInstance.getUsers.mockRejectedValue(new Error('connect ECONNREFUSED 10.0.0.5:8090')); + + const result = await (usersHandler as any)(mockEvent); + + expect(result).toEqual({messageKey: GENERIC_KEY, success: false}); + }); +}); diff --git a/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx b/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx index 8e6c1d0c..66211c1c 100644 --- a/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx +++ b/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx @@ -21,6 +21,7 @@ import { ConsentAttributeElement, OrganizationUnitListResponse, PrefixOption, + FetchUsers, } from '@thunderid/browser'; import DOMPurify from 'dompurify'; import {ChangeEvent, cloneElement, CSSProperties, FormEvent, ReactElement} from 'react'; @@ -53,6 +54,7 @@ import Select from '../../primitives/Select/Select'; import {affixPostfixKey, affixPrefixKey} from '../../../utils/composeAffixedInputs'; import Typography from '../../primitives/Typography/Typography'; import {TypographyVariant} from '../../primitives/Typography/Typography.styles'; +import BaseUserSelect from '../../primitives/UserSelect/BaseUserSelect'; const logger: ReturnType = createPackageComponentLogger( '@thunderid/react', @@ -216,6 +218,8 @@ const createAuthComponentFromFlow = ( /** Flag to determine if the step timeline has expired */ isTimeoutDisabled?: boolean; key?: string | number; + /** Data source for USER_SELECT fields. */ + fetchUsers?: FetchUsers; /** Flow metadata for resolving {{meta(...)}} expressions at render time */ meta?: FlowMetadataResponse | null; onInputBlur?: (name: string) => void; @@ -579,6 +583,36 @@ const createAuthComponentFromFlow = ( return null; } + case EmbeddedFlowComponentType.UserSelect: { + const identifier: string = component.ref!; + const value: string = formValues[identifier] || ''; + const isTouched: boolean = touchedFields[identifier] || false; + const error: string = isTouched ? formErrors[identifier] : undefined!; + + if (!options.fetchUsers) { + logger.warn('USER_SELECT is only rendered where a signed-in user is available (InviteUser). Skipping render.'); + return null; + } + + return ( + onInputChange(identifier, e.target.value)} + onBlur={(): void => options.onInputBlur?.(identifier)} + className={cx(options.inputClassName, component.classes)} + /> + ); + } + case EmbeddedFlowComponentType.Block: { if (component.components && component.components.length > 0) { const formClass: string = css({ @@ -1041,6 +1075,8 @@ export const renderInviteUserComponents = ( offset: number, ) => Promise; inputClassName?: string; + /** Data source for USER_SELECT fields. */ + fetchUsers?: FetchUsers; /** Flow metadata for resolving {{meta(...)}} expressions at render time */ meta?: FlowMetadataResponse | null; onInputBlur?: (name: string) => void; diff --git a/packages/react/src/components/presentation/auth/InviteUser/BaseInviteUser.tsx b/packages/react/src/components/presentation/auth/InviteUser/BaseInviteUser.tsx index 352ce406..8ce6f60e 100644 --- a/packages/react/src/components/presentation/auth/InviteUser/BaseInviteUser.tsx +++ b/packages/react/src/components/presentation/auth/InviteUser/BaseInviteUser.tsx @@ -7,6 +7,7 @@ import { FlowExecutionError, FlowMetadataResponse, buildValidatorFromRules, + FetchUsers, logger, OrganizationUnitListResponse, Preferences, @@ -162,6 +163,12 @@ export interface BaseInviteUserProps { offset: number, ) => Promise; + /** + * Function to load one page of users. + * When provided, enables the user picker for USER_SELECT components. + */ + fetchUsers?: FetchUsers; + /** * Whether the SDK is initialized. */ @@ -240,6 +247,7 @@ const BaseInviteUser: FC = ({ className = '', children, fetchOrganizationUnitChildren, + fetchUsers, isInitialized = true, preferences, size = 'medium', @@ -413,7 +421,8 @@ const BaseInviteUser: FC = ({ comp.type === 'SELECT' || comp.type === 'PHONE_INPUT' || comp.type === 'OTP_INPUT' || - comp.type === 'DATE_INPUT') && + comp.type === 'DATE_INPUT' || + comp.type === 'USER_SELECT') && comp.ref ) { const value: any = formValues[comp.ref]; @@ -650,6 +659,7 @@ const BaseInviteUser: FC = ({ _theme: theme, additionalData: currentFlow?.data?.additionalData, fetchOrganizationUnitChildren, + fetchUsers, onInputBlur: handleInputBlur, onSubmit: handleSubmit, size, @@ -660,6 +670,7 @@ const BaseInviteUser: FC = ({ customRenderers, currentFlow?.data?.additionalData, fetchOrganizationUnitChildren, + fetchUsers, formValues, touchedFields, formErrors, diff --git a/packages/react/src/components/presentation/auth/InviteUser/InviteUser.tsx b/packages/react/src/components/presentation/auth/InviteUser/InviteUser.tsx index 0e7b11ed..5f958f05 100644 --- a/packages/react/src/components/presentation/auth/InviteUser/InviteUser.tsx +++ b/packages/react/src/components/presentation/auth/InviteUser/InviteUser.tsx @@ -3,6 +3,7 @@ import { EmbeddedFlowType, + FetchUsers, getOrganizationUnitChildren, OrganizationUnitListResponse, resolveResourceEndpoint, @@ -11,6 +12,7 @@ import {FC, ReactElement, ReactNode, useCallback} from 'react'; // eslint-disable-next-line import/no-named-as-default import BaseInviteUser, {BaseInviteUserRenderProps, InviteUserFlowResponse} from './BaseInviteUser'; import useThunderID from '../../../../contexts/ThunderID/useThunderID'; +import useFetchUsers from '../../../../hooks/useFetchUsers'; /** * Render props for InviteUser (re-exported for convenience). @@ -181,6 +183,8 @@ const InviteUser: FC = ({ [baseUrl, getAccessToken], ); + const fetchUsers: FetchUsers = useFetchUsers(); + return ( = ({ onFlowChange={onFlowChange} className={className} fetchOrganizationUnitChildren={fetchOrganizationUnitChildren} + fetchUsers={fetchUsers} isInitialized={isInitialized} size={size} variant={variant} diff --git a/packages/react/src/components/presentation/auth/InviteUser/__tests__/BaseInviteUser.test.tsx b/packages/react/src/components/presentation/auth/InviteUser/__tests__/BaseInviteUser.test.tsx new file mode 100644 index 00000000..7bf8f707 --- /dev/null +++ b/packages/react/src/components/presentation/auth/InviteUser/__tests__/BaseInviteUser.test.tsx @@ -0,0 +1,111 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {act, render, waitFor} from '@testing-library/react'; +import {createTheme, EmbeddedFlowComponentType, FetchUsers} from '@thunderid/browser'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import BaseInviteUser from '../BaseInviteUser'; + +const mocks = vi.hoisted(() => ({ + renderInviteUserComponents: vi.fn(), +})); + +vi.mock('../../AuthOptionFactory', async (importOriginal: () => Promise>) => ({ + ...(await importOriginal()), + renderInviteUserComponents: mocks.renderInviteUserComponents, +})); +vi.mock('../../../../../contexts/Theme/useTheme', () => ({default: () => ({theme: createTheme()})})); +vi.mock('../../../../../contexts/ThunderID/useThunderID', () => ({ + default: () => ({getStorageManager: vi.fn(), isInitialized: true, meta: null}), +})); +vi.mock('../../../../../hooks/useTranslation', () => ({ + default: () => ({t: (key: string): string => key}), +})); + +const ownerInput = {id: 'owner_input', ref: 'owner', type: EmbeddedFlowComponentType.UserSelect}; + +describe('BaseInviteUser USER_SELECT data source', () => { + beforeEach(() => { + mocks.renderInviteUserComponents.mockReset().mockReturnValue([]); + }); + + it('gives the flow renderer the data source it was supplied', async () => { + const fetchUsers: FetchUsers = vi.fn(); + const onInitialize = vi.fn().mockResolvedValue({ + data: {components: [ownerInput]}, + executionId: 'exec-1', + flowStatus: 'INCOMPLETE', + }); + + render(); + + await waitFor(() => expect(mocks.renderInviteUserComponents).toHaveBeenCalled()); + const options = mocks.renderInviteUserComponents.mock.calls.at(-1)![8] as {fetchUsers?: FetchUsers}; + expect(options.fetchUsers).toBe(fetchUsers); + }); +}); + +describe('BaseInviteUser USER_SELECT required-field validation', () => { + interface RenderOptions { + onSubmit: (component: unknown) => Promise; + } + + const mountWithRequiredOwner = async (required: boolean) => { + const onSubmit = vi.fn().mockResolvedValue({data: {components: []}, flowStatus: 'INCOMPLETE'}); + const onInitialize = vi.fn().mockResolvedValue({ + data: { + components: [ + {...ownerInput, label: 'Owner', required}, + {id: 'invite_action', type: 'ACTION'}, + ], + }, + executionId: 'exec-1', + flowStatus: 'INCOMPLETE', + }); + + render(); + await waitFor(() => expect(mocks.renderInviteUserComponents).toHaveBeenCalled()); + + const lastCall = (): unknown[] => mocks.renderInviteUserComponents.mock.calls.at(-1)!; + const submit = (): Promise => + act(async () => { + await (lastCall()[8] as RenderOptions).onSubmit({id: 'invite_action'}); + }); + + return {lastCall, onSubmit, submit}; + }; + + beforeEach(() => { + mocks.renderInviteUserComponents.mockReset().mockReturnValue([]); + }); + + it('blocks submission and surfaces a field error when a required picker is empty', async () => { + const {lastCall, onSubmit, submit} = await mountWithRequiredOwner(true); + + await submit(); + + expect(onSubmit).not.toHaveBeenCalled(); + await waitFor(() => expect(lastCall()[3]).toHaveProperty('owner')); + }); + + it('lets an optional picker stay empty', async () => { + const {onSubmit, submit} = await mountWithRequiredOwner(false); + + await submit(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('lets a required picker through once a user has been chosen', async () => { + const {lastCall, onSubmit, submit} = await mountWithRequiredOwner(true); + + act(() => { + (lastCall()[7] as (name: string, value: string) => void)('owner', 'user-1'); + }); + await submit(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + const expectedInputs: unknown = expect.objectContaining({owner: 'user-1'}); + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({inputs: expectedInputs})); + }); +}); diff --git a/packages/react/src/components/presentation/auth/InviteUser/__tests__/InviteUser.test.tsx b/packages/react/src/components/presentation/auth/InviteUser/__tests__/InviteUser.test.tsx new file mode 100644 index 00000000..6bfc1553 --- /dev/null +++ b/packages/react/src/components/presentation/auth/InviteUser/__tests__/InviteUser.test.tsx @@ -0,0 +1,111 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {render} from '@testing-library/react'; +import {FetchUsers} from '@thunderid/browser'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import InviteUser from '../InviteUser'; + +const mocks = vi.hoisted(() => ({ + baseProps: {} as {fetchUsers?: FetchUsers}, + getUsers: vi.fn(), + thunderID: { + baseUrl: 'https://thunder.example', + endpoints: undefined as {users?: string} | undefined, + getAccessToken: vi.fn(), + http: {request: vi.fn()}, + isInitialized: true, + }, +})); + +vi.mock('../BaseInviteUser', () => ({ + default: (props: {fetchUsers?: FetchUsers}): null => { + mocks.baseProps = props; + return null; + }, +})); +vi.mock('@thunderid/browser', async (importOriginal) => ({ + ...(await importOriginal()), + getUsers: mocks.getUsers, +})); +vi.mock('../../../../../contexts/ThunderID/useThunderID', () => ({default: () => mocks.thunderID})); + +const usersResponse = { + count: 2, + links: [], + startIndex: 31, + totalResults: 75, + users: [ + {display: 'Ada Lovelace', id: 'user-1'}, + {display: 'Grace Hopper', id: 'user-2'}, + ], +}; + +describe('InviteUser USER_SELECT data source', () => { + beforeEach(() => { + mocks.baseProps = {}; + mocks.thunderID.endpoints = undefined; + mocks.getUsers.mockReset().mockResolvedValue(usersResponse); + }); + + it('hands BaseInviteUser a data source for USER_SELECT fields', () => { + render(); + + expect(mocks.baseProps.fetchUsers).toBeTypeOf('function'); + }); + + it('pages the user directory from the base URL and turns the response into a picker page', async () => { + render(); + const signal = new AbortController().signal; + + const page = await mocks.baseProps.fetchUsers!({ + component: {id: 'owner_input', type: 'USER_SELECT'}, + filter: undefined, + limit: 30, + offset: 30, + signal, + }); + + const anyFetcher: unknown = expect.any(Function); + expect(mocks.getUsers).toHaveBeenCalledWith({ + baseUrl: 'https://thunder.example', + fetcher: anyFetcher, + filter: undefined, + limit: 30, + offset: 30, + signal, + url: undefined, + }); + expect(page.nextOffset).toBe(32); + expect(page.totalResults).toBe(75); + expect(page.options).toEqual([ + {label: 'Ada Lovelace', value: 'user-1'}, + {label: 'Grace Hopper', value: 'user-2'}, + ]); + }); + + it('honors the `users` endpoint override', async () => { + mocks.thunderID.endpoints = {users: 'https://resources.example/users'}; + render(); + + await mocks.baseProps.fetchUsers!({ + component: {id: 'owner_input', type: 'USER_SELECT'}, + limit: 30, + offset: 0, + signal: new AbortController().signal, + }); + + expect(mocks.getUsers).toHaveBeenCalledWith(expect.objectContaining({url: 'https://resources.example/users'})); + }); + + it('keeps the same data source across re-renders so an open picker keeps its list', () => { + mocks.thunderID.endpoints = {users: 'https://resources.example/users'}; + const {rerender} = render(); + const first = mocks.baseProps.fetchUsers; + + mocks.thunderID.endpoints = {users: 'https://resources.example/users'}; + rerender(); + + expect(mocks.baseProps.fetchUsers).toBe(first); + }); +}); diff --git a/packages/react/src/components/presentation/auth/__tests__/AuthOptionFactory.test.tsx b/packages/react/src/components/presentation/auth/__tests__/AuthOptionFactory.test.tsx index 735b6a83..b3d3a3e0 100644 --- a/packages/react/src/components/presentation/auth/__tests__/AuthOptionFactory.test.tsx +++ b/packages/react/src/components/presentation/auth/__tests__/AuthOptionFactory.test.tsx @@ -1,7 +1,7 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {render} from '@testing-library/react'; +import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import { ConsentConstants, createTheme, @@ -9,10 +9,12 @@ import { EmbeddedFlowComponent, EmbeddedFlowComponentType, EmbeddedFlowEventType, + PagedSelectPage, } from '@thunderid/browser'; -import {describe, expect, it, vi} from 'vitest'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import I18nProvider from '../../../../contexts/I18n/I18nProvider'; import ThemeContext, {ThemeContextValue} from '../../../../contexts/Theme/ThemeContext'; -import {renderSignInComponents} from '../AuthOptionFactory'; +import {renderInviteUserComponents, renderSignInComponents} from '../AuthOptionFactory'; const richTextWithLink = (label: string, action?: {ref: string; eventType?: string}): EmbeddedFlowComponent => ({ action, @@ -402,3 +404,103 @@ describe('AuthOptionFactory consent decisions', () => { expect(purpose.elements[0]).toEqual({approved: true, name: 'system'}); }); }); + +describe('AuthOptionFactory USER_SELECT', () => { + afterEach(() => { + cleanup(); + }); + + const themeContextValue: ThemeContextValue = { + colorScheme: 'light', + direction: 'ltr', + theme: createTheme(), + toggleTheme: vi.fn(), + }; + + const ownerInput: EmbeddedFlowComponent = { + id: 'owner_input', + label: 'Owner', + placeholder: 'Select an owner', + ref: 'owner', + required: false, + type: EmbeddedFlowComponentType.UserSelect, + }; + + const usersPage: PagedSelectPage = { + items: [ + {attributes: {username: 'ada'}, display: 'Ada Lovelace', id: 'user-1'}, + {attributes: {username: 'grace'}, display: 'Grace Hopper', id: 'user-2'}, + ], + nextOffset: null, + totalResults: 2, + }; + + const renderOwnerInput = ({ + fetchUsers, + formErrors = {}, + onInputChange = vi.fn(), + touchedFields = {}, + }: { + fetchUsers?: ReturnType; + formErrors?: Record; + onInputChange?: (name: string, value: string) => void; + touchedFields?: Record; + }): ReturnType => { + const elements = renderInviteUserComponents( + [ownerInput], + {owner: ''}, + touchedFields, + formErrors, + false, + true, + () => undefined, + onInputChange, + {fetchUsers}, + ); + + return render( + + {elements} + , + ); + }; + + it('renders a labelled picker that requests no users until it is opened', () => { + const fetchUsers = vi.fn(); + renderOwnerInput({fetchUsers}); + + expect(screen.getByRole('combobox', {name: /owner/i})).toBeInTheDocument(); + expect(fetchUsers).not.toHaveBeenCalled(); + }); + + it("loads the first page from the supplied data source and submits the chosen user's ID under the field ref", async () => { + const fetchUsers = vi.fn().mockResolvedValue(usersPage); + const onInputChange = vi.fn(); + renderOwnerInput({fetchUsers, onInputChange}); + + fireEvent.click(screen.getByRole('combobox', {name: /owner/i})); + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(2)); + expect(fetchUsers).toHaveBeenCalledTimes(1); + expect(fetchUsers).toHaveBeenCalledWith(expect.objectContaining({limit: 30, offset: 0})); + + fireEvent.click(screen.getByRole('option', {name: 'Grace Hopper'})); + + expect(onInputChange).toHaveBeenCalledWith('owner', 'user-2'); + }); + + it('shows the field error once the field has been touched', () => { + renderOwnerInput({ + fetchUsers: vi.fn(), + formErrors: {owner: 'Owner is not valid'}, + touchedFields: {owner: true}, + }); + + expect(screen.getByText('Owner is not valid')).toBeInTheDocument(); + }); + + it('renders nothing when no data source is supplied', () => { + const {container} = renderOwnerInput({}); + + expect(container.querySelector('[role="combobox"]')).toBeNull(); + }); +}); diff --git a/packages/react/src/components/presentation/auth/__tests__/UserSelectFlowWiring.test.tsx b/packages/react/src/components/presentation/auth/__tests__/UserSelectFlowWiring.test.tsx new file mode 100644 index 00000000..3ce1ee19 --- /dev/null +++ b/packages/react/src/components/presentation/auth/__tests__/UserSelectFlowWiring.test.tsx @@ -0,0 +1,120 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {render, screen, cleanup, fireEvent, waitFor} from '@testing-library/react'; +import {EmbeddedFlowComponentType} from '@thunderid/browser'; +import type {EmbeddedFlowComponent} from '@thunderid/browser'; +import {ReactElement} from 'react'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import I18nProvider from '../../../../contexts/I18n/I18nProvider'; +import ThemeProvider from '../../../../contexts/Theme/ThemeProvider'; +import ThunderIDContext, {ThunderIDContextProps} from '../../../../contexts/ThunderID/ThunderIDContext'; +import BaseAcceptInvite from '../AcceptInvite/BaseAcceptInvite'; +import BaseRecovery from '../Recovery/BaseRecovery'; +import BaseSignIn from '../SignIn/BaseSignIn'; +import BaseSignUp from '../SignUp/BaseSignUp'; + +const thunderIDContext: ThunderIDContextProps = { + applicationId: 'app-1', + getStorageManager: vi.fn(() => + Promise.resolve({ + getTemporaryData: vi.fn(() => Promise.resolve({})), + removeTemporaryDataParameter: vi.fn(), + setTemporaryDataParameter: vi.fn(), + }), + ), + isInitialized: true, + isLoading: false, + meta: null, + vendor: 'thunderid', +} as unknown as ThunderIDContextProps; + +const withProviders = (ui: ReactElement): ReactElement => ( + + + {ui} + + +); + +const ownerPicker = (required = false): EmbeddedFlowComponent => + ({ + id: 'owner_input', + label: 'Owner', + ref: 'owner', + required, + type: EmbeddedFlowComponentType.UserSelect, + }) as EmbeddedFlowComponent; + +const submitAction = { + eventType: 'SUBMIT', + id: 'continue_action', + label: 'Continue', + type: EmbeddedFlowComponentType.Action, + variant: 'PRIMARY', +} as EmbeddedFlowComponent; + +const incompleteStep = (components: EmbeddedFlowComponent[]) => ({ + data: {components}, + executionId: 'exec-1', + flowStatus: 'INCOMPLETE', +}); + +/** The shape the server sends and BaseSignUp normalizes: components live under `data.meta`. */ +const incompleteServerStep = (components: EmbeddedFlowComponent[]) => ({ + data: {meta: {components}}, + executionId: 'exec-1', + flowStatus: 'INCOMPLETE', +}); + +const ownerPickerOnly = (): EmbeddedFlowComponent[] => [ownerPicker(), submitAction]; + +const expectNoPicker = async (): Promise => { + await screen.findByRole('button', {name: /continue/i}); + expect(screen.queryByRole('combobox', {name: /owner/i})).not.toBeInTheDocument(); +}; + +describe('USER_SELECT is skipped where no signed-in user exists', () => { + afterEach(() => { + cleanup(); + }); + + it('BaseSignIn skips the picker and still renders the rest of the step', async () => { + render(withProviders()); + + await expectNoPicker(); + }); + + it('BaseSignUp skips the picker and still renders the rest of the step', async () => { + const onInitialize = vi.fn().mockResolvedValue(incompleteServerStep(ownerPickerOnly())); + + render(withProviders()); + + await expectNoPicker(); + }); + + it('BaseRecovery skips the picker and still renders the rest of the step', async () => { + const onInitialize = vi.fn().mockResolvedValue(incompleteStep(ownerPickerOnly())); + + render(withProviders()); + + await expectNoPicker(); + }); + + it('BaseAcceptInvite skips the picker and still renders the rest of the step', async () => { + const onSubmit = vi.fn().mockResolvedValue(incompleteStep(ownerPickerOnly())); + + render(withProviders()); + + await expectNoPicker(); + }); + + it('a required USER_SELECT that is not rendered does not block the step from being submitted', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + + render(withProviders()); + fireEvent.click(await screen.findByRole('button', {name: /continue/i})); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/packages/react/src/components/primitives/PagedSelect/PagedSelect.styles.ts b/packages/react/src/components/primitives/PagedSelect/PagedSelect.styles.ts new file mode 100644 index 00000000..f0b43383 --- /dev/null +++ b/packages/react/src/components/primitives/PagedSelect/PagedSelect.styles.ts @@ -0,0 +1,159 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {Theme} from '@thunderid/browser'; +import {useMemo} from 'react'; +import {css} from '../../../styles/emotion'; + +const useStyles = (theme: Theme, colorScheme: string, disabled: boolean, hasError: boolean): Record => + useMemo(() => { + const root: string = css` + position: relative; + width: 100%; + `; + + const trigger: string = css` + display: flex; + align-items: center; + justify-content: space-between; + gap: ${theme.vars.spacing.unit}; + width: 100%; + padding: ${theme.vars.spacing.unit} calc(${theme.vars.spacing.unit} * 1.5); + border: 1px solid ${hasError ? theme.vars.colors.error.main : theme.vars.colors.border}; + border-radius: ${theme.vars.components?.Field?.root?.borderRadius ?? theme.vars.borderRadius.medium}; + font-size: ${theme.vars.typography.fontSizes.md}; + font-family: ${theme.vars.typography.fontFamily}; + color: ${theme.vars.colors.text.primary}; + background-color: ${disabled ? theme.vars.colors.background.disabled : theme.vars.colors.background.surface}; + cursor: ${disabled ? 'not-allowed' : 'pointer'}; + text-align: left; + + &:focus-visible { + border-color: ${hasError ? theme.vars.colors.error.main : theme.vars.colors.primary.main}; + box-shadow: 0 0 0 2px ${hasError ? `${theme.vars.colors.error.main}20` : `${theme.vars.colors.primary.main}20`}; + outline: none; + } + `; + + const triggerError: string = css` + border-color: ${theme.vars.colors.error.main}; + `; + + const triggerDisabled: string = css` + opacity: 0.6; + cursor: not-allowed; + `; + + const triggerLabel: string = css` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: ${theme.vars.colors.text.primary}; + `; + + const content: string = css` + display: flex; + flex-direction: column; + max-height: 240px; + overflow-y: auto; + min-width: 200px; + background-color: ${theme.vars.colors.background.surface}; + border: 1px solid ${theme.vars.colors.border}; + border-radius: ${theme.vars.borderRadius.medium}; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + z-index: 1000; + `; + + const option: string = css` + display: flex; + align-items: center; + gap: calc(${theme.vars.spacing.unit} / 2); + padding: calc(${theme.vars.spacing.unit} / 2) ${theme.vars.spacing.unit}; + border: none; + background: none; + color: ${theme.vars.colors.text.primary}; + font-size: ${theme.vars.typography.fontSizes.md}; + font-family: ${theme.vars.typography.fontFamily}; + text-align: left; + cursor: pointer; + + &:hover:not(:disabled) { + background-color: ${theme.vars.colors.action.hover}; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + `; + + const optionActive: string = css` + background-color: ${theme.vars.colors.primary.main}10; + font-weight: 600; + `; + + const optionLabel: string = css` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + `; + + const statusRow: string = css` + display: flex; + align-items: center; + gap: calc(${theme.vars.spacing.unit} / 2); + padding: ${theme.vars.spacing.unit}; + color: ${theme.vars.colors.text.secondary}; + font-size: ${theme.vars.typography.fontSizes.sm}; + `; + + const retryButton: string = css` + border: none; + background: none; + color: ${theme.vars.colors.primary.main}; + cursor: pointer; + font-weight: 600; + padding: 0; + `; + + const loadMore: string = css` + border: none; + border-top: 1px solid ${theme.vars.colors.border}; + background: none; + color: ${theme.vars.colors.primary.main}; + cursor: pointer; + font-weight: 600; + padding: ${theme.vars.spacing.unit}; + text-align: center; + `; + + const visuallyHidden: string = css` + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + `; + + return { + content, + loadMore, + option, + optionActive, + optionLabel, + retryButton, + root, + statusRow, + trigger, + triggerDisabled, + triggerError, + triggerLabel, + visuallyHidden, + }; + }, [theme, colorScheme, disabled, hasError]); + +export default useStyles; diff --git a/packages/react/src/components/primitives/PagedSelect/PagedSelect.tsx b/packages/react/src/components/primitives/PagedSelect/PagedSelect.tsx new file mode 100644 index 00000000..fe83f16c --- /dev/null +++ b/packages/react/src/components/primitives/PagedSelect/PagedSelect.tsx @@ -0,0 +1,328 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + autoUpdate, + flip, + FloatingFocusManager, + FloatingPortal, + offset as floatingOffset, + shift, + size as floatingSize, + useClick, + useDismiss, + useFloating, + useInteractions, + useListNavigation, + useRole, +} from '@floating-ui/react'; +import type {MiddlewareState} from '@floating-ui/react'; +import {bem, withVendorCSSClassPrefix} from '@thunderid/browser'; +import type { + EmbeddedFlowComponent, + FetchPagedOptions, + PagedSelectOption, + PagedSelectOptionDefaults, + PagedSelectOptionMapping, +} from '@thunderid/browser'; +import {FC, UIEvent, useMemo, useRef, useState} from 'react'; +import useStyles from './PagedSelect.styles'; +import useTheme from '../../../contexts/Theme/useTheme'; +import usePagedSelect from '../../../hooks/usePagedSelect'; +import useTranslation from '../../../hooks/useTranslation'; +import {cx} from '../../../styles/emotion'; +import FormControl from '../FormControl/FormControl'; +import Check from '../Icons/Check'; +import ChevronDown from '../Icons/ChevronDown'; +import InputLabel from '../InputLabel/InputLabel'; +import Spinner from '../Spinner/Spinner'; + +const SCROLL_NEAR_BOTTOM_THRESHOLD_PX = 64; + +export interface PagedSelectChangeEvent { + target: {value: string}; +} + +export interface PagedSelectMessages { + ariaLabel: string; + empty: string; + loaded: (count: number) => string; + loadMore: string; + loading: string; + loadingMore: string; + retry: string; +} + +export interface PagedSelectProps { + className?: string; + component?: EmbeddedFlowComponent; + defaults?: PagedSelectOptionDefaults; + disabled?: boolean; + error?: string; + /** Loads one page at a time. Nothing is requested while this is unset. */ + fetchOptions?: FetchPagedOptions; + filter?: string; + helperText?: string; + id?: string; + label?: string; + /** Which field of each raw item is the label and which is the value. */ + mapping?: PagedSelectOptionMapping; + messages?: Partial; + name?: string; + onBlur?: () => void; + onChange?: (event: PagedSelectChangeEvent) => void; + pageSize?: number; + placeholder?: string; + required?: boolean; + /** The selected value, or an empty string when nothing is selected. */ + value?: string; +} + +const DEFAULT_MESSAGES: PagedSelectMessages = { + ariaLabel: 'Select an option', + empty: 'No results found.', + loaded: (count: number): string => `${count} result${count === 1 ? '' : 's'} loaded.`, + loadMore: 'Load more', + loading: 'Loading…', + loadingMore: 'Loading more…', + retry: 'Retry', +}; + +/** + * Single-choice dropdown whose options load a page at a time from `fetchOptions`. Specialised + * pickers (users, emails, ...) wrap it with their own loader, defaults, and wording. + */ +const PagedSelect: FC = ({ + className, + component, + defaults, + disabled = false, + error, + fetchOptions, + filter, + helperText, + id, + label, + mapping, + messages, + name, + onBlur, + onChange, + pageSize, + placeholder, + required, + value = '', +}: PagedSelectProps) => { + const {theme, colorScheme}: ReturnType = useTheme(); + const {t} = useTranslation(); + const hasError = !!error; + const styles: Record = useStyles(theme, colorScheme, disabled, hasError); + const text: PagedSelectMessages = { + ariaLabel: t('elements.fields.paged_select.aria_label') || DEFAULT_MESSAGES.ariaLabel, + empty: t('elements.fields.paged_select.empty') || DEFAULT_MESSAGES.empty, + loaded: (count: number): string => + t('elements.fields.paged_select.loaded', {count}) || DEFAULT_MESSAGES.loaded(count), + loadMore: t('elements.fields.paged_select.load_more') || DEFAULT_MESSAGES.loadMore, + loading: t('elements.fields.paged_select.loading') || DEFAULT_MESSAGES.loading, + loadingMore: t('elements.fields.paged_select.loading_more') || DEFAULT_MESSAGES.loadingMore, + retry: t('elements.fields.paged_select.retry') || DEFAULT_MESSAGES.retry, + ...messages, + }; + + const [isOpen, setIsOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(null); + const listRef = useRef<(HTMLElement | null)[]>([]); + + const { + abort, + error: loadError, + hasMore, + isLoading, + isLoadingMore, + loadInitial, + loadMore, + options, + retry, + } = usePagedSelect({component, defaults, fetchOptions, fieldId: id ?? name, filter, mapping, pageSize}); + + const {refs, floatingStyles, context} = useFloating({ + middleware: [ + floatingOffset(4), + flip(), + shift(), + floatingSize({ + apply({rects, elements}: MiddlewareState): void { + Object.assign(elements.floating.style, {width: `${rects.reference.width}px`}); + }, + }), + ], + onOpenChange: (open: boolean): void => { + setIsOpen(open); + if (open) { + loadInitial(); + } else { + abort(); + setActiveIndex(null); + } + }, + open: isOpen, + placement: 'bottom-start', + whileElementsMounted: autoUpdate, + }); + + const disabledIndices: number[] = useMemo( + () => options.reduce((acc, option, index) => (option.disabled ? [...acc, index] : acc), []), + [options], + ); + + const click: ReturnType = useClick(context, {enabled: !disabled}); + const dismiss: ReturnType = useDismiss(context); + const role: ReturnType = useRole(context, {role: 'listbox'}); + const listNavigation: ReturnType = useListNavigation(context, { + activeIndex, + disabledIndices, + listRef, + loop: true, + onNavigate: setActiveIndex, + }); + const {getReferenceProps, getFloatingProps, getItemProps} = useInteractions([click, dismiss, role, listNavigation]); + + const handleScroll = (event: UIEvent): void => { + const target = event.currentTarget; + + if (target.scrollHeight - target.scrollTop - target.clientHeight <= SCROLL_NEAR_BOTTOM_THRESHOLD_PX) { + loadMore(); + } + }; + + const handleSelect = (option: PagedSelectOption): void => { + if (option.disabled) { + return; + } + onChange?.({target: {value: option.value}}); + setIsOpen(false); + }; + + const selectedOption: PagedSelectOption | undefined = options.find( + (option: PagedSelectOption) => option.value === value, + ); + const triggerLabel: string = value ? (selectedOption?.label ?? value) : ''; + const errorText: string | null = loadError ? (loadError.message ?? t(loadError.messageKey)) : null; + const statusMessage: string = isLoading ? text.loading : (errorText ?? text.loaded(options.length)); + + return ( + + {label && ( + + {label} + + )} +
+ + + {isOpen && ( + + {/* initialFocus={-1}: options are roving-tabindex (-1), so the default "first tabbable" would be the Load more button, and focusing it scrolls the list to the bottom and pages in more results. */} + +
+ {isLoading && options.length === 0 && ( +
+ {text.loading} +
+ )} + + {!isLoading && errorText && options.length === 0 && ( +
+ {errorText} + +
+ )} + + {!isLoading && !errorText && options.length === 0 && !hasMore && ( +
{text.empty}
+ )} + + {options.map((option: PagedSelectOption, index: number) => ( + + ))} + + {isLoadingMore && ( +
+ {text.loadingMore} +
+ )} + + {!isLoadingMore && errorText && options.length > 0 && ( +
+ {errorText} + +
+ )} + + {!isLoadingMore && !errorText && hasMore && ( + + )} +
+
+
+ )} +
+ + {statusMessage} + +
+ ); +}; + +export default PagedSelect; diff --git a/packages/react/src/components/primitives/PagedSelect/__tests__/PagedSelect.test.tsx b/packages/react/src/components/primitives/PagedSelect/__tests__/PagedSelect.test.tsx new file mode 100644 index 00000000..4809b3da --- /dev/null +++ b/packages/react/src/components/primitives/PagedSelect/__tests__/PagedSelect.test.tsx @@ -0,0 +1,304 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {render, screen, cleanup, fireEvent, waitFor, within} from '@testing-library/react'; +import { + createTheme, + FetchPagedOptions, + PagedSelectPage, + PagedSelectRequest, + ThunderIDAPIError, +} from '@thunderid/browser'; +import {ReactElement} from 'react'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import I18nProvider from '../../../../contexts/I18n/I18nProvider'; +import ThemeContext, {ThemeContextValue} from '../../../../contexts/Theme/ThemeContext'; +import PagedSelect from '../PagedSelect'; + +const themeContextValue: ThemeContextValue = { + colorScheme: 'light', + direction: 'ltr', + theme: createTheme(), + toggleTheme: vi.fn(), +}; + +const withProviders = (ui: ReactElement): ReactElement => ( + + {ui} + +); + +const page = (start: number, count: number, totalResults: number): PagedSelectPage => ({ + nextOffset: start + count < totalResults ? start + count : null, + options: Array.from({length: count}, (_, index) => ({ + label: `User ${start + index + 1}`, + value: `user-${start + index + 1}`, + })), + totalResults, +}); + +describe('PagedSelect', () => { + afterEach(() => { + cleanup(); + }); + + it('makes no request while closed', () => { + const fetchOptions: FetchPagedOptions = vi.fn(); + render(withProviders()); + + expect(fetchOptions).not.toHaveBeenCalled(); + }); + + it('requests exactly one page of the default size on first open', async () => { + const fetchOptions: FetchPagedOptions = vi.fn().mockResolvedValue(page(0, 30, 75)); + render(withProviders()); + + fireEvent.click(screen.getByRole('combobox', {name: /assignee/i})); + + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(30)); + expect(fetchOptions).toHaveBeenCalledTimes(1); + expect(fetchOptions).toHaveBeenCalledWith(expect.objectContaining({filter: undefined, limit: 30, offset: 0})); + }); + + it('does not refetch on reopening within the same session', async () => { + const fetchOptions: FetchPagedOptions = vi.fn().mockResolvedValue(page(0, 30, 30)); + render(withProviders()); + + const trigger = screen.getByRole('combobox', {name: /assignee/i}); + fireEvent.click(trigger); + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(30)); + + fireEvent.click(trigger); // close + fireEvent.click(trigger); // reopen + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(30)); + + expect(fetchOptions).toHaveBeenCalledTimes(1); + }); + + it('loads the next page when the listbox scrolls near the bottom', async () => { + const fetchOptions: FetchPagedOptions = vi + .fn() + .mockResolvedValueOnce(page(0, 30, 75)) + .mockResolvedValueOnce(page(30, 30, 75)); + + render(withProviders()); + fireEvent.click(screen.getByRole('combobox', {name: /assignee/i})); + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(30)); + + const listbox: HTMLElement = screen.getByRole('listbox'); + Object.defineProperty(listbox, 'scrollHeight', {configurable: true, value: 1000}); + Object.defineProperty(listbox, 'clientHeight', {configurable: true, value: 300}); + Object.defineProperty(listbox, 'scrollTop', {configurable: true, value: 1000 - 300}); + + fireEvent.scroll(listbox); + + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(60)); + expect(fetchOptions).toHaveBeenCalledTimes(2); + expect(fetchOptions).toHaveBeenNthCalledWith(2, expect.objectContaining({limit: 30, offset: 30})); + }); + + it('stops paging once nextOffset is null', async () => { + const fetchOptions: FetchPagedOptions = vi.fn().mockResolvedValue(page(0, 10, 10)); + render(withProviders()); + + fireEvent.click(screen.getByRole('combobox', {name: /assignee/i})); + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(10)); + + expect(screen.queryByRole('button', {name: /load more/i})).not.toBeInTheDocument(); + }); + + it('submits the option value, not its label, on selection', async () => { + const fetchOptions: FetchPagedOptions = vi.fn().mockResolvedValue(page(0, 3, 3)); + const onChange = vi.fn(); + render(withProviders()); + + fireEvent.click(screen.getByRole('combobox', {name: /assignee/i})); + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(3)); + + fireEvent.click(screen.getByRole('option', {name: 'User 2'})); + + expect(onChange).toHaveBeenCalledWith({target: {value: 'user-2'}}); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('shows a retry control when the first page fails, and recovers on retry', async () => { + const fetchOptions: FetchPagedOptions = vi + .fn() + .mockRejectedValueOnce(new ThunderIDAPIError('Upstream exploded', 'test-code', 'test-origin', 500)) + .mockResolvedValueOnce(page(0, 5, 5)); + + render(withProviders()); + fireEvent.click(screen.getByRole('combobox', {name: /assignee/i})); + + expect(await within(screen.getByRole('listbox')).findByText('Upstream exploded')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', {name: /retry/i})); + + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(5)); + expect(fetchOptions).toHaveBeenCalledTimes(2); + }); + + it('shows the generic translated message instead of a raw exception', async () => { + const fetchOptions: FetchPagedOptions = vi.fn().mockRejectedValueOnce(new Error('a raw exception message')); + + render(withProviders()); + fireEvent.click(screen.getByRole('combobox', {name: /assignee/i})); + + expect(await within(screen.getByRole('listbox')).findByText('Failed to load options.')).toBeInTheDocument(); + expect(screen.queryByText('a raw exception message')).not.toBeInTheDocument(); + }); + + it('renders no request-driving UI when fetchOptions is not provided but still shows the trigger', () => { + render(withProviders()); + + expect(screen.getByRole('combobox', {name: /assignee/i})).toBeInTheDocument(); + }); + + it('preserves a previously selected ID even when its page is not loaded', () => { + render(withProviders()); + + expect(within(screen.getByRole('combobox', {name: /assignee/i})).getByText('user-99')).toBeInTheDocument(); + }); + + it('forwards the request payload passed to fetchOptions, including the AbortSignal', async () => { + let received: PagedSelectRequest | undefined; + const fetchOptions: FetchPagedOptions = vi.fn().mockImplementation((request: PagedSelectRequest) => { + received = request; + return Promise.resolve(page(0, 1, 1)); + }); + + render( + withProviders(), + ); + fireEvent.click(screen.getByRole('combobox', {name: /assignee/i})); + + await waitFor(() => expect(received).toBeDefined()); + expect(received?.filter).toBe('department eq "Engineering"'); + expect(received?.signal).toBeInstanceOf(AbortSignal); + }); + + it('shows the configured label field and submits the configured value field for raw objects', async () => { + const fetchOptions: FetchPagedOptions = vi.fn().mockResolvedValue({ + items: [ + {email: 'alice@example.com', fullName: 'Alice Example', id: 'u1'}, + {email: 'bob@example.com', fullName: 'Bob Example', id: 'u2'}, + ], + nextOffset: null, + }); + const onChange = vi.fn(); + render( + withProviders( + , + ), + ); + + fireEvent.click(screen.getByRole('combobox', {name: /assignee/i})); + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(2)); + fireEvent.click(screen.getByRole('option', {name: 'Bob Example'})); + + expect(onChange).toHaveBeenCalledWith({target: {value: 'bob@example.com'}}); + }); + + it('lists a plain array of strings with the text as both label and value', async () => { + const fetchOptions: FetchPagedOptions = vi.fn().mockResolvedValue({items: ['alice@example.com'], nextOffset: null}); + const onChange = vi.fn(); + render(withProviders()); + + fireEvent.click(screen.getByRole('combobox', {name: /assignee/i})); + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(1)); + fireEvent.click(screen.getByRole('option', {name: 'alice@example.com'})); + + expect(onChange).toHaveBeenCalledWith({target: {value: 'alice@example.com'}}); + }); + + describe('keyboard', () => { + it('opens with ArrowDown on the trigger and moves focus between options with the arrow keys', async () => { + const fetchOptions: FetchPagedOptions = vi.fn().mockResolvedValue(page(0, 3, 3)); + render(withProviders()); + + const trigger: HTMLElement = screen.getByRole('combobox', {name: /assignee/i}); + trigger.focus(); + fireEvent.keyDown(trigger, {key: 'ArrowDown'}); + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(3)); + + await waitFor(() => expect(screen.getByRole('option', {name: 'User 1'})).toHaveFocus()); + fireEvent.keyDown(screen.getByRole('option', {name: 'User 1'}), {key: 'ArrowDown'}); + await waitFor(() => expect(screen.getByRole('option', {name: 'User 2'})).toHaveFocus()); + fireEvent.keyDown(screen.getByRole('option', {name: 'User 2'}), {key: 'ArrowUp'}); + await waitFor(() => expect(screen.getByRole('option', {name: 'User 1'})).toHaveFocus()); + }); + + it('skips a disabled option and selects the focused one with Enter', async () => { + const fetchOptions: FetchPagedOptions = vi.fn().mockResolvedValue({ + nextOffset: null, + options: [ + {label: 'User 1', value: 'user-1'}, + {disabled: true, label: 'Suspended', value: 'user-2'}, + {label: 'User 3', value: 'user-3'}, + ], + }); + const onChange = vi.fn(); + render(withProviders()); + + const trigger: HTMLElement = screen.getByRole('combobox', {name: /assignee/i}); + trigger.focus(); + fireEvent.keyDown(trigger, {key: 'ArrowDown'}); + await waitFor(() => expect(screen.getByRole('option', {name: 'User 1'})).toHaveFocus()); + + fireEvent.keyDown(screen.getByRole('option', {name: 'User 1'}), {key: 'ArrowDown'}); + await waitFor(() => expect(screen.getByRole('option', {name: 'User 3'})).toHaveFocus()); + expect(screen.getByRole('option', {name: 'Suspended'})).not.toHaveFocus(); + + fireEvent.click(screen.getByRole('option', {name: 'User 3'})); + expect(onChange).toHaveBeenCalledWith({target: {value: 'user-3'}}); + }); + + it('closes on Escape and returns focus to the trigger', async () => { + const fetchOptions: FetchPagedOptions = vi.fn().mockResolvedValue(page(0, 3, 3)); + render(withProviders()); + + const trigger: HTMLElement = screen.getByRole('combobox', {name: /assignee/i}); + trigger.focus(); + fireEvent.keyDown(trigger, {key: 'ArrowDown'}); + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(3)); + + fireEvent.keyDown(screen.getByRole('listbox'), {key: 'Escape'}); + + await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument()); + expect(trigger).toHaveFocus(); + }); + }); + + describe('a page that maps to no options while more pages remain', () => { + it('still offers Load more, and does not claim the list is empty', async () => { + const fetchOptions: FetchPagedOptions = vi + .fn() + .mockResolvedValueOnce({nextOffset: 30, options: []}) + .mockResolvedValueOnce(page(30, 2, 32)); + render(withProviders()); + + fireEvent.click(screen.getByRole('combobox', {name: /assignee/i})); + + const loadMore: HTMLElement = await screen.findByRole('button', {name: /load more/i}); + expect(screen.queryByText(/no results found/i)).not.toBeInTheDocument(); + + fireEvent.click(loadMore); + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(2)); + expect(fetchOptions).toHaveBeenNthCalledWith(2, expect.objectContaining({offset: 30})); + }); + + it('shows the empty message only once the list is genuinely exhausted', async () => { + const fetchOptions: FetchPagedOptions = vi.fn().mockResolvedValue({nextOffset: null, options: []}); + render(withProviders()); + + fireEvent.click(screen.getByRole('combobox', {name: /assignee/i})); + + expect(await screen.findByText(/no results found/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', {name: /load more/i})).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/react/src/components/primitives/UserSelect/BaseUserSelect.tsx b/packages/react/src/components/primitives/UserSelect/BaseUserSelect.tsx new file mode 100644 index 00000000..5edb7295 --- /dev/null +++ b/packages/react/src/components/primitives/UserSelect/BaseUserSelect.tsx @@ -0,0 +1,64 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {bem, EmbeddedFlowComponentType, UserSelectConstants, withVendorCSSClassPrefix} from '@thunderid/browser'; +import type { + EmbeddedFlowComponent, + FetchUsers, + PagedSelectOptionDefaults, + UserSelectOptionMapping, +} from '@thunderid/browser'; +import {FC, useMemo} from 'react'; +import useTranslation from '../../../hooks/useTranslation'; +import {cx} from '../../../styles/emotion'; +import PagedSelect, {PagedSelectMessages, PagedSelectProps} from '../PagedSelect/PagedSelect'; + +export interface BaseUserSelectProps + extends Omit { + fetchUsers?: FetchUsers; + mapping?: UserSelectOptionMapping; +} + +const USER_DEFAULTS: PagedSelectOptionDefaults = {labelAttributes: UserSelectConstants.LABEL_ATTRIBUTES}; + +/** + * Single-user picker: a {@link PagedSelect} fed by the given `fetchUsers`, submitting the user's ID and + * labelling each user from `display`, `username`, or `email` unless `mapping` says otherwise. + * Building block for {@link UserSelect}; for a custom data source use {@link PagedSelect}. + */ +const BaseUserSelect: FC = ({ + className, + component, + fetchUsers, + id, + name, + ...rest +}: BaseUserSelectProps) => { + const {t} = useTranslation(); + const userComponent: EmbeddedFlowComponent = useMemo( + () => + component ?? + ({id: id ?? name ?? 'user-select', type: EmbeddedFlowComponentType.UserSelect} as EmbeddedFlowComponent), + [component, id, name], + ); + const userMessages: Partial = { + ariaLabel: t('elements.fields.user_select.aria_label') || 'Select a user', + empty: t('elements.fields.user_select.empty') || 'No users found.', + loading: t('elements.fields.user_select.loading') || 'Loading users…', + }; + + return ( + + ); +}; + +export default BaseUserSelect; diff --git a/packages/react/src/components/primitives/UserSelect/UserSelect.tsx b/packages/react/src/components/primitives/UserSelect/UserSelect.tsx new file mode 100644 index 00000000..4f106782 --- /dev/null +++ b/packages/react/src/components/primitives/UserSelect/UserSelect.tsx @@ -0,0 +1,19 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {FC} from 'react'; +import BaseUserSelect, {BaseUserSelectProps} from './BaseUserSelect'; +import useFetchUsers from '../../../hooks/useFetchUsers'; + +export type UserSelectProps = Omit; + +/** + * Single-user picker for signed-in screens. It loads the user directory itself with the signed-in + * user's access token and submits the chosen user's ID, so it takes no data source. For any other + * data source use {@link PagedSelect}. + */ +const UserSelect: FC = (props: UserSelectProps) => ( + +); + +export default UserSelect; diff --git a/packages/react/src/components/primitives/UserSelect/__tests__/BaseUserSelect.test.tsx b/packages/react/src/components/primitives/UserSelect/__tests__/BaseUserSelect.test.tsx new file mode 100644 index 00000000..b0679164 --- /dev/null +++ b/packages/react/src/components/primitives/UserSelect/__tests__/BaseUserSelect.test.tsx @@ -0,0 +1,98 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {render, screen, cleanup, fireEvent, waitFor} from '@testing-library/react'; +import {createTheme} from '@thunderid/browser'; +import type {FetchUsers, PagedSelectRequest} from '@thunderid/browser'; +import {ReactElement} from 'react'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import I18nProvider from '../../../../contexts/I18n/I18nProvider'; +import ThemeContext, {ThemeContextValue} from '../../../../contexts/Theme/ThemeContext'; +import BaseUserSelect from '../BaseUserSelect'; + +const themeContextValue: ThemeContextValue = { + colorScheme: 'light', + direction: 'ltr', + theme: createTheme(), + toggleTheme: vi.fn(), +}; + +const withProviders = (ui: ReactElement): ReactElement => ( + + {ui} + +); + +const users = [ + {attributes: {email: 'alice@example.com', username: 'alice'}, display: 'u1', id: 'u1'}, + {attributes: {email: 'bob@example.com'}, id: 'u2'}, + {id: 'u3'}, +]; + +describe('BaseUserSelect', () => { + afterEach(() => { + cleanup(); + }); + + it('submits the user ID and labels users from display, username, email, then the ID', async () => { + const fetchUsers: FetchUsers = vi.fn().mockResolvedValue({items: users, nextOffset: null}); + const onChange = vi.fn(); + render(withProviders()); + + fireEvent.click(screen.getByRole('combobox', {name: 'Owner'})); + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(3)); + + expect(screen.getAllByRole('option').map((option) => option.textContent)).toEqual([ + 'alice', + 'bob@example.com', + 'u3', + ]); + + fireEvent.click(screen.getByRole('option', {name: 'alice'})); + expect(onChange).toHaveBeenCalledWith({target: {value: 'u1'}}); + }); + + it('honours a mapping over the user defaults', async () => { + const fetchUsers: FetchUsers = vi.fn().mockResolvedValue({items: users.slice(0, 2), nextOffset: null}); + const onChange = vi.fn(); + render( + withProviders( + , + ), + ); + + fireEvent.click(screen.getByRole('combobox', {name: 'Owner'})); + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(2)); + fireEvent.click(screen.getByRole('option', {name: 'bob@example.com'})); + + expect(onChange).toHaveBeenCalledWith({target: {value: 'bob@example.com'}}); + }); + + it('identifies itself as a USER_SELECT field to the loader', async () => { + let request: PagedSelectRequest | undefined; + const fetchUsers: FetchUsers = vi.fn().mockImplementation((received: PagedSelectRequest) => { + request = received; + return Promise.resolve({items: [], nextOffset: null}); + }); + render(withProviders()); + + fireEvent.click(screen.getByRole('combobox', {name: 'Owner'})); + await waitFor(() => expect(request).toBeDefined()); + + expect(request?.component).toMatchObject({id: 'owner', type: 'USER_SELECT'}); + }); + + it('uses user wording for empty and unlabelled states', async () => { + const fetchUsers: FetchUsers = vi.fn().mockResolvedValue({items: [], nextOffset: null}); + render(withProviders()); + + fireEvent.click(screen.getByRole('combobox', {name: 'Select a user'})); + + expect(await screen.findByText('No users found.')).toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/components/primitives/UserSelect/__tests__/UserSelect.test.tsx b/packages/react/src/components/primitives/UserSelect/__tests__/UserSelect.test.tsx new file mode 100644 index 00000000..a49ac1c1 --- /dev/null +++ b/packages/react/src/components/primitives/UserSelect/__tests__/UserSelect.test.tsx @@ -0,0 +1,71 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {render, screen, cleanup, fireEvent, waitFor} from '@testing-library/react'; +import {getUsers} from '@thunderid/browser'; +import {ReactElement} from 'react'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import I18nProvider from '../../../../contexts/I18n/I18nProvider'; +import ThemeProvider from '../../../../contexts/Theme/ThemeProvider'; +import ThunderIDContext, {ThunderIDContextProps} from '../../../../contexts/ThunderID/ThunderIDContext'; +import UserSelect from '../UserSelect'; + +vi.mock('@thunderid/browser', async (importOriginal) => ({ + ...(await importOriginal()), + getUsers: vi.fn(), +})); + +const thunderIDContext: ThunderIDContextProps = { + baseUrl: 'https://localhost:8090', + endpoints: {users: 'https://rs.example.com/users'}, +} as unknown as ThunderIDContextProps; + +const withProviders = (ui: ReactElement): ReactElement => ( + + + {ui} + + +); + +describe('UserSelect', () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('loads the user directory itself and submits the chosen ID', async () => { + vi.mocked(getUsers).mockResolvedValue({ + count: 2, + startIndex: 1, + totalResults: 2, + users: [ + {display: 'Jane Doe', id: 'user-1'}, + {display: 'John Roe', id: 'user-2'}, + ], + } as never); + const onChange = vi.fn(); + + render(withProviders()); + fireEvent.click(screen.getByRole('combobox', {name: 'Owner'})); + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(2)); + fireEvent.click(screen.getByRole('option', {name: 'Jane Doe'})); + + expect(getUsers).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: 'https://localhost:8090', + limit: 30, + offset: 0, + url: 'https://rs.example.com/users', + }), + ); + const chosen: unknown = expect.objectContaining({value: 'user-1'}); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({target: chosen})); + }); + + it('makes no request while closed', () => { + render(withProviders()); + + expect(getUsers).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/hooks/__tests__/usePagedSelect.test.tsx b/packages/react/src/hooks/__tests__/usePagedSelect.test.tsx new file mode 100644 index 00000000..63bad0a6 --- /dev/null +++ b/packages/react/src/hooks/__tests__/usePagedSelect.test.tsx @@ -0,0 +1,247 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {act, cleanup, renderHook, waitFor} from '@testing-library/react'; +import {ThunderIDAPIError} from '@thunderid/browser'; +import type {FetchPagedOptions, PagedSelectPage, PagedSelectRequest} from '@thunderid/browser'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import usePagedSelect from '../usePagedSelect'; + +const page = (start: number, count: number, totalResults: number): PagedSelectPage => ({ + nextOffset: start + count < totalResults ? start + count : null, + options: Array.from({length: count}, (_, index) => ({ + label: `User ${start + index + 1}`, + value: `user-${start + index + 1}`, + })), + totalResults, +}); + +describe('usePagedSelect', () => { + afterEach(() => { + cleanup(); + }); + + it('makes no request until loadInitial is called', () => { + const fetchOptions: FetchPagedOptions = vi.fn(); + renderHook(() => usePagedSelect({fetchOptions})); + + expect(fetchOptions).not.toHaveBeenCalled(); + }); + + it('never requests anything while fetchOptions is unset', () => { + const {result} = renderHook(() => usePagedSelect({})); + + act(() => result.current.loadInitial()); + + expect(result.current.options).toEqual([]); + expect(result.current.isLoading).toBe(false); + }); + + it('loads the first page once, and ignores repeated loadInitial calls', async () => { + const fetchOptions: FetchPagedOptions = vi.fn().mockResolvedValue(page(0, 30, 75)); + const {result} = renderHook(() => usePagedSelect({fetchOptions})); + + act(() => { + result.current.loadInitial(); + result.current.loadInitial(); + }); + await waitFor(() => expect(result.current.options).toHaveLength(30)); + act(() => result.current.loadInitial()); + + expect(fetchOptions).toHaveBeenCalledTimes(1); + expect(fetchOptions).toHaveBeenCalledWith(expect.objectContaining({limit: 30, offset: 0})); + expect(result.current.hasMore).toBe(true); + }); + + it('appends following pages, de-duplicates, and stops at the end', async () => { + const fetchOptions: FetchPagedOptions = vi + .fn() + .mockResolvedValueOnce(page(0, 30, 45)) + .mockResolvedValueOnce({...page(29, 16, 45), nextOffset: null}); + const {result} = renderHook(() => usePagedSelect({fetchOptions})); + + act(() => result.current.loadInitial()); + await waitFor(() => expect(result.current.hasLoaded).toBe(true)); + act(() => result.current.loadMore()); + await waitFor(() => expect(result.current.options).toHaveLength(45)); + + expect(result.current.hasMore).toBe(false); + act(() => result.current.loadMore()); + expect(fetchOptions).toHaveBeenCalledTimes(2); + }); + + it('does not start a second request while one is in flight', async () => { + let resolveFirst: (value: PagedSelectPage) => void = () => undefined; + const fetchOptions: FetchPagedOptions = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + const {result} = renderHook(() => usePagedSelect({fetchOptions})); + + act(() => result.current.loadInitial()); + act(() => result.current.loadMore()); + expect(fetchOptions).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveFirst(page(0, 5, 5)); + await Promise.resolve(); + }); + await waitFor(() => expect(result.current.hasLoaded).toBe(true)); + }); + + it('treats a non-advancing nextOffset as the end of the list', async () => { + const fetchOptions: FetchPagedOptions = vi.fn().mockResolvedValue({...page(0, 5, 100), nextOffset: 0}); + const {result} = renderHook(() => usePagedSelect({fetchOptions})); + + act(() => result.current.loadInitial()); + await waitFor(() => expect(result.current.hasLoaded).toBe(true)); + + expect(result.current.hasMore).toBe(false); + }); + + it('retries the page that failed, keeping already-loaded options on a next-page failure', async () => { + const fetchOptions: FetchPagedOptions = vi + .fn() + .mockResolvedValueOnce(page(0, 30, 60)) + .mockRejectedValueOnce(new ThunderIDAPIError('Upstream exploded', 'test-code', 'test-origin', 500)) + .mockResolvedValueOnce(page(30, 30, 60)); + const {result} = renderHook(() => usePagedSelect({fetchOptions})); + + act(() => result.current.loadInitial()); + await waitFor(() => expect(result.current.hasLoaded).toBe(true)); + act(() => result.current.loadMore()); + await waitFor(() => expect(result.current.error?.message).toBe('Upstream exploded')); + expect(result.current.options).toHaveLength(30); + + act(() => result.current.retry()); + await waitFor(() => expect(result.current.options).toHaveLength(60)); + expect(fetchOptions).toHaveBeenNthCalledWith(3, expect.objectContaining({offset: 30})); + expect(result.current.error).toBeNull(); + }); + + it('does not trust a plain Error, falling back to the generic translatable key', async () => { + const fetchOptions: FetchPagedOptions = vi.fn().mockRejectedValueOnce(new Error('a raw exception message')); + const {result} = renderHook(() => usePagedSelect({fetchOptions})); + + act(() => result.current.loadInitial()); + + await waitFor(() => expect(result.current.error).not.toBeNull()); + expect(result.current.error?.message).toBeUndefined(); + expect(result.current.error?.messageKey).toBe('elements.fields.paged_select.load_error'); + }); + + it('aborts the in-flight request on abort() without surfacing an error, and allows reloading', async () => { + let signal: AbortSignal | undefined; + const fetchOptions: FetchPagedOptions = vi + .fn() + .mockImplementationOnce( + (request: PagedSelectRequest) => + new Promise((_resolve, reject) => { + signal = request.signal; + request.signal.addEventListener('abort', () => reject(new Error('aborted'))); + }), + ) + .mockResolvedValueOnce(page(0, 3, 3)); + const {result} = renderHook(() => usePagedSelect({fetchOptions})); + + act(() => result.current.loadInitial()); + act(() => result.current.abort()); + + expect(signal?.aborted).toBe(true); + expect(result.current.error).toBeNull(); + expect(result.current.isLoading).toBe(false); + + act(() => result.current.loadInitial()); + await waitFor(() => expect(result.current.options).toHaveLength(3)); + }); + + it('resets loaded pages and ignores stale results when the filter changes', async () => { + let resolveStale: (value: PagedSelectPage) => void = () => undefined; + const fetchOptions: FetchPagedOptions = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveStale = resolve; + }), + ) + .mockResolvedValueOnce(page(100, 2, 2)); + const {result, rerender} = renderHook(({filter}) => usePagedSelect({fetchOptions, filter}), { + initialProps: {filter: 'department eq "A"'}, + }); + + act(() => result.current.loadInitial()); + rerender({filter: 'department eq "B"'}); + await act(async () => { + resolveStale(page(0, 30, 30)); + await Promise.resolve(); + }); + + expect(result.current.options).toEqual([]); + + act(() => result.current.loadInitial()); + await waitFor(() => expect(result.current.options).toHaveLength(2)); + expect(fetchOptions).toHaveBeenLastCalledWith(expect.objectContaining({filter: 'department eq "B"', offset: 0})); + }); + + describe('mapping raw items into options', () => { + it('uses a plain list of strings for both label and value, with no mapping', async () => { + const fetchOptions: FetchPagedOptions = vi + .fn() + .mockResolvedValue({items: ['alice@example.com', 'bob@example.com'], nextOffset: null}); + const {result} = renderHook(() => usePagedSelect({fetchOptions})); + + act(() => result.current.loadInitial()); + await waitFor(() => expect(result.current.hasLoaded).toBe(true)); + + expect(result.current.options).toEqual([ + {label: 'alice@example.com', value: 'alice@example.com'}, + {label: 'bob@example.com', value: 'bob@example.com'}, + ]); + }); + + it('applies the configured label and value fields to an array of objects', async () => { + const items = [ + {email: 'alice@example.com', fullName: 'Alice Example', id: 'u1'}, + {email: 'bob@example.com', fullName: 'Bob Example', id: 'u2'}, + ]; + const fetchOptions: FetchPagedOptions = vi.fn().mockResolvedValue({items, nextOffset: null}); + const mapping = {labelAttributes: ['fullName'], valueAttribute: 'email'}; + const {result} = renderHook(() => usePagedSelect({fetchOptions, mapping})); + + act(() => result.current.loadInitial()); + await waitFor(() => expect(result.current.hasLoaded).toBe(true)); + + expect(result.current.options).toEqual([ + {label: 'Alice Example', value: 'alice@example.com'}, + {label: 'Bob Example', value: 'bob@example.com'}, + ]); + }); + + it('falls back to the first text field for both when objects have no id and nothing is configured', async () => { + const fetchOptions: FetchPagedOptions = vi + .fn() + .mockResolvedValue({items: [{name: 'Alice', city: 'Colombo'}], nextOffset: null}); + const {result} = renderHook(() => usePagedSelect({fetchOptions})); + + act(() => result.current.loadInitial()); + await waitFor(() => expect(result.current.hasLoaded).toBe(true)); + + expect(result.current.options).toEqual([{label: 'Alice', value: 'Alice'}]); + }); + + it('prefers ready-made options over raw items when a page returns both', async () => { + const fetchOptions: FetchPagedOptions = vi + .fn() + .mockResolvedValue({items: ['ignored'], nextOffset: null, options: [{label: 'Kept', value: 'kept'}]}); + const {result} = renderHook(() => usePagedSelect({fetchOptions})); + + act(() => result.current.loadInitial()); + await waitFor(() => expect(result.current.hasLoaded).toBe(true)); + + expect(result.current.options).toEqual([{label: 'Kept', value: 'kept'}]); + }); + }); +}); diff --git a/packages/react/src/hooks/useFetchUsers.ts b/packages/react/src/hooks/useFetchUsers.ts new file mode 100644 index 00000000..c7a803d0 --- /dev/null +++ b/packages/react/src/hooks/useFetchUsers.ts @@ -0,0 +1,25 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {getUsers, toUserSelectPage} from '@thunderid/browser'; +import type {FetchUsers, PagedSelectPage, PagedSelectRequest} from '@thunderid/browser'; +import {useCallback} from 'react'; +import useManagementEndpoint, {ManagementEndpoint} from './useManagementEndpoint'; +import {useResolvedFetcher} from './useResourceQuery'; + +/** + * A `FetchUsers` backed by the user directory, authenticated with the signed-in user's access token. + * Its identity is stable across renders, since a picker resets its loaded pages when the loader changes. + */ +const useFetchUsers = (): FetchUsers => { + const {baseUrl, url}: ManagementEndpoint = useManagementEndpoint('users'); + const fetcher: ReturnType = useResolvedFetcher(); + + return useCallback( + async ({filter, limit, offset, signal}: PagedSelectRequest): Promise => + toUserSelectPage(await getUsers({baseUrl, fetcher, filter, limit, offset, signal, url}), offset), + [baseUrl, fetcher, url], + ); +}; + +export default useFetchUsers; diff --git a/packages/react/src/hooks/usePagedSelect.ts b/packages/react/src/hooks/usePagedSelect.ts new file mode 100644 index 00000000..8251c3f0 --- /dev/null +++ b/packages/react/src/hooks/usePagedSelect.ts @@ -0,0 +1,193 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + dedupePagedSelectOptions, + EmbeddedFlowComponentType, + isAdvancingPageOffset, + mapPagedSelectError, + toPagedSelectOptions, +} from '@thunderid/browser'; +import type { + EmbeddedFlowComponent, + FetchPagedOptions, + PagedSelectErrorResult, + PagedSelectOption, + PagedSelectOptionDefaults, + PagedSelectOptionMapping, +} from '@thunderid/browser'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; + +const DEFAULT_PAGE_SIZE = 30; + +export interface UsePagedSelectOptions { + component?: EmbeddedFlowComponent; + defaults?: PagedSelectOptionDefaults; + fetchOptions?: FetchPagedOptions; + fieldId?: string; + filter?: string; + /** Read when a page loads; changing it does not re-map options that are already loaded. */ + mapping?: PagedSelectOptionMapping; + pageSize?: number; +} + +export interface UsePagedSelectResult { + abort: () => void; + error: PagedSelectErrorResult | null; + hasLoaded: boolean; + hasMore: boolean; + isLoading: boolean; + isLoadingMore: boolean; + loadInitial: () => void; + loadMore: () => void; + options: PagedSelectOption[]; + retry: () => void; +} + +/** + * Loads options page by page from `fetchOptions`: lazy first page, incremental paging, + * de-duplication, cancellation, and a generation guard that drops results from a superseded + * scope (filter, field, or loader change). Renders nothing itself, so any design system can use it. + */ +const usePagedSelect = ({ + component, + defaults, + fetchOptions, + fieldId = 'paged-select', + filter, + mapping, + pageSize = DEFAULT_PAGE_SIZE, +}: UsePagedSelectOptions): UsePagedSelectResult => { + const effectiveComponent: EmbeddedFlowComponent = useMemo( + () => component ?? ({id: fieldId, type: EmbeddedFlowComponentType.Select} as EmbeddedFlowComponent), + [component, fieldId], + ); + + const [options, setOptions] = useState([]); + const [hasMore, setHasMore] = useState(true); + const [hasLoaded, setHasLoaded] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [error, setError] = useState(null); + + const generationRef = useRef(0); + const abortControllerRef = useRef(null); + const inFlightRef = useRef(false); + const hasLoadedRef = useRef(false); + const nextOffsetRef = useRef(0); + const failedOffsetRef = useRef(0); + + const abort = useCallback((): void => { + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + inFlightRef.current = false; + setIsLoading(false); + setIsLoadingMore(false); + }, []); + + const resetPaging = useCallback((): void => { + generationRef.current += 1; + hasLoadedRef.current = false; + nextOffsetRef.current = 0; + failedOffsetRef.current = 0; + abort(); + setOptions([]); + setHasMore(true); + setHasLoaded(false); + setError(null); + }, [abort]); + + useEffect(() => { + resetPaging(); + }, [effectiveComponent.id, filter, fetchOptions, resetPaging]); + + useEffect(() => abort, [abort]); + + const loadPage = useCallback( + async (pageOffset: number): Promise => { + if (!fetchOptions) { + return; + } + + const generation: number = generationRef.current; + const isFirstPage: boolean = pageOffset === 0; + + abortControllerRef.current?.abort(); + const controller: AbortController = new AbortController(); + abortControllerRef.current = controller; + inFlightRef.current = true; + + if (isFirstPage) { + setIsLoading(true); + } else { + setIsLoadingMore(true); + } + setError(null); + + try { + const page = await fetchOptions({ + component: effectiveComponent, + filter, + limit: pageSize, + offset: pageOffset, + signal: controller.signal, + }); + + if (generation !== generationRef.current || controller.signal.aborted) { + return; + } + + const next: number | null = isAdvancingPageOffset(pageOffset, page.nextOffset) ? page.nextOffset : null; + const pageOptions: PagedSelectOption[] = + page.options ?? toPagedSelectOptions(page.items ?? [], mapping, defaults); + + setOptions((existing: PagedSelectOption[]) => + dedupePagedSelectOptions(isFirstPage ? [] : existing, pageOptions), + ); + nextOffsetRef.current = next; + setHasMore(next !== null); + hasLoadedRef.current = true; + setHasLoaded(true); + } catch (err) { + if (generation !== generationRef.current || controller.signal.aborted) { + return; + } + failedOffsetRef.current = pageOffset; + setError(mapPagedSelectError(err)); + } finally { + if (abortControllerRef.current === controller) { + abortControllerRef.current = null; + inFlightRef.current = false; + setIsLoading(false); + setIsLoadingMore(false); + } + } + }, + [defaults, effectiveComponent, fetchOptions, filter, mapping, pageSize], + ); + + const loadInitial = useCallback((): void => { + if (!fetchOptions || hasLoadedRef.current || inFlightRef.current) { + return; + } + void loadPage(0); + }, [fetchOptions, loadPage]); + + const loadMore = useCallback((): void => { + if (nextOffsetRef.current === null || inFlightRef.current || !hasLoadedRef.current) { + return; + } + void loadPage(nextOffsetRef.current); + }, [loadPage]); + + const retry = useCallback((): void => { + if (inFlightRef.current) { + return; + } + void loadPage(failedOffsetRef.current); + }, [loadPage]); + + return {abort, error, hasLoaded, hasMore, isLoading, isLoadingMore, loadInitial, loadMore, options, retry}; +}; + +export default usePagedSelect; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index c196d5d0..bdc3bcee 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -50,6 +50,9 @@ export * from './hooks/useBrowserUrl'; export {default as useTranslation} from './hooks/useTranslation'; export * from './hooks/useTranslation'; +export {default as usePagedSelect} from './hooks/usePagedSelect'; +export * from './hooks/usePagedSelect'; + export {normalizeFlowResponse, extractErrorMessage} from './utils/flowTransformer'; export {default as useForm} from './hooks/useForm'; @@ -215,6 +218,15 @@ export * from './components/primitives/Select/Select'; export {default as DatePicker} from './components/primitives/DatePicker/DatePicker'; export * from './components/primitives/DatePicker/DatePicker'; +export {default as PagedSelect} from './components/primitives/PagedSelect/PagedSelect'; +export * from './components/primitives/PagedSelect/PagedSelect'; + +export {default as UserSelect} from './components/primitives/UserSelect/UserSelect'; +export * from './components/primitives/UserSelect/UserSelect'; + +export {default as BaseUserSelect} from './components/primitives/UserSelect/BaseUserSelect'; +export * from './components/primitives/UserSelect/BaseUserSelect'; + export {default as Checkbox} from './components/primitives/Checkbox/Checkbox'; export * from './components/primitives/Checkbox/Checkbox'; @@ -327,6 +339,21 @@ export { type EmbeddedRecoveryFlowInitiateRequest, type EmbeddedRecoveryFlowRequest, type EmbeddedRecoveryFlowErrorResponse, + mapPagedSelectError, + toPagedSelectOption, + toPagedSelectOptions, + toUserSelectOption, + toUserSelectPage, + UserSelectConstants, + type FetchPagedOptions, + type FetchUsers, + type PagedSelectErrorResult, + type PagedSelectOption, + type PagedSelectOptionDefaults, + type PagedSelectOptionMapping, + type PagedSelectPage, + type PagedSelectRequest, + type UserSelectOptionMapping, } from '@thunderid/browser'; export { diff --git a/packages/react/src/utils/createAuthenticatedFetcher.ts b/packages/react/src/utils/createAuthenticatedFetcher.ts index 142c9da8..52277b58 100644 --- a/packages/react/src/utils/createAuthenticatedFetcher.ts +++ b/packages/react/src/utils/createAuthenticatedFetcher.ts @@ -32,6 +32,7 @@ const createAuthenticatedFetcher = data: typeof config.body === 'string' ? (JSON.parse(config.body) as unknown) : undefined, headers: config.headers as Record, method: config.method ?? 'GET', + signal: config.signal, url, } as HttpRequestConfig); diff --git a/packages/vue/src/__tests__/api/get-users.test.ts b/packages/vue/src/__tests__/api/get-users.test.ts new file mode 100644 index 00000000..78dbdb50 --- /dev/null +++ b/packages/vue/src/__tests__/api/get-users.test.ts @@ -0,0 +1,78 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {afterEach, describe, expect, it, vi} from 'vitest'; +import getUsers from '../../api/getUsers'; + +// The httpClient adapter is covered in @thunderid/browser's createHttpClientFetcher tests. +// This file covers the wrapper: plumbing that adapter into the core getUsers as the default fetcher. +const mockFetcher = vi.fn(); + +vi.mock('@thunderid/browser', async () => { + const actual = await vi.importActual('@thunderid/browser'); + return { + ...actual, + createHttpClientFetcher: () => mockFetcher, + }; +}); + +describe('getUsers (vue)', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('resolves one page of the user directory using the default fetcher', async () => { + mockFetcher.mockResolvedValueOnce({ + json: () => + Promise.resolve({count: 2, links: [], startIndex: 0, totalResults: 2, users: [{id: 'u1'}, {id: 'u2'}]}), + ok: true, + status: 200, + statusText: 'OK', + text: () => Promise.resolve(''), + } as unknown as Response); + + const page = await getUsers({baseUrl: 'https://localhost:8090', limit: 30, offset: 0}); + + expect(page.users).toHaveLength(2); + expect(page.totalResults).toBe(2); + expect(mockFetcher).toHaveBeenCalledWith( + expect.stringContaining('/users'), + expect.objectContaining({method: 'GET'}), + ); + }); + + it('surfaces the real status code when the request is rejected', async () => { + mockFetcher.mockResolvedValueOnce({ + json: () => Promise.resolve({code: 'USR-1017', message: 'Forbidden'}), + ok: false, + status: 403, + statusText: 'Forbidden', + text: () => Promise.resolve(JSON.stringify({code: 'USR-1017', message: 'Forbidden'})), + } as unknown as Response); + + await expect(getUsers({baseUrl: 'https://localhost:8090', limit: 30, offset: 0})).rejects.toMatchObject({ + statusCode: 403, + }); + }); + + it('still throws a network error when the request never reaches the server', async () => { + mockFetcher.mockRejectedValueOnce(Object.assign(new Error('Failed to fetch'), {code: 'NETWORK_ERROR'})); + + await expect(getUsers({baseUrl: 'https://localhost:8090', limit: 30, offset: 0})).rejects.toThrow(); + }); + + it('uses a caller-supplied fetcher instead of the default when provided', async () => { + const customFetcher = vi.fn().mockResolvedValue({ + json: () => Promise.resolve({count: 0, links: [], startIndex: 0, totalResults: 0, users: []}), + ok: true, + status: 200, + statusText: 'OK', + text: () => Promise.resolve(''), + } as unknown as Response); + + await getUsers({baseUrl: 'https://localhost:8090', fetcher: customFetcher, limit: 30, offset: 0}); + + expect(customFetcher).toHaveBeenCalled(); + expect(mockFetcher).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/vue/src/__tests__/components/base-user-select.test.ts b/packages/vue/src/__tests__/components/base-user-select.test.ts new file mode 100644 index 00000000..fe2ff29a --- /dev/null +++ b/packages/vue/src/__tests__/components/base-user-select.test.ts @@ -0,0 +1,250 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {getDefaultI18nBundles, substituteTranslationParams} from '@thunderid/browser'; +import type {FetchUsers, PagedSelectPage} from '@thunderid/browser'; +import {mount} from '@vue/test-utils'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {ref} from 'vue'; +import BaseUserSelect from '../../components/primitives/UserSelect/BaseUserSelect'; +import {I18N_KEY} from '../../keys'; +import type {I18nContextValue} from '../../models/contexts'; + +/** Resolves keys through the real en-US bundle so assertions read actual user-facing copy. */ +const createI18nContext = (): I18nContextValue => + ({ + bundles: ref({}), + currentLanguage: ref('en-US'), + fallbackLanguage: 'en-US', + injectBundles: vi.fn(), + setLanguage: vi.fn(), + t: (key: string, params?: Record): string => { + const translations = getDefaultI18nBundles()['en-US']?.translations as Record; + const value: string = translations?.[key] ?? key; + return params ? substituteTranslationParams(value, params) : value; + }, + }) as unknown as I18nContextValue; + +// Mounted into the document (not a detached node) so focus, blur, and click-outside behave as +// they do in an app; every wrapper is unmounted after its test. +const mounted: {unmount: () => void}[] = []; +const mountUserSelect = (props: Record = {}) => { + const wrapper = mount(BaseUserSelect, { + attachTo: document.body, + global: {provide: {[I18N_KEY as symbol]: createI18nContext()}}, + props, + }); + mounted.push(wrapper); + return wrapper; +}; +type UserSelectWrapper = ReturnType; + +const TRIGGER = '.thunderid-paged-select__trigger'; +const OPTION = '.thunderid-paged-select__option'; +const PANEL = '.thunderid-paged-select__panel'; + +const users = (...names: string[]): {display: string; id: string}[] => + names.map((name: string, index: number) => ({display: name, id: `user-${index + 1}`})); + +const page = (items: {display: string; id: string}[], nextOffset: number | null = null): PagedSelectPage => ({ + items, + nextOffset, + totalResults: items.length, +}); + +const open = async (wrapper: UserSelectWrapper, expectedOptions: number): Promise => { + await wrapper.find(TRIGGER).trigger('click'); + await vi.waitFor(() => expect(wrapper.findAll(OPTION)).toHaveLength(expectedOptions)); +}; + +describe('BaseUserSelect (vue)', () => { + afterEach(() => { + mounted.splice(0).forEach((wrapper) => wrapper.unmount()); + }); + + it('shows the placeholder and makes no request while closed', () => { + const fetchUsers: FetchUsers = vi.fn(); + const wrapper = mountUserSelect({fetchUsers, placeholder: 'Select a user'}); + + expect(fetchUsers).not.toHaveBeenCalled(); + expect(wrapper.find('.thunderid-paged-select__trigger-label').text()).toBe('Select a user'); + expect(wrapper.find(PANEL).exists()).toBe(false); + }); + + it('is disabled when the disabled prop is set, and never opens', async () => { + const fetchUsers: FetchUsers = vi.fn(); + const wrapper = mountUserSelect({disabled: true, fetchUsers}); + + expect((wrapper.find(TRIGGER).element as HTMLButtonElement).disabled).toBe(true); + await wrapper.find(TRIGGER).trigger('click'); + expect(wrapper.find(PANEL).exists()).toBe(false); + expect(fetchUsers).not.toHaveBeenCalled(); + }); + + it('opens on click, requests exactly one first page, and lists the returned users', async () => { + const fetchUsers: FetchUsers = vi.fn().mockResolvedValue(page(users('Jane Doe', 'John Roe'))); + const wrapper = mountUserSelect({fetchUsers}); + + await open(wrapper, 2); + + expect(fetchUsers).toHaveBeenCalledTimes(1); + expect(fetchUsers).toHaveBeenCalledWith(expect.objectContaining({limit: 30, offset: 0})); + expect(wrapper.text()).toContain('Jane Doe'); + expect(wrapper.text()).toContain('John Roe'); + }); + + it("selecting an option emits the user's id, not the label, and closes the panel", async () => { + const fetchUsers: FetchUsers = vi.fn().mockResolvedValue(page(users('Jane Doe'))); + const wrapper = mountUserSelect({fetchUsers}); + + await open(wrapper, 1); + await wrapper.find(OPTION).trigger('click'); + + expect(wrapper.emitted('update:modelValue')).toEqual([['user-1']]); + expect(wrapper.find(PANEL).exists()).toBe(false); + }); + + it('shows the selected user by name once its option is loaded, and by id before that', async () => { + const fetchUsers: FetchUsers = vi.fn().mockResolvedValue(page(users('Jane Doe'))); + const wrapper = mountUserSelect({fetchUsers, modelValue: 'user-1'}); + + expect(wrapper.find('.thunderid-paged-select__trigger-label').text()).toBe('user-1'); + + await open(wrapper, 1); + await wrapper.find(TRIGGER).trigger('click'); // close, so the label is read from the trigger + + expect(wrapper.find('.thunderid-paged-select__trigger-label').text()).toBe('Jane Doe'); + }); + + it('a disabled option cannot be selected', async () => { + const fetchUsers: FetchUsers = vi + .fn() + .mockResolvedValue({nextOffset: null, options: [{disabled: true, label: 'Suspended', value: 'user-1'}]}); + const wrapper = mountUserSelect({fetchUsers}); + + await open(wrapper, 1); + await wrapper.find(OPTION).trigger('click'); + + expect(wrapper.emitted('update:modelValue')).toBeUndefined(); + }); + + it('shows an error with a working retry after a failed load', async () => { + const fetchUsers: FetchUsers = vi + .fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce(page(users('Jane Doe'))); + const wrapper = mountUserSelect({fetchUsers}); + + await wrapper.find(TRIGGER).trigger('click'); + await vi.waitFor(() => expect(wrapper.text()).toContain('Failed to load options.')); + expect(wrapper.text()).not.toContain('boom'); + + await wrapper.find('.thunderid-paged-select__retry').trigger('click'); + await vi.waitFor(() => expect(wrapper.findAll(OPTION)).toHaveLength(1)); + expect(fetchUsers).toHaveBeenCalledTimes(2); + }); + + it('loads the next page when the panel is scrolled near the bottom', async () => { + const fetchUsers: FetchUsers = vi + .fn() + .mockResolvedValueOnce(page(users('User 1', 'User 2'), 2)) + .mockResolvedValueOnce(page([{display: 'User 3', id: 'user-3'}])); + const wrapper = mountUserSelect({fetchUsers, pageSize: 2}); + + await open(wrapper, 2); + const panel: HTMLElement = wrapper.find(PANEL).element as HTMLElement; + Object.defineProperty(panel, 'scrollHeight', {configurable: true, value: 1000}); + Object.defineProperty(panel, 'clientHeight', {configurable: true, value: 300}); + Object.defineProperty(panel, 'scrollTop', {configurable: true, value: 700}); + await wrapper.find(PANEL).trigger('scroll'); + + await vi.waitFor(() => expect(wrapper.findAll(OPTION)).toHaveLength(3)); + expect(fetchUsers).toHaveBeenNthCalledWith(2, expect.objectContaining({limit: 2, offset: 2})); + }); + + it('still offers Load more when a page maps to no options but more pages remain', async () => { + const fetchUsers: FetchUsers = vi + .fn() + .mockResolvedValueOnce({nextOffset: 30, options: []}) + .mockResolvedValueOnce(page(users('Jane Doe'))); + const wrapper = mountUserSelect({fetchUsers}); + + await wrapper.find(TRIGGER).trigger('click'); + await vi.waitFor(() => expect(wrapper.find('.thunderid-paged-select__load-more').exists()).toBe(true)); + expect(wrapper.text()).not.toContain('No users found'); + + await wrapper.find('.thunderid-paged-select__load-more').trigger('click'); + await vi.waitFor(() => expect(wrapper.findAll(OPTION)).toHaveLength(1)); + expect(fetchUsers).toHaveBeenNthCalledWith(2, expect.objectContaining({offset: 30})); + }); + + it('shows the empty message only once the list is genuinely exhausted', async () => { + const fetchUsers: FetchUsers = vi.fn().mockResolvedValue({nextOffset: null, options: []}); + const wrapper = mountUserSelect({fetchUsers}); + + await wrapper.find(TRIGGER).trigger('click'); + + await vi.waitFor(() => expect(wrapper.text()).toContain('No users found.')); + expect(wrapper.find('.thunderid-paged-select__load-more').exists()).toBe(false); + }); + + describe('keyboard', () => { + it('Escape closes an open panel', async () => { + const fetchUsers: FetchUsers = vi.fn().mockResolvedValue(page(users('Jane Doe'))); + const wrapper = mountUserSelect({fetchUsers}); + + await open(wrapper, 1); + await wrapper.find(TRIGGER).trigger('keydown', {key: 'Escape'}); + + expect(wrapper.find(PANEL).exists()).toBe(false); + }); + + it('ArrowDown on the trigger opens the panel, then arrows move focus between options', async () => { + const fetchUsers: FetchUsers = vi.fn().mockResolvedValue(page(users('User 1', 'User 2', 'User 3'))); + const wrapper = mountUserSelect({fetchUsers}); + + await wrapper.find(TRIGGER).trigger('keydown', {key: 'ArrowDown'}); + await vi.waitFor(() => expect(wrapper.findAll(OPTION)).toHaveLength(3)); + + await wrapper.find(TRIGGER).trigger('keydown', {key: 'ArrowDown'}); + await vi.waitFor(() => expect(document.activeElement).toBe(wrapper.findAll(OPTION)[0].element)); + + await wrapper.find(PANEL).trigger('keydown', {key: 'ArrowDown'}); + await vi.waitFor(() => expect(document.activeElement).toBe(wrapper.findAll(OPTION)[1].element)); + + await wrapper.find(PANEL).trigger('keydown', {key: 'ArrowUp'}); + await vi.waitFor(() => expect(document.activeElement).toBe(wrapper.findAll(OPTION)[0].element)); + }); + + it('skips a disabled option and selects the focused one with Enter', async () => { + const fetchUsers: FetchUsers = vi.fn().mockResolvedValue({ + nextOffset: null, + options: [ + {label: 'User 1', value: 'user-1'}, + {disabled: true, label: 'Suspended', value: 'user-2'}, + {label: 'User 3', value: 'user-3'}, + ], + }); + const wrapper = mountUserSelect({fetchUsers}); + + await open(wrapper, 3); + await wrapper.find(PANEL).trigger('keydown', {key: 'ArrowDown'}); + await wrapper.find(PANEL).trigger('keydown', {key: 'ArrowDown'}); + await vi.waitFor(() => expect(document.activeElement).toBe(wrapper.findAll(OPTION)[2].element)); + + await wrapper.find(PANEL).trigger('keydown', {key: 'Enter'}); + + expect(wrapper.emitted('update:modelValue')).toEqual([['user-3']]); + }); + }); + + it('closes when the user clicks outside it', async () => { + const fetchUsers: FetchUsers = vi.fn().mockResolvedValue(page(users('Jane Doe'))); + const wrapper = mountUserSelect({fetchUsers}); + + await open(wrapper, 1); + document.body.click(); + + await vi.waitFor(() => expect(wrapper.find(PANEL).exists()).toBe(false)); + }); +}); diff --git a/packages/vue/src/__tests__/components/invite-user-users-source.test.ts b/packages/vue/src/__tests__/components/invite-user-users-source.test.ts new file mode 100644 index 00000000..f59e87e2 --- /dev/null +++ b/packages/vue/src/__tests__/components/invite-user-users-source.test.ts @@ -0,0 +1,109 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import type {FetchUsers} from '@thunderid/browser'; +import {mount} from '@vue/test-utils'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import InviteUser from '../../components/presentation/invite-user/InviteUser'; + +const mocks = vi.hoisted(() => ({ + baseProps: {} as {fetchUsers?: FetchUsers}, + getUsers: vi.fn(), + thunderID: { + baseUrl: 'https://thunder.example', + endpoints: undefined as {users?: string} | undefined, + http: {request: vi.fn()}, + isInitialized: {value: true}, + }, +})); + +// A stand-in for BaseInviteUser that records what InviteUser hands it, so this file tests only +// InviteUser's own job: building the default data source for USER_SELECT fields. +vi.mock('../../components/presentation/invite-user/BaseInviteUser', async () => { + const {defineComponent: define, h: render} = await import('vue'); + return { + default: define({ + name: 'BaseInviteUser', + props: {fetchUsers: {default: undefined, type: Function}}, + setup(props: {fetchUsers?: FetchUsers}) { + mocks.baseProps = props; + return () => render('div'); + }, + }), + }; +}); +vi.mock('../../api/getUsers', () => ({default: mocks.getUsers})); +vi.mock('../../composables/useThunderID', () => ({default: () => mocks.thunderID})); + +const usersResponse = { + count: 2, + links: [], + startIndex: 31, + totalResults: 75, + users: [ + {display: 'Ada Lovelace', id: 'user-1'}, + {display: 'Grace Hopper', id: 'user-2'}, + ], +}; + +const request = (overrides: Record = {}) => ({ + component: {id: 'owner_input', type: 'USER_SELECT'} as never, + limit: 30, + offset: 30, + signal: new AbortController().signal, + ...overrides, +}); + +describe('InviteUser USER_SELECT data source (vue)', () => { + beforeEach(() => { + mocks.baseProps = {}; + mocks.thunderID.endpoints = undefined; + mocks.getUsers.mockReset().mockResolvedValue(usersResponse); + }); + + it('hands BaseInviteUser a data source for USER_SELECT fields', () => { + mount(InviteUser); + + expect(mocks.baseProps.fetchUsers).toBeTypeOf('function'); + }); + + it('pages the user directory from the base URL and turns the response into a picker page', async () => { + mount(InviteUser); + const req = request(); + + const page = await mocks.baseProps.fetchUsers!(req); + + expect(mocks.getUsers).toHaveBeenCalledWith({ + baseUrl: 'https://thunder.example', + filter: undefined, + limit: 30, + offset: 30, + signal: req.signal, + url: undefined, + }); + expect(page.nextOffset).toBe(32); + expect(page.totalResults).toBe(75); + expect(page.options).toEqual([ + {label: 'Ada Lovelace', value: 'user-1'}, + {label: 'Grace Hopper', value: 'user-2'}, + ]); + }); + + it('honors the `users` endpoint override', async () => { + mocks.thunderID.endpoints = {users: 'https://resources.example/users'}; + mount(InviteUser); + + await mocks.baseProps.fetchUsers!(request({offset: 0})); + + expect(mocks.getUsers).toHaveBeenCalledWith(expect.objectContaining({url: 'https://resources.example/users'})); + }); + + it('keeps one data source for the component lifetime so an open picker keeps its list', async () => { + const wrapper = mount(InviteUser); + const first = mocks.baseProps.fetchUsers; + + await wrapper.setProps({className: 'changed'}); + + expect(mocks.baseProps.fetchUsers).toBe(first); + }); +}); diff --git a/packages/vue/src/__tests__/components/user-select-flow-wiring.test.ts b/packages/vue/src/__tests__/components/user-select-flow-wiring.test.ts new file mode 100644 index 00000000..9b27d34b --- /dev/null +++ b/packages/vue/src/__tests__/components/user-select-flow-wiring.test.ts @@ -0,0 +1,148 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {EmbeddedFlowComponentType, getDefaultI18nBundles, substituteTranslationParams} from '@thunderid/browser'; +import type {EmbeddedFlowComponent, FetchUsers} from '@thunderid/browser'; +import {mount} from '@vue/test-utils'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {ref, type Component} from 'vue'; +import BaseSignIn from '../../components/auth/sign-in/BaseSignIn'; +import BaseSignUp from '../../components/auth/sign-up/BaseSignUp'; +import BaseAcceptInvite from '../../components/presentation/accept-invite/BaseAcceptInvite'; +import BaseInviteUser from '../../components/presentation/invite-user/BaseInviteUser'; +import {FLOW_KEY, FLOW_META_KEY, I18N_KEY} from '../../keys'; + +const i18n = { + bundles: ref({}), + currentLanguage: ref('en-US'), + fallbackLanguage: 'en-US', + injectBundles: vi.fn(), + setLanguage: vi.fn(), + t: (key: string, params?: Record): string => { + const translations = getDefaultI18nBundles()['en-US']?.translations as Record; + const value: string = translations?.[key] ?? key; + return params ? substituteTranslationParams(value, params) : value; + }, +}; + +const provide = { + [FLOW_KEY as symbol]: { + addMessage: vi.fn(), + clearMessages: vi.fn(), + messages: ref([]), + subtitle: ref(''), + title: ref(''), + }, + [FLOW_META_KEY as symbol]: {meta: ref(null)}, + [I18N_KEY as symbol]: i18n, +}; + +const mounted: {unmount: () => void}[] = []; +const mountFlow = (component: Component, props: Record) => { + const wrapper = mount(component, {attachTo: document.body, global: {provide}, props}); + mounted.push(wrapper); + return wrapper; +}; +type FlowWrapper = ReturnType; + +const ownerPicker = (required = false): EmbeddedFlowComponent => + ({ + id: 'owner_input', + label: 'Owner', + ref: 'owner', + required, + type: EmbeddedFlowComponentType.UserSelect, + }) as EmbeddedFlowComponent; + +const submitAction = { + eventType: 'SUBMIT', + id: 'continue_action', + label: 'Continue', + type: EmbeddedFlowComponentType.Action, + variant: 'PRIMARY', +} as EmbeddedFlowComponent; + +const usersPage = { + nextOffset: null, + options: [ + {label: 'Jane Doe', value: 'user-1'}, + {label: 'John Roe', value: 'user-2'}, + ], +}; + +const TRIGGER = '.thunderid-paged-select__trigger'; +const OPTION = '.thunderid-paged-select__option'; + +/** Opens the picker and waits for the users the supplied data source returned. */ +const openOwnerPicker = async (wrapper: FlowWrapper): Promise => { + await vi.waitFor(() => expect(wrapper.find(TRIGGER).exists()).toBe(true)); + await wrapper.find(TRIGGER).trigger('click'); + await vi.waitFor(() => expect(wrapper.findAll(OPTION)).toHaveLength(2)); +}; + +const expectNoPicker = async (wrapper: FlowWrapper): Promise => { + await vi.waitFor(() => expect(wrapper.find('button[type="submit"]').exists()).toBe(true)); + expect(wrapper.find(TRIGGER).exists()).toBe(false); +}; + +const incompleteStep = { + data: {components: [ownerPicker(), submitAction]}, + executionId: 'exec-1', + flowStatus: 'INCOMPLETE', +}; + +describe('USER_SELECT is skipped where no signed-in user exists (vue)', () => { + afterEach(() => { + mounted.splice(0).forEach((wrapper) => wrapper.unmount()); + }); + + it('BaseSignIn skips the picker and still renders the rest of the step', async () => { + const wrapper = mountFlow(BaseSignIn, {components: [ownerPicker(), submitAction], onSubmit: vi.fn()}); + + await expectNoPicker(wrapper); + }); + + it('BaseSignUp skips the picker and still renders the rest of the step', async () => { + const onInitialize = vi.fn().mockResolvedValue({ + data: {meta: {components: [ownerPicker(), submitAction]}}, + executionId: 'exec-1', + flowStatus: 'INCOMPLETE', + }); + const wrapper = mountFlow(BaseSignUp, {isInitialized: true, onInitialize, onSubmit: vi.fn()}); + + await expectNoPicker(wrapper); + }); + + it('BaseAcceptInvite skips the picker and still renders the rest of the step', async () => { + const onSubmit = vi.fn().mockResolvedValue(incompleteStep); + const wrapper = mountFlow(BaseAcceptInvite, {flowId: 'exec-1', inviteToken: 'token-1', onSubmit}); + + await expectNoPicker(wrapper); + }); + + it('a required USER_SELECT that is not rendered does not block the step from being submitted', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + const wrapper = mountFlow(BaseSignIn, {components: [ownerPicker(true), submitAction], onSubmit}); + + await vi.waitFor(() => expect(wrapper.find('button[type="submit"]').exists()).toBe(true)); + await wrapper.find('button[type="submit"]').trigger('click'); + + await vi.waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + }); +}); + +describe('USER_SELECT is wired into InviteUser, where a signed-in admin exists (vue)', () => { + afterEach(() => { + mounted.splice(0).forEach((wrapper) => wrapper.unmount()); + }); + + it('BaseInviteUser renders a usable picker fed by the supplied data source', async () => { + const fetchUsers: FetchUsers = vi.fn().mockResolvedValue(usersPage); + const onInitialize = vi.fn().mockResolvedValue(incompleteStep); + const wrapper = mountFlow(BaseInviteUser, {fetchUsers, onInitialize, onSubmit: vi.fn()}); + + await openOwnerPicker(wrapper); + + expect(fetchUsers).toHaveBeenCalledWith(expect.objectContaining({limit: 30, offset: 0})); + }); +}); diff --git a/packages/vue/src/__tests__/components/user-select.test.ts b/packages/vue/src/__tests__/components/user-select.test.ts new file mode 100644 index 00000000..7faf1536 --- /dev/null +++ b/packages/vue/src/__tests__/components/user-select.test.ts @@ -0,0 +1,84 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {getDefaultI18nBundles, substituteTranslationParams} from '@thunderid/browser'; +import {mount} from '@vue/test-utils'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {ref} from 'vue'; +import UserSelect from '../../components/primitives/UserSelect/UserSelect'; +import {I18N_KEY} from '../../keys'; + +const mocks = vi.hoisted(() => ({ + getUsers: vi.fn(), + thunderID: { + baseUrl: 'https://thunder.example', + endpoints: {users: 'https://rs.example.com/users'} as {users?: string}, + }, +})); + +vi.mock('../../api/getUsers', () => ({default: mocks.getUsers})); +vi.mock('../../composables/useThunderID', () => ({default: () => mocks.thunderID})); + +const i18n = { + bundles: ref({}), + currentLanguage: ref('en-US'), + fallbackLanguage: 'en-US', + injectBundles: vi.fn(), + setLanguage: vi.fn(), + t: (key: string, params?: Record): string => { + const translations = getDefaultI18nBundles()['en-US']?.translations as Record; + const value: string = translations?.[key] ?? key; + return params ? substituteTranslationParams(value, params) : value; + }, +}; + +const mounted: {unmount: () => void}[] = []; +const mountUserSelect = () => { + const wrapper = mount(UserSelect, { + attachTo: document.body, + global: {provide: {[I18N_KEY as symbol]: i18n}}, + props: {label: 'Owner'}, + }); + mounted.push(wrapper); + return wrapper; +}; + +describe('UserSelect (vue)', () => { + afterEach(() => { + mounted.splice(0).forEach((wrapper) => wrapper.unmount()); + mocks.getUsers.mockReset(); + }); + + it('makes no request while closed', () => { + mountUserSelect(); + + expect(mocks.getUsers).not.toHaveBeenCalled(); + }); + + it('loads the user directory itself and submits the chosen ID', async () => { + mocks.getUsers.mockResolvedValue({ + count: 2, + startIndex: 1, + totalResults: 2, + users: [ + {display: 'Jane Doe', id: 'user-1'}, + {display: 'John Roe', id: 'user-2'}, + ], + }); + const wrapper = mountUserSelect(); + + await wrapper.find('.thunderid-paged-select__trigger').trigger('click'); + await vi.waitFor(() => expect(wrapper.findAll('.thunderid-paged-select__option')).toHaveLength(2)); + await wrapper.findAll('.thunderid-paged-select__option')[0].trigger('click'); + + expect(mocks.getUsers).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: 'https://thunder.example', + limit: 30, + offset: 0, + url: 'https://rs.example.com/users', + }), + ); + expect(wrapper.emitted('update:modelValue')).toEqual([['user-1']]); + }); +}); diff --git a/packages/vue/src/__tests__/composables/use-paged-select.test.ts b/packages/vue/src/__tests__/composables/use-paged-select.test.ts new file mode 100644 index 00000000..f6305a69 --- /dev/null +++ b/packages/vue/src/__tests__/composables/use-paged-select.test.ts @@ -0,0 +1,227 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {ThunderIDAPIError} from '@thunderid/browser'; +import type {FetchPagedOptions, PagedSelectPage, PagedSelectRequest} from '@thunderid/browser'; +import {mount} from '@vue/test-utils'; +import {describe, expect, it, vi} from 'vitest'; +import {defineComponent, h, reactive} from 'vue'; +import usePagedSelect, {type UsePagedSelectOptions, type UsePagedSelectResult} from '../../composables/usePagedSelect'; + +const page = (start: number, count: number, totalResults: number): PagedSelectPage => ({ + nextOffset: start + count < totalResults ? start + count : null, + options: Array.from({length: count}, (_, index) => ({ + label: `User ${start + index + 1}`, + value: `user-${start + index + 1}`, + })), + totalResults, +}); + +/** + * Mounts `usePagedSelect` in a minimal host component so lifecycle hooks have an instance. + * The options are `reactive`, so mutating `filter`/`fetchOptions` after mounting reaches the composable's watch. + */ +function setup(options: UsePagedSelectOptions): { + reactiveOptions: UsePagedSelectOptions; + result: UsePagedSelectResult; + wrapper: ReturnType; +} { + let result!: UsePagedSelectResult; + const reactiveOptions = reactive(options) as UsePagedSelectOptions; + const TestChild = defineComponent({ + setup() { + result = usePagedSelect(reactiveOptions); + return () => h('div'); + }, + }); + const wrapper = mount(TestChild); + return {reactiveOptions, result, wrapper}; +} + +describe('usePagedSelect (vue)', () => { + it('makes no request until loadInitial is called', () => { + const fetchOptions: FetchPagedOptions = vi.fn(); + setup({fetchOptions}); + + expect(fetchOptions).not.toHaveBeenCalled(); + }); + + it('never requests anything while fetchOptions is unset', () => { + const {result} = setup({}); + + result.loadInitial(); + + expect(result.options.value).toEqual([]); + expect(result.isLoading.value).toBe(false); + }); + + it('loads the first page once, and ignores repeated loadInitial calls', async () => { + const fetchOptions: FetchPagedOptions = vi.fn().mockResolvedValue(page(0, 30, 75)); + const {result} = setup({fetchOptions}); + + result.loadInitial(); + result.loadInitial(); + await vi.waitFor(() => expect(result.options.value).toHaveLength(30)); + result.loadInitial(); + + expect(fetchOptions).toHaveBeenCalledTimes(1); + expect(fetchOptions).toHaveBeenCalledWith(expect.objectContaining({limit: 30, offset: 0})); + expect(result.hasMore.value).toBe(true); + }); + + it('appends following pages, de-duplicates, and stops at the end', async () => { + const fetchOptions: FetchPagedOptions = vi + .fn() + .mockResolvedValueOnce(page(0, 30, 45)) + .mockResolvedValueOnce({...page(29, 16, 45), nextOffset: null}); + const {result} = setup({fetchOptions}); + + result.loadInitial(); + await vi.waitFor(() => expect(result.hasLoaded.value).toBe(true)); + result.loadMore(); + await vi.waitFor(() => expect(result.options.value).toHaveLength(45)); + + expect(result.hasMore.value).toBe(false); + result.loadMore(); + expect(fetchOptions).toHaveBeenCalledTimes(2); + }); + + it('treats a non-advancing nextOffset as the end of the list', async () => { + const fetchOptions: FetchPagedOptions = vi.fn().mockResolvedValue({...page(0, 5, 100), nextOffset: 0}); + const {result} = setup({fetchOptions}); + + result.loadInitial(); + await vi.waitFor(() => expect(result.hasLoaded.value).toBe(true)); + + expect(result.hasMore.value).toBe(false); + }); + + it('retries the page that failed, keeping already-loaded options on a next-page failure', async () => { + const fetchOptions: FetchPagedOptions = vi + .fn() + .mockResolvedValueOnce(page(0, 30, 60)) + .mockRejectedValueOnce(new ThunderIDAPIError('Upstream exploded', 'test-code', 'test-origin', 500)) + .mockResolvedValueOnce(page(30, 30, 60)); + const {result} = setup({fetchOptions}); + + result.loadInitial(); + await vi.waitFor(() => expect(result.hasLoaded.value).toBe(true)); + result.loadMore(); + await vi.waitFor(() => expect(result.error.value?.message).toBe('Upstream exploded')); + expect(result.options.value).toHaveLength(30); + + result.retry(); + await vi.waitFor(() => expect(result.options.value).toHaveLength(60)); + expect(fetchOptions).toHaveBeenNthCalledWith(3, expect.objectContaining({offset: 30})); + expect(result.error.value).toBeNull(); + }); + + it('does not trust a plain Error, falling back to the generic translatable key', async () => { + const fetchOptions: FetchPagedOptions = vi.fn().mockRejectedValueOnce(new Error('a raw exception message')); + const {result} = setup({fetchOptions}); + + result.loadInitial(); + + await vi.waitFor(() => expect(result.error.value).not.toBeNull()); + expect(result.error.value?.message).toBeUndefined(); + expect(result.error.value?.messageKey).toBe('elements.fields.paged_select.load_error'); + }); + + it('aborts the in-flight request on abort() without surfacing an error, and allows reloading', async () => { + let signal: AbortSignal | undefined; + const fetchOptions: FetchPagedOptions = vi + .fn() + .mockImplementationOnce( + (request: PagedSelectRequest) => + new Promise((_resolve, reject) => { + signal = request.signal; + request.signal.addEventListener('abort', () => reject(new Error('aborted'))); + }), + ) + .mockResolvedValueOnce(page(0, 3, 3)); + const {result} = setup({fetchOptions}); + + result.loadInitial(); + result.abort(); + + expect(signal?.aborted).toBe(true); + expect(result.error.value).toBeNull(); + expect(result.isLoading.value).toBe(false); + + result.loadInitial(); + await vi.waitFor(() => expect(result.options.value).toHaveLength(3)); + }); + + it('resets loaded pages and ignores stale results when the filter changes', async () => { + let resolveStale: (value: PagedSelectPage) => void = () => undefined; + const fetchOptions: FetchPagedOptions = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveStale = resolve; + }), + ) + .mockResolvedValueOnce(page(100, 2, 2)); + const {reactiveOptions, result} = setup({fetchOptions, filter: 'department eq "A"'}); + + result.loadInitial(); + reactiveOptions.filter = 'department eq "B"'; + resolveStale(page(0, 30, 30)); + await Promise.resolve(); + await Promise.resolve(); + + expect(result.options.value).toEqual([]); + + result.loadInitial(); + await vi.waitFor(() => expect(result.options.value).toHaveLength(2)); + expect(fetchOptions).toHaveBeenLastCalledWith(expect.objectContaining({filter: 'department eq "B"', offset: 0})); + }); + + describe('mapping raw items into options', () => { + it('uses a plain list of strings for both label and value, with no mapping', async () => { + const fetchOptions: FetchPagedOptions = vi + .fn() + .mockResolvedValue({items: ['alice@example.com', 'bob@example.com'], nextOffset: null}); + const {result} = setup({fetchOptions}); + + result.loadInitial(); + await vi.waitFor(() => expect(result.hasLoaded.value).toBe(true)); + + expect(result.options.value).toEqual([ + {label: 'alice@example.com', value: 'alice@example.com'}, + {label: 'bob@example.com', value: 'bob@example.com'}, + ]); + }); + + it('applies the configured label and value fields to an array of objects', async () => { + const items = [ + {email: 'alice@example.com', fullName: 'Alice Example', id: 'u1'}, + {email: 'bob@example.com', fullName: 'Bob Example', id: 'u2'}, + ]; + const fetchOptions: FetchPagedOptions = vi.fn().mockResolvedValue({items, nextOffset: null}); + const mapping = {labelAttributes: ['fullName'], valueAttribute: 'email'}; + const {result} = setup({fetchOptions, mapping}); + + result.loadInitial(); + await vi.waitFor(() => expect(result.hasLoaded.value).toBe(true)); + + expect(result.options.value).toEqual([ + {label: 'Alice Example', value: 'alice@example.com'}, + {label: 'Bob Example', value: 'bob@example.com'}, + ]); + }); + + it('prefers ready-made options over raw items when a page returns both', async () => { + const fetchOptions: FetchPagedOptions = vi + .fn() + .mockResolvedValue({items: ['ignored'], nextOffset: null, options: [{label: 'Kept', value: 'kept'}]}); + const {result} = setup({fetchOptions}); + + result.loadInitial(); + await vi.waitFor(() => expect(result.hasLoaded.value).toBe(true)); + + expect(result.options.value).toEqual([{label: 'Kept', value: 'kept'}]); + }); + }); +}); diff --git a/packages/vue/src/api/getUsers.ts b/packages/vue/src/api/getUsers.ts new file mode 100644 index 00000000..1370d9a4 --- /dev/null +++ b/packages/vue/src/api/getUsers.ts @@ -0,0 +1,51 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + createHttpClientFetcher, + getUsers as baseGetUsers, + GetUsersConfig as BaseGetUsersConfig, + ManagedUserListResponse, +} from '@thunderid/browser'; + +/** + * Configuration for the getUsers request (Vue-specific) + */ +export interface GetUsersConfig extends Omit { + /** + * Optional custom fetcher function. If not provided, the ThunderID SPA client's httpClient will be used + * which is a wrapper around axios http.request + */ + fetcher?: (url: string, config: RequestInit) => Promise; + /** + * Optional instance ID for multi-instance support. Defaults to 0. + */ + instanceId?: number; +} + +/** + * Retrieves one page of the user directory. Uses the ThunderID SPA client's httpClient by default, + * which attaches the access token, but allows a custom fetcher. + * + * @param config - Configuration object with URL, paging options and optional request config. + * @returns A promise that resolves with one page of the user directory. + * @example + * ```typescript + * const page = await getUsers({ + * baseUrl: "https://localhost:8090", + * limit: 30, + * offset: 0, + * }); + * ``` + */ +const getUsers = async ({ + fetcher, + instanceId = 0, + ...requestConfig +}: GetUsersConfig): Promise => + baseGetUsers({ + ...requestConfig, + fetcher: fetcher ?? createHttpClientFetcher(instanceId), + }); + +export default getUsers; diff --git a/packages/vue/src/components/auth/sign-in/AuthOptionFactoryCore.ts b/packages/vue/src/components/auth/sign-in/AuthOptionFactoryCore.ts index a665b792..6d81f4ba 100644 --- a/packages/vue/src/components/auth/sign-in/AuthOptionFactoryCore.ts +++ b/packages/vue/src/components/auth/sign-in/AuthOptionFactoryCore.ts @@ -20,6 +20,7 @@ import { ConsentPurposeDecision, ConsentAttributeElement, } from '@thunderid/browser'; +import type {FetchUsers} from '@thunderid/browser'; import DOMPurify from 'dompurify'; import {h, type VNode} from 'vue'; import {createVueLogger} from '../../../utils/logger'; @@ -32,6 +33,7 @@ import Button from '../../primitives/Button'; import Divider from '../../primitives/Divider'; import Select from '../../primitives/Select/Select'; import Typography from '../../primitives/Typography'; +import BaseUserSelect from '../../primitives/UserSelect/BaseUserSelect'; const logger: ReturnType = createVueLogger('AuthOptionFactory'); @@ -139,6 +141,8 @@ const createAuthComponentFromFlow = ( options: { additionalData?: Record; buttonClassName?: string; + /** Data source for USER_SELECT fields. */ + fetchUsers?: FetchUsers; inStack?: boolean; inputClassName?: string; isTimeoutDisabled?: boolean; @@ -380,6 +384,33 @@ const createAuthComponentFromFlow = ( }); } + case EmbeddedFlowComponentType.UserSelect: { + if (!options.fetchUsers) { + logger.warn('USER_SELECT is only rendered where a signed-in user is available (InviteUser). Skipping render.'); + return null; + } + + const identifier: string = component.ref ?? ''; + const value: string = formValues[identifier] || ''; + const isTouched: boolean = touchedFields[identifier] || false; + const error: string | undefined = isTouched ? formErrors[identifier] : undefined; + + return h(BaseUserSelect, { + class: options.inputClassName, + component, + error, + fetchUsers: options.fetchUsers, + key, + label: resolve(component.label) || '', + modelValue: value, + name: identifier, + onBlur: () => options.onInputBlur?.(identifier), + 'onUpdate:modelValue': (val: string) => onInputChange(identifier, val), + placeholder: resolve(component.placeholder), + required: component.required, + }); + } + case EmbeddedFlowComponentType.Block: { if (component.components && component.components.length > 0) { const blockChildren: (VNode | null)[] = component.components @@ -650,6 +681,8 @@ export const renderInviteUserComponents = ( options?: { additionalData?: Record; buttonClassName?: string; + /** Data source for USER_SELECT fields. */ + fetchUsers?: FetchUsers; inputClassName?: string; isTimeoutDisabled?: boolean; meta?: FlowMetadataResponse | null; diff --git a/packages/vue/src/components/presentation/invite-user/BaseInviteUser.ts b/packages/vue/src/components/presentation/invite-user/BaseInviteUser.ts index 4d590fd3..31af9ab3 100644 --- a/packages/vue/src/components/presentation/invite-user/BaseInviteUser.ts +++ b/packages/vue/src/components/presentation/invite-user/BaseInviteUser.ts @@ -1,7 +1,7 @@ // Copyright 2025-2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {EmbeddedFlowType, FlowMetadataResponse, withVendorCSSClassPrefix} from '@thunderid/browser'; +import {EmbeddedFlowType, FetchUsers, FlowMetadataResponse, withVendorCSSClassPrefix} from '@thunderid/browser'; import { type Component, type PropType, @@ -93,6 +93,11 @@ const BaseInviteUser: Component = defineComponent({ name: 'BaseInviteUser', props: { className: {default: '', type: String}, + /** + * Function to load one page of users. + * When provided, enables the user picker for USER_SELECT components. + */ + fetchUsers: {default: undefined, type: Function as PropType}, isInitialized: {default: true, type: Boolean}, onError: {default: undefined, type: Function as PropType<(error: Error) => void>}, onFlowChange: { @@ -182,7 +187,10 @@ const BaseInviteUser: Component = defineComponent({ const validateComponents = (comps: any[]): void => { comps.forEach((comp: any) => { if ( - (comp.type === 'TEXT_INPUT' || comp.type === 'EMAIL_INPUT' || comp.type === 'SELECT') && + (comp.type === 'TEXT_INPUT' || + comp.type === 'EMAIL_INPUT' || + comp.type === 'SELECT' || + comp.type === 'USER_SELECT') && comp.required && comp.ref ) { @@ -494,6 +502,7 @@ const BaseInviteUser: Component = defineComponent({ resetForm, handleInputChange, { + fetchUsers: props.fetchUsers, meta, onInputBlur: handleInputBlur, onSubmit: handleSubmit, diff --git a/packages/vue/src/components/presentation/invite-user/InviteUser.ts b/packages/vue/src/components/presentation/invite-user/InviteUser.ts index 38677ea8..afe6bb89 100644 --- a/packages/vue/src/components/presentation/invite-user/InviteUser.ts +++ b/packages/vue/src/components/presentation/invite-user/InviteUser.ts @@ -2,9 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import {EmbeddedFlowType} from '@thunderid/browser'; +import type {FetchUsers} from '@thunderid/browser'; import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; import BaseInviteUser from './BaseInviteUser'; import type {BaseInviteUserRenderProps, InviteUserFlowResponse} from './BaseInviteUser'; +import useFetchUsers from '../../../composables/useFetchUsers'; import useThunderID from '../../../composables/useThunderID'; export type InviteUserRenderProps = BaseInviteUserRenderProps; @@ -33,6 +35,8 @@ const InviteUser: Component = defineComponent({ setup(props: any, {slots}: SetupContext): () => VNode | null { const {http, baseUrl, isInitialized} = useThunderID(); + const fetchUsers: FetchUsers = useFetchUsers(); + const handleInitialize = async (payload: Record): Promise => { const response: any = await http.request({ data: {...payload, flowType: EmbeddedFlowType.UserOnboarding, verbose: true}, @@ -58,6 +62,7 @@ const InviteUser: Component = defineComponent({ BaseInviteUser, { className: props.className, + fetchUsers, isInitialized: isInitialized?.value ?? false, onError: props.onError, onFlowChange: props.onFlowChange, diff --git a/packages/vue/src/components/primitives/PagedSelect/PagedSelect.css.ts b/packages/vue/src/components/primitives/PagedSelect/PagedSelect.css.ts new file mode 100644 index 00000000..53e0e6ed --- /dev/null +++ b/packages/vue/src/components/primitives/PagedSelect/PagedSelect.css.ts @@ -0,0 +1,192 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Styles for the PagedSelect primitive component. + * + * BEM block: `.thunderid-paged-select` + * + * Elements: + * __label | __required | __trigger | __trigger-label | __chevron | __panel | __option + * __option-label | __status-row | __retry | __load-more | __error | __helper | __visually-hidden + */ +const PAGED_SELECT_CSS = ` +/* ============================================================ + PagedSelect + ============================================================ */ + +.thunderid-paged-select { + display: flex; + flex-direction: column; + gap: calc(var(--thunderid-spacing-unit) * 0.5); + font-family: var(--thunderid-typography-fontFamily); + width: 100%; + box-sizing: border-box; + position: relative; +} + +.thunderid-paged-select__label { + font-size: var(--thunderid-typography-fontSize-sm); + font-weight: var(--thunderid-typography-fontWeight-medium); + color: var(--thunderid-color-text-primary); + display: block; + line-height: var(--thunderid-typography-lineHeight-normal); +} + +.thunderid-paged-select__required { + color: var(--thunderid-color-error-main); + margin-left: 2px; +} + +.thunderid-paged-select__trigger { + width: 100%; + height: var(--thunderid-input-height); + padding: 0 var(--thunderid-input-paddingX); + border: 1px solid var(--thunderid-input-borderColor); + border-radius: var(--thunderid-input-borderRadius); + font-family: var(--thunderid-typography-fontFamily); + font-size: var(--thunderid-input-fontSize); + color: var(--thunderid-color-text-primary); + background-color: var(--thunderid-color-background-surface); + display: flex; + align-items: center; + justify-content: space-between; + gap: calc(var(--thunderid-spacing-unit) * 1); + cursor: pointer; + box-sizing: border-box; + transition: + border-color var(--thunderid-transition-fast), + box-shadow var(--thunderid-transition-fast); + outline: none; +} +.thunderid-paged-select__trigger:focus { + border-color: var(--thunderid-input-focusBorderColor); + box-shadow: var(--thunderid-input-focusRing); +} +.thunderid-paged-select__trigger:disabled { + background-color: var(--thunderid-color-background-disabled); + color: var(--thunderid-color-action-disabled); + cursor: not-allowed; +} + +.thunderid-paged-select--error .thunderid-paged-select__trigger { + border-color: var(--thunderid-color-error-main); +} +.thunderid-paged-select--error .thunderid-paged-select__trigger:focus { + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); +} + +.thunderid-paged-select__trigger-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; + flex: 1; +} + +.thunderid-paged-select__chevron { + display: inline-flex; + flex-shrink: 0; + color: var(--thunderid-color-text-secondary); +} + +.thunderid-paged-select__panel { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 1000; + max-height: 280px; + overflow-y: auto; + background-color: var(--thunderid-color-background-surface); + border: 1px solid var(--thunderid-input-borderColor); + border-radius: var(--thunderid-input-borderRadius); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + padding: calc(var(--thunderid-spacing-unit) * 1) 0; +} + +.thunderid-paged-select__option { + width: 100%; + display: flex; + align-items: center; + gap: calc(var(--thunderid-spacing-unit) * 1); + padding: calc(var(--thunderid-spacing-unit) * 1.5) calc(var(--thunderid-spacing-unit) * 2); + border: none; + background: none; + color: var(--thunderid-color-text-primary); + font-family: var(--thunderid-typography-fontFamily); + font-size: var(--thunderid-input-fontSize); + text-align: left; + cursor: pointer; +} +.thunderid-paged-select__option:hover:not(:disabled), +.thunderid-paged-select__option--active:not(:disabled) { + background-color: var(--thunderid-color-background-hover, rgba(0, 0, 0, 0.04)); +} +.thunderid-paged-select__option:disabled { + color: var(--thunderid-color-action-disabled); + cursor: not-allowed; +} +.thunderid-paged-select__option--selected { + font-weight: var(--thunderid-typography-fontWeight-medium); +} + +.thunderid-paged-select__option-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.thunderid-paged-select__status-row { + display: flex; + align-items: center; + gap: calc(var(--thunderid-spacing-unit) * 1); + padding: calc(var(--thunderid-spacing-unit) * 1.5) calc(var(--thunderid-spacing-unit) * 2); + color: var(--thunderid-color-text-secondary); + font-size: var(--thunderid-typography-fontSize-sm); +} + +.thunderid-paged-select__retry, +.thunderid-paged-select__load-more { + border: none; + background: none; + color: var(--thunderid-color-primary-main, #4b6ef5); + font-family: var(--thunderid-typography-fontFamily); + font-size: var(--thunderid-typography-fontSize-sm); + cursor: pointer; + padding: 0; +} + +.thunderid-paged-select__load-more { + width: 100%; + padding: calc(var(--thunderid-spacing-unit) * 1.5) calc(var(--thunderid-spacing-unit) * 2); + text-align: left; +} + +.thunderid-paged-select__error { + font-size: var(--thunderid-typography-fontSize-xs); + color: var(--thunderid-color-error-contrastText); + line-height: var(--thunderid-typography-lineHeight-normal); +} + +.thunderid-paged-select__helper { + font-size: var(--thunderid-typography-fontSize-xs); + color: var(--thunderid-color-text-secondary); + line-height: var(--thunderid-typography-lineHeight-normal); +} + +.thunderid-paged-select__visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +`; + +export default PAGED_SELECT_CSS; diff --git a/packages/vue/src/components/primitives/PagedSelect/PagedSelect.ts b/packages/vue/src/components/primitives/PagedSelect/PagedSelect.ts new file mode 100644 index 00000000..f0dc0b00 --- /dev/null +++ b/packages/vue/src/components/primitives/PagedSelect/PagedSelect.ts @@ -0,0 +1,386 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {bem, withVendorCSSClassPrefix} from '@thunderid/browser'; +import type { + EmbeddedFlowComponent, + FetchPagedOptions, + PagedSelectOption, + PagedSelectOptionDefaults, + PagedSelectOptionMapping, +} from '@thunderid/browser'; +import { + type Component, + type PropType, + type Ref, + type VNode, + defineComponent, + h, + onMounted, + onUnmounted, + ref, +} from 'vue'; +import useI18n from '../../../composables/useI18n'; +import usePagedSelect, {type UsePagedSelectResult} from '../../../composables/usePagedSelect'; +import {CheckIcon, ChevronDownIcon} from '../Icons'; +import Spinner from '../Spinner'; + +const SCROLL_NEAR_BOTTOM_THRESHOLD_PX = 64; + +export interface PagedSelectMessages { + ariaLabel: string; + empty: string; + loaded: (count: number) => string; + loadMore: string; + loading: string; + loadingMore: string; + retry: string; +} + +const DEFAULT_MESSAGES: PagedSelectMessages = { + ariaLabel: 'Select an option', + empty: 'No results found.', + loaded: (count: number): string => `${count} result${count === 1 ? '' : 's'} loaded.`, + loadMore: 'Load more', + loading: 'Loading…', + loadingMore: 'Loading more…', + retry: 'Retry', +}; + +type PagedSelectProps = Readonly<{ + component?: EmbeddedFlowComponent; + defaults?: PagedSelectOptionDefaults; + disabled: boolean; + error: string | undefined; + /** Loads one page at a time. Nothing is requested while this is unset. */ + fetchOptions?: FetchPagedOptions; + filter: string | undefined; + helperText: string | undefined; + id: string | undefined; + label: string | undefined; + /** Which field of each raw item is the label and which is the value. */ + mapping?: PagedSelectOptionMapping; + messages?: Partial; + modelValue: string; + name: string | undefined; + pageSize: number | undefined; + placeholder: string | undefined; + required: boolean; +}>; + +/** + * Single-choice dropdown whose options load a page at a time from `fetchOptions`. Vue counterpart of the React `PagedSelect`; specialised pickers wrap it with their own loader and wording. + */ +const PagedSelect: Component = defineComponent({ + name: 'PagedSelect', + props: { + component: {default: undefined, type: Object as PropType}, + defaults: {default: undefined, type: Object as PropType}, + disabled: {default: false, type: Boolean}, + error: {default: undefined, type: String}, + fetchOptions: {default: undefined, type: Function as PropType}, + filter: {default: undefined, type: String}, + helperText: {default: undefined, type: String}, + id: {default: undefined, type: String}, + label: {default: undefined, type: String}, + mapping: {default: undefined, type: Object as PropType}, + messages: {default: undefined, type: Object as PropType>}, + modelValue: {default: '', type: String}, + name: {default: undefined, type: String}, + pageSize: {default: undefined, type: Number}, + placeholder: {default: undefined, type: String}, + required: {default: false, type: Boolean}, + }, + emits: ['update:modelValue'], + setup(props: PagedSelectProps, {emit, attrs}): () => VNode { + const {t} = useI18n(); + const px: typeof withVendorCSSClassPrefix = withVendorCSSClassPrefix; + + const isOpen: Ref = ref(false); + const activeIndex: Ref = ref(null); + const containerRef: Ref = ref(null); + const panelRef: Ref = ref(null); + const optionRefs: Ref<(HTMLElement | null)[]> = ref([]); + + const { + abort, + error: loadError, + hasMore, + isLoading, + isLoadingMore, + loadInitial, + loadMore, + options, + retry, + }: UsePagedSelectResult = usePagedSelect({ + component: props.component, + defaults: props.defaults, + fetchOptions: props.fetchOptions, + fieldId: props.id ?? props.name, + filter: props.filter, + mapping: props.mapping, + pageSize: props.pageSize, + }); + + const text = (): PagedSelectMessages => ({ + ariaLabel: t('elements.fields.paged_select.aria_label') || DEFAULT_MESSAGES.ariaLabel, + empty: t('elements.fields.paged_select.empty') || DEFAULT_MESSAGES.empty, + loaded: (count: number): string => + t('elements.fields.paged_select.loaded', {count}) || DEFAULT_MESSAGES.loaded(count), + loadMore: t('elements.fields.paged_select.load_more') || DEFAULT_MESSAGES.loadMore, + loading: t('elements.fields.paged_select.loading') || DEFAULT_MESSAGES.loading, + loadingMore: t('elements.fields.paged_select.loading_more') || DEFAULT_MESSAGES.loadingMore, + retry: t('elements.fields.paged_select.retry') || DEFAULT_MESSAGES.retry, + ...props.messages, + }); + + const closePanel = (): void => { + isOpen.value = false; + activeIndex.value = null; + abort(); + }; + + const openPanel = (): void => { + if (props.disabled) return; + isOpen.value = true; + loadInitial(); + }; + + const togglePanel = (): void => { + if (isOpen.value) { + closePanel(); + } else { + openPanel(); + } + }; + + const handleClickOutside = (event: MouseEvent): void => { + if (containerRef.value && !containerRef.value.contains(event.target as Node)) { + closePanel(); + } + }; + + const focusOption = (index: number): void => { + activeIndex.value = index; + optionRefs.value[index]?.focus(); + }; + + const moveActive = (delta: number): void => { + if (options.value.length === 0) return; + const enabledIndices: number[] = options.value + .map((option: PagedSelectOption, index: number) => (option.disabled ? -1 : index)) + .filter((index: number) => index >= 0); + if (enabledIndices.length === 0) return; + + const currentPos: number = activeIndex.value === null ? -1 : enabledIndices.indexOf(activeIndex.value); + const nextPos: number = (currentPos + delta + enabledIndices.length) % enabledIndices.length; + focusOption(enabledIndices[nextPos]); + }; + + const handleSelect = (option: PagedSelectOption): void => { + if (option.disabled) return; + emit('update:modelValue', option.value); + closePanel(); + containerRef.value?.querySelector(`.${px('paged-select__trigger')}`)?.focus(); + }; + + const handleTriggerKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + if (!isOpen.value) { + openPanel(); + } else if (event.key === 'ArrowDown') { + moveActive(1); + } else if (event.key === 'ArrowUp') { + moveActive(-1); + } + } else if (event.key === 'Escape' && isOpen.value) { + event.preventDefault(); + closePanel(); + } + }; + + const handlePanelKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'ArrowDown') { + event.preventDefault(); + moveActive(1); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + moveActive(-1); + } else if (event.key === 'Escape') { + event.preventDefault(); + closePanel(); + containerRef.value?.querySelector(`.${px('paged-select__trigger')}`)?.focus(); + } else if (event.key === 'Enter' || event.key === ' ') { + if (activeIndex.value !== null) { + event.preventDefault(); + const option: PagedSelectOption | undefined = options.value[activeIndex.value]; + if (option) handleSelect(option); + } + } + }; + + const handleScroll = (event: Event): void => { + const target = event.currentTarget as HTMLElement; + if (target.scrollHeight - target.scrollTop - target.clientHeight <= SCROLL_NEAR_BOTTOM_THRESHOLD_PX) { + loadMore(); + } + }; + + onMounted((): void => { + document.addEventListener('click', handleClickOutside); + }); + + onUnmounted((): void => { + document.removeEventListener('click', handleClickOutside); + abort(); + }); + + return (): VNode => { + const messages: PagedSelectMessages = text(); + const hasError = !!props.error; + const selectedOption: PagedSelectOption | undefined = options.value.find( + (option: PagedSelectOption) => option.value === props.modelValue, + ); + const triggerLabel: string = props.modelValue ? (selectedOption?.label ?? props.modelValue) : ''; + const errorText: string | null = loadError.value + ? (loadError.value.message ?? t(loadError.value.messageKey)) + : null; + const statusMessage: string = isLoading.value + ? messages.loading + : (errorText ?? messages.loaded(options.value.length)); + + const wrapperClass: string = [ + withVendorCSSClassPrefix(bem('paged-select')), + hasError ? withVendorCSSClassPrefix(bem('paged-select', undefined, 'error')) : '', + (attrs.class as string) || '', + ] + .filter(Boolean) + .join(' '); + + const panelChildren: (VNode | null)[] = []; + + if (isLoading.value && options.value.length === 0) { + panelChildren.push( + h('div', {class: px('paged-select__status-row')}, [h(Spinner, {size: 'small'}), messages.loading]), + ); + } + + if (!isLoading.value && errorText && options.value.length === 0) { + panelChildren.push( + h('div', {class: px('paged-select__status-row')}, [ + h('span', null, errorText), + h('button', {class: px('paged-select__retry'), onClick: retry, type: 'button'}, messages.retry), + ]), + ); + } + + if (!isLoading.value && !errorText && options.value.length === 0 && !hasMore.value) { + panelChildren.push(h('div', {class: px('paged-select__status-row')}, messages.empty)); + } + + options.value.forEach((option: PagedSelectOption, index: number) => { + const isSelected: boolean = option.value === props.modelValue; + panelChildren.push( + h( + 'button', + { + 'aria-selected': isSelected, + class: [ + px('paged-select__option'), + isSelected ? px('paged-select__option--selected') : '', + activeIndex.value === index ? px('paged-select__option--active') : '', + ] + .filter(Boolean) + .join(' '), + disabled: option.disabled, + key: option.value, + onClick: () => handleSelect(option), + ref: (el) => { + optionRefs.value[index] = el as HTMLElement | null; + }, + role: 'option', + tabindex: activeIndex.value === index ? 0 : -1, + type: 'button', + }, + [isSelected ? h(CheckIcon) : null, h('span', {class: px('paged-select__option-label')}, option.label)], + ), + ); + }); + + if (isLoadingMore.value) { + panelChildren.push( + h('div', {class: px('paged-select__status-row')}, [h(Spinner, {size: 'small'}), messages.loadingMore]), + ); + } + + if (!isLoadingMore.value && errorText && options.value.length > 0) { + panelChildren.push( + h('div', {class: px('paged-select__status-row')}, [ + h('span', null, errorText), + h('button', {class: px('paged-select__retry'), onClick: retry, type: 'button'}, messages.retry), + ]), + ); + } + + if (!isLoadingMore.value && !errorText && hasMore.value) { + panelChildren.push( + h('button', {class: px('paged-select__load-more'), onClick: loadMore, type: 'button'}, messages.loadMore), + ); + } + + return h('div', {class: wrapperClass}, [ + props.label + ? h('label', {class: px('paged-select__label'), for: props.name}, [ + props.label, + props.required ? h('span', {class: px('paged-select__required')}, ' *') : null, + ]) + : null, + h('div', {ref: containerRef, style: {position: 'relative'}}, [ + h( + 'button', + { + 'aria-expanded': isOpen.value, + 'aria-haspopup': 'listbox', + 'aria-label': props.label ?? props.placeholder ?? messages.ariaLabel, + class: px('paged-select__trigger'), + disabled: props.disabled, + id: props.id, + name: props.name, + onClick: togglePanel, + onKeydown: handleTriggerKeyDown, + type: 'button', + }, + [ + h('span', {class: px('paged-select__trigger-label')}, triggerLabel || props.placeholder), + h('span', {class: px('paged-select__chevron')}, [h(ChevronDownIcon)]), + ], + ), + isOpen.value + ? h( + 'div', + { + 'aria-label': props.label ?? messages.ariaLabel, + class: px('paged-select__panel'), + onKeydown: handlePanelKeyDown, + onScroll: handleScroll, + ref: panelRef, + role: 'listbox', + }, + panelChildren.filter(Boolean), + ) + : null, + ]), + hasError + ? h('span', {class: px('paged-select__error')}, props.error) + : props.helperText + ? h('span', {class: px('paged-select__helper')}, props.helperText) + : null, + h('span', {'aria-live': 'polite', class: px('paged-select__visually-hidden')}, statusMessage), + ]); + }; + }, +}); + +export default PagedSelect; +export type {PagedSelectProps}; diff --git a/packages/vue/src/components/primitives/UserSelect/BaseUserSelect.ts b/packages/vue/src/components/primitives/UserSelect/BaseUserSelect.ts new file mode 100644 index 00000000..18d5f058 --- /dev/null +++ b/packages/vue/src/components/primitives/UserSelect/BaseUserSelect.ts @@ -0,0 +1,88 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {bem, EmbeddedFlowComponentType, UserSelectConstants, withVendorCSSClassPrefix} from '@thunderid/browser'; +import type { + EmbeddedFlowComponent, + FetchUsers, + PagedSelectOptionDefaults, + UserSelectOptionMapping, +} from '@thunderid/browser'; +import {type Component, type PropType, type VNode, defineComponent, h} from 'vue'; +import useI18n from '../../../composables/useI18n'; +import PagedSelect from '../PagedSelect/PagedSelect'; + +type BaseUserSelectProps = Readonly<{ + component?: EmbeddedFlowComponent; + disabled: boolean; + error: string | undefined; + fetchUsers?: FetchUsers; + filter: string | undefined; + helperText: string | undefined; + id: string | undefined; + label: string | undefined; + mapping?: UserSelectOptionMapping; + modelValue: string; + name: string | undefined; + pageSize: number | undefined; + placeholder: string | undefined; + required: boolean; +}>; + +const USER_DEFAULTS: PagedSelectOptionDefaults = {labelAttributes: UserSelectConstants.LABEL_ATTRIBUTES}; + +/** + * Single-user picker: a {@link PagedSelect} fed by the given `fetchUsers` that submits the user's ID. + * Labels come from `display`, `username`, or `email` unless `mapping` says otherwise. + * Building block for {@link UserSelect}; for a custom data source use {@link PagedSelect}. + */ +const BaseUserSelect: Component = defineComponent({ + name: 'BaseUserSelect', + props: { + component: {default: undefined, type: Object as PropType}, + disabled: {default: false, type: Boolean}, + error: {default: undefined, type: String}, + fetchUsers: {default: undefined, type: Function as PropType}, + filter: {default: undefined, type: String}, + helperText: {default: undefined, type: String}, + id: {default: undefined, type: String}, + label: {default: undefined, type: String}, + mapping: {default: undefined, type: Object as PropType}, + modelValue: {default: '', type: String}, + name: {default: undefined, type: String}, + pageSize: {default: undefined, type: Number}, + placeholder: {default: undefined, type: String}, + required: {default: false, type: Boolean}, + }, + emits: ['update:modelValue'], + setup(props: BaseUserSelectProps, {emit}): () => VNode { + const {t} = useI18n(); + + return (): VNode => { + const userComponent: EmbeddedFlowComponent = + props.component ?? + ({ + id: props.id ?? props.name ?? 'user-select', + type: EmbeddedFlowComponentType.UserSelect, + } as EmbeddedFlowComponent); + + // No manual `attrs.class` merge: Vue's attrs fallthrough already applies it to the root. + return h(PagedSelect, { + ...props, + class: withVendorCSSClassPrefix(bem('user-select')), + component: userComponent, + defaults: USER_DEFAULTS, + fetchOptions: props.fetchUsers, + messages: { + ariaLabel: t('elements.fields.user_select.aria_label') || 'Select a user', + empty: t('elements.fields.user_select.empty') || 'No users found.', + loading: t('elements.fields.user_select.loading') || 'Loading users…', + }, + 'onUpdate:modelValue': (value: string) => emit('update:modelValue', value), + }); + }; + }, +}); + +export default BaseUserSelect; +export type {BaseUserSelectProps}; diff --git a/packages/vue/src/components/primitives/UserSelect/UserSelect.ts b/packages/vue/src/components/primitives/UserSelect/UserSelect.ts new file mode 100644 index 00000000..a3c6c32f --- /dev/null +++ b/packages/vue/src/components/primitives/UserSelect/UserSelect.ts @@ -0,0 +1,61 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import type {EmbeddedFlowComponent, UserSelectOptionMapping} from '@thunderid/browser'; +import {type Component, type PropType, type VNode, defineComponent, h} from 'vue'; +import BaseUserSelect from './BaseUserSelect'; +import useFetchUsers from '../../../composables/useFetchUsers'; + +type UserSelectProps = Readonly<{ + component?: EmbeddedFlowComponent; + disabled: boolean; + error: string | undefined; + filter: string | undefined; + helperText: string | undefined; + id: string | undefined; + label: string | undefined; + mapping?: UserSelectOptionMapping; + modelValue: string; + name: string | undefined; + pageSize: number | undefined; + placeholder: string | undefined; + required: boolean; +}>; + +/** + * Single-user picker for signed-in screens. It loads the user directory itself with the signed-in + * user's access token and submits the chosen user's ID, so it takes no data source. For any other + * data source use {@link PagedSelect}. + */ +const UserSelect: Component = defineComponent({ + name: 'UserSelect', + props: { + component: {default: undefined, type: Object as PropType}, + disabled: {default: false, type: Boolean}, + error: {default: undefined, type: String}, + filter: {default: undefined, type: String}, + helperText: {default: undefined, type: String}, + id: {default: undefined, type: String}, + label: {default: undefined, type: String}, + mapping: {default: undefined, type: Object as PropType}, + modelValue: {default: '', type: String}, + name: {default: undefined, type: String}, + pageSize: {default: undefined, type: Number}, + placeholder: {default: undefined, type: String}, + required: {default: false, type: Boolean}, + }, + emits: ['update:modelValue'], + setup(props: UserSelectProps, {emit}): () => VNode { + const fetchUsers = useFetchUsers(); + + return (): VNode => + h(BaseUserSelect, { + ...props, + fetchUsers, + 'onUpdate:modelValue': (value: string) => emit('update:modelValue', value), + }); + }, +}); + +export default UserSelect; +export type {UserSelectProps}; diff --git a/packages/vue/src/composables/useFetchUsers.ts b/packages/vue/src/composables/useFetchUsers.ts new file mode 100644 index 00000000..08de87e0 --- /dev/null +++ b/packages/vue/src/composables/useFetchUsers.ts @@ -0,0 +1,18 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {resolveResourceEndpoint, toUserSelectPage} from '@thunderid/browser'; +import type {FetchUsers, PagedSelectPage, PagedSelectRequest} from '@thunderid/browser'; +import useThunderID from './useThunderID'; +import getUsers from '../api/getUsers'; + +/** A `FetchUsers` backed by the user directory, authenticated with the signed-in user's access token. */ +const useFetchUsers = (): FetchUsers => { + const {baseUrl, endpoints} = useThunderID(); + const usersUrl: string | undefined = resolveResourceEndpoint('users', {endpoints}); + + return async ({filter, limit, offset, signal}: PagedSelectRequest): Promise => + toUserSelectPage(await getUsers({baseUrl, filter, limit, offset, signal, url: usersUrl}), offset); +}; + +export default useFetchUsers; diff --git a/packages/vue/src/composables/usePagedSelect.ts b/packages/vue/src/composables/usePagedSelect.ts new file mode 100644 index 00000000..390140fa --- /dev/null +++ b/packages/vue/src/composables/usePagedSelect.ts @@ -0,0 +1,181 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import { + dedupePagedSelectOptions, + EmbeddedFlowComponentType, + isAdvancingPageOffset, + mapPagedSelectError, + toPagedSelectOptions, +} from '@thunderid/browser'; +import type { + EmbeddedFlowComponent, + FetchPagedOptions, + PagedSelectErrorResult, + PagedSelectOption, + PagedSelectOptionDefaults, + PagedSelectOptionMapping, +} from '@thunderid/browser'; +import {computed, onUnmounted, ref, shallowRef, watch, type ComputedRef, type Ref} from 'vue'; + +const DEFAULT_PAGE_SIZE = 30; + +export interface UsePagedSelectOptions { + component?: EmbeddedFlowComponent; + defaults?: PagedSelectOptionDefaults; + fetchOptions?: FetchPagedOptions; + fieldId?: string; + filter?: string; + /** Read when a page loads; changing it does not re-map options that are already loaded. */ + mapping?: PagedSelectOptionMapping; + pageSize?: number; +} + +export interface UsePagedSelectResult { + abort: () => void; + error: Ref; + hasLoaded: Ref; + hasMore: Ref; + isLoading: Ref; + isLoadingMore: Ref; + loadInitial: () => void; + loadMore: () => void; + options: Ref; + retry: () => void; +} + +/** + * Loads options page by page from `props.fetchOptions`: lazy first page, de-duplication, cancellation, and a generation guard that drops results from a superseded scope (filter, field, or loader change). Vue counterpart of the React `usePagedSelect`. + * + * `props` is read live, so prop changes are picked up without passing refs. + */ +const usePagedSelect = (props: UsePagedSelectOptions): UsePagedSelectResult => { + const effectiveComponent: ComputedRef = computed( + () => + props.component ?? + ({id: props.fieldId ?? 'paged-select', type: EmbeddedFlowComponentType.Select} as EmbeddedFlowComponent), + ); + + const options: Ref = ref([]) as Ref; + const hasMore: Ref = ref(true); + const hasLoaded: Ref = ref(false); + const isLoading: Ref = ref(false); + const isLoadingMore: Ref = ref(false); + const error: Ref = shallowRef(null); + + // Plain closure state (not refs): never read during render, only coordinates loading. + let generation = 0; + let abortController: AbortController | null = null; + let inFlight = false; + let hasLoadedInternal = false; + let nextOffset: number | null = 0; + let failedOffset = 0; + + const abort = (): void => { + abortController?.abort(); + abortController = null; + inFlight = false; + isLoading.value = false; + isLoadingMore.value = false; + }; + + const resetPaging = (): void => { + generation += 1; + hasLoadedInternal = false; + nextOffset = 0; + failedOffset = 0; + abort(); + options.value = []; + hasMore.value = true; + hasLoaded.value = false; + error.value = null; + }; + + const loadPage = async (pageOffset: number): Promise => { + const fetchOptionsFn: FetchPagedOptions | undefined = props.fetchOptions; + if (!fetchOptionsFn) { + return; + } + + const thisGeneration: number = generation; + const isFirstPage: boolean = pageOffset === 0; + + abortController?.abort(); + const controller: AbortController = new AbortController(); + abortController = controller; + inFlight = true; + + if (isFirstPage) { + isLoading.value = true; + } else { + isLoadingMore.value = true; + } + error.value = null; + + try { + const page = await fetchOptionsFn({ + component: effectiveComponent.value, + filter: props.filter, + limit: props.pageSize ?? DEFAULT_PAGE_SIZE, + offset: pageOffset, + signal: controller.signal, + }); + + if (thisGeneration !== generation || controller.signal.aborted) { + return; + } + + const next: number | null = isAdvancingPageOffset(pageOffset, page.nextOffset) ? page.nextOffset : null; + const pageOptions: PagedSelectOption[] = + page.options ?? toPagedSelectOptions(page.items ?? [], props.mapping, props.defaults); + + options.value = dedupePagedSelectOptions(isFirstPage ? [] : options.value, pageOptions); + nextOffset = next; + hasMore.value = next !== null; + hasLoadedInternal = true; + hasLoaded.value = true; + } catch (err) { + if (thisGeneration !== generation || controller.signal.aborted) { + return; + } + failedOffset = pageOffset; + error.value = mapPagedSelectError(err); + } finally { + if (abortController === controller) { + abortController = null; + inFlight = false; + isLoading.value = false; + isLoadingMore.value = false; + } + } + }; + + const loadInitial = (): void => { + if (!props.fetchOptions || hasLoadedInternal || inFlight) { + return; + } + void loadPage(0); + }; + + const loadMore = (): void => { + if (nextOffset === null || inFlight || !hasLoadedInternal) { + return; + } + void loadPage(nextOffset); + }; + + const retry = (): void => { + if (inFlight) { + return; + } + void loadPage(failedOffset); + }; + + watch(() => [effectiveComponent.value.id, props.filter, props.fetchOptions] as const, resetPaging); + + onUnmounted(abort); + + return {abort, error, hasLoaded, hasMore, isLoading, isLoadingMore, loadInitial, loadMore, options, retry}; +}; + +export default usePagedSelect; diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 48025bb6..920a4f37 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -24,6 +24,8 @@ export {default as useTheme} from './composables/useTheme'; export {default as useUser} from './composables/useUser'; export {useOAuthCallback} from './composables/useOAuthCallback'; export type {UseOAuthCallbackOptions, OAuthCallbackPayload} from './composables/useOAuthCallback'; +export {default as usePagedSelect} from './composables/usePagedSelect'; +export type {UsePagedSelectOptions, UsePagedSelectResult} from './composables/usePagedSelect'; // ── Client ── export {default as ThunderIDVueClient} from './ThunderIDVueClient'; @@ -52,6 +54,11 @@ export {default as TextField} from './components/primitives/TextField/TextField' export {default as PasswordField} from './components/primitives/PasswordField/PasswordField'; export {default as Select} from './components/primitives/Select/Select'; export type {SelectOption} from './components/primitives/Select/Select'; +export {default as PagedSelect} from './components/primitives/PagedSelect/PagedSelect'; +export type {PagedSelectMessages} from './components/primitives/PagedSelect/PagedSelect'; +export {default as UserSelect} from './components/primitives/UserSelect/UserSelect'; +export {default as BaseUserSelect} from './components/primitives/UserSelect/BaseUserSelect'; +export type {BaseUserSelectProps} from './components/primitives/UserSelect/BaseUserSelect'; export {default as Checkbox} from './components/primitives/Checkbox/Checkbox'; export {default as DatePicker} from './components/primitives/DatePicker/DatePicker'; export {default as OtpField} from './components/primitives/OtpField/OtpField'; diff --git a/packages/vue/src/styles/injectStyles.ts b/packages/vue/src/styles/injectStyles.ts index 0d671e94..f6b180e0 100644 --- a/packages/vue/src/styles/injectStyles.ts +++ b/packages/vue/src/styles/injectStyles.ts @@ -37,6 +37,7 @@ import DATE_PICKER_CSS from '../components/primitives/DatePicker/DatePicker.css' import DIVIDER_CSS from '../components/primitives/Divider/Divider.css'; import LOGO_CSS from '../components/primitives/Logo/Logo.css'; import OTP_FIELD_CSS from '../components/primitives/OtpField/OtpField.css'; +import PAGED_SELECT_CSS from '../components/primitives/PagedSelect/PagedSelect.css'; import PASSWORD_FIELD_CSS from '../components/primitives/PasswordField/PasswordField.css'; import SELECT_CSS from '../components/primitives/Select/Select.css'; import SPINNER_CSS from '../components/primitives/Spinner/Spinner.css'; @@ -62,6 +63,7 @@ const STYLES: string = [ TEXT_FIELD_CSS, PASSWORD_FIELD_CSS, SELECT_CSS, + PAGED_SELECT_CSS, CHECKBOX_CSS, DATE_PICKER_CSS, OTP_FIELD_CSS,