Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 221 additions & 0 deletions src/layout/OrganisationLookup/OrganisationLookupComponent.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import React from 'react';

import { jest } from '@jest/globals';
import { screen, waitFor } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';

import { defaultDataTypeMock } from 'src/__mocks__/getLayoutSetsMock';
import { OrganisationLookupComponent } from 'src/layout/OrganisationLookup/OrganisationLookupComponent';
import { renderGenericComponentTest } from 'src/test/renderWithProviders';
import { httpGet } from 'src/utils/network/networking';
import type { ILayoutCollection } from 'src/layout/layout';
import type { RenderGenericComponentTestProps } from 'src/test/renderWithProviders';

jest.mock('src/utils/network/networking', () => ({
httpGet: jest.fn(),
}));

const mockedHttpGet = jest.mocked(httpGet);

const validOrgNr = '043871668';
const orgName = 'Skog og Fjell Consulting';
const orgLookupId = 'org-lookup';
const textSiblingId = 'text-sibling';

const defaultBindings = {
organisation_lookup_orgnr: { field: 'orgNr', dataType: defaultDataTypeMock },
organisation_lookup_name: { field: 'orgName', dataType: defaultDataTypeMock },
};

const render = async ({
component,
queries,
...rest
}: Partial<RenderGenericComponentTestProps<'OrganisationLookup'>> = {}) =>
await renderGenericComponentTest({
type: 'OrganisationLookup',
renderer: (props) => <OrganisationLookupComponent {...props} />,
mockFormDataSaving: true,
component: {
id: orgLookupId,
dataModelBindings: defaultBindings,
textResourceBindings: {
title: 'Organisation lookup',
},
...component,
},
queries: {
fetchFormData: async () => ({
orgNr: '',
orgName: '',
address: { name: '', street: '' },
}),
...queries,
},
...rest,
});

const layoutWithSiblingText: ILayoutCollection = {
FormLayout: {
data: {
layout: [
{
id: 'group-1',
type: 'Group',
children: [orgLookupId, textSiblingId],
},
{
id: orgLookupId,
type: 'OrganisationLookup',
dataModelBindings: defaultBindings,
textResourceBindings: {
title: 'Organisation lookup',
},
},
{
id: textSiblingId,
type: 'Text',
textResourceBindings: {
title: 'Org name title',
},
value: ['dataModel', 'address.name', defaultDataTypeMock],
},
{
id: 'text-street',
type: 'Text',
value: ['dataModel', 'address.street', defaultDataTypeMock],
},
],
},
},
};

describe('OrganisationLookupComponent', () => {
beforeEach(() => {
mockedHttpGet.mockReset();
});

it('renders lookup field and submit button', async () => {
await render();

expect(screen.getByRole('textbox', { name: /Organisasjonsnummer/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Hent opplysninger/i })).toBeInTheDocument();
});

it('shows validation error for invalid organisation number', async () => {
await render();

await userEvent.type(screen.getByRole('textbox', { name: /Organisasjonsnummer/i }), '123456789');
await userEvent.click(screen.getByRole('button', { name: /Hent opplysninger/i }));

expect(screen.getByText(/Organisasjonsnummeret er ugyldig/i)).toBeInTheDocument();
expect(mockedHttpGet).not.toHaveBeenCalled();

const statusRegion = screen.getByTestId('organisation-lookup-status');
await waitFor(() => {
expect(statusRegion).toHaveTextContent(/Organisasjonsnummeret er ugyldig/i);
});
});

it('fetches organisation, announces details, and allows clearing', async () => {
mockedHttpGet.mockResolvedValue({
success: true,
organisationDetails: {
orgNr: validOrgNr,
name: orgName,
},
});

await render({
queries: {
fetchLayouts: async (_layoutSetId): Promise<ILayoutCollection> => layoutWithSiblingText,
},
mockFormDataSaving: (data) => ({
...(data as object),
address: { name: 'Sibling Name', street: 'Street 1' },
}),
});

await userEvent.type(screen.getByRole('textbox', { name: /Organisasjonsnummer/i }), validOrgNr);
await userEvent.click(screen.getByRole('button', { name: /Hent opplysninger/i }));

await waitFor(() => expect(mockedHttpGet).toHaveBeenCalled());
await waitFor(() => expect(screen.getByRole('button', { name: /Fjern/i })).toBeInTheDocument());

expect(screen.getByLabelText('Organisasjonsnavn')).toHaveTextContent(orgName);

const statusRegion = screen.getByTestId('organisation-lookup-status');
await waitFor(() => {
expect(statusRegion).toHaveTextContent(`Organisasjonsnummer ${validOrgNr}`);
expect(statusRegion).toHaveTextContent('Sibling Name');
});

await userEvent.click(screen.getByRole('button', { name: /Fjern/i }));

expect(screen.getByRole('button', { name: /Hent opplysninger/i })).toBeInTheDocument();
expect(statusRegion).toHaveTextContent('');
});

it('submits lookup on Enter key', async () => {
mockedHttpGet.mockResolvedValue({
success: true,
organisationDetails: {
orgNr: validOrgNr,
name: orgName,
},
});

await render();

await userEvent.type(screen.getByRole('textbox', { name: /Organisasjonsnummer/i }), `${validOrgNr}{Enter}`);

await waitFor(() => expect(mockedHttpGet).toHaveBeenCalled());
});

it('shows not found error when lookup returns no organisation', async () => {
mockedHttpGet.mockResolvedValue({
success: false,
organisationDetails: null,
});

await render();

await userEvent.type(screen.getByRole('textbox', { name: /Organisasjonsnummer/i }), validOrgNr);
await userEvent.click(screen.getByRole('button', { name: /Hent opplysninger/i }));

expect(await screen.findByText(/Organisasjonsnummeret ble ikke funnet i enhetsregisteret/i)).toBeInTheDocument();
});

it('shows invalid response error when lookup response is invalid', async () => {
mockedHttpGet.mockResolvedValue({ unexpected: true });

await render();

await userEvent.type(screen.getByRole('textbox', { name: /Organisasjonsnummer/i }), validOrgNr);
await userEvent.click(screen.getByRole('button', { name: /Hent opplysninger/i }));

expect(await screen.findByText(/Ugyldig respons fra server/i)).toBeInTheDocument();
});

it('shows unknown error when lookup request fails', async () => {
mockedHttpGet.mockRejectedValue(new Error('network error'));

await render();

await userEvent.type(screen.getByRole('textbox', { name: /Organisasjonsnummer/i }), validOrgNr);
await userEvent.click(screen.getByRole('button', { name: /Hent opplysninger/i }));

expect(await screen.findByText(/Ukjent feil. Vennligst prøv igjen senere/i)).toBeInTheDocument();
});

it('does not render action buttons when read only', async () => {
await render({
component: {
readOnly: true,
},
});

expect(screen.queryByRole('button', { name: /Hent opplysninger/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Fjern/i })).not.toBeInTheDocument();
});
});
83 changes: 75 additions & 8 deletions src/layout/OrganisationLookup/OrganisationLookupComponent.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useRef, useState } from 'react';

