diff --git a/src/features/expressions/shared-tests/functions/displayValue/organisation-lookup.json b/src/features/expressions/shared-tests/functions/displayValue/organisation-lookup.json new file mode 100644 index 000000000..f744251dc --- /dev/null +++ b/src/features/expressions/shared-tests/functions/displayValue/organisation-lookup.json @@ -0,0 +1,33 @@ +{ + "name": "Display value of Organisation Lookup component", + "expression": [ + "displayValue", + "organisationlookup" + ], + "context": { + "component": "organisationlookup", + "currentLayout": "Page" + }, + "expects": "043871668", + "layouts": { + "Page": { + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "organisationlookup", + "type": "OrganisationLookup", + "dataModelBindings": { + "organisation_lookup_orgnr": "OrganisationLookup.OrgNr" + } + } + ] + } + } + }, + "dataModel": { + "OrganisationLookup": { + "OrgNr": "043871668" + } + } +} diff --git a/src/language/texts/en.ts b/src/language/texts/en.ts index 4c7453a4c..b6a680c15 100644 --- a/src/language/texts/en.ts +++ b/src/language/texts/en.ts @@ -317,6 +317,15 @@ export function en() { vat: 'VAT', }, }, + organisation_lookup: { + orgnr_label: 'Organisation number', + org_name: 'Organisation name', + from_registry_description: 'From the CCR', + validation_error_not_found: 'Organisation number not found in the registry', + validation_invalid_response_from_server: 'Invalid response from the server', + unknown_error: 'An unknown error occurred. Please try again later', + validation_error_orgnr: 'The organisation number is invalid', + }, person_lookup: { ssn_label: 'National ID number/D-number', surname_label: 'Surname', diff --git a/src/language/texts/nb.ts b/src/language/texts/nb.ts index 21672f88b..506ff89f5 100644 --- a/src/language/texts/nb.ts +++ b/src/language/texts/nb.ts @@ -318,6 +318,15 @@ export function nb(): FixedLanguageList { vat: 'MVA', }, }, + organisation_lookup: { + orgnr_label: 'Organisasjonsnummer', + org_name: 'Organisasjonsnavn', + from_registry_description: 'Fra enhetsregisteret', + validation_error_not_found: 'Organisasjonsnummeret ble ikke funnet i enhetsregisteret', + validation_invalid_response_from_server: 'Ugyldig respons fra server', + unknown_error: 'Ukjent feil. Vennligst prøv igjen senere', + validation_error_orgnr: 'Organisasjonsnummeret er ugyldig', + }, person_lookup: { ssn_label: 'Fødselsnummer', surname_label: 'Etternavn', diff --git a/src/language/texts/nn.ts b/src/language/texts/nn.ts index 2fe92e4a0..4203c421f 100644 --- a/src/language/texts/nn.ts +++ b/src/language/texts/nn.ts @@ -318,6 +318,15 @@ export function nn(): FixedLanguageList { vat: 'MVA', }, }, + organisation_lookup: { + orgnr_label: 'Organisasjonsnummer', + org_name: 'Organisasjonsnamn', + from_registry_description: 'Frå enhetsregisteret', + validation_error_not_found: 'Organisasjonsnummeret er ikkje funne i registeret', + validation_invalid_response_from_server: 'Ugyldig respons frå server', + unknown_error: 'Ukjent feil. Ver venleg og prøv igjen seinare', + validation_error_orgnr: 'Organisasjonsnummeret er ugyldig', + }, person_lookup: { ssn_label: 'Fødselsnummer', surname_label: 'Etternamn', diff --git a/src/layout/OrganisationLookup/OrganisationLookupComponent.module.css b/src/layout/OrganisationLookup/OrganisationLookupComponent.module.css new file mode 100644 index 000000000..ed180861c --- /dev/null +++ b/src/layout/OrganisationLookup/OrganisationLookupComponent.module.css @@ -0,0 +1,51 @@ +/* breakpoints-xs */ +@media only screen and (min-width: 0) { + .componentWrapper { + grid-template-areas: + 'orgnrLabel' + 'orgnr' + 'orgname' + 'submit' + 'apiError'; + gap: var(--fds-spacing-2); + } +} + +/* breakpoints-md */ +@media only screen and (min-width: 840px) { + .componentWrapper { + grid-template-columns: repeat(2, minmax(250px, 250px)) auto; + grid-template-areas: + 'orgnrLabel .' + 'orgnr submit' + 'orgname .' + 'apiError .'; + align-items: end; + column-gap: var(--fds-spacing-2); + } +} + +.componentWrapper { + display: grid; + + .orgnrLabel { + grid-area: orgnrLabel; + align-self: start; + } + + .orgnr { + grid-area: orgnr; + align-self: start; + } + + .orgname { + grid-area: orgname; + align-self: start; + } + + .submit { + grid-area: submit; + align-self: start; + white-space: nowrap; + } +} diff --git a/src/layout/OrganisationLookup/OrganisationLookupComponent.tsx b/src/layout/OrganisationLookup/OrganisationLookupComponent.tsx new file mode 100644 index 000000000..2a8f90a10 --- /dev/null +++ b/src/layout/OrganisationLookup/OrganisationLookupComponent.tsx @@ -0,0 +1,210 @@ +import React, { useState } from 'react'; + +import { ErrorMessage } from '@digdir/designsystemet-react'; +import { queryOptions, useQuery } from '@tanstack/react-query'; + +import type { PropsFromGenericComponent } from '..'; + +import { Button } from 'src/app-components/Button/Button'; +import { NumericInput } from 'src/app-components/Input/NumericInput'; +import { Fieldset } from 'src/app-components/Label/Fieldset'; +import { Label } from 'src/app-components/Label/Label'; +import { Description } from 'src/components/form/Description'; +import { RequiredIndicator } from 'src/components/form/RequiredIndicator'; +import { getDescriptionId } from 'src/components/label/Label'; +import { useDataModelBindings } from 'src/features/formData/useDataModelBindings'; +import { Lang } from 'src/features/language/Lang'; +import { useLanguage } from 'src/features/language/useLanguage'; +import { ComponentValidations } from 'src/features/validation/ComponentValidations'; +import { useBindingValidationsForNode } from 'src/features/validation/selectors/bindingValidationsForNode'; +import { hasValidationErrors } from 'src/features/validation/utils'; +import { ComponentStructureWrapper } from 'src/layout/ComponentStructureWrapper'; +import classes from 'src/layout/OrganisationLookup/OrganisationLookupComponent.module.css'; +import { validateOrganisationLookupResponse, validateOrgnr } from 'src/layout/OrganisationLookup/validation'; +import { useLabel } from 'src/utils/layout/useLabel'; +import { useNodeItem } from 'src/utils/layout/useNodeItem'; +import { httpGet } from 'src/utils/network/networking'; +import { appPath } from 'src/utils/urls/appUrlHelper'; + +const orgLookupQueries = { + lookup: (orgNr: string) => + queryOptions({ + queryKey: [{ scope: 'organisationLookup', orgNr }], + queryFn: () => fetchOrg(orgNr), + enabled: false, + gcTime: 0, + }), +}; + +export type Organisation = { + orgNr: string; + name: string; +}; +export type OrganisationLookupResponse = + | { success: false; organisationDetails: null } + | { success: true; organisationDetails: Organisation }; + +async function fetchOrg(orgNr: string): Promise<{ org: Organisation; error: null } | { org: null; error: string }> { + if (!orgNr) { + throw new Error('orgNr is required'); + } + const url = `${appPath}/api/v1/lookup/organisation/${orgNr}`; + + try { + const response = await httpGet(url); + + if (!validateOrganisationLookupResponse(response)) { + return { org: null, error: 'organisation_lookup.validation_invalid_response_from_server' }; + } + + if (!response.success || !response.organisationDetails) { + return { org: null, error: 'organisation_lookup.validation_error_not_found' }; + } + + return { org: response.organisationDetails, error: null }; + } catch { + return { org: null, error: 'organisation_lookup.unknown_error' }; + } +} + +export function OrganisationLookupComponent({ + node, + overrideDisplay, +}: PropsFromGenericComponent<'OrganisationLookup'>) { + const { id, dataModelBindings, required } = useNodeItem(node); + const { labelText, getHelpTextComponent, getDescriptionComponent } = useLabel({ node, overrideDisplay }); + const [orgName, setOrgName] = useState(''); + const [tempOrgNr, setTempOrgNr] = useState(''); + const [orgNrErrors, setOrgNrErrors] = useState(); + + const { + formData: { organisation_lookup_orgnr }, + setValue, + } = useDataModelBindings(dataModelBindings); + + const bindingValidations = useBindingValidationsForNode(node); + const { langAsString } = useLanguage(); + + const { data, refetch: performLookup, isFetching } = useQuery(orgLookupQueries.lookup(tempOrgNr)); + + function handleValidateOrgnr(orgNr: string) { + if (!validateOrgnr({ orgNr })) { + const errors = validateOrgnr.errors + ?.filter((error) => error.instancePath === '/orgNr') + .map((error) => error.message) + .filter((it) => it != null); + + setOrgNrErrors(errors); + return false; + } + setOrgNrErrors(undefined); + return true; + } + + async function handleSubmit() { + const isValid = handleValidateOrgnr(tempOrgNr); + + if (!isValid) { + return; + } + + const { data } = await performLookup(); + if (data?.org) { + setOrgName(data.org.name); + setValue('organisation_lookup_orgnr', data.org.orgNr); + dataModelBindings.organisation_lookup_name && setValue('organisation_lookup_name', data.org.name); + } + } + + function handleClear() { + setValue('organisation_lookup_orgnr', ''); + dataModelBindings.organisation_lookup_name && setValue('organisation_lookup_name', ''); + setOrgName(''); + setTempOrgNr(''); + } + + const hasSuccessfullyFetched = !!organisation_lookup_orgnr; + + return ( +
+ +
+
+
+ ) || + (hasValidationErrors(bindingValidations?.organisation_lookup_orgnr) && ( + + )) + } + onValueChange={(e) => { + setTempOrgNr(e.value); + }} + onBlur={(e) => handleValidateOrgnr(e.target.value)} + allowLeadingZeros + /> +
+ {!hasSuccessfullyFetched ? ( + + ) : ( + + )} +
+ {data?.error && ( + + + + )} +
+ {hasSuccessfullyFetched && {orgName}} +
+
+
+
+ ); +} diff --git a/src/layout/OrganisationLookup/OrganisationLookupSummary.module.css b/src/layout/OrganisationLookup/OrganisationLookupSummary.module.css new file mode 100644 index 000000000..c245f0e3d --- /dev/null +++ b/src/layout/OrganisationLookup/OrganisationLookupSummary.module.css @@ -0,0 +1,29 @@ +/* breakpoints-xs */ +@media only screen and (min-width: 0) { + .organisationLookupSummary { + display: flex; + flex-direction: column; + width: 100%; + } +} + +/* breakpoints-md */ +@media only screen and (min-width: 840px) { + .organisationLookupSummary { + flex-direction: row; + } +} + +.organisationSummaryWrapper { + display: flex; + flex-direction: column; + gap: var(--fds-spacing-6); + width: 100%; +} + +.organisationLookupSummaryNr { + flex-grow: 5; +} +.organisationLookupSummaryName { + flex-grow: 4; +} diff --git a/src/layout/OrganisationLookup/OrganisationLookupSummary.tsx b/src/layout/OrganisationLookup/OrganisationLookupSummary.tsx new file mode 100644 index 000000000..36f71d087 --- /dev/null +++ b/src/layout/OrganisationLookup/OrganisationLookupSummary.tsx @@ -0,0 +1,79 @@ +import React from 'react'; + +import { Heading } from '@digdir/designsystemet-react'; + +import { useDataModelBindings } from 'src/features/formData/useDataModelBindings'; +import { Lang } from 'src/features/language/Lang'; +import { ComponentValidations } from 'src/features/validation/ComponentValidations'; +import { useBindingValidationsForNode } from 'src/features/validation/selectors/bindingValidationsForNode'; +import classes from 'src/layout/OrganisationLookup/OrganisationLookupSummary.module.css'; +import { SingleValueSummary } from 'src/layout/Summary2/CommonSummaryComponents/SingleValueSummary'; +import { useNodeItem } from 'src/utils/layout/useNodeItem'; +import type { LayoutNode } from 'src/utils/layout/LayoutNode'; + +interface OrganisationLookupSummaryProps { + componentNode: LayoutNode<'OrganisationLookup'>; +} + +export function OrganisationLookupSummary({ componentNode }: OrganisationLookupSummaryProps) { + const { dataModelBindings, title } = useNodeItem(componentNode, (i) => ({ + dataModelBindings: i.dataModelBindings, + title: i.textResourceBindings?.title, + })); + const { formData } = useDataModelBindings(dataModelBindings); + const { organisation_lookup_orgnr, organisation_lookup_name } = formData; + + const bindingValidations = useBindingValidationsForNode(componentNode); + + return ( +
+ + + +
+
+ + } + displayData={organisation_lookup_orgnr} + componentNode={componentNode} + hideEditButton={organisation_lookup_name ? true : false} + /> + +
+ {organisation_lookup_name && ( +
+ + } + displayData={organisation_lookup_name} + componentNode={componentNode} + hideEditButton={false} + /> + +
+ )} +
+
+ ); +} diff --git a/src/layout/OrganisationLookup/config.ts b/src/layout/OrganisationLookup/config.ts new file mode 100644 index 000000000..7ad51062f --- /dev/null +++ b/src/layout/OrganisationLookup/config.ts @@ -0,0 +1,60 @@ +import { CG } from 'src/codegen/CG'; +import { CompCategory } from 'src/layout/common'; + +export const Config = new CG.component({ + category: CompCategory.Form, + capabilities: { + renderInTable: false, + renderInButtonGroup: false, + renderInAccordion: true, + renderInAccordionGroup: false, + renderInCards: false, + renderInCardsMedia: false, + renderInTabs: true, + }, + functionality: { + customExpressions: false, + }, +}) + .addDataModelBinding( + new CG.obj( + new CG.prop( + 'organisation_lookup_orgnr', + new CG.dataModelBinding() + .setTitle('Data binding for organisation number') + .setDescription( + 'Describes the location in the data model where the component should store the organisation number of the organisation to look up.', + ), + ), + new CG.prop( + 'organisation_lookup_name', + new CG.dataModelBinding() + .setTitle('Data binding for organisation name') + .setDescription( + 'Describes the location in the data model where the component should store the name of the organisation.', + ) + .optional(), + ), + ), + ) + .addTextResource( + new CG.trb({ + name: 'title', + title: 'Title', + description: 'The title of the component', + }), + ) + .addTextResource( + new CG.trb({ + name: 'description', + title: 'Description', + description: 'Description, optionally shown below the title', + }), + ) + .addTextResource( + new CG.trb({ + name: 'help', + title: 'Help Text', + description: 'Help text, optionally shown next to the title', + }), + ); diff --git a/src/layout/OrganisationLookup/index.tsx b/src/layout/OrganisationLookup/index.tsx new file mode 100644 index 000000000..fb77b2707 --- /dev/null +++ b/src/layout/OrganisationLookup/index.tsx @@ -0,0 +1,34 @@ +import React, { forwardRef } from 'react'; +import type { JSX } from 'react'; + +import type { PropsFromGenericComponent } from '..'; + +import { OrganisationLookupDef } from 'src/layout/OrganisationLookup/config.def.generated'; +import { OrganisationLookupComponent } from 'src/layout/OrganisationLookup/OrganisationLookupComponent'; +import { OrganisationLookupSummary } from 'src/layout/OrganisationLookup/OrganisationLookupSummary'; +import type { LayoutValidationCtx } from 'src/features/devtools/layoutValidation/types'; +import type { DisplayDataProps } from 'src/features/displayData'; +import type { SummaryRendererProps } from 'src/layout/LayoutComponent'; +import type { Summary2Props } from 'src/layout/Summary2/SummaryComponent2/types'; +import type { LayoutNode } from 'src/utils/layout/LayoutNode'; + +export class OrganisationLookup extends OrganisationLookupDef { + validateDataModelBindings(_ctx: LayoutValidationCtx<'OrganisationLookup'>): string[] { + return []; + } + getDisplayData(node: LayoutNode<'OrganisationLookup'>, { nodeFormDataSelector }: DisplayDataProps): string { + const data = nodeFormDataSelector(node); + return Object.values(data).join(', '); + } + renderSummary2(props: Summary2Props<'OrganisationLookup'>): JSX.Element | null { + return ; + } + renderSummary(_props: SummaryRendererProps<'OrganisationLookup'>): JSX.Element | null { + throw new Error('Method not implemented.'); + } + render = forwardRef>( + function LayoutComponentOrganisationLookupRender(props, _): JSX.Element | null { + return ; + }, + ); +} diff --git a/src/layout/OrganisationLookup/validation.test.ts b/src/layout/OrganisationLookup/validation.test.ts new file mode 100644 index 000000000..66b50103a --- /dev/null +++ b/src/layout/OrganisationLookup/validation.test.ts @@ -0,0 +1,16 @@ +import { checkValidOrgnNr } from 'src/layout/OrganisationLookup/validation'; + +describe('CheckValidOrgNr', () => { + it('should return true when the orgNr is valid', () => { + expect(checkValidOrgnNr('043871668')).toBe(true); + }); + it('should return false when the orgNr is invalid', () => { + expect(checkValidOrgnNr('143871668')).toBe(false); + }); + it('should return false when the orgNr is too short', () => { + expect(checkValidOrgnNr('12345678')).toBe(false); + }); + it('should return false when the orgNr is too long', () => { + expect(checkValidOrgnNr('1234567890')).toBe(false); + }); +}); diff --git a/src/layout/OrganisationLookup/validation.ts b/src/layout/OrganisationLookup/validation.ts new file mode 100644 index 000000000..0f4427186 --- /dev/null +++ b/src/layout/OrganisationLookup/validation.ts @@ -0,0 +1,80 @@ +import { Ajv, type JSONSchemaType } from 'ajv'; +import adderrors from 'ajv-errors'; + +import type { + Organisation, + OrganisationLookupResponse, +} from 'src/layout/OrganisationLookup/OrganisationLookupComponent'; + +const ajv = new Ajv({ allErrors: true }); +adderrors(ajv); + +ajv.addKeyword({ + keyword: 'isValidOrgNr', + type: 'string', + validate: (_, data: string) => { + if (typeof data !== 'string') { + return false; + } + + return checkValidOrgnNr(data); + }, +}); + +const orgNrSchema: JSONSchemaType> = { + type: 'object', + properties: { + orgNr: { + type: 'string', + isValidOrgNr: true, + errorMessage: 'organisation_lookup.validation_error_orgnr', + }, + }, + required: ['orgNr'], +}; + +export function checkValidOrgnNr(orgNr: string): boolean { + if (orgNr.length !== 9) { + return false; + } + const [a1, a2, a3, a4, a5, a6, a7, a8, a9] = orgNr.split('').map(Number); + const allegedCheckDigit = a9; + + const [w1, w2, w3, w4, w5, w6, w7, w8] = [3, 2, 7, 6, 5, 4, 3, 2]; + const sum = a1 * w1 + a2 * w2 + a3 * w3 + a4 * w4 + a5 * w5 + a6 * w6 + a7 * w7 + a8 * w8; + const calculatedCheckDigit = 11 - (sum % 11); + + return calculatedCheckDigit === allegedCheckDigit; +} + +export const validateOrgnr = ajv.compile(orgNrSchema); + +const organisationLookupResponseSchema: JSONSchemaType = { + type: 'object', + oneOf: [ + { + properties: { + success: { const: false }, + organisationDetails: { type: 'null' }, + }, + required: ['success', 'organisationDetails'], + }, + { + properties: { + success: { const: true }, + organisationDetails: { + type: 'object', + properties: { + orgNr: { type: 'string' }, + name: { type: 'string' }, + }, + required: ['orgNr', 'name'], + }, + }, + required: ['success', 'organisationDetails'], + }, + ], + required: ['success', 'organisationDetails'], +}; + +export const validateOrganisationLookupResponse = ajv.compile(organisationLookupResponseSchema); diff --git a/test/e2e/integration/component-library/organisationlookup.ts b/test/e2e/integration/component-library/organisationlookup.ts new file mode 100644 index 000000000..0b6628de0 --- /dev/null +++ b/test/e2e/integration/component-library/organisationlookup.ts @@ -0,0 +1,79 @@ +import { AppFrontend } from 'test/e2e/pageobjects/app-frontend'; + +const appFrontend = new AppFrontend(); + +describe('Organisation lookup', () => { + it('Renders the organisation lookup component correctly', () => { + cy.intercept('GET', '/ttd/component-library/api/v1/lookup/organisation/*', { + statusCode: 200, + body: { + success: true, + organisationDetails: { + orgNr: '043871668', + name: 'Skog og Fjell Consulting', + }, + }, + }).as('successfullyFetchedOrganisation'); + + // Contrary to person looku, organisation lookup does not require authentication level >2 + cy.startAppInstance(appFrontend.apps.componentLibrary, { authenticationLevel: '1' }); + cy.gotoNavPage('OrganisationLookupPage'); + + // Check that the component is rendered + cy.findByText(/Her legger du inn organisasjonsnummer/i).should('exist'); + cy.findByRole('button', { name: /Hjelpetekst for legg til virksomhet/i }).should('exist'); + cy.findByRole('textbox', { name: /Organisasjonsnummer/i }).should('exist'); + cy.findByRole('button', { name: /Hent opplysninger/i }).should('exist'); + + // Type invalid orgNr + cy.findByRole('textbox', { name: /Organisasjonsnummer/i }).type('123456789'); + cy.findByRole('textbox', { name: /Organisasjonsnummer/i }).blur(); + cy.findByText(/Organisasjonsnummeret er ugyldig/i).should('exist'); + + // Type valid orgNr + cy.findByRole('textbox', { name: /Organisasjonsnummer/i }).clear(); + cy.findByRole('textbox', { name: /Organisasjonsnummer/i }).type('043871668'); + cy.findByRole('textbox', { name: /Organisasjonsnummer/i }).blur(); + cy.findByText(/Organisasjonsnummeret er ugyldig/i).should('not.exist'); + + // Fetch organisation + cy.findByRole('button', { name: /Hent opplysninger/i }).click(); + cy.wait('@successfullyFetchedOrganisation'); + cy.findByRole('button', { name: /Fjern/i }).should('exist'); + cy.findByRole('textbox', { name: /Organisasjonsnummer/i, description: /Fra enhetsregisteret/i }).should('exist'); + cy.findByLabelText('Organisasjonsnavn').within(() => { + cy.findByText(/Skog og Fjell Consulting/i).should('exist'); + }); + + // Remove organisation + cy.findByRole('button', { name: /Fjern/i }).click(); + cy.findByRole('button', { name: /Fjern/i }).should('not.exist'); + + // Add interceptor for failed fetch + cy.intercept('GET', '/ttd/component-library/api/v1/lookup/organisation/*', { + statusCode: 200, + body: { + success: false, + organisationDetails: null, + }, + }).as('failedFetchOrganisation'); + + // Fetch organisation that does not exist + cy.findByRole('textbox', { name: /Organisasjonsnummer/i }).type('043871668'); + cy.findByRole('button', { name: /Hent opplysninger/i }).click(); + cy.wait('@failedFetchOrganisation'); + cy.findByText(/Organisasjonsnummeret ble ikke funnet i enhetsregisteret/i).should('exist'); + + // Add interceptor for failed fetch due to server error + cy.intercept('GET', '/ttd/component-library/api/v1/lookup/organisation/*', { + statusCode: 500, + }).as('failedFetchOrganisationServerError'); + + // Fetch organisation with server error + cy.findByRole('textbox', { name: /Organisasjonsnummer/i }).clear(); + cy.findByRole('textbox', { name: /Organisasjonsnummer/i }).type('043871668'); + cy.findByRole('button', { name: /Hent opplysninger/i }).click(); + cy.wait('@failedFetchOrganisationServerError'); + cy.findByText(/Ukjent feil. Vennligst prøv igjen senere/i).should('exist'); + }); +});