Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 7 additions & 0 deletions src/login-web-app/src/haapi-stepper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ 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

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.



## HAAPI Stepper UI Components
Expand All @@ -132,6 +138,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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -869,7 +870,7 @@ function TestComponent() {
</button>
))}
{currentStep.dataHelpers.links.map(link =>
link.subtype?.startsWith('image/') ? (
isQrCodeLink(link) ? (
<img key={link.id} src={link.href} alt={link.title ?? link.rel} data-testid="link-image" />
) : (
<button key={link.id} type="button" data-testid="link-button" onClick={() => nextStep(link)}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(<HaapiStepperStepUI />, { 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(<HaapiStepperStepUI />, {
currentStep: createMockStep(HAAPI_STEPS.POLLING, { properties: { status: HAAPI_POLLING_STATUS.DONE } }),
});

expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument();
unmount();

renderWithContext(<HaapiStepperStepUI />, {
currentStep: createMockStep(HAAPI_STEPS.POLLING, { properties: { status: HAAPI_POLLING_STATUS.FAILED } }),
});

expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument();
});
});
});

Expand Down Expand Up @@ -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(<HaapiStepperStepUI />, { 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(<HaapiStepperStepUI />, { 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(<HaapiStepperStepUI stepRenderInterceptor={passThroughInterceptor} />, {
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 <div data-testid="custom-step-element">Custom UI</div>;
};

renderWithContext(<HaapiStepperStepUI stepRenderInterceptor={elementInterceptor} />, {
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(<HaapiStepperStepUI stepRenderInterceptor={nullInterceptor} />, {
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 = () => (
<div data-testid="custom-loading">Custom Loading</div>
);

renderWithContext(<HaapiStepperStepUI loadingRenderInterceptor={loadingRenderInterceptor} />, {
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 }) => (
<div data-testid="custom-error">Custom Error: {error?.app?.title ?? ''}</div>
);
const errorStep: HaapiStepperUnexpectedProblemStep = {
type: HAAPI_PROBLEM_STEPS.UNEXPECTED,
title: 'Unexpected Error',
dataHelpers: { messages: [], links: [] },
};

renderWithContext(<HaapiStepperStepUI errorRenderInterceptor={errorRenderInterceptor} />, {
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(<HaapiStepperStepUI messageRenderInterceptor={messageRenderInterceptor} />, {
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 = () => (
<div data-testid="custom-actions">Custom Actions</div>
);

renderWithContext(<HaapiStepperStepUI actionsRenderInterceptor={actionsRenderInterceptor} />, {
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 }) => (
<div data-testid="custom-form-action">{action.title}</div>
);

renderWithContext(<HaapiStepperStepUI formActionRenderInterceptor={formActionRenderInterceptor} />, {
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 => (
<div data-testid={`custom-form-field-${field.name}`}>{field.label}</div>
);

renderWithContext(<HaapiStepperStepUI formFieldRenderInterceptor={formFieldRenderInterceptor} />, {
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 }) => (
<div data-testid="custom-selector-action">{action.title}</div>
);

renderWithContext(<HaapiStepperStepUI selectorActionRenderInterceptor={selectorActionRenderInterceptor} />, {
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,
}) => <div data-testid="custom-client-op-action">{action.title}</div>;

renderWithContext(
<HaapiStepperStepUI clientOperationActionRenderInterceptor={clientOperationActionRenderInterceptor} />,
{ 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(<HaapiStepperStepUI linkRenderInterceptor={linkRenderInterceptor} />, {
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(<HaapiStepperStepUI />, { 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(<HaapiStepperStepUI />, { currentStep: step });

expect(screen.queryByTestId('qr-code-button')).not.toBeInTheDocument();
expect(screen.queryByTestId('messages')).toBeInTheDocument();
expect(screen.queryByTestId('links')).toBeInTheDocument();
});
});
});
});
});
Loading
Loading