import { Field, Paragraph, ValidationMessage } from '@digdir/designsystemet-react';
import { queryOptions, useQuery } from '@tanstack/react-query';
Expand All @@ -12,12 +12,16 @@
import { Description } from 'src/components/form/Description';
import { RequiredIndicator } from 'src/components/form/RequiredIndicator';
import { getDescriptionId } from 'src/components/label/Label';
import { useLayoutLookups } from 'src/features/form/layout/LayoutsContext';
import { FD } from 'src/features/formData/FormDataWrite';
import { useDataModelBindings } from 'src/features/formData/useDataModelBindings';
import { Lang } from 'src/features/language/Lang';
import { useCurrentLanguage } from 'src/features/language/LanguageProvider';
import { useLanguage } from 'src/features/language/useLanguage';
import { ComponentStructureWrapper } from 'src/layout/ComponentStructureWrapper';
import classes from 'src/layout/OrganisationLookup/OrganisationLookupComponent.module.css';
import { validateOrganisationLookupResponse, validateOrgnr } from 'src/layout/OrganisationLookup/validation';
import utilClasses from 'src/styles/utils.module.css';
import { useLabel } from 'src/utils/layout/useLabel';
import { useItemWhenType } from 'src/utils/layout/useNodeItem';
import { httpGet } from 'src/utils/network/networking';
Expand All @@ -33,6 +37,8 @@
}),
};

const LIVE_REGION_RESET_DELAY_MS = 100;

export type Organisation = {
orgNr: string;
name: string;
Expand Down Expand Up @@ -75,40 +81,89 @@
});
const [tempOrgNr, setTempOrgNr] = useState('');
const [orgNrErrors, setOrgNrErrors] = useState<string[]>();
const [statusMessage, setStatusMessage] = useState('');
const statusRef = useRef<HTMLDivElement>(null);

const {
formData: { organisation_lookup_orgnr, organisation_lookup_name: orgName },
setValue,
} = useDataModelBindings(dataModelBindings);

const { langAsString } = useLanguage();
const currentLanguage = useCurrentLanguage();
const layoutLookups = useLayoutLookups();
const pickFormValue = FD.useCurrentSelector();
const waitForSave = FD.useWaitForSave();

const { data, refetch: performLookup, isFetching } = useQuery(orgLookupQueries.lookup(tempOrgNr));

