From 6fa712b8362f380efeede2fb1b670e9abffb40da Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Thu, 15 Jan 2026 10:25:23 +0100 Subject: [PATCH 1/4] Backporting post code lookup fix to v3 --- src/layout/Address/AddressComponent.test.tsx | 12 +-- src/layout/Address/AddressComponent.tsx | 84 ++++++++----------- src/layout/Address/usePostPlace.ts | 40 +++++++++ src/queries/queries.ts | 5 +- src/testUtils.tsx | 10 +++ src/types/shared.ts | 5 ++ src/utils/urls/appUrlHelper.ts | 1 + .../integration/frontend-test/components.ts | 15 +--- 8 files changed, 95 insertions(+), 77 deletions(-) create mode 100644 src/layout/Address/usePostPlace.ts diff --git a/src/layout/Address/AddressComponent.test.tsx b/src/layout/Address/AddressComponent.test.tsx index 39e4df6623..384a056c94 100644 --- a/src/layout/Address/AddressComponent.test.tsx +++ b/src/layout/Address/AddressComponent.test.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { act, screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; -import mockAxios from 'jest-mock-axios'; import { AddressComponent } from 'src/layout/Address/AddressComponent'; import { renderGenericComponentTest } from 'src/testUtils'; @@ -229,16 +228,7 @@ describe('AddressComponent', () => { }, }); - mockAxios.mockResponseFor( - { url: 'https://api.bring.com/shippingguide/api/postalCode.json' }, - { - data: { - valid: true, - result: 'OSLO', - }, - }, - ); - + // The postal codes mock from testUtils.tsx has 0001 -> OSLO await screen.findByDisplayValue('OSLO'); expect(handleDataChange).toHaveBeenCalledWith('OSLO', { key: 'postPlace' }); diff --git a/src/layout/Address/AddressComponent.tsx b/src/layout/Address/AddressComponent.tsx index 52d72353a0..87dfe4a20d 100644 --- a/src/layout/Address/AddressComponent.tsx +++ b/src/layout/Address/AddressComponent.tsx @@ -1,14 +1,13 @@ import React from 'react'; import { LegacyTextField } from '@digdir/design-system-react'; -import axios from 'axios'; import { Label } from 'src/components/form/Label'; import { useDelayedSavedState } from 'src/hooks/useDelayedSavedState'; import { useLanguage } from 'src/hooks/useLanguage'; import { useStateDeepEqual } from 'src/hooks/useStateDeepEqual'; import classes from 'src/layout/Address/AddressComponent.module.css'; -import { httpGet } from 'src/utils/network/sharedNetworking'; +import { usePostPlace } from 'src/layout/Address/usePostPlace'; import { renderValidationMessagesForComponent } from 'src/utils/render'; import type { PropsFromGenericComponent } from 'src/layout'; import type { IComponentValidations } from 'src/utils/validation/types'; @@ -31,9 +30,6 @@ export enum AddressKeys { } export function AddressComponent({ formData, handleDataChange, componentValidations, node }: IAddressComponentProps) { - // eslint-disable-next-line import/no-named-as-default-member - const cancelToken = axios.CancelToken; - const source = cancelToken.source(); const { id, required, readOnly, labelSettings, simplified, saveWhileTyping } = node.item; const { lang, langAsString } = useLanguage(); @@ -73,8 +69,9 @@ export function AddressComponent({ formData, handleDataChange, componentValidati ); const [validations, setValidations] = useStateDeepEqual({}); - const prevZipCode = React.useRef(undefined); - const hasFetchedPostPlace = React.useRef(false); + const isValidZipCode = formData.zipCode?.match(/^\d{4}$/); + const postPlaceFromHook = usePostPlace(isValidZipCode ? formData.zipCode : undefined, true); + const hasLookedUp = React.useRef(false); const validate = React.useCallback(() => { const validationErrors: IAddressValidationErrors = {}; @@ -108,54 +105,39 @@ export function AddressComponent({ formData, handleDataChange, componentValidati ); React.useEffect(() => { - if (!formData.zipCode || !formData.zipCode.match(/^\d{4}$/)) { - setPostPlace(''); - return; - } - - if (prevZipCode.current === formData.zipCode && hasFetchedPostPlace.current === true) { + if (!formData.zipCode || !isValidZipCode) { + if (postPlace !== '') { + setPostPlace(''); + } + hasLookedUp.current = false; return; } - const fetchPostPlace = async (pnr: string, cancellationToken: any) => { - hasFetchedPostPlace.current = false; - try { - prevZipCode.current = formData.zipCode; - const response = await httpGet('https://api.bring.com/shippingguide/api/postalCode.json', { - params: { - clientUrl: window.location.href, - pnr, - }, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - cancelToken: cancellationToken, - }); - if (response.valid) { - setPostPlace(response.result); - setValidations({ ...validations, zipCode: undefined }); - onSaveField(AddressKeys.postPlace, response.result); - } else { - const errorMessage = langAsString('address_component.validation_error_zipcode'); - setPostPlace(''); - setValidations({ ...validations, zipCode: errorMessage }); - } - hasFetchedPostPlace.current = true; - } catch (err) { - // eslint-disable-next-line import/no-named-as-default-member - if (axios.isCancel(err)) { - // Intentionally ignored - } else { - window.logError(`AddressComponent (${id}):\n`, err); - } + if (postPlaceFromHook) { + setPostPlace(postPlaceFromHook); + setValidations({ ...validations, zipCode: undefined }); + handleDataChange(postPlaceFromHook, { key: AddressKeys.postPlace }); + hasLookedUp.current = true; + } else if (hasLookedUp.current || postPlaceFromHook === '') { + // If we've looked up before and got empty result, or hook returned empty string for valid format + // This means the zip code format is valid but doesn't exist in the registry + if (postPlaceFromHook === '' && hasLookedUp.current) { + const errorMessage = langAsString('address_component.validation_error_zipcode'); + setPostPlace(''); + setValidations({ ...validations, zipCode: errorMessage }); } - }; - - fetchPostPlace(formData.zipCode, source.token); - return function cleanup() { - source.cancel('ComponentWillUnmount'); - }; - }, [formData.zipCode, langAsString, source, onSaveField, validations, setPostPlace, id, setValidations]); + } + }, [ + formData.zipCode, + isValidZipCode, + postPlaceFromHook, + postPlace, + setPostPlace, + handleDataChange, + langAsString, + setValidations, + validations, + ]); const updateField = (key: AddressKeys, saveImmediately: boolean, event: any): void => { const changedFieldValue: string = event.target.value; diff --git a/src/layout/Address/usePostPlace.ts b/src/layout/Address/usePostPlace.ts new file mode 100644 index 0000000000..4988dd071c --- /dev/null +++ b/src/layout/Address/usePostPlace.ts @@ -0,0 +1,40 @@ +import { useQuery } from '@tanstack/react-query'; + +import { useAppQueriesContext } from 'src/contexts/appQueriesContext'; +import type { PostalCodesRegistry } from 'src/types/shared'; + +const __default__ = ''; + +function lookupPostPlace(data: PostalCodesRegistry, zip: string): string { + const index = parseInt(zip, 10); + if (isNaN(index) || index < 0 || index >= data.mapping.length) { + return ''; + } + const placeIndex = data.mapping[index]; + if (placeIndex === 0) { + return ''; + } + return data.places[placeIndex] ?? ''; +} + +/** + * Looks up the post place for a given zip code by fetching postal code data. + * This hook was designed primarily for use in the Address component. + */ +export function usePostPlace(zipCode: string | undefined, enabled: boolean) { + const { fetchPostalCodes } = useAppQueriesContext(); + const _enabled = enabled && Boolean(zipCode?.length) && zipCode !== __default__ && zipCode !== '0'; + + const { data } = useQuery({ + queryKey: ['postalCodes'], + queryFn: fetchPostalCodes, + staleTime: Infinity, + enabled: _enabled, + }); + + if (!_enabled || !data) { + return __default__; + } + + return lookupPostPlace(data, zipCode!); +} diff --git a/src/queries/queries.ts b/src/queries/queries.ts index 0bb8cf91cf..14626dd5a6 100644 --- a/src/queries/queries.ts +++ b/src/queries/queries.ts @@ -12,6 +12,7 @@ import { getJsonSchemaUrl, getLayoutSetsUrl, getPartyValidationUrl, + postalCodesUrl, profileApiUrl, refreshJwtTokenUrl, validPartiesUrl, @@ -20,7 +21,7 @@ import { orgsListUrl } from 'src/utils/urls/urlHelper'; import type { IApplicationMetadata } from 'src/features/applicationMetadata'; import type { IFooterLayout } from 'src/features/footer/types'; import type { ILayoutSets, ISimpleInstance } from 'src/types'; -import type { IAltinnOrgs, IApplicationSettings, IProfile } from 'src/types/shared'; +import type { IAltinnOrgs, IApplicationSettings, IProfile, PostalCodesRegistry } from 'src/types/shared'; export const doPartyValidation = async (partyId: string) => (await httpPost(getPartyValidationUrl(partyId))).data; @@ -52,3 +53,5 @@ export const fetchDataModelSchema = (dataTypeName: string): Promise httpGet(getJsonSchemaUrl() + dataTypeName); export const fetchFormData = (url: string, options?: AxiosRequestConfig): Promise => httpGet(url, options); + +export const fetchPostalCodes = (): Promise => httpGet(postalCodesUrl); diff --git a/src/testUtils.tsx b/src/testUtils.tsx index ea693b5bb8..47cc429aab 100644 --- a/src/testUtils.tsx +++ b/src/testUtils.tsx @@ -50,6 +50,16 @@ export const renderWithProviders = ( fetchParties: () => Promise.resolve({}), fetchRefreshJwtToken: () => Promise.resolve({}), fetchFormData: () => Promise.resolve({}), + fetchPostalCodes: () => + Promise.resolve({ + places: [null, 'OSLO', 'BERGEN'], + mapping: (() => { + const m = new Array(10000).fill(0); + m[1] = 1; // 0001 -> OSLO + m[2] = 2; // 0002 -> BERGEN + return m; + })(), + }), } as AppQueriesContext; const mockedQueries = { ...allMockedQueries, ...queries }; diff --git a/src/types/shared.ts b/src/types/shared.ts index 34188ae0a7..878aa74c06 100644 --- a/src/types/shared.ts +++ b/src/types/shared.ts @@ -278,3 +278,8 @@ export type IAuthContext = { read: boolean; write: boolean; } & { [action in IActionType]: boolean }; + +export interface PostalCodesRegistry { + places: (string | null)[]; + mapping: number[]; +} diff --git a/src/utils/urls/appUrlHelper.ts b/src/utils/urls/appUrlHelper.ts index 03fa9c4c83..66f560dff2 100644 --- a/src/utils/urls/appUrlHelper.ts +++ b/src/utils/urls/appUrlHelper.ts @@ -17,6 +17,7 @@ export const validPartiesUrl = `${appPath}/api/v1/parties?allowedtoinstantiatefi export const currentPartyUrl = `${appPath}/api/authorization/parties/current?returnPartyObject=true`; export const instancesControllerUrl = `${appPath}/instances`; export const refreshJwtTokenUrl = `${appPath}/api/authentication/keepAlive`; +export const postalCodesUrl = 'https://olemartin.org/postnummerregister/'; export const updateCookieUrl = (partyId: string) => `${appPath}/api/v1/parties/${partyId}`; diff --git a/test/e2e/integration/frontend-test/components.ts b/test/e2e/integration/frontend-test/components.ts index 44121bbdc5..543f6ca483 100644 --- a/test/e2e/integration/frontend-test/components.ts +++ b/test/e2e/integration/frontend-test/components.ts @@ -170,22 +170,9 @@ describe('UI Components', () => { it('address component fetches post place from zip code', () => { cy.goto('changename'); - // Mock zip code API, so that we don't rely on external services for our tests - cy.intercept('GET', 'https://api.bring.com/shippingguide/api/postalCode.json**', (req) => { - req.reply((res) => { - res.send({ - body: { - postalCodeType: 'NORMAL', - result: 'KARDEMOMME BY', // Intentionally wrong, to test that our mock is used - valid: true, - }, - }); - }); - }).as('zipCodeApi'); - cy.get(appFrontend.changeOfName.address.street_name).type('Sesame Street 1A'); cy.get(appFrontend.changeOfName.address.street_name).blur(); - cy.get(appFrontend.changeOfName.address.zip_code).type('0123'); + cy.get(appFrontend.changeOfName.address.zip_code).type('4609'); cy.get(appFrontend.changeOfName.address.zip_code).blur(); cy.get(appFrontend.changeOfName.address.post_place).should('have.value', 'KARDEMOMME BY'); cy.get('@zipCodeApi').its('request.url').should('include', '0123'); From eb89bc806a6f4bf491582d0fd3eda43bcb64e78f Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Thu, 15 Jan 2026 12:27:30 +0100 Subject: [PATCH 2/4] Updating URL to use CDN --- src/utils/urls/appUrlHelper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/urls/appUrlHelper.ts b/src/utils/urls/appUrlHelper.ts index 66f560dff2..7d956064ce 100644 --- a/src/utils/urls/appUrlHelper.ts +++ b/src/utils/urls/appUrlHelper.ts @@ -17,7 +17,7 @@ export const validPartiesUrl = `${appPath}/api/v1/parties?allowedtoinstantiatefi export const currentPartyUrl = `${appPath}/api/authorization/parties/current?returnPartyObject=true`; export const instancesControllerUrl = `${appPath}/instances`; export const refreshJwtTokenUrl = `${appPath}/api/authentication/keepAlive`; -export const postalCodesUrl = 'https://olemartin.org/postnummerregister/'; +export const postalCodesUrl = 'https://altinncdn.no/postcodes/registry.json'; export const updateCookieUrl = (partyId: string) => `${appPath}/api/v1/parties/${partyId}`; From c84e95ff4d7563389f1910ae796e2e6a2060dd1d Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Thu, 15 Jan 2026 13:22:09 +0100 Subject: [PATCH 3/4] Using fetch instead, as we can control referer-policy (axios failed to fetch this via CORS on this older version, but it worked on main) --- src/queries/queries.ts | 3 ++- test/e2e/integration/frontend-test/components.ts | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/queries/queries.ts b/src/queries/queries.ts index 14626dd5a6..48539599d5 100644 --- a/src/queries/queries.ts +++ b/src/queries/queries.ts @@ -54,4 +54,5 @@ export const fetchDataModelSchema = (dataTypeName: string): Promise export const fetchFormData = (url: string, options?: AxiosRequestConfig): Promise => httpGet(url, options); -export const fetchPostalCodes = (): Promise => httpGet(postalCodesUrl); +export const fetchPostalCodes = async (): Promise => + (await fetch(postalCodesUrl, { referrerPolicy: 'no-referrer' })).json(); diff --git a/test/e2e/integration/frontend-test/components.ts b/test/e2e/integration/frontend-test/components.ts index 543f6ca483..cb2a7cffac 100644 --- a/test/e2e/integration/frontend-test/components.ts +++ b/test/e2e/integration/frontend-test/components.ts @@ -175,7 +175,6 @@ describe('UI Components', () => { cy.get(appFrontend.changeOfName.address.zip_code).type('4609'); cy.get(appFrontend.changeOfName.address.zip_code).blur(); cy.get(appFrontend.changeOfName.address.post_place).should('have.value', 'KARDEMOMME BY'); - cy.get('@zipCodeApi').its('request.url').should('include', '0123'); }); it('radios, checkboxes and other components can be readOnly', () => { From 0a45826bcd593c9349305db855f8b24ec67a0c67 Mon Sep 17 00:00:00 2001 From: Ole Martin Handeland Date: Thu, 15 Jan 2026 13:28:40 +0100 Subject: [PATCH 4/4] Updating texts after earlier inbox link change --- src/features/receipt/ReceiptContainer.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/receipt/ReceiptContainer.test.tsx b/src/features/receipt/ReceiptContainer.test.tsx index 52e44e982c..732a01a0e2 100644 --- a/src/features/receipt/ReceiptContainer.test.tsx +++ b/src/features/receipt/ReceiptContainer.test.tsx @@ -247,7 +247,7 @@ describe('ReceiptContainer', () => { expect( screen.getByRole('link', { - name: /Kopi av din kvittering er sendt til ditt arkiv/i, + name: /Din kvittering er lagret og tilgjengelig i din innboks/i, }), ).toBeInTheDocument(); @@ -271,7 +271,7 @@ describe('ReceiptContainer', () => { expect( screen.getByRole('link', { - name: /Kopi av din kvittering er sendt til ditt arkiv/i, + name: /Din kvittering er lagret og tilgjengelig i din innboks/i, }), ).toBeInTheDocument();