nextStep(link)}>
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 e71ee598..d5dfb7b3 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,16 @@ 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 { HTTP_METHODS } from '../../data-access/types/haapi-form.types';
import { HaapiStepperStepUI } from './HaapiStepperStepUI';
import {
+ createPollingStep,
createMockClientOperationAction,
createMockFormAction,
createMockLink,
createMockMessage,
+ createMockQrLink,
createMockSelectorAction,
createMockStep,
defaultStepperAPI,
@@ -125,6 +127,31 @@ describe('HaapiStepperStepUI', () => {
expect(screen.queryByTestId('messages')).toBeInTheDocument();
expect(screen.queryByTestId('links')).toBeInTheDocument();
});
+
+ it('should render loading spinner when currentStep is a polling step in pending status', () => {
+ const step = createMockStep(HAAPI_STEPS.POLLING, {
+ properties: { status: HAAPI_POLLING_STATUS.PENDING },
+ });
+
+ renderWithContext( , { currentStep: step });
+
+ expect(screen.queryByTestId('loading-spinner')).toBeInTheDocument();
+ });
+
+ it('should not render loading spinner when currentStep is a polling step in done/failed status', () => {
+ const { unmount } = renderWithContext( , {
+ currentStep: createMockStep(HAAPI_STEPS.POLLING, { properties: { status: HAAPI_POLLING_STATUS.DONE } }),
+ });
+
+ expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument();
+ unmount();
+
+ renderWithContext( , {
+ currentStep: createMockStep(HAAPI_STEPS.POLLING, { properties: { status: HAAPI_POLLING_STATUS.FAILED } }),
+ });
+
+ expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument();
+ });
});
});
@@ -1765,4 +1792,234 @@ describe('HaapiStepperStepUI', () => {
});
});
});
+
+ describe('ViewName built-in UIs Rendering', () => {
+ describe('Default Rendering', () => {
+ it('should render the matching built-in UI by default for a registered viewName', () => {
+ const step = createPollingStep();
+
+ renderWithContext( , { currentStep: step });
+
+ expect(screen.queryByTestId('loading-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('loading-spinner')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('messages')).toBeInTheDocument();
+ expect(screen.queryByTestId('form-action')).toBeInTheDocument();
+ });
+ });
+
+ describe('Custom Rendering', () => {
+ describe('Composition with stepRenderInterceptor', () => {
+ it('should apply the built-in when stepRenderInterceptor returns pass-through data', () => {
+ const step = createPollingStep();
+ const passThroughInterceptor: HaapiStepperStepUIStepRenderInterceptor = (
+ haapiStepperAPI: HaapiStepperAPIWithRequiredCurrentStep
+ ) => {
+ return haapiStepperAPI;
+ };
+
+ renderWithContext( , {
+ currentStep: step,
+ });
+
+ expect(screen.queryByTestId('loading-spinner')).toBeInTheDocument();
+ });
+
+ it('should be skipped when stepRenderInterceptor returns a React element', () => {
+ const step = createPollingStep();
+ const elementInterceptor: HaapiStepperStepUIStepRenderInterceptor = () => {
+ return Custom UI
;
+ };
+
+ renderWithContext( , {
+ currentStep: step,
+ });
+
+ expect(screen.queryByTestId('custom-step-element')).toBeInTheDocument();
+ expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument();
+ });
+
+ it('should be skipped (and render nothing) when stepRenderInterceptor returns null', () => {
+ const step = createPollingStep();
+ const nullInterceptor: HaapiStepperStepUIStepRenderInterceptor = () => {
+ return null;
+ };
+
+ renderWithContext( , {
+ currentStep: step,
+ });
+
+ expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('messages')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('form-action')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Composition with element-level render interceptors', () => {
+ it('should apply loadingRenderInterceptor to the loadingElement reused by the built-in', () => {
+ const step = createPollingStep();
+ const loadingRenderInterceptor: HaapiStepperStepUILoadingRenderInterceptor = () => (
+ Custom Loading
+ );
+
+ renderWithContext( , {
+ currentStep: step,
+ });
+
+ expect(screen.getByTestId('custom-loading')).toBeInTheDocument();
+ expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument();
+ });
+
+ it('should apply errorRenderInterceptor to the errorElement reused by the built-in', () => {
+ const step = createPollingStep();
+ const errorRenderInterceptor: HaapiStepperStepUIErrorRenderInterceptor = ({ error }) => (
+ Custom Error: {error?.app?.title ?? ''}
+ );
+ const errorStep: HaapiStepperUnexpectedProblemStep = {
+ type: HAAPI_PROBLEM_STEPS.UNEXPECTED,
+ title: 'Unexpected Error',
+ dataHelpers: { messages: [], links: [] },
+ };
+
+ renderWithContext( , {
+ currentStep: step,
+ error: { app: errorStep, input: null },
+ });
+
+ expect(screen.getByTestId('custom-error')).toHaveTextContent('Custom Error: Unexpected Error');
+ });
+
+ it('should apply messageRenderInterceptor to the messagesElement reused by the built-in', () => {
+ const step = createPollingStep();
+ const messageRenderInterceptor: HaapiStepperStepUIMessageRenderInterceptor = ({ message }) => {
+ return { ...message, text: `Modified ${message.text}` };
+ };
+
+ renderWithContext( , {
+ currentStep: step,
+ });
+
+ const messagesContainer = screen.getByTestId('messages');
+ expect(within(messagesContainer).getByText(`Modified ${MockMessageText}`)).toBeInTheDocument();
+ });
+
+ it('should apply actionsRenderInterceptor to the actionsElement reused by the built-in', () => {
+ const step = createPollingStep();
+ const actionsRenderInterceptor: HaapiStepperStepUIActionsRenderInterceptor = () => (
+ Custom Actions
+ );
+
+ renderWithContext( , {
+ currentStep: step,
+ });
+
+ expect(screen.getByTestId('custom-actions')).toBeInTheDocument();
+ });
+
+ it('should apply formActionRenderInterceptor to the actionsElement reused by the built-in', () => {
+ const step = createPollingStep();
+ const formActionRenderInterceptor: HaapiStepperStepUIFormActionRenderInterceptor = ({ action }) => (
+ {action.title}
+ );
+
+ renderWithContext( , {
+ currentStep: step,
+ });
+
+ expect(screen.getByTestId('custom-form-action')).toHaveTextContent(MockActionTitle);
+ });
+
+ it('should apply formFieldRenderInterceptor to the actionsElement reused by the built-in', () => {
+ const step = createPollingStep();
+ const formFieldRenderInterceptor: HaapiStepperFormFieldRenderInterceptor = field => (
+ {field.label}
+ );
+
+ renderWithContext( , {
+ currentStep: step,
+ });
+
+ expect(screen.getByTestId('custom-form-field-username')).toHaveTextContent('Username');
+ expect(screen.getByTestId('custom-form-field-password')).toHaveTextContent('Password');
+ });
+
+ it('should apply selectorActionRenderInterceptor to the actionsElement reused by the built-in', () => {
+ const step = createPollingStep({ actions: [createMockSelectorAction({ title: 'Pick One' })] });
+ const selectorActionRenderInterceptor: HaapiStepperStepUISelectorActionRenderInterceptor = ({ action }) => (
+ {action.title}
+ );
+
+ renderWithContext( , {
+ currentStep: step,
+ });
+
+ expect(screen.getByTestId('custom-selector-action')).toHaveTextContent('Pick One');
+ });
+
+ it('should apply clientOperationActionRenderInterceptor to the actionsElement reused by the built-in', () => {
+ const step = createPollingStep({
+ actions: [createMockClientOperationAction({ title: 'Launch BankID App' })],
+ });
+ const clientOperationActionRenderInterceptor: HaapiStepperStepUIClientOperationActionRenderInterceptor = ({
+ action,
+ }) => {action.title}
;
+
+ renderWithContext(
+ ,
+ { currentStep: step }
+ );
+
+ expect(screen.getByTestId('custom-client-op-action')).toHaveTextContent('Launch BankID App');
+ });
+
+ it('should apply linkRenderInterceptor to the QR link rendered by the built-in', () => {
+ const qrLink = createMockQrLink({ title: 'Original QR' });
+ const step = createPollingStep({ links: [qrLink] });
+ const linkRenderInterceptor: HaapiStepperStepUILinkRenderInterceptor = ({ link }) => {
+ return { ...link, title: `Modified ${link.title ?? ''}` };
+ };
+
+ renderWithContext( , {
+ currentStep: step,
+ });
+
+ const qrButton = screen.getByTestId('qr-code-button');
+ expect(within(qrButton).getByText('Modified Original QR')).toBeInTheDocument();
+ });
+ });
+
+ describe('BankID viewName built-in UI', () => {
+ it('should render the QR link above the actions', () => {
+ const qrLink = createMockQrLink();
+ const otherLink = createMockLink({ rel: 'help', title: 'Help' });
+ const step = createPollingStep({ 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 = createPollingStep({ links: [otherLink] });
+
+ renderWithContext( , { currentStep: step });
+
+ expect(screen.queryByTestId('qr-code-button')).not.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 2b3fb88e..39ed2f36 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
@@ -9,50 +9,21 @@
* For further information, please contact Curity AB.
*/
-import { ReactElement, isValidElement } from 'react';
-import { Spinner } from '../../../shared/ui/spinner/Spinner';
-import { HaapiStepperActionsUI } from '../../ui/actions/HaapiStepperActionsUI';
-import { HaapiStepperLinksUI } from '../../ui/links/HaapiStepperLinksUI';
-import { HaapiStepperMessagesUI } from '../../ui/messages/HaapiStepperMessagesUI';
+import { isValidElement, ReactElement } from 'react';
import { Well } from '../../ui/well/Well';
-import { applyRenderInterceptor } from '../../util/generic-render-interceptor';
import { formatNextStepData } from '../stepper/data-formatters/format-next-step-data';
-import type {
- HaapiStepperAPI,
- HaapiStepperAPIWithRequiredCurrentStep,
- HaapiStepperFormFieldRenderInterceptor,
- HaapiStepperStepUIActionsRenderInterceptor,
- HaapiStepperStepUIClientOperationActionRenderInterceptor,
- HaapiStepperStepUIErrorRenderInterceptor,
- HaapiStepperStepUIFormActionRenderInterceptor,
- HaapiStepperStepUILinkRenderInterceptor,
- HaapiStepperStepUILoadingRenderInterceptor,
- HaapiStepperStepUIMessageRenderInterceptor,
- HaapiStepperStepUISelectorActionRenderInterceptor,
- HaapiStepperStepUIStepRenderInterceptor,
-} from '../stepper/haapi-stepper.types';
-import {
- HaapiStepperClientOperationAction,
- HaapiStepperFormAction,
- HaapiStepperLink,
- HaapiStepperSelectorAction,
- HaapiStepperStep,
- HaapiStepperUserMessage,
-} from '../stepper/haapi-stepper.types';
+import { getViewNameBuiltInUI } from '../viewnames';
+import type { HaapiStepperAPIWithRequiredCurrentStep } from '../stepper/haapi-stepper.types';
import { useHaapiStepper } from '../stepper/HaapiStepperHook';
-
-interface HaapiStepperStepUIProps {
- loadingRenderInterceptor?: HaapiStepperStepUILoadingRenderInterceptor;
- errorRenderInterceptor?: HaapiStepperStepUIErrorRenderInterceptor;
- stepRenderInterceptor?: HaapiStepperStepUIStepRenderInterceptor;
- actionsRenderInterceptor?: HaapiStepperStepUIActionsRenderInterceptor;
- formActionRenderInterceptor?: HaapiStepperStepUIFormActionRenderInterceptor;
- formFieldRenderInterceptor?: HaapiStepperFormFieldRenderInterceptor;
- selectorActionRenderInterceptor?: HaapiStepperStepUISelectorActionRenderInterceptor;
- clientOperationActionRenderInterceptor?: HaapiStepperStepUIClientOperationActionRenderInterceptor;
- linkRenderInterceptor?: HaapiStepperStepUILinkRenderInterceptor;
- messageRenderInterceptor?: HaapiStepperStepUIMessageRenderInterceptor;
-}
+import {
+ getActionsElement,
+ getErrorElement,
+ getLinksElement,
+ getLinksToDisplay,
+ getLoadingElement,
+ getMessagesElement,
+} from './step-element-factories';
+import type { HaapiStepperStepUIProps } from './typings';
/**
* @description
@@ -79,6 +50,13 @@ 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 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.
+ *
* ## CUSTOMIZATION
*
* ### CUSTOMIZATION DIMENSIONS
@@ -230,18 +208,28 @@ interface HaapiStepperStepUIProps {
*
* See more data, UI and behaviour customization examples in the [unit tests](./haapi-stepper/feature/steps/HaapiStepperStepUI.spec.tsx)
*/
-export const HaapiStepperStepUI = ({
- loadingRenderInterceptor,
- errorRenderInterceptor,
- stepRenderInterceptor,
- actionsRenderInterceptor,
- formActionRenderInterceptor,
- formFieldRenderInterceptor,
- selectorActionRenderInterceptor,
- clientOperationActionRenderInterceptor,
- linkRenderInterceptor,
- messageRenderInterceptor,
-}: HaapiStepperStepUIProps) => {
+export const HaapiStepperStepUI = (props: HaapiStepperStepUIProps) => {
+ const {
+ /**
+ * The default loadingRenderInterceptor factory renders a spinner whenever `loading === true` *or*
+ * `currentStep` is a polling step in `HAAPI_POLLING_STATUS.PENDING`.
+ *
+ * Consumers replacing this interceptor are therefore replacing both signals: returning a React
+ * element only when `loading === true` will hide the polling-pending progress indicator. Either
+ * check `currentStep` explicitly, or return the pass-through `HaapiStepperAPI` data to delegate to
+ * the default factory for the cases you don't want to handle.
+ */
+ loadingRenderInterceptor,
+ errorRenderInterceptor,
+ stepRenderInterceptor,
+ actionsRenderInterceptor,
+ formActionRenderInterceptor,
+ formFieldRenderInterceptor,
+ selectorActionRenderInterceptor,
+ clientOperationActionRenderInterceptor,
+ linkRenderInterceptor,
+ messageRenderInterceptor,
+ } = props;
const haapiStepperAPI = useHaapiStepper();
const loadingElement: ReactElement | null = getLoadingElement(haapiStepperAPI, loadingRenderInterceptor);
@@ -249,146 +237,57 @@ 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(stepRenderInterceptorResult)) {
+ return stepRenderInterceptorResult;
+ }
- if (isValidElement(customStepRenderInterceptorResult)) {
- return customStepRenderInterceptorResult;
- } else if (customStepRenderInterceptorResult === null || customStepRenderInterceptorResult === undefined) {
+ if (stepRenderInterceptorResult === null || stepRenderInterceptorResult === undefined) {
return null;
- } else {
- haapiUIStepperAPI = {
- ...customStepRenderInterceptorResult,
- currentStep: formatNextStepData(customStepRenderInterceptorResult.currentStep),
- };
}
+
+ haapiStepperUiAPI = {
+ ...stepRenderInterceptorResult,
+ currentStep: formatNextStepData(stepRenderInterceptorResult.currentStep),
+ };
}
- const { error, currentStep } = haapiUIStepperAPI;
- const errorElement: ReactElement | null = getErrorElement(haapiUIStepperAPI, errorRenderInterceptor);
+ const { error, currentStep } = haapiStepperUiAPI;
const linksToDisplay = getLinksToDisplay(error, currentStep);
const messagesToDisplay = error?.input ? error.input.dataHelpers.messages : currentStep.dataHelpers.messages;
- const messagesElement = getMessagesElement(haapiUIStepperAPI, messagesToDisplay, messageRenderInterceptor);
- const actionsElement = getActionsElement(
- haapiUIStepperAPI,
- actionsRenderInterceptor,
- formActionRenderInterceptor,
- formFieldRenderInterceptor,
- selectorActionRenderInterceptor,
- clientOperationActionRenderInterceptor
- );
- const linksElement = getLinksElement(haapiUIStepperAPI, linksToDisplay, linkRenderInterceptor);
+ const stepElements = {
+ loadingElement,
+ errorElement: getErrorElement(haapiStepperUiAPI, errorRenderInterceptor),
+ messagesElement: getMessagesElement(haapiStepperUiAPI, messagesToDisplay, messageRenderInterceptor),
+ actionsElement: getActionsElement(
+ haapiStepperUiAPI,
+ actionsRenderInterceptor,
+ formActionRenderInterceptor,
+ formFieldRenderInterceptor,
+ selectorActionRenderInterceptor,
+ clientOperationActionRenderInterceptor
+ ),
+ linksElement: getLinksElement(haapiStepperUiAPI, linksToDisplay, linkRenderInterceptor),
+ };
+
+ const ViewNameBuiltInUI = getViewNameBuiltInUI(haapiStepperUiAPI);
+
+ if (ViewNameBuiltInUI) {
+ return ;
+ }
return (
- {loadingElement}
- {errorElement}
- {messagesElement}
- {actionsElement}
- {linksElement}
+ {stepElements.loadingElement}
+ {stepElements.errorElement}
+ {stepElements.messagesElement}
+ {stepElements.actionsElement}
+ {stepElements.linksElement}
);
};
-
-const getLoadingElement = (
- haapiStepperAPI: HaapiStepperAPI,
- loadingRenderInterceptor?: HaapiStepperStepUILoadingRenderInterceptor
-): ReactElement | null => {
- const loadingElements = applyRenderInterceptor([haapiStepperAPI], loadingRenderInterceptor, ({ loading }) =>
- loading ? : null
- );
-
- return loadingElements.length > 0 ? loadingElements[0] : null;
-};
-
-const getErrorElement = (
- haapiStepperAPI: HaapiStepperAPIWithRequiredCurrentStep,
- errorRenderInterceptor?: HaapiStepperStepUIErrorRenderInterceptor
-): ReactElement | null => {
- const errorElements = applyRenderInterceptor([haapiStepperAPI], errorRenderInterceptor, () => null);
-
- return errorElements[0] ?? null;
-};
-
-const getMessagesElement = (
- haapiStepperAPI: HaapiStepperAPIWithRequiredCurrentStep,
- messages: HaapiStepperUserMessage[] | undefined,
- messageRenderInterceptor?: HaapiStepperStepUIMessageRenderInterceptor
-): ReactElement => {
- const renderInterceptor = messageRenderInterceptor
- ? (message: HaapiStepperUserMessage) => messageRenderInterceptor({ message, ...haapiStepperAPI })
- : undefined;
-
- return ;
-};
-
-const getActionsElement = (
- haapiStepperAPI: HaapiStepperAPIWithRequiredCurrentStep,
- actionsRenderInterceptor?: HaapiStepperStepUIActionsRenderInterceptor,
- formActionRenderInterceptor?: HaapiStepperStepUIFormActionRenderInterceptor,
- formFieldRenderInterceptor?: HaapiStepperFormFieldRenderInterceptor,
- selectorActionRenderInterceptor?: HaapiStepperStepUISelectorActionRenderInterceptor,
- clientOperationActionRenderInterceptor?: HaapiStepperStepUIClientOperationActionRenderInterceptor
-): ReactElement | null => {
- const defaultActionsElementFactory = (haapiStepperAPI: HaapiStepperAPIWithRequiredCurrentStep) => {
- const actions = haapiStepperAPI.currentStep.dataHelpers.actions?.all;
-
- if (!actions?.length) {
- return null;
- }
-
- const formActionInterceptor = formActionRenderInterceptor
- ? (action: HaapiStepperFormAction) => formActionRenderInterceptor({ action, ...haapiStepperAPI })
- : undefined;
-
- const selectorActionInterceptor = selectorActionRenderInterceptor
- ? (action: HaapiStepperSelectorAction) => selectorActionRenderInterceptor({ action, ...haapiStepperAPI })
- : undefined;
-
- const clientOperationActionInterceptor = clientOperationActionRenderInterceptor
- ? (action: HaapiStepperClientOperationAction) =>
- clientOperationActionRenderInterceptor({ action, ...haapiStepperAPI })
- : undefined;
-
- return (
-
- );
- };
-
- const actionsElements = applyRenderInterceptor(
- [haapiStepperAPI],
- actionsRenderInterceptor,
- defaultActionsElementFactory
- );
-
- return actionsElements[0] ?? null;
-};
-
-const getLinksElement = (
- haapiStepperAPI: HaapiStepperAPIWithRequiredCurrentStep,
- links: HaapiStepperLink[] | undefined,
- linkRenderInterceptor?: HaapiStepperStepUILinkRenderInterceptor
-): ReactElement => {
- const renderInterceptor = linkRenderInterceptor
- ? (link: HaapiStepperLink) => linkRenderInterceptor({ link, ...haapiStepperAPI })
- : undefined;
-
- return ;
-};
-
-const getLinksToDisplay = (
- error: HaapiStepperAPI['error'],
- currentStep: HaapiStepperStep
-): HaapiStepperLink[] | undefined => {
- return error?.input?.dataHelpers.links.length ? error.input.dataHelpers.links : currentStep.dataHelpers.links;
-};
diff --git a/src/login-web-app/src/haapi-stepper/feature/steps/step-element-factories.tsx b/src/login-web-app/src/haapi-stepper/feature/steps/step-element-factories.tsx
new file mode 100644
index 00000000..a1de582c
--- /dev/null
+++ b/src/login-web-app/src/haapi-stepper/feature/steps/step-element-factories.tsx
@@ -0,0 +1,145 @@
+/*
+ * 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 { ReactElement } from 'react';
+import { HAAPI_POLLING_STATUS, HAAPI_STEPS } from '../../data-access/types/haapi-step.types';
+import { HaapiStepperActionsUI } from '../../ui/actions/HaapiStepperActionsUI';
+import { HaapiStepperLinksUI } from '../../ui/links/HaapiStepperLinksUI';
+import { HaapiStepperMessagesUI } from '../../ui/messages/HaapiStepperMessagesUI';
+import { applyRenderInterceptor } from '../../util/generic-render-interceptor';
+import type {
+ HaapiStepperAPI,
+ HaapiStepperAPIWithRequiredCurrentStep,
+ HaapiStepperClientOperationAction,
+ HaapiStepperFormAction,
+ HaapiStepperFormFieldRenderInterceptor,
+ HaapiStepperLink,
+ HaapiStepperSelectorAction,
+ HaapiStepperStep,
+ HaapiStepperStepUIActionsRenderInterceptor,
+ HaapiStepperStepUIClientOperationActionRenderInterceptor,
+ HaapiStepperStepUIErrorRenderInterceptor,
+ HaapiStepperStepUIFormActionRenderInterceptor,
+ HaapiStepperStepUILinkRenderInterceptor,
+ HaapiStepperStepUILoadingRenderInterceptor,
+ HaapiStepperStepUIMessageRenderInterceptor,
+ HaapiStepperStepUISelectorActionRenderInterceptor,
+ HaapiStepperUserMessage,
+} from '../stepper/haapi-stepper.types';
+import { Spinner } from '../../../shared/ui/spinner/Spinner';
+
+export const getLoadingElement = (
+ haapiStepperAPI: HaapiStepperAPI,
+ loadingRenderInterceptor?: HaapiStepperStepUILoadingRenderInterceptor
+): ReactElement | null => {
+ const loadingElements = applyRenderInterceptor(
+ [haapiStepperAPI],
+ loadingRenderInterceptor,
+ ({ loading, currentStep }) => {
+ const isPollingPending =
+ currentStep?.type === HAAPI_STEPS.POLLING && currentStep.properties.status === HAAPI_POLLING_STATUS.PENDING;
+ const showSpinner = loading || isPollingPending;
+
+ return showSpinner ? : null;
+ }
+ );
+
+ return loadingElements.length > 0 ? loadingElements[0] : null;
+};
+
+export const getErrorElement = (
+ haapiStepperAPI: HaapiStepperAPIWithRequiredCurrentStep,
+ errorRenderInterceptor?: HaapiStepperStepUIErrorRenderInterceptor
+): ReactElement | null => {
+ const errorElements = applyRenderInterceptor([haapiStepperAPI], errorRenderInterceptor, () => null);
+
+ return errorElements[0] ?? null;
+};
+
+export const getMessagesElement = (
+ haapiStepperAPI: HaapiStepperAPIWithRequiredCurrentStep,
+ messages: HaapiStepperUserMessage[] | undefined,
+ messageRenderInterceptor?: HaapiStepperStepUIMessageRenderInterceptor
+): ReactElement => {
+ const renderInterceptor = messageRenderInterceptor
+ ? (message: HaapiStepperUserMessage) => messageRenderInterceptor({ message, ...haapiStepperAPI })
+ : undefined;
+
+ return ;
+};
+
+export const getActionsElement = (
+ haapiStepperAPI: HaapiStepperAPIWithRequiredCurrentStep,
+ actionsRenderInterceptor?: HaapiStepperStepUIActionsRenderInterceptor,
+ formActionRenderInterceptor?: HaapiStepperStepUIFormActionRenderInterceptor,
+ formFieldRenderInterceptor?: HaapiStepperFormFieldRenderInterceptor,
+ selectorActionRenderInterceptor?: HaapiStepperStepUISelectorActionRenderInterceptor,
+ clientOperationActionRenderInterceptor?: HaapiStepperStepUIClientOperationActionRenderInterceptor
+): ReactElement | null => {
+ const defaultActionsElementFactory = (haapiStepperAPI: HaapiStepperAPIWithRequiredCurrentStep) => {
+ const actions = haapiStepperAPI.currentStep.dataHelpers.actions?.all;
+
+ if (!actions?.length) {
+ return null;
+ }
+
+ const formActionInterceptor = formActionRenderInterceptor
+ ? (action: HaapiStepperFormAction) => formActionRenderInterceptor({ action, ...haapiStepperAPI })
+ : undefined;
+
+ const selectorActionInterceptor = selectorActionRenderInterceptor
+ ? (action: HaapiStepperSelectorAction) => selectorActionRenderInterceptor({ action, ...haapiStepperAPI })
+ : undefined;
+
+ const clientOperationActionInterceptor = clientOperationActionRenderInterceptor
+ ? (action: HaapiStepperClientOperationAction) =>
+ clientOperationActionRenderInterceptor({ action, ...haapiStepperAPI })
+ : undefined;
+
+ return (
+
+ );
+ };
+
+ const actionsElements = applyRenderInterceptor(
+ [haapiStepperAPI],
+ actionsRenderInterceptor,
+ defaultActionsElementFactory
+ );
+
+ return actionsElements[0] ?? null;
+};
+
+export const getLinksElement = (
+ haapiStepperAPI: HaapiStepperAPIWithRequiredCurrentStep,
+ links: HaapiStepperLink[] | undefined,
+ linkRenderInterceptor?: HaapiStepperStepUILinkRenderInterceptor
+): ReactElement => {
+ const renderInterceptor = linkRenderInterceptor
+ ? (link: HaapiStepperLink) => linkRenderInterceptor({ link, ...haapiStepperAPI })
+ : undefined;
+
+ return ;
+};
+
+export const getLinksToDisplay = (
+ error: HaapiStepperAPI['error'],
+ currentStep: HaapiStepperStep
+): HaapiStepperLink[] | undefined => {
+ return error?.input?.dataHelpers.links.length ? error.input.dataHelpers.links : currentStep.dataHelpers.links;
+};
diff --git a/src/login-web-app/src/haapi-stepper/feature/steps/typings.ts b/src/login-web-app/src/haapi-stepper/feature/steps/typings.ts
new file mode 100644
index 00000000..6c1d9e4b
--- /dev/null
+++ b/src/login-web-app/src/haapi-stepper/feature/steps/typings.ts
@@ -0,0 +1,50 @@
+/*
+ * 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 {
+ HaapiStepperFormFieldRenderInterceptor,
+ HaapiStepperStepUIActionsRenderInterceptor,
+ HaapiStepperStepUIClientOperationActionRenderInterceptor,
+ HaapiStepperStepUIErrorRenderInterceptor,
+ HaapiStepperStepUIFormActionRenderInterceptor,
+ HaapiStepperStepUILinkRenderInterceptor,
+ HaapiStepperStepUILoadingRenderInterceptor,
+ HaapiStepperStepUIMessageRenderInterceptor,
+ HaapiStepperStepUISelectorActionRenderInterceptor,
+ HaapiStepperStepUIStepRenderInterceptor,
+} from '../stepper/haapi-stepper.types';
+
+/**
+ * Props for `HaapiStepperStepUI`. Lives here (rather than in `HaapiStepperStepUI.tsx`) so the
+ * `viewnames/` package can depend on the type without re-introducing a runtime cycle with
+ * `HaapiStepperStepUI.tsx` (which itself imports `getViewNameBuiltInUI` from `../viewnames`).
+ */
+export interface HaapiStepperStepUIProps {
+ /**
+ * The default factory renders a spinner whenever `loading === true` *or* `currentStep` is a
+ * polling step in `HAAPI_POLLING_STATUS.PENDING`.
+ *
+ * Consumers replacing this interceptor are therefore replacing both signals: returning a React
+ * element only when `loading === true` will hide the polling-pending progress indicator. Either
+ * check `currentStep` explicitly, or return the pass-through `HaapiStepperAPI` data to delegate to
+ * the default factory for the cases you don't want to handle.
+ */
+ loadingRenderInterceptor?: HaapiStepperStepUILoadingRenderInterceptor;
+ errorRenderInterceptor?: HaapiStepperStepUIErrorRenderInterceptor;
+ stepRenderInterceptor?: HaapiStepperStepUIStepRenderInterceptor;
+ actionsRenderInterceptor?: HaapiStepperStepUIActionsRenderInterceptor;
+ formActionRenderInterceptor?: HaapiStepperStepUIFormActionRenderInterceptor;
+ formFieldRenderInterceptor?: HaapiStepperFormFieldRenderInterceptor;
+ selectorActionRenderInterceptor?: HaapiStepperStepUISelectorActionRenderInterceptor;
+ clientOperationActionRenderInterceptor?: HaapiStepperStepUIClientOperationActionRenderInterceptor;
+ linkRenderInterceptor?: HaapiStepperStepUILinkRenderInterceptor;
+ messageRenderInterceptor?: HaapiStepperStepUIMessageRenderInterceptor;
+}
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 00000000..81c2e948
--- /dev/null
+++ b/src/login-web-app/src/haapi-stepper/feature/viewnames/BankIdViewNameBuiltInUI.tsx
@@ -0,0 +1,38 @@
+/*
+ * 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 { Well } from '../../ui/well/Well';
+import { isQrCodeLink } from '../../util/isQrCodeLink';
+import { getLinksElement } from '../steps/step-element-factories';
+import type { ViewNameBuiltInUIProps } from './typings';
+
+/**
+ * Built-in UI for the BankID viewName (`HaapiStepperViewNameBuiltInUI.BANKID`).
+ *
+ * - Lifts the QR code link above the actions so it's the primary element on the screen.
+ */
+export const BankIdViewNameBuiltInUI = (props: ViewNameBuiltInUIProps) => {
+ const { currentStep, linkRenderInterceptor, loadingElement, errorElement, messagesElement, actionsElement } = props;
+ const { links } = currentStep.dataHelpers;
+ const qrLink = links.find(isQrCodeLink);
+ const nonQrLinks = links.filter(link => !isQrCodeLink(link));
+
+ return (
+
+ {loadingElement}
+ {errorElement}
+ {messagesElement}
+ {qrLink && getLinksElement(props, [qrLink], linkRenderInterceptor)}
+ {actionsElement}
+ {nonQrLinks.length > 0 && getLinksElement(props, nonQrLinks, linkRenderInterceptor)}
+
+ );
+};
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 00000000..c1275232
--- /dev/null
+++ b/src/login-web-app/src/haapi-stepper/feature/viewnames/index.ts
@@ -0,0 +1,15 @@
+/*
+ * 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 './typings';
+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/typings.ts b/src/login-web-app/src/haapi-stepper/feature/viewnames/typings.ts
new file mode 100644
index 00000000..f06d7a26
--- /dev/null
+++ b/src/login-web-app/src/haapi-stepper/feature/viewnames/typings.ts
@@ -0,0 +1,29 @@
+/*
+ * 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 { ReactElement } from 'react';
+import type { HaapiStepperAPIWithRequiredCurrentStep } from '../stepper/haapi-stepper.types';
+import type { HaapiStepperStepUIProps } from '../steps/typings';
+
+/**
+ * Props every viewName built-in UI receives. It's `HaapiStepperStepUIProps` (so the consumer's
+ * element-level interceptors are visible) plus the API plus the already-rendered element slots
+ * (so the built-in can reuse them for any slot it doesn't modify). Helpers like `getLinksElement`
+ * are exported from `step-element-factories` for built-ins that need to render filtered subsets.
+ */
+export type ViewNameBuiltInUIProps = HaapiStepperAPIWithRequiredCurrentStep &
+ HaapiStepperStepUIProps & {
+ loadingElement: ReactElement | null;
+ errorElement: ReactElement | null;
+ messagesElement: ReactElement;
+ actionsElement: ReactElement | null;
+ linksElement: ReactElement;
+ };
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 00000000..68d1bee1
--- /dev/null
+++ b/src/login-web-app/src/haapi-stepper/feature/viewnames/viewname-built-in-uis.ts
@@ -0,0 +1,36 @@
+/*
+ * 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 type { ViewNameBuiltInUIProps } from './typings';
+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_UI_MAP: Record> = {
+ [HaapiStepperViewNameBuiltInUI.BANKID]: BankIdViewNameBuiltInUI,
+};
+
+const isBuiltInViewName = (viewName?: string): viewName is HaapiStepperViewNameBuiltInUI =>
+ !!viewName && viewName in VIEW_NAME_BUILT_IN_UI_MAP;
+
+export const getViewNameBuiltInUI = (
+ haapiStepperAPI: HaapiStepperAPIWithRequiredCurrentStep
+): FC | undefined => {
+ const viewName = haapiStepperAPI.currentStep.metadata?.viewName;
+ return isBuiltInViewName(viewName) ? VIEW_NAME_BUILT_IN_UI_MAP[viewName] : undefined;
+};
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 00000000..e40417f8
--- /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/ui/links/HaapiStepperLinkUI.tsx b/src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperLinkUI.tsx
index 07237585..436466ac 100644
--- a/src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperLinkUI.tsx
+++ b/src/login-web-app/src/haapi-stepper/ui/links/HaapiStepperLinkUI.tsx
@@ -10,6 +10,7 @@
*/
import { HaapiStepperLink } from '../../feature/stepper/haapi-stepper.types';
+import { isQrCodeLink } from '../../util/isQrCodeLink';
export const HaapiStepperLinkUI = ({
link,
@@ -18,9 +19,7 @@ export const HaapiStepperLinkUI = ({
link: HaapiStepperLink;
onClick: (action: HaapiStepperLink) => void;
}) => {
- const isQRCodeLink = link.subtype?.startsWith('image/');
-
- if (isQRCodeLink) {
+ if (isQrCodeLink(link)) {
return (
{displayQrCodeInDialog => {
const handleLinkClick = (link: HaapiStepperLink) => {
- if (link.subtype?.startsWith('image/')) {
+ if (isQrCodeLink(link)) {
displayQrCodeInDialog(link);
} else {
onClick(link);
diff --git a/src/login-web-app/src/haapi-stepper/util/isQrCodeLink.ts b/src/login-web-app/src/haapi-stepper/util/isQrCodeLink.ts
new file mode 100644
index 00000000..3bdac7d5
--- /dev/null
+++ b/src/login-web-app/src/haapi-stepper/util/isQrCodeLink.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.
+ */
+
+import type { HaapiStepperLink } from '../feature/stepper/haapi-stepper.types';
+
+/**
+ * Returns `true` when the link's `subtype` indicates an inline image (e.g. `image/svg+xml`,
+ * `image/png`). HAAPI uses this convention to expose QR codes as image-typed links so the UI can
+ * render them as a scannable figure rather than a regular text link.
+ */
+export const isQrCodeLink = (link: HaapiStepperLink): boolean => {
+ return link.subtype?.startsWith('image/') ?? false;
+};
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 7a5739e6..59b8593e 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,9 +1,14 @@
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 {
HaapiStepperStep,
+ HaapiStepperAction,
HaapiStepperFormAction,
HaapiStepperSelectorAction,
HaapiStepperClientOperationAction,
@@ -12,6 +17,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 +145,37 @@ export const defaultStepperAPI: HaapiStepperAPI = {
history: [],
nextStep: mockNextStep,
};
+
+/**
+ * Builds a polling step mock. Defaults to the BankID viewName + `PENDING` status, since that's the
+ * combination most tests care about, but callers can override `viewName` (e.g. pass a non-BankID
+ * value for generic loading-factory tests) and any of the other fields independently.
+ */
+export const createPollingStep = (
+ overrides: {
+ status?: HAAPI_POLLING_STATUS;
+ links?: HaapiStepperLink[];
+ actions?: HaapiStepperAction[];
+ 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 }),
+ ...(overrides.actions !== undefined && { actions: overrides.actions }),
+ });
+};
+
+export const createMockQrLink = (overrides: Partial = {}) => {
+ return createMockLink({
+ rel: 'activation',
+ title: 'QR Code',
+ href: 'data:image/svg+xml;base64,abc',
+ type: 'image/svg+xml',
+ ...overrides,
+ });
+};