function handleValidateOrgnr(orgNr: string) {
function announceStatusMessage(message: string) {
setStatusMessage('');
window.setTimeout(() => {

Check warning on line 102 in src/layout/OrganisationLookup/OrganisationLookupComponent.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=Altinn_app-frontend-react&issues=AZ62btbSypqkM8mGofGw&open=AZ62btbSypqkM8mGofGw&pullRequest=4264
setStatusMessage(message);
statusRef.current?.focus();
}, LIVE_REGION_RESET_DELAY_MS);
}

function announceOrgDetails(orgNr: string) {
const parts = [`${langAsString('organisation_lookup.orgnr_label')} ${orgNr}`];

const parent = layoutLookups.componentToParent[baseComponentId];
const childIds = parent?.type === 'node' ? layoutLookups.componentToChildren[parent.id] : undefined;
const lookupIndex = childIds?.indexOf(baseComponentId) ?? -1;

for (const childId of childIds?.slice(lookupIndex + 1) ?? []) {
const component = layoutLookups.allComponents[childId];
if (component?.type !== 'Text' || !Array.isArray(component.value) || component.value[0] !== 'dataModel') {
continue;
}

const [, field, dataType] = component.value;
if (typeof field !== 'string' || typeof dataType !== 'string') {
continue;
}

const textValue = String(pickFormValue({ field, dataType }) ?? '').trim();

Check warning on line 126 in src/layout/OrganisationLookup/OrganisationLookupComponent.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'pickFormValue({ field, dataType }) ?? ''' will use Object's default stringification format ('[object Object]') when stringified.

See more on https://sonarcloud.io/project/issues?id=Altinn_app-frontend-react&issues=AZ62btbSypqkM8mGofGv&open=AZ62btbSypqkM8mGofGv&pullRequest=4264
if (!textValue) {
continue;
}

const titleKey = component.textResourceBindings?.title;
parts.push(typeof titleKey === 'string' ? `${langAsString(titleKey)} ${textValue}` : textValue);
}

announceStatusMessage(parts.join(', '));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function handleValidateOrgnr(orgNr: string): string[] | undefined {
if (!validateOrgnr({ orgNr })) {
const errors = validateOrgnr.errors
?.filter((error) => error.instancePath === '/orgNr')
.map((error) => error.message)
.filter((it) => it != null);
setOrgNrErrors(errors);
return false;
return errors;
}
setOrgNrErrors(undefined);
return true;
return undefined;
}

async function handleSubmit() {
const isValid = handleValidateOrgnr(tempOrgNr);
const validationErrors = handleValidateOrgnr(tempOrgNr);

if (!isValid) {
if (validationErrors?.length) {
announceStatusMessage(langAsString(validationErrors.join(' ')));
return;
}

const { data } = await performLookup();
if (data?.org) {
setValue('organisation_lookup_orgnr', data.org.orgNr);
dataModelBindings.organisation_lookup_name && setValue('organisation_lookup_name', data.org.name);
await waitForSave(true);
announceOrgDetails(data.org.orgNr);
} else if (data?.error) {
announceStatusMessage(langAsString(data.error));
}
}

Expand All @@ -117,6 +172,7 @@
dataModelBindings.organisation_lookup_name && setValue('organisation_lookup_name', '');
setTempOrgNr('');
setOrgNrErrors(undefined);
setStatusMessage('');
}

const hasSuccessfullyFetched = !!organisation_lookup_orgnr;
Expand Down Expand Up @@ -157,10 +213,11 @@
value={hasSuccessfullyFetched ? organisation_lookup_orgnr : tempOrgNr}
required={required}
readOnly={hasSuccessfullyFetched || isFetching || readOnly}
error={isValid}
error={!!isValid}
onValueChange={(e) => {
setTempOrgNr(e.value);
setOrgNrErrors(undefined);
setStatusMessage('');
}}
onKeyDown={async (ev) => {
if (ev.key === 'Enter' && !readOnly) {
Expand Down Expand Up @@ -207,14 +264,24 @@
</ValidationMessage>
)}
{hasSuccessfullyFetched && orgName && (
<div
className={classes.orgname}
role='group'
aria-label={langAsString('organisation_lookup.org_name')}
>

Check warning on line 271 in src/layout/OrganisationLookup/OrganisationLookupComponent.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <details>, <fieldset>, <optgroup>, or <address> instead of the "group" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=Altinn_app-frontend-react&issues=AZ62btbSypqkM8mGofGx&open=AZ62btbSypqkM8mGofGx&pullRequest=4264
{hasSuccessfullyFetched && <Paragraph data-size='sm'>{orgName}</Paragraph>}
<Paragraph data-size='sm'>{orgName}</Paragraph>
</div>
)}
</div>
<div
ref={statusRef}
tabIndex={-1}
lang={currentLanguage}
data-testid='organisation-lookup-status'
className={utilClasses.visuallyHidden}
>
{statusMessage}
</div>
</ComponentStructureWrapper>
</Fieldset>
);
Expand Down
Loading
Loading