Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,23 @@ export interface BootstrapConfiguration {
path: string;
isInsideWell: boolean;
};
/**
* Optional per-page icon configuration. Only present when symbols are enabled in the server theme.
* Resolved against the current step's `metadata.viewName` (see `resolvePageSymbol`).
*/
pageSymbols?: PageSymbols;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will need to be moved after that other PR improving handling of bootstrap configuration is merged,

};
}

export interface PageSymbols {
/** Map of full HAAPI viewName -> symbol path. Highest precedence. */
views?: Record<string, string>;
/** Map of plugin implementation type (e.g. `html-form`, `bankid`) -> symbol path. */
plugins?: Record<string, string>;
/** Fallback symbol path used when no per-view / per-plugin entry matches. */
default?: string;
}

// @ts-expect-error window.__CONFIG__ is not declared on the Window type
const _configuration = window.__CONFIG__ as BootstrapConfiguration | undefined;
if (!_configuration) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import { useEffect } from 'react';
import userEvent from '@testing-library/user-event';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { HaapiStepperContext } from '../stepper/HaapiStepperContext';
import { AppConfigContext } from '../../../shared/feature/app-config/AppConfigContext';
import type {
BootstrapConfiguration,
PageSymbols,
} from '../../data-access/bootstrap-configuration';
import type {
HaapiStepperAPI,
HaapiStepperNextStep,
Expand Down Expand Up @@ -67,13 +72,34 @@ import {
} from '../../util/tests/mocks';
import { HaapiStepperFormFieldUI } from '../actions/form/fields/HaapiStepperFormFieldUI';

const renderWithContext = (ui: React.ReactElement, contextValue: Partial<HaapiStepperAPI> = {}) => {
const buildAppConfig = (pageSymbols?: PageSymbols): BootstrapConfiguration => ({
initialUrl: 'https://example/start',
haapi: {} as BootstrapConfiguration['haapi'],
theme: {
logo: { path: '/assets/logo.svg', isInsideWell: false },
pageSymbols,
},
});

/** Returns true when `a` appears before `b` in document order. */
const rendersBefore = (a: Element, b: Element): boolean =>
!!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);

const renderWithContext = (
ui: React.ReactElement,
contextValue: Partial<HaapiStepperAPI> = {},
pageSymbols?: PageSymbols
) => {
const value: HaapiStepperAPI = {
...defaultStepperAPI,
...contextValue,
};

return render(<HaapiStepperContext value={value}>{ui}</HaapiStepperContext>);
return render(
<AppConfigContext value={buildAppConfig(pageSymbols)}>
<HaapiStepperContext value={value}>{ui}</HaapiStepperContext>
</AppConfigContext>
);
};

describe('HaapiStepperStepUI', () => {
Expand Down Expand Up @@ -2022,4 +2048,42 @@ describe('HaapiStepperStepUI', () => {
});
});
});

