From 25a82462130f6ecce323169b6a2f651fb6d50c65 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Wed, 22 Apr 2026 08:59:44 +0200 Subject: [PATCH 1/5] IS-11241 Rename HaapiStepper UI components to HaapiStepperUI. Rename the six UI components (Actions, Link, Links, Messages, ClientOperation, HaapiSelector) plus HaapiStepperForm and the message element factory to the existing HaapiStepperUI convention. This disambiguates the React components from the HAAPI data interfaces they render (HaapiStepperLink vs HaapiStepperLinkUI, etc.), preventing import collisions and making it obvious at a glance which layer is being referenced. Also fixes stale HaapiUIStep and Form.tsx references in README and JSDoc. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/login-web-app/src/haapi-stepper/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/src/login-web-app/src/haapi-stepper/README.md b/src/login-web-app/src/haapi-stepper/README.md index b6c8e09..70ede99 100644 --- a/src/login-web-app/src/haapi-stepper/README.md +++ b/src/login-web-app/src/haapi-stepper/README.md @@ -132,6 +132,7 @@ Check out documentation and usage examples in the links below: * [HaapiStepperSelectorUI](./feature/actions/selector/HaapiStepperSelectorUI.tsx) * [HaapiStepperClientOperationUI](./feature/actions/client-operation/HaapiStepperClientOperationUI.tsx) * [HaapiStepperMessagesUI](./ui/messages/HaapiStepperMessagesUI.tsx) +* [HaapiStepperMessageUI](./ui/messages/HaapiStepperMessageUI.tsx) * [HaapiStepperLinksUI](./ui/links/HaapiStepperLinksUI.tsx) * [HaapiStepperLinkUI](./ui/links/HaapiStepperLinkUI.tsx) From e2ec03d120fd6ea8d9c21abcf8490a2af7002b24 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Wed, 29 Apr 2026 10:36:49 +0200 Subject: [PATCH 2/5] IS-11275 Add viewName built-in UIs (first: BankID wait UI). --- src/login-web-app/src/haapi-stepper/README.md | 19 ++ .../feature/steps/HaapiStepperStepUI.spec.tsx | 171 +++++++++++++++++- .../feature/steps/HaapiStepperStepUI.tsx | 91 ++++++++-- .../viewnames/BankIdViewNameBuiltInUI.tsx | 45 +++++ .../haapi-stepper/feature/viewnames/index.ts | 14 ++ .../viewnames/viewname-built-in-uis.ts | 53 ++++++ .../feature/viewnames/viewname.types.ts | 21 +++ .../src/haapi-stepper/util/tests/mocks.ts | 31 +++- 8 files changed, 428 insertions(+), 17 deletions(-) create mode 100644 src/login-web-app/src/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx create mode 100644 src/login-web-app/src/haapi-stepper/feature/viewnames/index.ts create mode 100644 src/login-web-app/src/haapi-stepper/feature/viewnames/viewname-built-in-uis.ts create mode 100644 src/login-web-app/src/haapi-stepper/feature/viewnames/viewname.types.ts diff --git a/src/login-web-app/src/haapi-stepper/README.md b/src/login-web-app/src/haapi-stepper/README.md index 70ede99..76b8ec3 100644 --- a/src/login-web-app/src/haapi-stepper/README.md +++ b/src/login-web-app/src/haapi-stepper/README.md @@ -114,6 +114,25 @@ Because the `HaapiStepperStepUI` handles all possible HAAPI authentication flows Check out [the HaapiStepperStepUI documentation and usage examples](./feature/steps/HaapiStepperStepUI.tsx). +### ViewName built-in UIs + +Some HAAPI viewNames (`step.metadata.viewName`) need a UI that the generic step rendering can't deliver well. For example, the **BankID** screen needs to render a spinner while the polling status is `pending`, not only while `loading` is true, and lifts the QR code above the actions. + +To handle this kind of view, the library ships **viewName built-in UIs** that automatically take over when the matching `step.metadata.viewName` arrives from the server. + +#### The `enableViewNameBuiltInUIs` prop + +`` accepts a +`enableViewNameBuiltInUIs?: HaapiStepperViewNameBuiltInUI[] | boolean` prop that opts in to which viewName built-in UIs are active. It is **opt-in**: when the prop is omitted (or `false`), no viewName built-in UIs are applied and every step renders through the generic render pipeline. Pass `true` to enable all known built-ins, or an array of built-in view names (`HaapiStepperViewNameBuiltInUI[]`) to pin a specific subset. + +#### Current set + +| `metadata.viewName` | Enum member | What it delivers | +| --------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------ | +| `authenticator/bankid/wait/index` | `HaapiStepperViewNameBuiltInUI.BANKID` | Spinner while polling `status === pending`; QR code link rendered above the actions. | + +Check out documentation and usage examples in [`HaapiStepperStepUI`](./feature/steps/HaapiStepperStepUI.tsx), and the test use cases in [`HaapiStepperStepUI.spec.tsx`](./feature/steps/HaapiStepperStepUI.spec.tsx) (`describe('ViewName built-in UIs Rendering')`) for more details. + ## HAAPI Stepper UI Components diff --git a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx index e71ee59..3867cf2 100644 --- a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx @@ -46,14 +46,17 @@ import { HAAPI_ACTION_CLIENT_OPERATIONS, HaapiBaseClientOperationModel, } from '../../data-access/types/haapi-action.types'; -import { HAAPI_STEPS, HAAPI_PROBLEM_STEPS } from '../../data-access/types/haapi-step.types'; +import { HAAPI_STEPS, HAAPI_PROBLEM_STEPS, HAAPI_POLLING_STATUS } from '../../data-access/types/haapi-step.types'; +import { HaapiStepperViewNameBuiltInUI } from '../viewnames'; import { HTTP_METHODS } from '../../data-access/types/haapi-form.types'; import { HaapiStepperStepUI } from './HaapiStepperStepUI'; import { + createBankIdPollingStep, createMockClientOperationAction, createMockFormAction, createMockLink, createMockMessage, + createMockQrLink, createMockSelectorAction, createMockStep, defaultStepperAPI, @@ -1765,4 +1768,170 @@ describe('HaapiStepperStepUI', () => { }); }); }); + + describe('ViewName built-in UIs Rendering', () => { + describe('Default Rendering', () => { + it('should render the generic step shell when enableViewNameBuiltInUIs is not provided', () => { + const step = createBankIdPollingStep(); + + renderWithContext(, { currentStep: step }); + + expect(screen.queryByTestId('bankid-spinner')).not.toBeInTheDocument(); + expect(screen.queryByTestId('messages')).toBeInTheDocument(); + }); + + it('should render the generic step shell when enableViewNameBuiltInUIs is an empty array', () => { + const step = createBankIdPollingStep(); + + renderWithContext(, { currentStep: step }); + + expect(screen.queryByTestId('bankid-spinner')).not.toBeInTheDocument(); + expect(screen.queryByTestId('messages')).toBeInTheDocument(); + }); + }); + + describe('Custom Rendering', () => { + describe('Opt-in via boolean shorthand', () => { + it('should apply the matching built-in when enableViewNameBuiltInUIs is true', () => { + const step = createBankIdPollingStep(); + + renderWithContext(, { currentStep: step }); + + expect(screen.queryByTestId('bankid-spinner')).toBeInTheDocument(); + }); + + it('should apply the matching built-in when the JSX boolean shorthand is used', () => { + const step = createBankIdPollingStep(); + + renderWithContext(, { currentStep: step }); + + expect(screen.queryByTestId('bankid-spinner')).toBeInTheDocument(); + }); + + it('should render the generic step shell when the viewName has no registered built-in', () => { + const step = createMockStep(HAAPI_STEPS.AUTHENTICATION); + + renderWithContext(, { currentStep: step }); + + expect(screen.queryByTestId('bankid-spinner')).not.toBeInTheDocument(); + expect(screen.queryByTestId('messages')).toBeInTheDocument(); + expect(screen.queryByTestId('form-action')).toBeInTheDocument(); + }); + }); + + describe('Opt-in via subset array', () => { + it('should apply the built-in when its viewName is in the array', () => { + const step = createBankIdPollingStep(); + + renderWithContext(, { + currentStep: step, + }); + + expect(screen.queryByTestId('bankid-spinner')).toBeInTheDocument(); + }); + }); + + describe('Composition with stepRenderInterceptor', () => { + it('should apply the built-in when stepRenderInterceptor returns pass-through data', () => { + const step = createBankIdPollingStep(); + const passThroughInterceptor: HaapiStepperStepUIStepRenderInterceptor = ( + haapiStepperAPI: HaapiStepperAPIWithRequiredCurrentStep + ) => { + return haapiStepperAPI; + }; + + renderWithContext( + , + { currentStep: step } + ); + + expect(screen.queryByTestId('bankid-spinner')).toBeInTheDocument(); + }); + + it('should be skipped when stepRenderInterceptor returns a React element', () => { + const step = createBankIdPollingStep(); + const elementInterceptor: HaapiStepperStepUIStepRenderInterceptor = () => { + return
Custom UI
; + }; + + renderWithContext( + , + { + currentStep: step, + } + ); + + expect(screen.queryByTestId('custom-step-element')).toBeInTheDocument(); + expect(screen.queryByTestId('bankid-spinner')).not.toBeInTheDocument(); + }); + + it('should be skipped (and render nothing) when stepRenderInterceptor returns null', () => { + const step = createBankIdPollingStep(); + const nullInterceptor: HaapiStepperStepUIStepRenderInterceptor = () => { + return null; + }; + + renderWithContext(, { + currentStep: step, + }); + + expect(screen.queryByTestId('bankid-spinner')).not.toBeInTheDocument(); + expect(screen.queryByTestId('messages')).not.toBeInTheDocument(); + expect(screen.queryByTestId('form-action')).not.toBeInTheDocument(); + }); + }); + + describe('BankID viewName built-in UI', () => { + it('should render the spinner while polling status is pending', () => { + const step = createBankIdPollingStep({ status: HAAPI_POLLING_STATUS.PENDING }); + + renderWithContext(, { currentStep: step }); + + expect(screen.queryByTestId('bankid-spinner')).toBeInTheDocument(); + }); + + it('should not render the spinner when polling status is done', () => { + const step = createBankIdPollingStep({ status: HAAPI_POLLING_STATUS.DONE }); + + renderWithContext(, { currentStep: step }); + + expect(screen.queryByTestId('bankid-spinner')).not.toBeInTheDocument(); + }); + + it('should not render the spinner when polling status is failed', () => { + const step = createBankIdPollingStep({ status: HAAPI_POLLING_STATUS.FAILED }); + + renderWithContext(, { currentStep: step }); + + expect(screen.queryByTestId('bankid-spinner')).not.toBeInTheDocument(); + }); + + it('should render the QR link above the actions', () => { + const qrLink = createMockQrLink(); + const otherLink = createMockLink({ rel: 'help', title: 'Help' }); + const step = createBankIdPollingStep({ links: [qrLink, otherLink] }); + + renderWithContext(, { currentStep: step }); + + const renderedTestIds = screen.getAllByTestId(/^(qr-code-button|form-action)$/).map(element => { + return element.getAttribute('data-testid'); + }); + + expect(renderedTestIds).toEqual(['qr-code-button', 'form-action']); + }); + + it('should render gracefully when no QR link is present', () => { + const otherLink = createMockLink({ rel: 'help', title: 'Help' }); + const step = createBankIdPollingStep({ links: [otherLink] }); + + renderWithContext(, { currentStep: step }); + + expect(screen.queryByTestId('qr-code-button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('bankid-spinner')).toBeInTheDocument(); + expect(screen.queryByTestId('messages')).toBeInTheDocument(); + expect(screen.queryByTestId('links')).toBeInTheDocument(); + }); + }); + }); + }); }); diff --git a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.tsx b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.tsx index cae2962..84bc871 100644 --- a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.tsx @@ -17,6 +17,7 @@ import { HaapiStepperMessagesUI } from '../../ui/messages/HaapiStepperMessagesUI import { Well } from '../../ui/well/Well'; import { applyRenderInterceptor } from '../../util/generic-render-interceptor'; import { formatNextStepData } from '../stepper/data-formatters/format-next-step-data'; +import { HaapiStepperViewNameBuiltInUI, getViewNameBuiltInUI } from '../viewnames'; import type { HaapiStepperAPI, HaapiStepperAPIWithRequiredCurrentStep, @@ -52,6 +53,7 @@ interface HaapiStepperStepUIProps { clientOperationActionRenderInterceptor?: HaapiStepperStepUIClientOperationActionRenderInterceptor; linkRenderInterceptor?: HaapiStepperStepUILinkRenderInterceptor; messageRenderInterceptor?: HaapiStepperStepUIMessageRenderInterceptor; + enableViewNameBuiltInUIs?: HaapiStepperViewNameBuiltInUI[] | boolean; } /** @@ -79,6 +81,56 @@ interface HaapiStepperStepUIProps { * Note: Redirection, and Continue Same steps are handled automatically by the HaapiStepper and never * reach this component * + * ### VIEW NAME BUILT-IN UIs + * + * The HaapiStepperStepUI component also provides built-in UIs for specific HAAPI `viewName`s that require a more + * tailored UI than the generic step shell can provide (e.g. the BankID QR code step, which requires lifting + * the QR code up and showing a spinner while polling). + * + * The viewName built-in UIs are opt-in: `enableViewNameBuiltInUIs` defaults to `undefined` (no built-ins active). + * Pass: + * + * - `true` (or the JSX shorthand `enableViewNameBuiltInUIs`) to enable all known built-ins. This + * stays in sync with the library — if a new built-in is added in a future release, it is + * activated automatically. + * - An array of `HaapiStepperViewNameBuiltInUI` values to enable only specific built-ins. + * This pins the active set, so adding a new built-in to the library is a purely additive + * change that doesn't affect existing rendering. + * - `false` or `undefined` to keep all built-ins disabled (every view renders through the + * generic shell). + * + * Composition: the matching viewName built-in UI is rendered after the `stepRenderInterceptor` has processed the + * step, and before any of the per-element render interceptors (actions, messages, links…). It is only rendered + * when `stepRenderInterceptor` was not provided or if it returns the stepper API data (pass-through) — the same + * rule that governs every other render interceptor. + * + * #### ViewName Built-in UIs Example + * + * @example + * ```tsx + * import { HaapiStepperViewNameBuiltInUI } from '...'; + * + * // No prop = no built-ins active. The component renders every view through the generic shell. + * + * + * // Boolean shorthand: opt in to all known built-ins (current and future). + * + * + * // Pin to a specific subset. + * + * + * // Override a viewName built-in UI with a `stepRenderInterceptor` + * const customBankIdUI: HaapiStepperStepUIStepRenderInterceptor = ({ currentStep, ...rest }) => { + * if (currentStep.metadata?.viewName === 'authenticator/bankid/wait/index') { + * return ; + * } + * return { currentStep, ...rest }; + * }; + * + * // MyBankId will be rendered instead of the built-in UI for the BankID + * + * ``` + * * ## CUSTOMIZATION * * ### CUSTOMIZATION DIMENSIONS @@ -241,6 +293,7 @@ export const HaapiStepperStepUI = ({ clientOperationActionRenderInterceptor, linkRenderInterceptor, messageRenderInterceptor, + enableViewNameBuiltInUIs, }: HaapiStepperStepUIProps) => { const haapiStepperAPI = useHaapiStepper(); const loadingElement: ReactElement | null = getLoadingElement(haapiStepperAPI, loadingRenderInterceptor); @@ -249,38 +302,46 @@ export const HaapiStepperStepUI = ({ return loadingElement; } - let haapiUIStepperAPI = haapiStepperAPI as HaapiStepperAPIWithRequiredCurrentStep; + let haapiStepperUiAPI = haapiStepperAPI as HaapiStepperAPIWithRequiredCurrentStep; if (stepRenderInterceptor) { - const customStepRenderInterceptorResult = stepRenderInterceptor(haapiUIStepperAPI); + const stepRenderInterceptorResult = stepRenderInterceptor(haapiStepperUiAPI); - if (isValidElement(customStepRenderInterceptorResult)) { - return customStepRenderInterceptorResult; - } else if (customStepRenderInterceptorResult === null || customStepRenderInterceptorResult === undefined) { + if (isValidElement(stepRenderInterceptorResult)) { + return stepRenderInterceptorResult; + } + + if (stepRenderInterceptorResult === null || stepRenderInterceptorResult === undefined) { return null; - } else { - haapiUIStepperAPI = { - ...customStepRenderInterceptorResult, - currentStep: formatNextStepData(customStepRenderInterceptorResult.currentStep), - }; } + + haapiStepperUiAPI = { + ...stepRenderInterceptorResult, + currentStep: formatNextStepData(stepRenderInterceptorResult.currentStep), + }; + } + + const ViewNameBuiltInUI = getViewNameBuiltInUI(haapiStepperUiAPI, enableViewNameBuiltInUIs); + + if (ViewNameBuiltInUI) { + return ; } - const { error, currentStep } = haapiUIStepperAPI; - const errorElement: ReactElement | null = getErrorElement(haapiUIStepperAPI, errorRenderInterceptor); + const { error, currentStep } = haapiStepperUiAPI; + const errorElement: ReactElement | null = getErrorElement(haapiStepperUiAPI, errorRenderInterceptor); const linksToDisplay = getLinksToDisplay(error, currentStep); const messagesToDisplay = error?.input ? error.input.dataHelpers.messages : currentStep.dataHelpers.messages; - const messagesElement = getMessagesElement(haapiUIStepperAPI, messagesToDisplay, messageRenderInterceptor); + const messagesElement = getMessagesElement(haapiStepperUiAPI, messagesToDisplay, messageRenderInterceptor); const actionsElement = getActionsElement( - haapiUIStepperAPI, + haapiStepperUiAPI, actionsRenderInterceptor, formActionRenderInterceptor, formFieldRenderInterceptor, selectorActionRenderInterceptor, clientOperationActionRenderInterceptor ); - const linksElement = getLinksElement(haapiUIStepperAPI, linksToDisplay, linkRenderInterceptor); + const linksElement = getLinksElement(haapiStepperUiAPI, linksToDisplay, linkRenderInterceptor); return ( diff --git a/src/login-web-app/src/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx b/src/login-web-app/src/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx new file mode 100644 index 0000000..4b746e0 --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2025 Curity AB. All rights reserved. + * + * The contents of this file are the property of Curity AB. + * You may not copy or use this file, in either source code + * or executable form, except in compliance with terms + * set by Curity AB. + * + * For further information, please contact Curity AB. + */ + +import { Spinner } from '../../../shared/ui/Spinner'; +import { Well } from '../../ui/well/Well'; +import { HaapiStepperMessagesUI } from '../../ui/messages/HaapiStepperMessagesUI'; +import { HaapiStepperActionsUI } from '../../ui/actions/HaapiStepperActionsUI'; +import { HaapiStepperLinksUI } from '../../ui/links/HaapiStepperLinksUI'; +import { HAAPI_POLLING_STATUS, HAAPI_STEPS } from '../../data-access/types/haapi-step.types'; +import { HaapiStepperAPIWithRequiredCurrentStep, HaapiStepperLink } from '../stepper/haapi-stepper.types'; + +/** + * Built-in UI for the BankID viewName (`HaapiStepperViewNameBuiltInUI.BANKID`). + * + * - Renders a spinner while the polling status is `pending` (independent of the `loading` flag, + * which only covers the time the LWA is fetching the next step). Once the polling resolves + * to `done` or `failed`, the spinner is dropped. + * - Lifts the QR code link above the actions so it's the primary element on the screen. + */ +export const BankIdViewNameBuiltInUI = ({ currentStep, nextStep }: HaapiStepperAPIWithRequiredCurrentStep) => { + const { messages, actions, links } = currentStep.dataHelpers; + const isQrLink = (link: HaapiStepperLink) => link.subtype?.startsWith('image/') ?? false; + const qrLink = links.find(isQrLink); + const nonQrLinks = links.filter(link => !isQrLink(link)); + const isPollingPending = + currentStep.type === HAAPI_STEPS.POLLING && currentStep.properties.status === HAAPI_POLLING_STATUS.PENDING; + + return ( + + {isPollingPending && } + + {qrLink && } + + {nonQrLinks.length > 0 && } + + ); +}; diff --git a/src/login-web-app/src/haapi-stepper/feature/viewnames/index.ts b/src/login-web-app/src/haapi-stepper/feature/viewnames/index.ts new file mode 100644 index 0000000..d6cc12b --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/viewnames/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright (C) 2025 Curity AB. All rights reserved. + * + * The contents of this file are the property of Curity AB. + * You may not copy or use this file, in either source code + * or executable form, except in compliance with terms + * set by Curity AB. + * + * For further information, please contact Curity AB. + */ + +export * from './viewname.types'; +export * from './viewname-built-in-uis'; +export * from './BankIdViewNameBuiltInUI'; diff --git a/src/login-web-app/src/haapi-stepper/feature/viewnames/viewname-built-in-uis.ts b/src/login-web-app/src/haapi-stepper/feature/viewnames/viewname-built-in-uis.ts new file mode 100644 index 0000000..a9c344e --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/viewnames/viewname-built-in-uis.ts @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2025 Curity AB. All rights reserved. + * + * The contents of this file are the property of Curity AB. + * You may not copy or use this file, in either source code + * or executable form, except in compliance with terms + * set by Curity AB. + * + * For further information, please contact Curity AB. + */ + +import type { FC } from 'react'; +import type { HaapiStepperAPIWithRequiredCurrentStep } from '../stepper/haapi-stepper.types'; +import { BankIdViewNameBuiltInUI } from './BankIdViewNameBuiltInUI'; +import { HaapiStepperViewNameBuiltInUI } from './viewname.types'; + +/** + * Registry of built-in viewName UIs keyed by `HaapiStepperViewNameBuiltInUI`. + * + * Every enum member must have a matching entry here — this is the invariant that keeps the + * set of "view names with built-in UX" in sync with the set of available components. + */ +export const VIEW_NAME_BUILT_IN_UIS_MAP: Record< + HaapiStepperViewNameBuiltInUI, + FC +> = { + [HaapiStepperViewNameBuiltInUI.BANKID]: BankIdViewNameBuiltInUI, +}; + +export const VIEW_NAMES_BUILT_IN_UIS: HaapiStepperViewNameBuiltInUI[] = Object.values(HaapiStepperViewNameBuiltInUI); + +export const getViewNameBuiltInUI = ( + haapiStepperAPI: HaapiStepperAPIWithRequiredCurrentStep, + enableViewNameBuiltInUIs?: HaapiStepperViewNameBuiltInUI[] | boolean +): FC | undefined => { + const currentViewName = haapiStepperAPI.currentStep.metadata?.viewName; + const enabledViewNames: HaapiStepperViewNameBuiltInUI[] = + enableViewNameBuiltInUIs === true + ? VIEW_NAMES_BUILT_IN_UIS + : Array.isArray(enableViewNameBuiltInUIs) + ? enableViewNameBuiltInUIs + : []; + + const isOptedInViewNameBuiltIn = (viewName: string): viewName is HaapiStepperViewNameBuiltInUI => { + return enabledViewNames.includes(viewName as HaapiStepperViewNameBuiltInUI); + }; + + if (!currentViewName || !isOptedInViewNameBuiltIn(currentViewName)) { + return undefined; + } + + return VIEW_NAME_BUILT_IN_UIS_MAP[currentViewName]; +}; diff --git a/src/login-web-app/src/haapi-stepper/feature/viewnames/viewname.types.ts b/src/login-web-app/src/haapi-stepper/feature/viewnames/viewname.types.ts new file mode 100644 index 0000000..e40417f --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/viewnames/viewname.types.ts @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2025 Curity AB. All rights reserved. + * + * The contents of this file are the property of Curity AB. + * You may not copy or use this file, in either source code + * or executable form, except in compliance with terms + * set by Curity AB. + * + * For further information, please contact Curity AB. + */ + +/** + * HAAPI view names that the LWA ships built-in step render interceptors for + * (see `VIEW_NAME_BUILT_IN_UIS_MAP` in `./viewname-built-in-uis`). + * + * Members map one-to-one to entries in the built-in registry: adding a new member here + * must be accompanied by a matching interceptor registration. + */ +export enum HaapiStepperViewNameBuiltInUI { + BANKID = 'authenticator/bankid/wait/index', +} diff --git a/src/login-web-app/src/haapi-stepper/util/tests/mocks.ts b/src/login-web-app/src/haapi-stepper/util/tests/mocks.ts index 7a5739e..409d550 100644 --- a/src/login-web-app/src/haapi-stepper/util/tests/mocks.ts +++ b/src/login-web-app/src/haapi-stepper/util/tests/mocks.ts @@ -1,5 +1,9 @@ import { MEDIA_TYPES } from '../../../shared/util/types/media.types'; -import { HAAPI_STEPPER_ELEMENT_TYPES, HAAPI_STEPS } from '../../data-access/types/haapi-step.types'; +import { + HAAPI_POLLING_STATUS, + HAAPI_STEPPER_ELEMENT_TYPES, + HAAPI_STEPS, +} from '../../data-access/types/haapi-step.types'; import { HAAPI_ACTION_TYPES, HAAPI_ACTION_CLIENT_OPERATIONS } from '../../data-access/types/haapi-action.types'; import { HAAPI_FORM_FIELDS, HTTP_METHODS } from '../../data-access/types/haapi-form.types'; import type { @@ -12,6 +16,7 @@ import type { HaapiStepperAPI, } from '../../feature/stepper/haapi-stepper.types'; import { formatNextStepData } from '../../feature/stepper/data-formatters/format-next-step-data'; +import { HaapiStepperViewNameBuiltInUI } from '../../feature/viewnames'; export const mockNextStep = vi.fn(); export const MockMessageText = 'Step Message'; @@ -139,3 +144,27 @@ export const defaultStepperAPI: HaapiStepperAPI = { history: [], nextStep: mockNextStep, }; + +export const createBankIdPollingStep = ( + overrides: { status?: HAAPI_POLLING_STATUS; links?: HaapiStepperLink[]; viewName?: string } = {} +) => { + return createMockStep(HAAPI_STEPS.POLLING, { + metadata: { + templateArea: 'lwa', + viewName: overrides.viewName ?? HaapiStepperViewNameBuiltInUI.BANKID, + }, + properties: { status: overrides.status ?? HAAPI_POLLING_STATUS.PENDING }, + ...(overrides.links !== undefined && { links: overrides.links }), + }); +}; + +export const createMockQrLink = (overrides: Partial = {}) => { + return createMockLink({ + rel: 'activation', + title: 'QR Code', + href: 'data:image/svg+xml;base64,abc', + type: 'image/svg+xml', + subtype: 'image/svg+xml', + ...overrides, + }); +}; From 88d8bf3b3c884f2e246c8ef25248caf2344850d6 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Mon, 11 May 2026 09:00:51 +0200 Subject: [PATCH 3/5] IS-11275: show bankId spinner also while loading --- .../feature/steps/HaapiStepperStepUI.spec.tsx | 11 +++++++++++ .../feature/viewnames/BankIdViewNameBuiltInUI.tsx | 10 +++++----- .../src/haapi-stepper/util/tests/mocks.ts | 1 - 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx index 3867cf2..95500db 100644 --- a/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx @@ -1906,6 +1906,17 @@ describe('HaapiStepperStepUI', () => { expect(screen.queryByTestId('bankid-spinner')).not.toBeInTheDocument(); }); + it('should render the spinner during the step transition (loading=true after polling has resolved)', () => { + const step = createBankIdPollingStep({ status: HAAPI_POLLING_STATUS.DONE }); + + renderWithContext(, { + currentStep: step, + loading: true, + }); + + expect(screen.queryByTestId('bankid-spinner')).toBeInTheDocument(); + }); + it('should render the QR link above the actions', () => { const qrLink = createMockQrLink(); const otherLink = createMockLink({ rel: 'help', title: 'Help' }); diff --git a/src/login-web-app/src/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx b/src/login-web-app/src/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx index 4b746e0..0a331ca 100644 --- a/src/login-web-app/src/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx @@ -20,22 +20,22 @@ import { HaapiStepperAPIWithRequiredCurrentStep, HaapiStepperLink } from '../ste /** * Built-in UI for the BankID viewName (`HaapiStepperViewNameBuiltInUI.BANKID`). * - * - Renders a spinner while the polling status is `pending` (independent of the `loading` flag, - * which only covers the time the LWA is fetching the next step). Once the polling resolves - * to `done` or `failed`, the spinner is dropped. + * - Renders a spinner while the polling status is `pending` *or* while the stepper is loading. Together + * these cover the full BankID-in-progress window so the user always sees a progress indicator. * - Lifts the QR code link above the actions so it's the primary element on the screen. */ -export const BankIdViewNameBuiltInUI = ({ currentStep, nextStep }: HaapiStepperAPIWithRequiredCurrentStep) => { +export const BankIdViewNameBuiltInUI = ({ currentStep, nextStep, loading }: HaapiStepperAPIWithRequiredCurrentStep) => { const { messages, actions, links } = currentStep.dataHelpers; const isQrLink = (link: HaapiStepperLink) => link.subtype?.startsWith('image/') ?? false; const qrLink = links.find(isQrLink); const nonQrLinks = links.filter(link => !isQrLink(link)); const isPollingPending = currentStep.type === HAAPI_STEPS.POLLING && currentStep.properties.status === HAAPI_POLLING_STATUS.PENDING; + const showSpinner = loading || isPollingPending; return ( - {isPollingPending && } + {showSpinner && } {qrLink && } diff --git a/src/login-web-app/src/haapi-stepper/util/tests/mocks.ts b/src/login-web-app/src/haapi-stepper/util/tests/mocks.ts index 409d550..04bc40c 100644 --- a/src/login-web-app/src/haapi-stepper/util/tests/mocks.ts +++ b/src/login-web-app/src/haapi-stepper/util/tests/mocks.ts @@ -164,7 +164,6 @@ export const createMockQrLink = (overrides: Partial = {}) => { title: 'QR Code', href: 'data:image/svg+xml;base64,abc', type: 'image/svg+xml', - subtype: 'image/svg+xml', ...overrides, }); }; From 834d799ba5c82a330066130914a0f3f6cace9534 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Mon, 11 May 2026 09:08:35 +0200 Subject: [PATCH 4/5] IS-11275: simplify docs --- src/login-web-app/src/haapi-stepper/README.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/login-web-app/src/haapi-stepper/README.md b/src/login-web-app/src/haapi-stepper/README.md index 76b8ec3..aac608e 100644 --- a/src/login-web-app/src/haapi-stepper/README.md +++ b/src/login-web-app/src/haapi-stepper/README.md @@ -116,7 +116,7 @@ Check out [the HaapiStepperStepUI documentation and usage examples](./feature/st ### ViewName built-in UIs -Some HAAPI viewNames (`step.metadata.viewName`) need a UI that the generic step rendering can't deliver well. For example, the **BankID** screen needs to render a spinner while the polling status is `pending`, not only while `loading` is true, and lifts the QR code above the actions. +Some HAAPI viewNames (`step.metadata.viewName`) need a UI that the generic step rendering can't deliver well. For example, the **BankID** screen needs to render a spinner while the polling status is `pending`, and lifts the QR code above the actions. To handle this kind of view, the library ships **viewName built-in UIs** that automatically take over when the matching `step.metadata.viewName` arrives from the server. @@ -125,12 +125,6 @@ To handle this kind of view, the library ships **viewName built-in UIs** that au `` accepts a `enableViewNameBuiltInUIs?: HaapiStepperViewNameBuiltInUI[] | boolean` prop that opts in to which viewName built-in UIs are active. It is **opt-in**: when the prop is omitted (or `false`), no viewName built-in UIs are applied and every step renders through the generic render pipeline. Pass `true` to enable all known built-ins, or an array of built-in view names (`HaapiStepperViewNameBuiltInUI[]`) to pin a specific subset. -#### Current set - -| `metadata.viewName` | Enum member | What it delivers | -| --------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------ | -| `authenticator/bankid/wait/index` | `HaapiStepperViewNameBuiltInUI.BANKID` | Spinner while polling `status === pending`; QR code link rendered above the actions. | - Check out documentation and usage examples in [`HaapiStepperStepUI`](./feature/steps/HaapiStepperStepUI.tsx), and the test use cases in [`HaapiStepperStepUI.spec.tsx`](./feature/steps/HaapiStepperStepUI.spec.tsx) (`describe('ViewName built-in UIs Rendering')`) for more details. From 1c664389459b9885ce500251aa685cb2673a2c4b Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Fri, 15 May 2026 10:54:50 +0200 Subject: [PATCH 5/5] IS-11275: make built-in viewName UIs default and customizable via render interceptors --- src/login-web-app/src/haapi-stepper/README.md | 9 +- .../feature/stepper/HaapiStepper.spec.tsx | 3 +- .../feature/steps/HaapiStepperStepUI.spec.tsx | 251 +++++++++------ .../feature/steps/HaapiStepperStepUI.tsx | 288 ++++-------------- .../feature/steps/step-element-factories.tsx | 145 +++++++++ .../haapi-stepper/feature/steps/typings.ts | 50 +++ .../viewnames/BankIdViewNameBuiltInUI.tsx | 35 +-- .../haapi-stepper/feature/viewnames/index.ts | 1 + .../feature/viewnames/typings.ts | 29 ++ .../viewnames/viewname-built-in-uis.ts | 33 +- .../ui/links/HaapiStepperLinkUI.tsx | 5 +- .../ui/links/HaapiStepperLinksUI.tsx | 3 +- .../src/haapi-stepper/util/isQrCodeLink.ts | 21 ++ .../src/haapi-stepper/util/tests/mocks.ts | 16 +- 14 files changed, 516 insertions(+), 373 deletions(-) create mode 100644 src/login-web-app/src/haapi-stepper/feature/steps/step-element-factories.tsx create mode 100644 src/login-web-app/src/haapi-stepper/feature/steps/typings.ts create mode 100644 src/login-web-app/src/haapi-stepper/feature/viewnames/typings.ts create mode 100644 src/login-web-app/src/haapi-stepper/util/isQrCodeLink.ts diff --git a/src/login-web-app/src/haapi-stepper/README.md b/src/login-web-app/src/haapi-stepper/README.md index aac608e..5bff19f 100644 --- a/src/login-web-app/src/haapi-stepper/README.md +++ b/src/login-web-app/src/haapi-stepper/README.md @@ -116,14 +116,7 @@ Check out [the HaapiStepperStepUI documentation and usage examples](./feature/st ### ViewName built-in UIs -Some HAAPI viewNames (`step.metadata.viewName`) need a UI that the generic step rendering can't deliver well. For example, the **BankID** screen needs to render a spinner while the polling status is `pending`, and lifts the QR code above the actions. - -To handle this kind of view, the library ships **viewName built-in UIs** that automatically take over when the matching `step.metadata.viewName` arrives from the server. - -#### The `enableViewNameBuiltInUIs` prop - -`` accepts a -`enableViewNameBuiltInUIs?: HaapiStepperViewNameBuiltInUI[] | boolean` prop that opts in to which viewName built-in UIs are active. It is **opt-in**: when the prop is omitted (or `false`), no viewName built-in UIs are applied and every step renders through the generic render pipeline. Pass `true` to enable all known built-ins, or an array of built-in view names (`HaapiStepperViewNameBuiltInUI[]`) to pin a specific subset. +The HaapiStepperStepUI ships built-in UIs for specific HAAPI `viewName`s (`step.metadata.viewName`) that need a more tailored UI than the generic step shell can provide (e.g. the BankID requires the QR link to be lifted above the actions). They are displayed by default and can be customized like any other step by using render interceptors. Check out documentation and usage examples in [`HaapiStepperStepUI`](./feature/steps/HaapiStepperStepUI.tsx), and the test use cases in [`HaapiStepperStepUI.spec.tsx`](./feature/steps/HaapiStepperStepUI.spec.tsx) (`describe('ViewName built-in UIs Rendering')`) for more details. diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx index 1b04f13..d62d329 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx @@ -29,6 +29,7 @@ import { act } from 'react'; import { useHaapiStepper } from './HaapiStepperHook'; import type { HaapiStepperHistoryEntry, HaapiStepperNextStepAction } from './haapi-stepper.types'; import { HaapiStepperActionStep, HaapiStepperFormAction } from './haapi-stepper.types'; +import { isQrCodeLink } from '../../util/isQrCodeLink'; import type { BootstrapConfiguration } from '../../data-access/bootstrap-configuration'; describe('HaapiStepper', () => { @@ -869,7 +870,7 @@ function TestComponent() { ))} {currentStep.dataHelpers.links.map(link => - link.subtype?.startsWith('image/') ? ( + isQrCodeLink(link) ? ( {link.title ) : (