Skip to content
Open
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
20 changes: 20 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,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.
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.

I think we don't need to be so specific here. Maybe just say some UIs need more tailored behavior or so.
Also no need to have such details in the table below.

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.

Btw: I think that "render a spinner while the polling status is pending" should be the default behavior for any polling step. I see you considered it out of scope of this ticket - and I recall there was more to be discussed about loading indicators - but please don't forget about that.


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

`<HaapiStepperStepUI>` 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
Expand All @@ -132,6 +151,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 @@ -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,
Expand Down Expand Up @@ -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(<HaapiStepperStepUI />, { 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(<HaapiStepperStepUI enableViewNameBuiltInUIs={[]} />, { 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(<HaapiStepperStepUI enableViewNameBuiltInUIs={true} />, { 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(<HaapiStepperStepUI enableViewNameBuiltInUIs />, { 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(<HaapiStepperStepUI enableViewNameBuiltInUIs />, { 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(<HaapiStepperStepUI enableViewNameBuiltInUIs={[HaapiStepperViewNameBuiltInUI.BANKID]} />, {
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(
<HaapiStepperStepUI enableViewNameBuiltInUIs stepRenderInterceptor={passThroughInterceptor} />,
{ 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 <div data-testid="custom-step-element">Custom UI</div>;
};

renderWithContext(
<HaapiStepperStepUI enableViewNameBuiltInUIs stepRenderInterceptor={elementInterceptor} />,
{
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(<HaapiStepperStepUI enableViewNameBuiltInUIs stepRenderInterceptor={nullInterceptor} />, {
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(<HaapiStepperStepUI enableViewNameBuiltInUIs />, { 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(<HaapiStepperStepUI enableViewNameBuiltInUIs />, { 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(<HaapiStepperStepUI enableViewNameBuiltInUIs />, { 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(<HaapiStepperStepUI enableViewNameBuiltInUIs />, { 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(<HaapiStepperStepUI enableViewNameBuiltInUIs />, { 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();
});
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -52,6 +53,7 @@ interface HaapiStepperStepUIProps {
clientOperationActionRenderInterceptor?: HaapiStepperStepUIClientOperationActionRenderInterceptor;
linkRenderInterceptor?: HaapiStepperStepUILinkRenderInterceptor;
messageRenderInterceptor?: HaapiStepperStepUIMessageRenderInterceptor;
enableViewNameBuiltInUIs?: HaapiStepperViewNameBuiltInUI[] | boolean;
}

/**
Expand Down Expand Up @@ -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).
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.

Again, I don't think this is needed as it makes it look like the default is flawed.

*
* 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
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.

👍

* 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.
* <HaapiStepperStepUI />
*
* // Boolean shorthand: opt in to all known built-ins (current and future).
* <HaapiStepperStepUI enableViewNameBuiltInUIs />
*
* // Pin to a specific subset.
* <HaapiStepperStepUI enableViewNameBuiltInUIs={[HaapiStepperViewNameBuiltInUI.BANKID]} />
*
* // Override a viewName built-in UI with a `stepRenderInterceptor`
* const customBankIdUI: HaapiStepperStepUIStepRenderInterceptor = ({ currentStep, ...rest }) => {
* if (currentStep.metadata?.viewName === 'authenticator/bankid/wait/index') {
* return <MyBankId step={currentStep} />;
* }
* return { currentStep, ...rest };
* };
*
* // MyBankId will be rendered instead of the built-in UI for the BankID
* <HaapiStepperStepUI stepRenderInterceptor={customBankIdUI} enableViewNameBuiltInUIs />
* ```
*
* ## CUSTOMIZATION
*
* ### CUSTOMIZATION DIMENSIONS
Expand Down Expand Up @@ -241,6 +293,7 @@ export const HaapiStepperStepUI = ({
clientOperationActionRenderInterceptor,
linkRenderInterceptor,
messageRenderInterceptor,
enableViewNameBuiltInUIs,
}: HaapiStepperStepUIProps) => {
const haapiStepperAPI = useHaapiStepper();
const loadingElement: ReactElement | null = getLoadingElement(haapiStepperAPI, loadingRenderInterceptor);
Expand All @@ -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 <ViewNameBuiltInUI {...haapiStepperUiAPI} />;
}
Comment thread
aleixsuau marked this conversation as resolved.

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 (
<Well>
Expand Down
Loading
Loading