From d38efa17382260cab5f77eb135b457c31e3d499a Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Thu, 26 Mar 2026 08:11:23 +0100 Subject: [PATCH 1/9] IS-5161: fix lwa lint issues --- .../data-access/haapi-fetch-initializer.ts | 4 +++- .../data-access/haapi-fetch-utils.spec.ts | 4 ++-- .../feature/stepper/HaapiStepper.spec.tsx | 14 +++++++------- .../feature/steps/HaapiStepperStepUI.spec.tsx | 2 -- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/login-web-app/src/haapi-stepper/data-access/haapi-fetch-initializer.ts b/src/login-web-app/src/haapi-stepper/data-access/haapi-fetch-initializer.ts index d557ed5..2d10ef8 100644 --- a/src/login-web-app/src/haapi-stepper/data-access/haapi-fetch-initializer.ts +++ b/src/login-web-app/src/haapi-stepper/data-access/haapi-fetch-initializer.ts @@ -28,7 +28,9 @@ function withDelay(f: FetchLike, d: number): FetchLike { return await f(link, init); }; delayed.init = () => f.init(); - delayed.close = () => f.close(); + delayed.close = () => { + f.close(); + }; return delayed; } diff --git a/src/login-web-app/src/haapi-stepper/data-access/haapi-fetch-utils.spec.ts b/src/login-web-app/src/haapi-stepper/data-access/haapi-fetch-utils.spec.ts index 3dd0ce8..0b1c455 100644 --- a/src/login-web-app/src/haapi-stepper/data-access/haapi-fetch-utils.spec.ts +++ b/src/login-web-app/src/haapi-stepper/data-access/haapi-fetch-utils.spec.ts @@ -95,7 +95,7 @@ describe(createRequestForForm.name, () => { b: 'b', }; - expect(() => createRequestForForm({ action, payload })).toThrowError(); + expect(() => createRequestForForm({ action, payload })).toThrow(); }); }); @@ -123,7 +123,7 @@ describe(createRequestForForm.name, () => { const action = formAction(HTTP_METHODS.GET, '/example/path', { a: '1' }, MEDIA_TYPES.JSON); const payload = { a: 'a' }; - expect(() => createRequestForForm({ action, payload })).toThrowError(); + expect(() => createRequestForForm({ action, payload })).toThrow(); }); }); }); diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx index c307fef..5900e42 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx @@ -476,7 +476,7 @@ describe('HaapiStepper', () => { const secondStep = HAAPI_STEPS.REGISTRATION; const thirdStep = HAAPI_STEPS.COMPLETED_WITH_SUCCESS; let history = await screen.findByTestId('history'); - let historyData = JSON.parse(history.textContent ?? '[]') as HaapiStepperHistoryEntry[]; + let historyData = JSON.parse(history.textContent) as HaapiStepperHistoryEntry[]; let previousStepTriggerActionKind = bootstrapLinkAction; expect(historyData).toHaveLength(1); @@ -490,7 +490,7 @@ describe('HaapiStepper', () => { await waitFor(() => expect(screen.getByTestId('step-type')).toHaveTextContent(secondStep)); history = screen.getByTestId('history'); - historyData = JSON.parse(history.textContent ?? '[]') as HaapiStepperHistoryEntry[]; + historyData = JSON.parse(history.textContent) as HaapiStepperHistoryEntry[]; // @ts-expect-error - accessing mock step actions for test validation - getStepMock returns mock data with actions array // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access previousStepTriggerActionKind = getStepMock(initialStep).actions[0].kind; @@ -506,7 +506,7 @@ describe('HaapiStepper', () => { await waitFor(() => expect(screen.getByTestId('step-type')).toHaveTextContent(thirdStep)); history = screen.getByTestId('history'); - historyData = JSON.parse(history.textContent ?? '[]') as HaapiStepperHistoryEntry[]; + historyData = JSON.parse(history.textContent) as HaapiStepperHistoryEntry[]; // @ts-expect-error - accessing mock step actions for test validation - getStepMock returns mock data with actions array // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access previousStepTriggerActionKind = getStepMock(secondStep).actions[0].kind; @@ -541,7 +541,7 @@ describe('HaapiStepper', () => { }); const history = screen.getByTestId('history'); - const historyData = JSON.parse(history.textContent ?? '[]') as unknown as HaapiStepperHistoryEntry[]; + const historyData = JSON.parse(history.textContent) as unknown as HaapiStepperHistoryEntry[]; // Should have authentication twice - once initially, once after continue same // The continue same step itself is NOT in history, but the updated authentication step is @@ -567,7 +567,7 @@ describe('HaapiStepper', () => { ); const history = screen.getByTestId('history'); - const historyData = JSON.parse(history.textContent ?? '[]') as unknown as HaapiStepperHistoryEntry[]; + const historyData = JSON.parse(history.textContent) as unknown as HaapiStepperHistoryEntry[]; expect(historyData).toHaveLength(2); expect(historyData[0].step.type).toBe(HAAPI_STEPS.AUTHENTICATION); @@ -594,7 +594,7 @@ describe('HaapiStepper', () => { }); const history = screen.getByTestId('history'); - const historyData = JSON.parse(history.textContent ?? '[]') as unknown as HaapiStepperHistoryEntry[]; + const historyData = JSON.parse(history.textContent) as unknown as HaapiStepperHistoryEntry[]; expect(historyData).toHaveLength(1); expect(historyData[0].step.type).toBe(HAAPI_STEPS.AUTHENTICATION); @@ -621,7 +621,7 @@ describe('HaapiStepper', () => { }); const history = screen.getByTestId('history'); - const historyData = JSON.parse(history.textContent ?? '[]') as unknown as HaapiStepperHistoryEntry[]; + const historyData = JSON.parse(history.textContent) as unknown as HaapiStepperHistoryEntry[]; expect(historyData).toHaveLength(1); expect(historyData[0].step.type).toBe(HAAPI_STEPS.AUTHENTICATION); 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 813b04c..d171361 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 @@ -416,7 +416,6 @@ describe('HaapiStepperStepUI', () => { ...rest }) => { if (error?.app) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition errorTracker('error_occurred', { type: error.app.type, title: error.app.title, @@ -463,7 +462,6 @@ describe('HaapiStepperStepUI', () => { const selectorAction = screen.getByTestId('selector-action'); const selectorFormActions = within(selectorAction).queryAllByTestId('form-action'); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition expect(standaloneFormAction.textContent).toContain('Login Form'); expect(selectorAction.textContent).toContain('Choose Authenticator'); expect(selectorFormActions).toHaveLength(2); From 7f573307700c8c9c9d4d0bbeb72690fedf6b71fd Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Thu, 26 Mar 2026 13:34:03 +0100 Subject: [PATCH 2/9] IS-5161: fix lwa lint issues --- package-lock.json | 211 ++++++++++++++++- src/login-web-app/package.json | 2 +- src/login-web-app/src/App.tsx | 11 +- .../actions/form/HaapiStepperForm.spec.tsx | 1 + .../feature/actions/form/HaapiStepperForm.tsx | 8 +- .../actions/form/HaapiStepperFormContext.ts | 1 + .../feature/stepper/HaapiStepper.spec.tsx | 24 +- .../feature/stepper/HaapiStepper.tsx | 17 +- .../HaapiStepperErrorNotifier.spec.tsx | 218 ++++++++++++++++++ .../stepper/HaapiStepperErrorNotifier.tsx | 37 ++- .../feature/steps/HaapiStepperStepUI.spec.tsx | 1 + .../error-handling/ErrorBoundary.spec.tsx | 5 +- 12 files changed, 488 insertions(+), 48 deletions(-) create mode 100644 src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.spec.tsx diff --git a/package-lock.json b/package-lock.json index 1836375..bcf3722 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21817,7 +21817,7 @@ "typescript": "~5.7.2", "typescript-eslint": "^8.24.1", "vite": "^7.3.1", - "vitest": "^4.0.1" + "vitest": "~4.0.6" }, "engines": { "node": ">=22.22.0", @@ -21838,6 +21838,90 @@ "lru-cache": "^10.4.3" } }, + "src/login-web-app/node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "src/login-web-app/node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "src/login-web-app/node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "src/login-web-app/node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "src/login-web-app/node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "src/login-web-app/node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "src/login-web-app/node_modules/cssstyle": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", @@ -21952,6 +22036,26 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "src/login-web-app/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "src/login-web-app/node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "src/login-web-app/node_modules/tr46": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", @@ -21979,6 +22083,111 @@ "node": ">=14.17" } }, + "src/login-web-app/node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "src/login-web-app/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "src/login-web-app/node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/src/login-web-app/package.json b/src/login-web-app/package.json index 91c412a..f2d8645 100644 --- a/src/login-web-app/package.json +++ b/src/login-web-app/package.json @@ -51,6 +51,6 @@ "typescript": "~5.7.2", "typescript-eslint": "^8.24.1", "vite": "^7.3.1", - "vitest": "^4.0.1" + "vitest": "~4.0.6" } } \ No newline at end of file diff --git a/src/login-web-app/src/App.tsx b/src/login-web-app/src/App.tsx index 4f93b82..6cc2541 100644 --- a/src/login-web-app/src/App.tsx +++ b/src/login-web-app/src/App.tsx @@ -14,15 +14,18 @@ import { DevBar } from './shared/ui/devbar/DevBar'; import { ErrorBoundary } from './shared/feature/error-handling/ErrorBoundary'; import { HaapiStepperStepUI } from './haapi-stepper/feature/steps/HaapiStepperStepUI'; import { HaapiStepper } from './haapi-stepper/feature/stepper/HaapiStepper'; +import { HaapiStepperErrorNotifier } from './haapi-stepper/feature'; export function App() { return ( - - - - + + + + + + ); diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.spec.tsx index be3d380..02d9f49 100644 --- a/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.spec.tsx @@ -265,6 +265,7 @@ describe('HaapiStepperForm', () => { if (field.type === HAAPI_FORM_FIELDS.USERNAME) { formState.set(field, prefilledUsernameValue); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [field]); return ( diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.tsx b/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.tsx index ebde03c..549b24b 100644 --- a/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.tsx @@ -9,6 +9,7 @@ * For further information, please contact Curity AB. */ +import { useCallback, useMemo } from 'react'; import { HAAPI_FORM_FIELDS, VisibleHaapiFormField } from '../../../data-access/types/haapi-form.types'; import { applyRenderInterceptor } from '../../../util/generic-render-interceptor'; import type { @@ -181,9 +182,10 @@ export function HaapiStepperForm({ action, onSubmit, formFieldRenderInterceptor, field => field.type !== HAAPI_FORM_FIELDS.HIDDEN && field.type !== HAAPI_FORM_FIELDS.CONTEXT ); const haapiStepperFormAPI: HaapiStepperFormAPI = { fields: visibleFields, formState }; - const submit = () => { + const submit = useCallback(() => { onSubmit(action, formState.values); - }; + }, [onSubmit, action, formState]); + const formContextValue = useMemo(() => ({ formState, action, submit }), [formState, action, submit]); const formContentElements = applyRenderInterceptor( [haapiStepperFormAPI], @@ -193,7 +195,7 @@ export function HaapiStepperForm({ action, onSubmit, formFieldRenderInterceptor, const formContentElement = formContentElements[0] ?? null; return ( - +
(null); export function useHaapiStepperForm(): HaapiStepperFormContextValue { + // eslint-disable-next-line @eslint-react/no-use-context -- useContext is preferred here over use() to keep explicit null handling const context = useContext(HaapiStepperFormContext); if (!context) { throw new Error('useHaapiStepperForm must be used within a .'); diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx index 5900e42..ece6e52 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx @@ -363,7 +363,7 @@ describe('HaapiStepper', () => { await goToNextStep(HAAPI_STEPS.POLLING, { bankId: true }); const startButton = await screen.findByRole('button', { name: 'Start BankID' }); - + act(() => startButton.click()); await waitFor(() => { @@ -476,7 +476,8 @@ describe('HaapiStepper', () => { const secondStep = HAAPI_STEPS.REGISTRATION; const thirdStep = HAAPI_STEPS.COMPLETED_WITH_SUCCESS; let history = await screen.findByTestId('history'); - let historyData = JSON.parse(history.textContent) as HaapiStepperHistoryEntry[]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + let historyData = JSON.parse(history.textContent!) as HaapiStepperHistoryEntry[]; let previousStepTriggerActionKind = bootstrapLinkAction; expect(historyData).toHaveLength(1); @@ -490,7 +491,8 @@ describe('HaapiStepper', () => { await waitFor(() => expect(screen.getByTestId('step-type')).toHaveTextContent(secondStep)); history = screen.getByTestId('history'); - historyData = JSON.parse(history.textContent) as HaapiStepperHistoryEntry[]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + historyData = JSON.parse(history.textContent!) as HaapiStepperHistoryEntry[]; // @ts-expect-error - accessing mock step actions for test validation - getStepMock returns mock data with actions array // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access previousStepTriggerActionKind = getStepMock(initialStep).actions[0].kind; @@ -506,7 +508,8 @@ describe('HaapiStepper', () => { await waitFor(() => expect(screen.getByTestId('step-type')).toHaveTextContent(thirdStep)); history = screen.getByTestId('history'); - historyData = JSON.parse(history.textContent) as HaapiStepperHistoryEntry[]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + historyData = JSON.parse(history.textContent!) as HaapiStepperHistoryEntry[]; // @ts-expect-error - accessing mock step actions for test validation - getStepMock returns mock data with actions array // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access previousStepTriggerActionKind = getStepMock(secondStep).actions[0].kind; @@ -541,7 +544,8 @@ describe('HaapiStepper', () => { }); const history = screen.getByTestId('history'); - const historyData = JSON.parse(history.textContent) as unknown as HaapiStepperHistoryEntry[]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const historyData = JSON.parse(history.textContent!) as unknown as HaapiStepperHistoryEntry[]; // Should have authentication twice - once initially, once after continue same // The continue same step itself is NOT in history, but the updated authentication step is @@ -567,7 +571,8 @@ describe('HaapiStepper', () => { ); const history = screen.getByTestId('history'); - const historyData = JSON.parse(history.textContent) as unknown as HaapiStepperHistoryEntry[]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const historyData = JSON.parse(history.textContent!) as unknown as HaapiStepperHistoryEntry[]; expect(historyData).toHaveLength(2); expect(historyData[0].step.type).toBe(HAAPI_STEPS.AUTHENTICATION); @@ -594,7 +599,8 @@ describe('HaapiStepper', () => { }); const history = screen.getByTestId('history'); - const historyData = JSON.parse(history.textContent) as unknown as HaapiStepperHistoryEntry[]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const historyData = JSON.parse(history.textContent!) as unknown as HaapiStepperHistoryEntry[]; expect(historyData).toHaveLength(1); expect(historyData[0].step.type).toBe(HAAPI_STEPS.AUTHENTICATION); @@ -621,7 +627,8 @@ describe('HaapiStepper', () => { }); const history = screen.getByTestId('history'); - const historyData = JSON.parse(history.textContent) as unknown as HaapiStepperHistoryEntry[]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const historyData = JSON.parse(history.textContent!) as unknown as HaapiStepperHistoryEntry[]; expect(historyData).toHaveLength(1); expect(historyData[0].step.type).toBe(HAAPI_STEPS.AUTHENTICATION); @@ -644,6 +651,7 @@ vi.mock('../../data-access/haapi-fetch-initializer', () => { const mockThrowErrorToAppErrorBoundary = vi.fn(); vi.mock('../../util/useThrowErrorToAppErrorBoundary', () => ({ + // eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-prefix useThrowErrorToAppErrorBoundary: () => mockThrowErrorToAppErrorBoundary, })); diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx index 6b8e304..057344d 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx @@ -314,6 +314,7 @@ export function HaapiStepper({ children, config }: HaapiStepperProps) { setCurrentStepAndUpdateHistory(nextStepData, action, payload); }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- nextStep is a stable ref via useRefCallback, defined below [configResult, currentStep, history, setCurrentStepAndUpdateHistory] ); @@ -333,18 +334,16 @@ export function HaapiStepper({ children, config }: HaapiStepperProps) { useEffect(() => { nextStep(getInitialStepLink()); + return () => cancelPendingOperation(pendingOperation); }, [nextStep]); + const contextValue = useMemo( + () => ({ currentStep, loading, error, nextStep, history }), + [currentStep, loading, error, history] + ); + return ( - + {children} ); diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.spec.tsx new file mode 100644 index 0000000..b1b4f4e --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.spec.tsx @@ -0,0 +1,218 @@ +/* + * 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 { act, fireEvent, render, screen } from '@testing-library/react'; +import { vi } from 'vitest'; +import { HaapiStepperErrorNotifier } from './HaapiStepperErrorNotifier'; +import { useHaapiStepper } from './HaapiStepperHook'; +import { HaapiStepperAppError, HaapiStepperError, HaapiStepperInputError } from './haapi-stepper.types'; + +vi.mock('./HaapiStepperHook', () => ({ + useHaapiStepper: vi.fn(), +})); + +const mockUseHaapiStepper = vi.mocked(useHaapiStepper); + +describe('HaapiStepperErrorNotifier', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseHaapiStepper.mockReturnValue(createStepperResponse(null)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not render when there is no error', () => { + render( + +
content
+
+ ); + + expect(screen.queryByTestId('haapi-error-toast')).not.toBeInTheDocument(); + expect(screen.getByTestId('children')).toBeInTheDocument(); + }); + + it('renders the current app error and messages', () => { + const appError = createAppError({ title: APP_ERROR_TITLE, messages: [APP_ERROR_MESSAGE] }); + mockUseHaapiStepper.mockReturnValue(createStepperResponse({ app: appError })); + + render( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-toast')).toBeInTheDocument(); + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-title')).toHaveTextContent(APP_ERROR_TITLE); + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-messages')).toHaveTextContent(APP_ERROR_MESSAGE); + expect(screen.getByTestId('children')).toBeInTheDocument(); + }); + + it('dismisses the current error when the dismiss button is clicked', () => { + const appError = createAppError(); + mockUseHaapiStepper.mockReturnValue(createStepperResponse({ app: appError })); + + render( + +
content
+
+ ); + + fireEvent.click(screen.getByTestId('haapi-error-haapi-error-notifier-toast-dismiss')); + + expect(screen.queryByTestId('haapi-error-toast')).not.toBeInTheDocument(); + }); + + it('auto-dismisses the error after the notification duration', () => { + vi.useFakeTimers(); + const appError = createAppError(); + mockUseHaapiStepper.mockReturnValue(createStepperResponse({ app: appError })); + + render( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-toast')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(screen.queryByTestId('haapi-error-toast')).not.toBeInTheDocument(); + }); + + it('shows new errors after an earlier one has been dismissed', () => { + const firstError = createAppError({ title: FIRST_ERROR_TITLE, messages: [FIRST_ERROR_MESSAGE] }); + const nextError = createAppError({ title: NEXT_ERROR_TITLE, messages: [NEXT_ERROR_MESSAGE] }); + let currentError: HaapiStepperError = { app: firstError }; + + mockUseHaapiStepper.mockImplementation(() => createStepperResponse(currentError)); + + const { rerender } = render( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-title')).toHaveTextContent(FIRST_ERROR_TITLE); + + fireEvent.click(screen.getByTestId('haapi-error-haapi-error-notifier-toast-dismiss')); + expect(screen.queryByTestId('haapi-error-toast')).not.toBeInTheDocument(); + + currentError = { app: nextError }; + rerender( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-title')).toHaveTextContent(NEXT_ERROR_TITLE); + }); + + it('renders input errors only when showInputErrorNotifications is true', () => { + const inputError = createInputError(INPUT_ERROR_TITLE); + + mockUseHaapiStepper.mockReturnValue(createStepperResponse({ input: inputError })); + + const { rerender } = render( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-title')).toHaveTextContent(INPUT_ERROR_TITLE); + + rerender( + +
content
+
+ ); + + expect(screen.queryByTestId('haapi-error-toast')).not.toBeInTheDocument(); + }); + + it('renders the latest error when a new error arrives without dismissing', () => { + const firstError = createAppError({ title: FIRST_ERROR_TITLE, messages: [FIRST_ERROR_MESSAGE] }); + const nextError = createAppError({ title: NEXT_ERROR_TITLE, messages: [NEXT_ERROR_MESSAGE] }); + let currentError: HaapiStepperError = { app: firstError }; + + mockUseHaapiStepper.mockImplementation(() => createStepperResponse(currentError)); + + const { rerender } = render( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-title')).toHaveTextContent(FIRST_ERROR_TITLE); + + currentError = { app: nextError }; + rerender( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-title')).toHaveTextContent(NEXT_ERROR_TITLE); + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-messages')).toHaveTextContent(NEXT_ERROR_MESSAGE); + }); + + it('uses a custom errorFormatter for the title', () => { + const appError = createAppError({ title: APP_ERROR_TITLE }); + mockUseHaapiStepper.mockReturnValue(createStepperResponse({ app: appError })); + const customFormatter = (error: HaapiStepperAppError | HaapiStepperInputError) => `Custom: ${error.title}`; + + render( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-title')).toHaveTextContent(`Custom: ${APP_ERROR_TITLE}`); + }); +}); + +function createAppError({ title = DEFAULT_APP_ERROR_TITLE, messages = [DEFAULT_APP_ERROR_MESSAGE] }: { title?: string; messages?: string[] } = {}) { + return { + title, + dataHelpers: { + messages: messages.map((text, index) => ({ id: `app-message-${index}`, text })), + }, + } as unknown as HaapiStepperAppError; +} + +function createInputError(title = INPUT_ERROR_TITLE) { + return { + title, + dataHelpers: { + messages: [], + }, + } as unknown as HaapiStepperInputError; +} + +function createStepperResponse(error: HaapiStepperError | null) { + return { + error, + } as unknown as ReturnType; +} + +const APP_ERROR_TITLE = 'Something went wrong'; +const APP_ERROR_MESSAGE = 'Details go here'; +const FIRST_ERROR_TITLE = 'First error'; +const FIRST_ERROR_MESSAGE = 'first'; +const NEXT_ERROR_TITLE = 'Next error'; +const NEXT_ERROR_MESSAGE = 'second'; +const INPUT_ERROR_TITLE = 'Invalid input'; +const DEFAULT_APP_ERROR_TITLE = 'Something went wrong'; +const DEFAULT_APP_ERROR_MESSAGE = 'Failure'; diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.tsx index 12d0cdd..c273c75 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.tsx @@ -18,37 +18,34 @@ export function HaapiStepperErrorNotifier({ errorFormatter = error => error.title ?? 'An error occurred', }: HaapiErrorNotifierProps) { const { error } = useHaapiStepper(); - const [notificationError, setNotificationError] = useState(); - const [isNotificationVisible, setIsNotificationVisible] = useState(false); + const currentError = error?.app ?? (showInputErrorNotifications ? error?.input : null); + const [dismissedError, setDismissedError] = useState(null); useEffect(() => { - const notificationError = error?.app ?? (showInputErrorNotifications ? error?.input : null); + if (!currentError) { + return; + } - if (notificationError) { - setNotificationError(notificationError); - setIsNotificationVisible(true); + const timeout = setTimeout(() => { + setDismissedError(currentError); + }, notificationDuration); - const timer = setTimeout(() => { - setIsNotificationVisible(false); - setNotificationError(null); - }, notificationDuration); + return () => clearTimeout(timeout); + }, [currentError, notificationDuration]); - return () => clearTimeout(timer); - } - }, [error?.app, error?.input, showInputErrorNotifications, notificationDuration]); + const isNotificationVisible = currentError && dismissedError !== currentError; + const notificationMessages = currentError?.dataHelpers.messages ?? []; + const handleDismiss = () => setDismissedError(currentError ?? null); - const handleDismiss = () => { - setIsNotificationVisible(false); - }; return ( <> - {isNotificationVisible && notificationError && ( + {isNotificationVisible && (

- {errorFormatter(notificationError)} + {errorFormatter(currentError)}

- {notificationError.dataHelpers.messages.length && ( + {notificationMessages.length && (
- {notificationError.dataHelpers.messages.map(message => ( + {notificationMessages.map(message => (

{message.text}

))}
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 d171361..82f2201 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 @@ -1141,6 +1141,7 @@ describe('HaapiStepperStepUI', () => { if (field.type === HAAPI_FORM_FIELDS.USERNAME) { formState.set(field, prefilledUsernameValue); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [field]); return ( diff --git a/src/login-web-app/src/shared/feature/error-handling/ErrorBoundary.spec.tsx b/src/login-web-app/src/shared/feature/error-handling/ErrorBoundary.spec.tsx index ae9a16b..86d34aa 100644 --- a/src/login-web-app/src/shared/feature/error-handling/ErrorBoundary.spec.tsx +++ b/src/login-web-app/src/shared/feature/error-handling/ErrorBoundary.spec.tsx @@ -223,7 +223,7 @@ function OnDemandErrorThrowerComponent({ } return ( - ); @@ -260,6 +260,7 @@ function AsyncErrorToErrorBoundaryThrowerComponent({ errorMessage }: { errorMess return (
From bbc7c2af0c8af3a18bf812f4fb164588809b7e1a Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Thu, 26 Mar 2026 13:34:03 +0100 Subject: [PATCH 3/9] IS-5161: fix lwa lint issues --- package-lock.json | 211 ++++++++++++++++- src/login-web-app/package.json | 2 +- src/login-web-app/src/App.tsx | 11 +- .../actions/form/HaapiStepperForm.spec.tsx | 1 + .../feature/actions/form/HaapiStepperForm.tsx | 8 +- .../actions/form/HaapiStepperFormContext.ts | 2 + .../feature/stepper/HaapiStepper.spec.tsx | 24 +- .../feature/stepper/HaapiStepper.tsx | 17 +- .../HaapiStepperErrorNotifier.spec.tsx | 218 ++++++++++++++++++ .../stepper/HaapiStepperErrorNotifier.tsx | 42 ++-- .../feature/steps/HaapiStepperStepUI.spec.tsx | 1 + .../error-handling/ErrorBoundary.spec.tsx | 5 +- 12 files changed, 494 insertions(+), 48 deletions(-) create mode 100644 src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.spec.tsx diff --git a/package-lock.json b/package-lock.json index 1836375..bcf3722 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21817,7 +21817,7 @@ "typescript": "~5.7.2", "typescript-eslint": "^8.24.1", "vite": "^7.3.1", - "vitest": "^4.0.1" + "vitest": "~4.0.6" }, "engines": { "node": ">=22.22.0", @@ -21838,6 +21838,90 @@ "lru-cache": "^10.4.3" } }, + "src/login-web-app/node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "src/login-web-app/node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "src/login-web-app/node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "src/login-web-app/node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "src/login-web-app/node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "src/login-web-app/node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "src/login-web-app/node_modules/cssstyle": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", @@ -21952,6 +22036,26 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "src/login-web-app/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "src/login-web-app/node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "src/login-web-app/node_modules/tr46": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", @@ -21979,6 +22083,111 @@ "node": ">=14.17" } }, + "src/login-web-app/node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "src/login-web-app/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "src/login-web-app/node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/src/login-web-app/package.json b/src/login-web-app/package.json index 91c412a..f2d8645 100644 --- a/src/login-web-app/package.json +++ b/src/login-web-app/package.json @@ -51,6 +51,6 @@ "typescript": "~5.7.2", "typescript-eslint": "^8.24.1", "vite": "^7.3.1", - "vitest": "^4.0.1" + "vitest": "~4.0.6" } } \ No newline at end of file diff --git a/src/login-web-app/src/App.tsx b/src/login-web-app/src/App.tsx index 4f93b82..6cc2541 100644 --- a/src/login-web-app/src/App.tsx +++ b/src/login-web-app/src/App.tsx @@ -14,15 +14,18 @@ import { DevBar } from './shared/ui/devbar/DevBar'; import { ErrorBoundary } from './shared/feature/error-handling/ErrorBoundary'; import { HaapiStepperStepUI } from './haapi-stepper/feature/steps/HaapiStepperStepUI'; import { HaapiStepper } from './haapi-stepper/feature/stepper/HaapiStepper'; +import { HaapiStepperErrorNotifier } from './haapi-stepper/feature'; export function App() { return ( - - - - + + + + + + ); diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.spec.tsx index be3d380..02d9f49 100644 --- a/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.spec.tsx @@ -265,6 +265,7 @@ describe('HaapiStepperForm', () => { if (field.type === HAAPI_FORM_FIELDS.USERNAME) { formState.set(field, prefilledUsernameValue); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [field]); return ( diff --git a/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.tsx b/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.tsx index ebde03c..549b24b 100644 --- a/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/actions/form/HaapiStepperForm.tsx @@ -9,6 +9,7 @@ * For further information, please contact Curity AB. */ +import { useCallback, useMemo } from 'react'; import { HAAPI_FORM_FIELDS, VisibleHaapiFormField } from '../../../data-access/types/haapi-form.types'; import { applyRenderInterceptor } from '../../../util/generic-render-interceptor'; import type { @@ -181,9 +182,10 @@ export function HaapiStepperForm({ action, onSubmit, formFieldRenderInterceptor, field => field.type !== HAAPI_FORM_FIELDS.HIDDEN && field.type !== HAAPI_FORM_FIELDS.CONTEXT ); const haapiStepperFormAPI: HaapiStepperFormAPI = { fields: visibleFields, formState }; - const submit = () => { + const submit = useCallback(() => { onSubmit(action, formState.values); - }; + }, [onSubmit, action, formState]); + const formContextValue = useMemo(() => ({ formState, action, submit }), [formState, action, submit]); const formContentElements = applyRenderInterceptor( [haapiStepperFormAPI], @@ -193,7 +195,7 @@ export function HaapiStepperForm({ action, onSubmit, formFieldRenderInterceptor, const formContentElement = formContentElements[0] ?? null; return ( - + (null); export function useHaapiStepperForm(): HaapiStepperFormContextValue { + // eslint-disable-next-line @eslint-react/no-use-context -- useContext is preferred here over use() to keep explicit null handling const context = useContext(HaapiStepperFormContext); if (!context) { throw new Error('useHaapiStepperForm must be used within a .'); diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx index 5900e42..ece6e52 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx @@ -363,7 +363,7 @@ describe('HaapiStepper', () => { await goToNextStep(HAAPI_STEPS.POLLING, { bankId: true }); const startButton = await screen.findByRole('button', { name: 'Start BankID' }); - + act(() => startButton.click()); await waitFor(() => { @@ -476,7 +476,8 @@ describe('HaapiStepper', () => { const secondStep = HAAPI_STEPS.REGISTRATION; const thirdStep = HAAPI_STEPS.COMPLETED_WITH_SUCCESS; let history = await screen.findByTestId('history'); - let historyData = JSON.parse(history.textContent) as HaapiStepperHistoryEntry[]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + let historyData = JSON.parse(history.textContent!) as HaapiStepperHistoryEntry[]; let previousStepTriggerActionKind = bootstrapLinkAction; expect(historyData).toHaveLength(1); @@ -490,7 +491,8 @@ describe('HaapiStepper', () => { await waitFor(() => expect(screen.getByTestId('step-type')).toHaveTextContent(secondStep)); history = screen.getByTestId('history'); - historyData = JSON.parse(history.textContent) as HaapiStepperHistoryEntry[]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + historyData = JSON.parse(history.textContent!) as HaapiStepperHistoryEntry[]; // @ts-expect-error - accessing mock step actions for test validation - getStepMock returns mock data with actions array // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access previousStepTriggerActionKind = getStepMock(initialStep).actions[0].kind; @@ -506,7 +508,8 @@ describe('HaapiStepper', () => { await waitFor(() => expect(screen.getByTestId('step-type')).toHaveTextContent(thirdStep)); history = screen.getByTestId('history'); - historyData = JSON.parse(history.textContent) as HaapiStepperHistoryEntry[]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + historyData = JSON.parse(history.textContent!) as HaapiStepperHistoryEntry[]; // @ts-expect-error - accessing mock step actions for test validation - getStepMock returns mock data with actions array // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access previousStepTriggerActionKind = getStepMock(secondStep).actions[0].kind; @@ -541,7 +544,8 @@ describe('HaapiStepper', () => { }); const history = screen.getByTestId('history'); - const historyData = JSON.parse(history.textContent) as unknown as HaapiStepperHistoryEntry[]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const historyData = JSON.parse(history.textContent!) as unknown as HaapiStepperHistoryEntry[]; // Should have authentication twice - once initially, once after continue same // The continue same step itself is NOT in history, but the updated authentication step is @@ -567,7 +571,8 @@ describe('HaapiStepper', () => { ); const history = screen.getByTestId('history'); - const historyData = JSON.parse(history.textContent) as unknown as HaapiStepperHistoryEntry[]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const historyData = JSON.parse(history.textContent!) as unknown as HaapiStepperHistoryEntry[]; expect(historyData).toHaveLength(2); expect(historyData[0].step.type).toBe(HAAPI_STEPS.AUTHENTICATION); @@ -594,7 +599,8 @@ describe('HaapiStepper', () => { }); const history = screen.getByTestId('history'); - const historyData = JSON.parse(history.textContent) as unknown as HaapiStepperHistoryEntry[]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const historyData = JSON.parse(history.textContent!) as unknown as HaapiStepperHistoryEntry[]; expect(historyData).toHaveLength(1); expect(historyData[0].step.type).toBe(HAAPI_STEPS.AUTHENTICATION); @@ -621,7 +627,8 @@ describe('HaapiStepper', () => { }); const history = screen.getByTestId('history'); - const historyData = JSON.parse(history.textContent) as unknown as HaapiStepperHistoryEntry[]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const historyData = JSON.parse(history.textContent!) as unknown as HaapiStepperHistoryEntry[]; expect(historyData).toHaveLength(1); expect(historyData[0].step.type).toBe(HAAPI_STEPS.AUTHENTICATION); @@ -644,6 +651,7 @@ vi.mock('../../data-access/haapi-fetch-initializer', () => { const mockThrowErrorToAppErrorBoundary = vi.fn(); vi.mock('../../util/useThrowErrorToAppErrorBoundary', () => ({ + // eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-prefix useThrowErrorToAppErrorBoundary: () => mockThrowErrorToAppErrorBoundary, })); diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx index 6b8e304..1205f05 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx @@ -314,6 +314,7 @@ export function HaapiStepper({ children, config }: HaapiStepperProps) { setCurrentStepAndUpdateHistory(nextStepData, action, payload); }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- nextStep is a stable ref via useRefCallback, defined below [configResult, currentStep, history, setCurrentStepAndUpdateHistory] ); @@ -333,18 +334,16 @@ export function HaapiStepper({ children, config }: HaapiStepperProps) { useEffect(() => { nextStep(getInitialStepLink()); + return () => cancelPendingOperation(pendingOperation); }, [nextStep]); + const contextValue = useMemo( + () => ({ currentStep, loading, error, nextStep, history }), + [currentStep, loading, error, nextStep, history] + ); + return ( - + {children} ); diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.spec.tsx new file mode 100644 index 0000000..a5d79fc --- /dev/null +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.spec.tsx @@ -0,0 +1,218 @@ +/* + * 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 { act, fireEvent, render, screen } from '@testing-library/react'; +import { vi } from 'vitest'; +import { HaapiStepperErrorNotifier } from './HaapiStepperErrorNotifier'; +import { useHaapiStepper } from './HaapiStepperHook'; +import { HaapiStepperAppError, HaapiStepperError, HaapiStepperInputError } from './haapi-stepper.types'; + +vi.mock('./HaapiStepperHook', () => ({ + useHaapiStepper: vi.fn(), +})); + +const mockUseHaapiStepper = vi.mocked(useHaapiStepper); + +describe('HaapiStepperErrorNotifier', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseHaapiStepper.mockReturnValue(createStepperResponse(null)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not render when there is no error', () => { + render( + +
content
+
+ ); + + expect(screen.queryByTestId('haapi-error-toast')).not.toBeInTheDocument(); + expect(screen.getByTestId('children')).toBeInTheDocument(); + }); + + it('renders the current app error and messages', () => { + const appError = createAppError({ title: APP_ERROR_TITLE, messages: [APP_ERROR_MESSAGE] }); + mockUseHaapiStepper.mockReturnValue(createStepperResponse({ app: appError })); + + render( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-toast')).toBeInTheDocument(); + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-title')).toHaveTextContent(APP_ERROR_TITLE); + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-messages')).toHaveTextContent(APP_ERROR_MESSAGE); + expect(screen.getByTestId('children')).toBeInTheDocument(); + }); + + it('dismisses the current error when the dismiss button is clicked', () => { + const appError = createAppError(); + mockUseHaapiStepper.mockReturnValue(createStepperResponse({ app: appError })); + + render( + +
content
+
+ ); + + fireEvent.click(screen.getByTestId('haapi-error-haapi-error-notifier-toast-dismiss')); + + expect(screen.queryByTestId('haapi-error-toast')).not.toBeInTheDocument(); + }); + + it('auto-dismisses the error after the notification duration', () => { + vi.useFakeTimers(); + const appError = createAppError(); + mockUseHaapiStepper.mockReturnValue(createStepperResponse({ app: appError })); + + render( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-toast')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(screen.queryByTestId('haapi-error-toast')).not.toBeInTheDocument(); + }); + + it('shows new errors after an earlier one has been dismissed', () => { + const firstError = createAppError({ title: FIRST_ERROR_TITLE, messages: [FIRST_ERROR_MESSAGE] }); + const nextError = createAppError({ title: NEXT_ERROR_TITLE, messages: [NEXT_ERROR_MESSAGE] }); + let currentError: HaapiStepperError = { app: firstError }; + + mockUseHaapiStepper.mockImplementation(() => createStepperResponse(currentError)); + + const { rerender } = render( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-title')).toHaveTextContent(FIRST_ERROR_TITLE); + + fireEvent.click(screen.getByTestId('haapi-error-haapi-error-notifier-toast-dismiss')); + expect(screen.queryByTestId('haapi-error-toast')).not.toBeInTheDocument(); + + currentError = { app: nextError }; + rerender( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-title')).toHaveTextContent(NEXT_ERROR_TITLE); + }); + + it('renders input errors only when showInputErrorNotifications is true', () => { + const inputError = createInputError(INPUT_ERROR_TITLE); + + mockUseHaapiStepper.mockReturnValue(createStepperResponse({ input: inputError })); + + const { rerender } = render( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-title')).toHaveTextContent(INPUT_ERROR_TITLE); + + rerender( + +
content
+
+ ); + + expect(screen.queryByTestId('haapi-error-toast')).not.toBeInTheDocument(); + }); + + it('renders the latest error when a new error arrives without dismissing', () => { + const firstError = createAppError({ title: FIRST_ERROR_TITLE, messages: [FIRST_ERROR_MESSAGE] }); + const nextError = createAppError({ title: NEXT_ERROR_TITLE, messages: [NEXT_ERROR_MESSAGE] }); + let currentError: HaapiStepperError = { app: firstError }; + + mockUseHaapiStepper.mockImplementation(() => createStepperResponse(currentError)); + + const { rerender } = render( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-title')).toHaveTextContent(FIRST_ERROR_TITLE); + + currentError = { app: nextError }; + rerender( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-title')).toHaveTextContent(NEXT_ERROR_TITLE); + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-messages')).toHaveTextContent(NEXT_ERROR_MESSAGE); + }); + + it('uses a custom errorFormatter for the title', () => { + const appError = createAppError({ title: APP_ERROR_TITLE }); + mockUseHaapiStepper.mockReturnValue(createStepperResponse({ app: appError })); + const customFormatter = (error: HaapiStepperAppError | HaapiStepperInputError) => `Custom: ${error.title ?? ''}`; + + render( + +
content
+
+ ); + + expect(screen.getByTestId('haapi-error-haapi-error-notifier-toast-title')).toHaveTextContent(`Custom: ${APP_ERROR_TITLE}`); + }); +}); + +function createAppError({ title = DEFAULT_APP_ERROR_TITLE, messages = [DEFAULT_APP_ERROR_MESSAGE] }: { title?: string; messages?: string[] } = {}) { + return { + title, + dataHelpers: { + messages: messages.map((text, index) => ({ id: `app-message-${String(index)}`, text })), + }, + } as unknown as HaapiStepperAppError; +} + +function createInputError(title = INPUT_ERROR_TITLE) { + return { + title, + dataHelpers: { + messages: [], + }, + } as unknown as HaapiStepperInputError; +} + +function createStepperResponse(error: HaapiStepperError | null) { + return { + error, + } as unknown as ReturnType; +} + +const APP_ERROR_TITLE = 'Something went wrong'; +const APP_ERROR_MESSAGE = 'Details go here'; +const FIRST_ERROR_TITLE = 'First error'; +const FIRST_ERROR_MESSAGE = 'first'; +const NEXT_ERROR_TITLE = 'Next error'; +const NEXT_ERROR_MESSAGE = 'second'; +const INPUT_ERROR_TITLE = 'Invalid input'; +const DEFAULT_APP_ERROR_TITLE = 'Something went wrong'; +const DEFAULT_APP_ERROR_MESSAGE = 'Failure'; diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.tsx index 12d0cdd..8ae5d5a 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.tsx @@ -15,40 +15,39 @@ export function HaapiStepperErrorNotifier({ children, showInputErrorNotifications = true, notificationDuration = 10000, - errorFormatter = error => error.title ?? 'An error occurred', + errorFormatter = defaultErrorFormatter, }: HaapiErrorNotifierProps) { const { error } = useHaapiStepper(); - const [notificationError, setNotificationError] = useState(); - const [isNotificationVisible, setIsNotificationVisible] = useState(false); + const currentError = error?.app ?? (showInputErrorNotifications ? error?.input : null); + const [dismissedError, setDismissedError] = useState(null); useEffect(() => { - const notificationError = error?.app ?? (showInputErrorNotifications ? error?.input : null); - - if (notificationError) { - setNotificationError(notificationError); - setIsNotificationVisible(true); + if (!currentError) { + return; + } - const timer = setTimeout(() => { - setIsNotificationVisible(false); - setNotificationError(null); - }, notificationDuration); + const timeout = setTimeout(() => { + setDismissedError(currentError); + }, notificationDuration); - return () => clearTimeout(timer); - } - }, [error?.app, error?.input, showInputErrorNotifications, notificationDuration]); + return () => clearTimeout(timeout); + }, [currentError, notificationDuration]); + const isNotificationVisible = currentError && dismissedError !== currentError; + const notificationMessages = currentError?.dataHelpers.messages ?? []; const handleDismiss = () => { - setIsNotificationVisible(false); + setDismissedError(currentError ?? null); }; + return ( <> - {isNotificationVisible && notificationError && ( + {isNotificationVisible && (

- {errorFormatter(notificationError)} + {errorFormatter(currentError)}

- {notificationError.dataHelpers.messages.length && ( + {notificationMessages.length && (
- {notificationError.dataHelpers.messages.map(message => ( + {notificationMessages.map(message => (

{message.text}

))}
@@ -77,3 +76,6 @@ export function HaapiStepperErrorNotifier({ ); } + +const defaultErrorFormatter = (error: HaapiStepperAppError | HaapiStepperInputError) => + error.title ?? 'An error occurred'; \ No newline at end of file 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 d171361..82f2201 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 @@ -1141,6 +1141,7 @@ describe('HaapiStepperStepUI', () => { if (field.type === HAAPI_FORM_FIELDS.USERNAME) { formState.set(field, prefilledUsernameValue); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [field]); return ( diff --git a/src/login-web-app/src/shared/feature/error-handling/ErrorBoundary.spec.tsx b/src/login-web-app/src/shared/feature/error-handling/ErrorBoundary.spec.tsx index ae9a16b..86d34aa 100644 --- a/src/login-web-app/src/shared/feature/error-handling/ErrorBoundary.spec.tsx +++ b/src/login-web-app/src/shared/feature/error-handling/ErrorBoundary.spec.tsx @@ -223,7 +223,7 @@ function OnDemandErrorThrowerComponent({ } return ( - ); @@ -260,6 +260,7 @@ function AsyncErrorToErrorBoundaryThrowerComponent({ errorMessage }: { errorMess return (
From e2d6dc3969759b9e5771c4609387ca5f4c5d4402 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Thu, 26 Mar 2026 14:39:46 +0100 Subject: [PATCH 4/9] IS-11176: remove comment --- .../src/haapi-stepper/feature/stepper/HaapiStepper.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx index da1ff32..1205f05 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.tsx @@ -339,11 +339,7 @@ export function HaapiStepper({ children, config }: HaapiStepperProps) { const contextValue = useMemo( () => ({ currentStep, loading, error, nextStep, history }), -<<<<<<< HEAD [currentStep, loading, error, nextStep, history] -======= - [currentStep, loading, error, history] ->>>>>>> 7f573307700c8c9c9d4d0bbeb72690fedf6b71fd ); return ( From 3359d399e617f9375796a84814275aa2b5ff2f41 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Thu, 26 Mar 2026 14:42:24 +0100 Subject: [PATCH 5/9] IS-11176: remove notifier from app --- src/login-web-app/src/App.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/login-web-app/src/App.tsx b/src/login-web-app/src/App.tsx index 6cc2541..f70c53b 100644 --- a/src/login-web-app/src/App.tsx +++ b/src/login-web-app/src/App.tsx @@ -20,12 +20,10 @@ export function App() { return ( - - ); From 4f9330066ea1a8db06433e754f5bf6c7fd4b151a Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Thu, 26 Mar 2026 14:43:50 +0100 Subject: [PATCH 6/9] IS-11176: fix formatting --- src/login-web-app/src/App.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/login-web-app/src/App.tsx b/src/login-web-app/src/App.tsx index f70c53b..4f93b82 100644 --- a/src/login-web-app/src/App.tsx +++ b/src/login-web-app/src/App.tsx @@ -14,16 +14,15 @@ import { DevBar } from './shared/ui/devbar/DevBar'; import { ErrorBoundary } from './shared/feature/error-handling/ErrorBoundary'; import { HaapiStepperStepUI } from './haapi-stepper/feature/steps/HaapiStepperStepUI'; import { HaapiStepper } from './haapi-stepper/feature/stepper/HaapiStepper'; -import { HaapiStepperErrorNotifier } from './haapi-stepper/feature'; export function App() { return ( - - - - + + + + ); From 31fc50ba4a1576d3346a2ae385ac706e63d3cb7d Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Fri, 27 Mar 2026 13:31:29 +0100 Subject: [PATCH 7/9] IS-11176: refactor history content assertion for cleanness --- .../feature/stepper/HaapiStepper.spec.tsx | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx index ece6e52..db5bc50 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx @@ -476,8 +476,7 @@ describe('HaapiStepper', () => { const secondStep = HAAPI_STEPS.REGISTRATION; const thirdStep = HAAPI_STEPS.COMPLETED_WITH_SUCCESS; let history = await screen.findByTestId('history'); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - let historyData = JSON.parse(history.textContent!) as HaapiStepperHistoryEntry[]; + let historyData = getHistoryData(history); let previousStepTriggerActionKind = bootstrapLinkAction; expect(historyData).toHaveLength(1); @@ -491,8 +490,7 @@ describe('HaapiStepper', () => { await waitFor(() => expect(screen.getByTestId('step-type')).toHaveTextContent(secondStep)); history = screen.getByTestId('history'); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - historyData = JSON.parse(history.textContent!) as HaapiStepperHistoryEntry[]; + historyData = getHistoryData(history); // @ts-expect-error - accessing mock step actions for test validation - getStepMock returns mock data with actions array // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access previousStepTriggerActionKind = getStepMock(initialStep).actions[0].kind; @@ -508,8 +506,7 @@ describe('HaapiStepper', () => { await waitFor(() => expect(screen.getByTestId('step-type')).toHaveTextContent(thirdStep)); history = screen.getByTestId('history'); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - historyData = JSON.parse(history.textContent!) as HaapiStepperHistoryEntry[]; + historyData = getHistoryData(history); // @ts-expect-error - accessing mock step actions for test validation - getStepMock returns mock data with actions array // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access previousStepTriggerActionKind = getStepMock(secondStep).actions[0].kind; @@ -544,8 +541,7 @@ describe('HaapiStepper', () => { }); const history = screen.getByTestId('history'); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - const historyData = JSON.parse(history.textContent!) as unknown as HaapiStepperHistoryEntry[]; + const historyData = getHistoryData(history); // Should have authentication twice - once initially, once after continue same // The continue same step itself is NOT in history, but the updated authentication step is @@ -571,8 +567,7 @@ describe('HaapiStepper', () => { ); const history = screen.getByTestId('history'); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - const historyData = JSON.parse(history.textContent!) as unknown as HaapiStepperHistoryEntry[]; + const historyData = getHistoryData(history); expect(historyData).toHaveLength(2); expect(historyData[0].step.type).toBe(HAAPI_STEPS.AUTHENTICATION); @@ -599,8 +594,7 @@ describe('HaapiStepper', () => { }); const history = screen.getByTestId('history'); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - const historyData = JSON.parse(history.textContent!) as unknown as HaapiStepperHistoryEntry[]; + const historyData = getHistoryData(history); expect(historyData).toHaveLength(1); expect(historyData[0].step.type).toBe(HAAPI_STEPS.AUTHENTICATION); @@ -627,8 +621,7 @@ describe('HaapiStepper', () => { }); const history = screen.getByTestId('history'); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - const historyData = JSON.parse(history.textContent!) as unknown as HaapiStepperHistoryEntry[]; + const historyData = getHistoryData(history); expect(historyData).toHaveLength(1); expect(historyData[0].step.type).toBe(HAAPI_STEPS.AUTHENTICATION); @@ -833,3 +826,13 @@ const clickNextStepButton = async () => { act(() => nextStepButton.click()); }; + +function getTextContent(element: HTMLElement): string { + const content: string | null = element.textContent; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- textContent is string|null per DOM types but the linter narrows it to string + return content ?? ''; +} + +function getHistoryData(element: HTMLElement): HaapiStepperHistoryEntry[] { + return JSON.parse(getTextContent(element)) as HaapiStepperHistoryEntry[]; +} From fde4aaf7c1e6e822fc9d07452367472dd2358ac4 Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Thu, 26 Mar 2026 15:13:34 +0100 Subject: [PATCH 8/9] IS-11176: create lwa github ci workflow --- .github/workflows/lwa-github-ci-workflow.yml | 126 +++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 .github/workflows/lwa-github-ci-workflow.yml diff --git a/.github/workflows/lwa-github-ci-workflow.yml b/.github/workflows/lwa-github-ci-workflow.yml new file mode 100644 index 0000000..787f02a --- /dev/null +++ b/.github/workflows/lwa-github-ci-workflow.yml @@ -0,0 +1,126 @@ +name: Login Web App CI Workflow + +on: + push: + paths: + - 'src/login-web-app/**' + pull_request: + types: [opened, synchronize, reopened, ready_for_review, closed] + paths: + - 'src/login-web-app/**' + workflow_dispatch: + +jobs: + build-and-test: + if: ${{ github.event.pull_request.draft != true || github.event_name != 'pull_request' }} + runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + steps: + - name: Checkout Code + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Node.js from .nvmrc + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Install Dependencies + run: npm ci + + - name: Lint Code + run: npm run lint -w src/login-web-app + + - name: Build Project + run: npm run build:login-web-app + + - name: Run Tests + run: npm run test -w src/login-web-app -- run + + notify-slack: + name: Notify Slack + needs: build-and-test + runs-on: ubuntu-latest + if: ${{ always() && github.event_name == 'pull_request' }} + strategy: + matrix: + webhook_secret: [LWA_SLACK_WEBHOOK_URL, LWA_SLACK_WEBHOOK_URL_FE_CHANNEL] + steps: + - name: Extract Jira ticket from branch name + id: jira + run: | + TICKET=$(echo "${{ github.head_ref }}" | grep -oE '[A-Z]+-[0-9]+' | head -1) + if [ -n "$TICKET" ]; then + echo "link=*Jira:* " >> $GITHUB_OUTPUT + else + echo "link=" >> $GITHUB_OUTPUT + fi + + - name: Notify new pull request + if: ${{ (github.event.action == 'opened' && github.event.pull_request.draft == false) || github.event.action == 'ready_for_review' }} + uses: slackapi/slack-github-action@v1.26.0 + with: + payload: | + { + "text": ":rocket: PR ready for review in `${{ github.repository }}`", + "attachments": [ + { + "color": "${{ needs.build-and-test.result == 'success' && '#2EB67D' || '#E01E5A' }}", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Title:* ${{ github.event.pull_request.title }}\n*Author:* ${{ github.actor }}\n*Status:* ${{ needs.build-and-test.result == 'success' && ':white_check_mark: Passed' || ':x: Failed' }}\n*PR:* <${{ github.event.pull_request.html_url }}|View Pull Request> | <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|Workflow Logs>${{ steps.jira.outputs.link && format('\n{0}', steps.jira.outputs.link) || '' }}" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ${{ toJSON(format('*Description:*{0}{1}', fromJSON('"\n"'), github.event.pull_request.body || 'No description provided.')) }} + } + } + ] + } + ] + } + env: + SLACK_WEBHOOK_URL: ${{ secrets[matrix.webhook_secret] }} + SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK + + - name: Notify merged pull request + if: ${{ github.event.action == 'closed' && github.event.pull_request.merged == true }} + uses: slackapi/slack-github-action@v1.26.0 + with: + payload: | + { + "text": ":tada: PR merged in `${{ github.repository }}`", + "attachments": [ + { + "color": "${{ needs.build-and-test.result == 'success' && '#2EB67D' || '#E01E5A' }}", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Title:* ${{ github.event.pull_request.title }}\n*Merged by:* ${{ github.actor }}\n*Status:* ${{ needs.build-and-test.result == 'success' && ':white_check_mark: Passed' || ':x: Failed' }}\n*PR:* <${{ github.event.pull_request.html_url }}|View Pull Request> | <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|Workflow Logs>${{ steps.jira.outputs.link && format('\n{0}', steps.jira.outputs.link) || '' }}" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ${{ toJSON(format('*Description:*{0}{1}', fromJSON('"\n"'), github.event.pull_request.body || 'No description provided.')) }} + } + } + ] + } + ] + } + env: + SLACK_WEBHOOK_URL: ${{ secrets[matrix.webhook_secret] }} + SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK From fcc2b68a26b3e5f4cfe8d6cc48793579286720be Mon Sep 17 00:00:00 2001 From: Aleix Suau Date: Tue, 31 Mar 2026 14:24:13 +0200 Subject: [PATCH 9/9] IS-11176: fix copilot feedback --- .../src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx | 2 +- .../haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx index c1736a0..47baedb 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepper.spec.tsx @@ -384,7 +384,7 @@ describe('HaapiStepper', () => { await goToNextStep(HAAPI_STEPS.POLLING, { bankId: true }); const startButton = await screen.findByRole('button', { name: 'Start BankID' }); - + act(() => startButton.click()); await waitFor(() => { diff --git a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.tsx b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.tsx index 8ae5d5a..aa423d0 100644 --- a/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.tsx +++ b/src/login-web-app/src/haapi-stepper/feature/stepper/HaapiStepperErrorNotifier.tsx @@ -61,7 +61,7 @@ export function HaapiStepperErrorNotifier({
- {notificationMessages.length && ( + {notificationMessages.length > 0 && (
{notificationMessages.map(message => (

{message.text}