Skip to content
19 changes: 14 additions & 5 deletions src/haapi-react-app/src/shared/util/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -358,14 +358,18 @@ svg {
width: min(100%, 100px);
}

.haapi-stepper-polling-progress {
@extend .mt2;
.haapi-stepper-polling-progress-bar {
@extend .mt2, .mb2;
display: block;
width: 100%;
max-width: 250px;
margin-inline: auto;
border: none;
border-radius: var(--form-field-border-radius);
background-color: var(--color-grey-subtle);
outline: 1px solid var(--color-grey-light);
outline-offset: 2px;
border: none;

/* Set the track/fill at class specificity — the base `progress` element rules lose the cascade
here, which left the fill as the browser default (blue) instead of the brand colour. */
&::-webkit-progress-bar {
background-color: var(--color-grey-subtle);
border-radius: var(--form-field-border-radius);
Expand All @@ -382,6 +386,11 @@ svg {
}
}

.haapi-stepper-polling-progress-duration {
@extend .center;
margin-block-start: var(--space-1);
}

.haapi-stepper-link-qr-code-button {
background: none;
border: none;
Expand Down
3 changes: 2 additions & 1 deletion src/haapi-react-sdk/haapi-stepper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,8 @@ The Curity utility composition shown above is just how *this* project chose to i
| `.haapi-stepper-heading` | `HaapiStepperMessagesUI` | Heading messages |
| `.haapi-stepper-userName` | `HaapiStepperMessagesUI` | User name display |
| `.haapi-stepper-userCode` | `HaapiStepperMessagesUI` | User code display (e.g. recovery codes) |
| `.haapi-stepper-polling-progress` | `HaapiStepperClientOperationUI` | Remaining polling time indicator (e.g. recovery codes) |
| `.haapi-stepper-polling-progress-bar` | `HaapiStepperBankIdPollingProgressUI` | Remaining polling time indicator (the "authentication time" bar, e.g. BankID) |
| `.haapi-stepper-polling-progress-duration` | `HaapiStepperBankIdPollingProgressUI` | Numeric time-left readout shown below the bar (e.g. "24 seconds left") |
| `.haapi-stepper-webauthn-registration-attachment` | `HaapiStepperWebAuthnRegistrationAttachmentCard` | WebAuthn registration attachment-selection option card (icon + title + description) |
| `.haapi-stepper-webauthn-registration-attachment-icon` | `HaapiStepperWebAuthnRegistrationAttachmentCard` | Attachment card icon |
| `.haapi-stepper-webauthn-registration-attachment-title` | `HaapiStepperWebAuthnRegistrationAttachmentCard` | Attachment card option label |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import userEvent from '@testing-library/user-event';

import { HAAPI_STEPS } from '../../../data-access/types/haapi-step.types';
import {
createMockBankIdAction,
Comment thread
aleixsuau marked this conversation as resolved.
createMockExternalBrowserFlowAction,
createMockStep,
createMockWebAuthnAnyDeviceBothOptionsAction,
Expand Down Expand Up @@ -89,26 +88,6 @@ describe('HaapiStepperClientOperationUI', () => {
});
});

describe('BankID polling progress', () => {
it('renders a progress bar reflecting the session remaining time', () => {
const action = createMockBankIdAction({ maxWaitTime: 60, maxWaitRemainingTime: 30 });

render(<HaapiStepperClientOperationUI action={action} onAction={vi.fn()} />);

const progress = screen.getByRole('progressbar');
expect(progress).toHaveAttribute('value', '30');
expect(progress).toHaveAttribute('max', '60');
});

it('hides the progress bar when showBankIdSessionTimeLeft is false', () => {
const action = createMockBankIdAction({ maxWaitTime: 60, maxWaitRemainingTime: 30 });

render(<HaapiStepperClientOperationUI action={action} onAction={vi.fn()} showBankIdSessionTimeLeft={false} />);

expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
});

describe('WebAuthn', () => {
afterEach(() => {
vi.unstubAllGlobals();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import { useIsClientOperationAvailable } from './useIsClientOperationAvailable';
interface HaapiStepperClientOperationUIProps {
action: HaapiStepperClientOperationAction;
onAction: (action: HaapiStepperClientOperationAction | HaapiStepperFormAction) => void;
showBankIdSessionTimeLeft?: boolean;
}
Comment thread
aleixsuau marked this conversation as resolved.

/**
Expand Down Expand Up @@ -44,11 +43,7 @@ interface HaapiStepperClientOperationUIProps {
* </HaapiStepper>
* ```
*/
export function HaapiStepperClientOperationUI({
action,
onAction,
showBankIdSessionTimeLeft = true,
}: HaapiStepperClientOperationUIProps) {
export function HaapiStepperClientOperationUI({ action, onAction }: HaapiStepperClientOperationUIProps) {
const isAvailable = useIsClientOperationAvailable(action);

if (action.webauthn?.registrationAttachment) {
Expand All @@ -63,13 +58,6 @@ export function HaapiStepperClientOperationUI({

return (
<div data-testid="client-operation-action">
{showBankIdSessionTimeLeft && action.maxWaitRemainingTime !== undefined && (
<progress
className="haapi-stepper-polling-progress"
value={action.maxWaitRemainingTime}
max={action.maxWaitTime}
/>
)}
<button type="button" className="haapi-stepper-button" disabled={!isAvailable} onClick={() => onAction(action)}>
{action.title}
</button>
Expand Down
1 change: 1 addition & 0 deletions src/haapi-react-sdk/haapi-stepper/feature/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export * from './stepper/step-handlers/polling-step';
export * from './stepper/data-formatters/problem-step';

export * from './steps/HaapiStepperStepUI';
export * from './viewnames/HaapiStepperBankIdPollingProgressUI';
export * from './actions/form/HaapiStepperFormUI';
export * from './actions/form/HaapiStepperFormValidationErrorInputWrapper';
export * from './actions/form/HaapiStepperFormHook';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import {
} from '../../../data-access/types';
import {
HaapiStepperAction,
HaapiStepperClientOperationAction,
HaapiStepperDataHelpers,
HaapiStepperDataHelpersActionsMap,
HaapiStepperLink,
Expand Down Expand Up @@ -110,18 +109,6 @@ function addActionDataHelpers(
};
}

if (step.type === HAAPI_STEPS.POLLING && actionWithDataHelpers.subtype === HAAPI_ACTION_TYPES.CLIENT_OPERATION) {
const clientOperationPollingAction = {
...actionWithDataHelpers,
...(step.properties.maxWaitTime != null && { maxWaitTime: step.properties.maxWaitTime }),
...(step.properties.maxWaitRemainingTime != null && {
maxWaitRemainingTime: step.properties.maxWaitRemainingTime,
}),
};

return clientOperationPollingAction as HaapiStepperClientOperationAction;
}

return { ...action, ...actionWithDataHelpers };
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,6 @@ export type HaapiStepperSelectorAction = Omit<HaapiSelectorAction, 'model'> &
};
export type HaapiStepperClientOperationAction = HaapiClientOperationAction &
HaapiStepperDataHelpersDetails<HAAPI_STEPPER_ELEMENT_TYPES.ACTION, HAAPI_ACTION_TYPES.CLIENT_OPERATION> & {
/** Polling session maximum time in seconds before the session expires. */
maxWaitTime?: number;
/** Polling session remaining time in seconds before the session expires. */
maxWaitRemainingTime?: number;
/**
* WebAuthn data resolved during step-data formatting. Present only on any-device
* `webauthn-registration` actions.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2017,7 +2017,83 @@ describe('HaapiStepperStepUI', () => {
expect(screen.queryByTestId('messages')).toBeInTheDocument();
expect(screen.queryByTestId('links')).toBeInTheDocument();
});

it('should render the polling progress bar between the QR code and the actions', () => {
const step = createPollingStep({
links: [createMockQrLink()],
actions: [createMockClientOperationAction({ title: 'Launch BankID App' })],
maxWaitTime: '60',
maxWaitRemainingTime: '30',
});

renderWithContext(<HaapiStepperStepUI />, { currentStep: step });

const progress = screen.getByRole('progressbar', { hidden: true });
expect(progress).toHaveAttribute('value', '30');
expect(progress).toHaveAttribute('max', '60');

const qrCode = screen.getByTestId('qr-code-button');
const actions = screen.getByTestId('client-operation-action');
expect(follows(progress, qrCode)).toBe(true);
expect(follows(actions, progress)).toBe(true);
});

it('should render the progress bar sourced from the step even without a client-operation (QR-only mode)', () => {
const step = createPollingStep({
links: [createMockQrLink()],
actions: [],
maxWaitTime: '60',
maxWaitRemainingTime: '30',
});

renderWithContext(<HaapiStepperStepUI />, { currentStep: step });

expect(screen.getByRole('progressbar', { hidden: true })).toHaveAttribute('value', '30');
expect(screen.queryByTestId('client-operation-action')).not.toBeInTheDocument();
});

it('should not render the progress bar without a QR code (client-operation-only mode)', () => {
const step = createPollingStep({
links: [],
actions: [createMockClientOperationAction({ title: 'Launch BankID App' })],
maxWaitTime: '60',
maxWaitRemainingTime: '30',
});

renderWithContext(<HaapiStepperStepUI />, { currentStep: step });

expect(screen.queryByRole('progressbar', { hidden: true })).not.toBeInTheDocument();
expect(screen.queryByTestId('qr-code-button')).not.toBeInTheDocument();
});

it('should not render a progress bar when the step exposes no remaining wait time', () => {
const step = createPollingStep({ links: [createMockQrLink()] });

renderWithContext(<HaapiStepperStepUI />, { currentStep: step });

expect(screen.queryByRole('progressbar', { hidden: true })).not.toBeInTheDocument();
});

it.each([HAAPI_POLLING_STATUS.DONE, HAAPI_POLLING_STATUS.FAILED])(
'should not render a progress bar for a %s polling step',
status => {
const step = createPollingStep({
status,
links: [createMockQrLink()],
maxWaitTime: '60',
maxWaitRemainingTime: '30',
});

renderWithContext(<HaapiStepperStepUI />, { currentStep: step });

expect(screen.queryByRole('progressbar', { hidden: true })).not.toBeInTheDocument();
}
);
});
});
});
});

/** True when `node` appears after `reference` in document order. */
const follows = (node: Element, reference: Element) =>
Boolean(reference.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING);
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,19 @@
* For further information, please contact Curity AB.
*/

import { HAAPI_STEPS } from '../../data-access/types/haapi-step.types';
import { isQrCodeLink } from '../../util/link-predicates';
import { getLinksElement } from '../steps/step-element-factories';
import { HaapiStepperBankIdPollingProgressUI } from './HaapiStepperBankIdPollingProgressUI';
import { HaapiStepperBankIdQrCodeAccessibilityMessages } from './HaapiStepperBankIdQrCodeAccessibilityMessages';
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.
* - Renders the polling "authentication time" progress bar under the QR code, only when a QR code is
* present.
* - Renders the QR-code accessibility messages (`metadata.viewData.messages`) as collapsible
* sections below the QR code.
*/
Expand All @@ -32,9 +36,14 @@ export const BankIdViewNameBuiltInUI = (props: ViewNameBuiltInUIProps) => {
{loadingElement}
{errorElement}
{messagesElement}
{qrCodeLink && getLinksElement(props, [qrCodeLink], linkRenderInterceptor)}
{qrCodeLink && (
<HaapiStepperBankIdQrCodeAccessibilityMessages viewDataMessages={currentStep.metadata?.viewData?.messages} />
<>
{getLinksElement(props, [qrCodeLink], linkRenderInterceptor)}
{currentStep.type === HAAPI_STEPS.POLLING && (
<HaapiStepperBankIdPollingProgressUI currentStep={currentStep} />
)}
<HaapiStepperBankIdQrCodeAccessibilityMessages viewDataMessages={currentStep.metadata?.viewData?.messages} />
</>
)}
{actionsElement}
{nonQrCodeLinks.length > 0 && getLinksElement(props, nonQrCodeLinks, linkRenderInterceptor)}
Expand Down
Loading
Loading