describe('Page symbol', () => {
it('renders the resolved symbol above the messages/actions/links for the current step', () => {
const step = createMockStep(HAAPI_STEPS.AUTHENTICATION, {
metadata: { templateArea: 'lwa', viewName: 'authenticator/html-form/index' },
});

renderWithContext(<HaapiStepperStepUI />, { currentStep: step }, {
plugins: { 'html-form': '/symbols/html-form.svg' },
});

const pageSymbol = document.querySelector<HTMLImageElement>('img.haapi-stepper-page-symbol')!;
const messages = screen.getByTestId('messages');

expect(pageSymbol).toHaveAttribute('src', '/symbols/html-form.svg');
expect(rendersBefore(pageSymbol, messages)).toBe(true);
});

it('renders nothing when theme.pageSymbols is absent', () => {
renderWithContext(<HaapiStepperStepUI />);
expect(document.querySelector<HTMLImageElement>('img.haapi-stepper-page-symbol')).toBeNull();
});

it('renders the page symbol above the BankID QR link in the BankID built-in UI', () => {
const qrLink = createMockQrLink();
const step = createPollingStep({ links: [qrLink] });

renderWithContext(<HaapiStepperStepUI />, { currentStep: step }, {
plugins: { bankid: '/symbols/bankid.svg' },
});

const pageSymbol = document.querySelector<HTMLImageElement>('img.haapi-stepper-page-symbol')!;
const qrButton = screen.getByTestId('qr-code-button');

expect(pageSymbol).toHaveAttribute('src', '/symbols/bankid.svg');
expect(rendersBefore(pageSymbol, qrButton)).toBe(true);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { formatNextStepData } from '../stepper/data-formatters/format-next-step-
import { getViewNameBuiltInUI } from '../viewnames';
import type { HaapiStepperAPIWithRequiredCurrentStep } from '../stepper/haapi-stepper.types';
import { useHaapiStepper } from '../stepper/HaapiStepperHook';
import { PageSymbol } from '../../../shared/ui/PageSymbol';
import {
getActionsElement,
getErrorElement,
Expand Down Expand Up @@ -259,6 +260,8 @@ export const HaapiStepperStepUI = (props: HaapiStepperStepUIProps) => {
const linksToDisplay = getLinksToDisplay(error, currentStep);
const messagesToDisplay = error?.input ? error.input.dataHelpers.messages : currentStep.dataHelpers.messages;

const pageSymbolElement = <PageSymbol viewName={currentStep.metadata?.viewName} />;
Comment thread
aleixsuau marked this conversation as resolved.

const stepElements = {
loadingElement,
errorElement: getErrorElement(haapiStepperUiAPI, errorRenderInterceptor),
Expand All @@ -272,6 +275,7 @@ export const HaapiStepperStepUI = (props: HaapiStepperStepUIProps) => {
clientOperationActionRenderInterceptor
),
linksElement: getLinksElement(haapiStepperUiAPI, linksToDisplay, linkRenderInterceptor),
pageSymbolElement,
};

const ViewNameBuiltInUI = getViewNameBuiltInUI(haapiStepperUiAPI);
Expand All @@ -282,6 +286,7 @@ export const HaapiStepperStepUI = (props: HaapiStepperStepUIProps) => {

return (
<>
{stepElements.pageSymbolElement}
{stepElements.loadingElement}
{stepElements.errorElement}
{stepElements.messagesElement}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,22 @@ import type { ViewNameBuiltInUIProps } from './typings';
* - 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 {
currentStep,
linkRenderInterceptor,
loadingElement,
errorElement,
messagesElement,
actionsElement,
pageSymbolElement,
} = props;
const { links } = currentStep.dataHelpers;
const qrLink = links.find(isQrCodeLink);
const nonQrLinks = links.filter(link => !isQrCodeLink(link));

return (
<>
{pageSymbolElement}
{loadingElement}
{errorElement}
{messagesElement}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ export type ViewNameBuiltInUIProps = HaapiStepperAPIWithRequiredCurrentStep &
messagesElement: ReactElement;
actionsElement: ReactElement | null;
linksElement: ReactElement;
pageSymbolElement: ReactElement;
};
121 changes: 121 additions & 0 deletions src/login-web-app/src/shared/ui/PageSymbol.spec.tsx
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* Copyright (C) 2026 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 { describe, expect, it } from 'vitest';
import { render } from '@testing-library/react';
import { PageSymbol } from './PageSymbol';
import { AppConfigContext } from '../feature/app-config/AppConfigContext';
import type { BootstrapConfiguration, PageSymbols } from '../../haapi-stepper/data-access/bootstrap-configuration';

const buildConfig = (pageSymbols?: PageSymbols): BootstrapConfiguration => ({
initialUrl: 'https://example/start',
haapi: {} as BootstrapConfiguration['haapi'],
theme: {
logo: { path: '/assets/logo.svg', isInsideWell: false },
pageSymbols,
},
});

const renderPageSymbol = (viewName: string | undefined, pageSymbols?: PageSymbols) =>
render(
<AppConfigContext value={buildConfig(pageSymbols)}>
<PageSymbol viewName={viewName} />
</AppConfigContext>
);

describe('PageSymbol', () => {
const pageSymbols: PageSymbols = {
views: {
'authenticator/html-form/create-account/post': '/symbols/create-account.svg',
'authentication-action/email-verifier/confirm': '/symbols/email-verifier-confirm.svg',
'consentor/scope-consent/review': '/symbols/scope-consent-review.svg',
},
plugins: {
'html-form': '/symbols/html-form.svg',
'email-verifier': '/symbols/email-verifier.svg',
'scope-consent': '/symbols/scope-consent.svg',
},
default: '/symbols/default.svg',
};

it.each([
['authenticator', 'authenticator/html-form/create-account/post', '/symbols/create-account.svg'],
['authentication-action', 'authentication-action/email-verifier/confirm', '/symbols/email-verifier-confirm.svg'],
['consentor', 'consentor/scope-consent/review', '/symbols/scope-consent-review.svg'],
])(
'renders the exact `views` entry for the %s category even when a plugin or default would also match',
(_, viewName, expected) => {
renderPageSymbol(viewName, pageSymbols);
expect(document.querySelector<HTMLImageElement>('img.haapi-stepper-page-symbol')).toHaveAttribute(
'src',
expected
);
}
);

it.each([
['authenticator', 'authenticator/html-form/index', '/symbols/html-form.svg'],
['authentication-action', 'authentication-action/email-verifier/verify', '/symbols/email-verifier.svg'],
['consentor', 'consentor/scope-consent/consent', '/symbols/scope-consent.svg'],
])(
'falls back to the plugin-type entry for the %s category when no `views` entry matches',
(_, viewName, expected) => {
renderPageSymbol(viewName, pageSymbols);
expect(document.querySelector<HTMLImageElement>('img.haapi-stepper-page-symbol')).toHaveAttribute(
'src',
expected
);
}
);

it('falls back to `default` when neither `views` nor `plugins` matches', () => {
renderPageSymbol('authenticator/unknown-plugin/index', pageSymbols);
expect(document.querySelector<HTMLImageElement>('img.haapi-stepper-page-symbol')).toHaveAttribute(
'src',
'/symbols/default.svg'
);
});

it('falls back to `default` when the viewName is outside the three plugin categories', () => {
renderPageSymbol('views/select-authenticator/index', pageSymbols);
expect(document.querySelector<HTMLImageElement>('img.haapi-stepper-page-symbol')).toHaveAttribute(
'src',
'/symbols/default.svg'
);
});

it('renders nothing when nothing resolves and no `default` is configured', () => {
renderPageSymbol('authenticator/unknown-plugin/index', {
plugins: { 'html-form': '/symbols/html-form.svg' },
});
expect(document.querySelector<HTMLImageElement>('img.haapi-stepper-page-symbol')).toBeNull();
});

it('renders nothing when pageSymbols is absent', () => {
renderPageSymbol('authenticator/html-form/index', undefined);
expect(document.querySelector<HTMLImageElement>('img.haapi-stepper-page-symbol')).toBeNull();
});

it('renders nothing when pageSymbols is empty', () => {
renderPageSymbol('authenticator/html-form/index', {});
expect(document.querySelector<HTMLImageElement>('img.haapi-stepper-page-symbol')).toBeNull();
});

it('renders nothing when viewName is undefined', () => {
renderPageSymbol(undefined, pageSymbols);
expect(document.querySelector<HTMLImageElement>('img.haapi-stepper-page-symbol')).toBeNull();
});

it('renders nothing when viewName is an empty string', () => {
renderPageSymbol('', pageSymbols);
expect(document.querySelector<HTMLImageElement>('img.haapi-stepper-page-symbol')).toBeNull();
});
});
35 changes: 35 additions & 0 deletions src/login-web-app/src/shared/ui/PageSymbol.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright (C) 2026 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 { useAppConfig } from '../feature/app-config/AppConfigHook';
import { resolvePageSymbol } from '../util/resolve-page-symbol';

interface PageSymbolProps {
viewName: string | undefined;
}

/**
* Renders the page symbol icon associated with the current step's HAAPI `viewName`.
*
* The mapping comes from `theme.pageSymbols` in the bootstrap configuration and is resolved by
* {@link resolvePageSymbol}. When `theme.pageSymbols` is absent, when `viewName` is absent, or when
* no entry resolves, this component renders nothing.
*/
export const PageSymbol = ({ viewName }: PageSymbolProps) => {
const { theme } = useAppConfig();
const src = resolvePageSymbol(viewName, theme.pageSymbols);

if (!src) {
return null;
}

return <img className="haapi-stepper-page-symbol" src={src} alt="" role="presentation" />;
};
11 changes: 11 additions & 0 deletions src/login-web-app/src/shared/util/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,17 @@ svg {
margin-block: 0 var(--space-2);
}

.haapi-stepper-page-symbol {
display: block;
max-width: 64px;
max-height: 64px;
width: auto;
height: auto;
object-fit: contain;
margin-inline: auto;
margin-block: 0 var(--space-2);
}

.haapi-stepper-links {
@extend .center, .py3, .flex, .flex-column;
}
Expand Down
Loading
Loading