From 29fb2815fa79739a8b866feda2b31dfc9615186b Mon Sep 17 00:00:00 2001 From: AlexisAfonso Date: Fri, 21 Aug 2026 15:38:24 +0200 Subject: [PATCH] feat(account): improve UX design for search assistant for FR e-invoicing (#23131) * feat(account): improve UX design for search assistant for FR e-invoicing The user experience on the manager for B2B/B2G account is still not comprehensible => Customers still need to contact support to have information of how they can do the company completion, and how they can add the e-invoicing address ref: #MANAGER-22144 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Alexis Afonso * feat(account): auto-search the SIRET already held when the lookup modal opens Opening the company lookup now runs the search straight away when the account already holds a valid SIRET, so the customer lands on the company found instead of retyping it. A response is dropped when the SIRET has since been edited, or when the modal was closed. The intro then asks the customer to check their information, and the SIRET field carries the ui-kit error state. Field errors of the edition form no longer show up before the customer touched them. ref: #MANAGER-22144 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Alexis Afonso * feat(account): tell the customer what to complete on a non-disclosed company The directory withholds the data of some companies, answering either the [ND] token or an empty string depending on the provider. Empty values were not detected at all, so the lookup silently blanked fields with no explanation, and the company name kept the value of the previously searched company, which also left the field locked. Both shapes now count as missing, on the name and on the three parts of the address. The modal lists what has to be filled in, its confirm button asks to complete the information rather than to validate, and validating focuses the first field to fill. ref: #MANAGER-22144 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Alexis Afonso * test(sign-up): cover the SIRET search assistant and its lookup modal The module had no test harness at all, so the three bugs found while building the lookup modal went unnoticed until they were reproduced by hand: an empty value was not recognised as withheld data, the company name inherited the previously searched company and stayed locked, and a stale lookup response overwrote the selection. Adds the vitest setup the rest of the repo uses, and 119 tests over the pure helpers, the modal controller and the siret controller. Each of those three bugs was reintroduced to check the suite fails, then reverted. ref: #MANAGER-22144 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Alexis Afonso * fix(account): fill the lookup field with the SIRET held, searchable or not The field was only filled in when the value already was a searchable 14-digit SIRET, because seeding it sat inside that check. An account holding a SIREN, a partially formatted value or a malformed one therefore opened the modal on an empty field, and the customer had to retype what we already knew. Seed the field with whatever is held, and keep the automatic lookup for a usable 14-digit SIRET only: searching a value known to be incomplete would greet the customer with an error they did not ask for. The tests missed it by asserting no lookup fired without asserting what the field showed, so they now cover the field content on both paths. ref: #MANAGER-22144 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Alexis Afonso * fix(account): only clear the e-invoicing address on evidence against it The selected e-invoicing billing address was emptied by things that said nothing about it. A failed PUT /me broadcast the stale-address warning on any 400, and the rules refresh that followed could drop the e-invoicing entry, which deleted the model value and took the picker off the screen with it. The form also refetches /newAccount/rules on every field change, so a refresh triggered by another field cleared the selection on its own. An error forced on the company data therefore cost the customer a choice the API had never pointed at. A submit error never names the field it rejected, so every path now asks the directory instead. The address is stale only when the refreshed rules offer other addresses without it; an answer that brings none, or that omits the entry altogether, is no evidence, and the entry is carried over so the picker keeps its value. A change of company still clears it, since another company may genuinely have no address (RG5), and the picker still hides itself once the account stops being eligible. Adds the vitest setup this module was missing, along with 23 tests over both decisions, each verified by reintroducing the behaviour it replaces. ref: #MANAGER-22144 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Alexis Afonso * feat(account): drop the API errors once a company has been validated The errors raised by the last submit stayed on screen after the customer validated a company in the SIRET lookup, both as the alert banner and as the inline message above the form. They described data that had just been replaced, so they read as if the new company had been refused too. Clear both when the siret component hands a company over, and name the alert container and the event it is listening to rather than repeating their strings. Adds 4 tests, verified by dropping the listener and the banner reset in turn. ref: #MANAGER-22144 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Alexis Afonso * fix(i18n): add missing translations [CDS 1300] Signed-off-by: CDS Translator Agent --------- Signed-off-by: Alexis Afonso Signed-off-by: CDS Translator Agent Co-authored-by: Alexis AFONSO Co-authored-by: Claude Opus 5 (1M context) --- packages/manager/modules/account/package.json | 7 +- .../new-account-form-einvoicing.controller.js | 18 +- ...account-form-einvoicing.controller.spec.js | 166 +++++++ ...account-form-field-component.controller.js | 9 +- .../new-account-form-component.html | 22 +- .../new-account-form-controller.js | 77 ++- .../new-account-form-controller.spec.js | 213 ++++++++ .../translations/Messages_de_DE.json | 4 +- .../translations/Messages_en_GB.json | 4 +- .../translations/Messages_es_ES.json | 4 +- .../translations/Messages_fr_CA.json | 4 +- .../translations/Messages_fr_FR.json | 4 +- .../translations/Messages_it_IT.json | 4 +- .../translations/Messages_pl_PL.json | 4 +- .../translations/Messages_pt_PT.json | 4 +- .../src/user/infos/user-infos.controller.js | 4 + .../manager/modules/account/vitest.config.js | 16 + packages/manager/modules/sign-up/package.json | 7 +- .../sign-up/src/components/siret/index.js | 2 + .../siret-search-modal.component.js | 19 + .../siret-search-modal.controller.js | 281 +++++++++++ .../siret-search-modal.controller.spec.js | 453 ++++++++++++++++++ .../siret/searchModal/siret-search-modal.html | 178 +++++++ .../src/components/siret/siret.constants.js | 58 ++- .../components/siret/siret.constants.spec.js | 215 +++++++++ .../src/components/siret/siret.controller.js | 135 ++++-- .../components/siret/siret.controller.spec.js | 334 +++++++++++++ .../sign-up/src/components/siret/siret.html | 31 +- .../sign-up/src/components/siret/siret.scss | 7 + .../siret/translations/Messages_de_DE.json | 14 +- .../siret/translations/Messages_en_GB.json | 14 +- .../siret/translations/Messages_es_ES.json | 14 +- .../siret/translations/Messages_fr_CA.json | 14 +- .../siret/translations/Messages_fr_FR.json | 14 +- .../siret/translations/Messages_it_IT.json | 14 +- .../siret/translations/Messages_pl_PL.json | 14 +- .../siret/translations/Messages_pt_PT.json | 14 +- .../manager/modules/sign-up/vitest.config.js | 16 + 38 files changed, 2326 insertions(+), 86 deletions(-) create mode 100644 packages/manager/modules/account/src/user/components/newAccountForm/einvoicing/new-account-form-einvoicing.controller.spec.js create mode 100644 packages/manager/modules/account/src/user/components/newAccountForm/new-account-form-controller.spec.js create mode 100644 packages/manager/modules/account/vitest.config.js create mode 100644 packages/manager/modules/sign-up/src/components/siret/searchModal/siret-search-modal.component.js create mode 100644 packages/manager/modules/sign-up/src/components/siret/searchModal/siret-search-modal.controller.js create mode 100644 packages/manager/modules/sign-up/src/components/siret/searchModal/siret-search-modal.controller.spec.js create mode 100644 packages/manager/modules/sign-up/src/components/siret/searchModal/siret-search-modal.html create mode 100644 packages/manager/modules/sign-up/src/components/siret/siret.constants.spec.js create mode 100644 packages/manager/modules/sign-up/src/components/siret/siret.controller.spec.js create mode 100644 packages/manager/modules/sign-up/vitest.config.js diff --git a/packages/manager/modules/account/package.json b/packages/manager/modules/account/package.json index f7c23674b921..963acf32e06c 100644 --- a/packages/manager/modules/account/package.json +++ b/packages/manager/modules/account/package.json @@ -13,7 +13,9 @@ "main": "./src/index.js", "scripts": { "lint": "manager-legacy-lint --kinds tsx,js,css,html,md --continue", - "lint:fix": "manager-legacy-lint --kinds tsx,js,css,html,md --fix --continue" + "lint:fix": "manager-legacy-lint --kinds tsx,js,css,html,md --fix --continue", + "test": "manager-test run", + "test:coverage": "manager-test run --coverage" }, "dependencies": { "@ovh-ux/manager-at-internet-configuration": "^1.5.1", @@ -71,7 +73,8 @@ "whatwg-fetch": "^3.5.0" }, "devDependencies": { - "@ovh-ux/manager-static-analysis-legacy-kit": "^0.1.0" + "@ovh-ux/manager-static-analysis-legacy-kit": "^0.1.0", + "@ovh-ux/manager-tests-setup": "^0.8.0" }, "peerDependencies": { "@ovh-ux/manager-core": "^13.0.0", diff --git a/packages/manager/modules/account/src/user/components/newAccountForm/einvoicing/new-account-form-einvoicing.controller.js b/packages/manager/modules/account/src/user/components/newAccountForm/einvoicing/new-account-form-einvoicing.controller.js index eba4ed3b10a5..ef750526fc00 100644 --- a/packages/manager/modules/account/src/user/components/newAccountForm/einvoicing/new-account-form-einvoicing.controller.js +++ b/packages/manager/modules/account/src/user/components/newAccountForm/einvoicing/new-account-form-einvoicing.controller.js @@ -25,6 +25,7 @@ export default class NewAccountFormEinvoicingController { constructor($scope) { this.$scope = $scope; this.staleAddress = false; + this.companyChanged = false; } $onInit() { @@ -38,6 +39,9 @@ export default class NewAccountFormEinvoicingController { $onChanges(changes) { if (changes.siret || changes.legalForm || changes.country) { this.staleAddress = false; + // Rules refreshed after this describe a different company, so an empty + // address list really means "this one has none" (RG5). + this.companyChanged = true; } if (changes.siret && !changes.siret.isFirstChange()) { if (this.isEligible()) { @@ -56,6 +60,7 @@ export default class NewAccountFormEinvoicingController { // entry gone: the parent's updateRules already dropped the model value this.selectedAddress = null; } + this.companyChanged = false; } } @@ -97,7 +102,18 @@ export default class NewAccountFormEinvoicingController { const addresses = (this.rule && this.rule.in) || null; if (this.addressesSource !== addresses) { this.addressesSource = addresses; - this.availableAddresses = (addresses || []).filter(Boolean); + const offered = (addresses || []).filter(Boolean); + // The parent refetches /newAccount/rules on every field change, and a + // refresh triggered by another field — a company address the API refuses, + // typically — can come back without any address at all. That says nothing + // about the e-invoicing selection, so keep what the directory offered + // last: dropping it here would take the picker off the screen and lose a + // choice the API never rejected. Only a company change, or a 400 on + // submit, may clear it. + this.availableAddresses = + offered.length || this.companyChanged + ? offered + : this.availableAddresses || []; } return this.availableAddresses; } diff --git a/packages/manager/modules/account/src/user/components/newAccountForm/einvoicing/new-account-form-einvoicing.controller.spec.js b/packages/manager/modules/account/src/user/components/newAccountForm/einvoicing/new-account-form-einvoicing.controller.spec.js new file mode 100644 index 000000000000..fdb39459dee8 --- /dev/null +++ b/packages/manager/modules/account/src/user/components/newAccountForm/einvoicing/new-account-form-einvoicing.controller.spec.js @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from 'vitest'; + +import EinvoicingCtrl from './new-account-form-einvoicing.controller'; + +const A = 'FR:SIRET:42476141900045'; +const B = 'FR:SIRET:42476141900099'; +const SIRET = '42476141900045'; +const SIRET_REGEX = '^[0-9]{14}$'; + +// the rules entry the parent hands down, as /newAccount/rules returns it +const rule = (addresses) => ({ + fieldName: 'einvoicingBillingAddress', + in: addresses, + mandatory: false, +}); + +const change = (currentValue, isFirst = false) => ({ + currentValue, + isFirstChange: () => isFirst, +}); + +const build = ({ addresses = [A, B], selected = A } = {}) => { + const ctrl = new EinvoicingCtrl({ $on: vi.fn() }); + Object.assign(ctrl, { + model: { einvoicingBillingAddress: selected }, + rule: rule(addresses), + siret: SIRET, + siretRegex: SIRET_REGEX, + legalForm: 'corporation', + country: 'FR', + onRefreshRules: vi.fn(), + }); + ctrl.$onInit(); + // initial load: the parent pushes the rules entry down + ctrl.$onChanges({ rule: change(ctrl.rule, true) }); + return ctrl; +}; + +// the parent refetched the rules and pushed a new entry down +const refreshWith = (ctrl, addresses) => { + ctrl.rule = rule(addresses); + ctrl.$onChanges({ rule: change(ctrl.rule) }); +}; + +describe('a rules refresh triggered by another field', () => { + // the reported regression: an unrelated field the API refuses made the whole + // picker vanish, losing a choice the API had never rejected + it('keeps the addresses when the refresh comes back with none', () => { + const ctrl = build(); + + refreshWith(ctrl, null); + + expect(ctrl.getAddresses()).toEqual([A, B]); + expect(ctrl.hasMultipleAddresses()).toBe(true); + expect(ctrl.model.einvoicingBillingAddress).toBe(A); + }); + + // /newAccount/rules answers with a single empty entry for "nothing known" + it('keeps them when the refresh comes back with an empty entry', () => { + const ctrl = build(); + + refreshWith(ctrl, ['']); + + expect(ctrl.getAddresses()).toEqual([A, B]); + expect(ctrl.model.einvoicingBillingAddress).toBe(A); + }); + + it('keeps the picker on screen rather than the "no address" banner', () => { + const ctrl = build(); + + refreshWith(ctrl, []); + + expect(ctrl.isEmpty()).toBe(false); + expect(ctrl.hasMultipleAddresses()).toBe(true); + }); + + it('still takes a refresh that does bring addresses', () => { + const ctrl = build(); + + refreshWith(ctrl, [A, B, 'FR:SIRET:42476141900123']); + + expect(ctrl.getAddresses()).toHaveLength(3); + }); + + it('drops a selection the directory no longer offers', () => { + const ctrl = build(); + + refreshWith(ctrl, [B, 'FR:SIRET:42476141900123']); + + expect(ctrl.model.einvoicingBillingAddress).toBe(null); + }); +}); + +describe('a rules refresh after the company changed', () => { + // a different company really may have no address at all (RG5) + it('clears the addresses when the new SIRET has none', () => { + const ctrl = build(); + + ctrl.siret = '98471504500014'; + ctrl.$onChanges({ siret: change('98471504500014') }); + refreshWith(ctrl, ['']); + + expect(ctrl.getAddresses()).toEqual([]); + expect(ctrl.isEmpty()).toBe(true); + expect(ctrl.model.einvoicingBillingAddress).toBe(null); + }); + + it.each([ + ['legalForm', 'association'], + ['country', 'GP'], + ])('clears them when the %s changed', (binding, value) => { + const ctrl = build(); + + ctrl[binding] = value; + ctrl.$onChanges({ [binding]: change(value) }); + refreshWith(ctrl, null); + + expect(ctrl.getAddresses()).toEqual([]); + }); + + it('goes back to keeping them on the refresh after that', () => { + const ctrl = build(); + + ctrl.$onChanges({ siret: change(SIRET) }); + refreshWith(ctrl, [A, B]); + // an unrelated field is edited: the company did not change this time + refreshWith(ctrl, null); + + expect(ctrl.getAddresses()).toEqual([A, B]); + }); +}); + +describe('the address the customer picked', () => { + it('is cleared when the submit told us it is stale', () => { + const listeners = {}; + const ctrl = new EinvoicingCtrl({ + $on: (name, fn) => { + listeners[name] = fn; + }, + }); + Object.assign(ctrl, { + model: { einvoicingBillingAddress: A }, + rule: rule([A, B]), + siret: SIRET, + siretRegex: SIRET_REGEX, + legalForm: 'corporation', + country: 'FR', + onRefreshRules: vi.fn(), + }); + ctrl.$onInit(); + + listeners['einvoicing.staleAddress'](); + + expect(ctrl.model.einvoicingBillingAddress).toBe(null); + expect(ctrl.staleAddress).toBe(true); + }); + + it('is cleared when the SIRET is no longer complete', () => { + const ctrl = build(); + + ctrl.siret = '424761419'; + ctrl.$onChanges({ siret: change('424761419') }); + + expect(ctrl.model.einvoicingBillingAddress).toBe(null); + }); +}); diff --git a/packages/manager/modules/account/src/user/components/newAccountForm/field/new-account-form-field-component.controller.js b/packages/manager/modules/account/src/user/components/newAccountForm/field/new-account-form-field-component.controller.js index 50b1dce18d15..b5f1cfa796ea 100644 --- a/packages/manager/modules/account/src/user/components/newAccountForm/field/new-account-form-field-component.controller.js +++ b/packages/manager/modules/account/src/user/components/newAccountForm/field/new-account-form-field-component.controller.js @@ -421,10 +421,15 @@ export default class NewAccountFormFieldController { return this.value && field && field.$valid; } - // true if current field is dirty and invalid + // Only surface the error once the customer has left the field, or once the + // form has been submitted — the ui-kit convention ($invalid && ($touched || + // $submitted)). Without it every mandatory empty field of a business account + // shows up red on page load, before anything has been typed. isInvalid() { const field = this.fieldset[this.id]; - return field && field.$invalid; + return Boolean( + field && field.$invalid && (field.$touched || this.fieldset.$submitted), + ); } // returns a normalized identifier (skip spaces) diff --git a/packages/manager/modules/account/src/user/components/newAccountForm/new-account-form-component.html b/packages/manager/modules/account/src/user/components/newAccountForm/new-account-form-component.html index 64a444e51612..a04817aef2a1 100644 --- a/packages/manager/modules/account/src/user/components/newAccountForm/new-account-form-component.html +++ b/packages/manager/modules/account/src/user/components/newAccountForm/new-account-form-component.html @@ -98,6 +98,23 @@

+ + + + + +
@@ -120,7 +137,7 @@

@@ -128,8 +145,7 @@

class="oui-button oui-button_primary" data-ng-disabled="ovhSignupForm.$invalid || (!$ctrl.hasChanges() && !$ctrl.submitError) || - $ctrl.isSubmitting || - ovhSignupForm.form_part_activity.searchForm" + $ctrl.isSubmitting" type="submit" data-track-on="click" data-track-name="account::myaccount::profile::validation_profile_edit" diff --git a/packages/manager/modules/account/src/user/components/newAccountForm/new-account-form-controller.js b/packages/manager/modules/account/src/user/components/newAccountForm/new-account-form-controller.js index 4fc1924a61ef..e5e6177acec1 100644 --- a/packages/manager/modules/account/src/user/components/newAccountForm/new-account-form-controller.js +++ b/packages/manager/modules/account/src/user/components/newAccountForm/new-account-form-controller.js @@ -30,6 +30,12 @@ import { SUPPORT_URLS } from '../../user.constants'; // generic section loop const EINVOICING_FIELD_NAME = 'einvoicingBillingAddress'; +// Alerter container the form's API errors are pushed to (see the ovh-alert +// directive in the template). +const INFO_ERRORS_CONTAINER = 'InfoErrors'; +// Broadcast by the siret component once the customer validated a company. +const COMPANY_SELECTED_EVENT = 'siret:companySelected'; + export default class NewAccountFormController { /* @ngInject */ constructor( @@ -85,6 +91,10 @@ export default class NewAccountFormController { this.consentDecision = null; this.smsConsentDecision = null; + // Validating a company in the SIRET lookup replaces the data the API + // complained about, so its errors no longer describe the form. + this.$scope.$on(COMPANY_SELECTED_EVENT, () => this.clearApiErrors()); + return this.ovhFeatureFlipping .checkFeatureAvailability([ FEATURES.emailConsent, @@ -323,7 +333,7 @@ export default class NewAccountFormController { this.Alerter.alertFromSWS( this.$translate.instant('signup_legalform_other_save_blocked'), 'ERROR', - 'InfoErrors', + INFO_ERRORS_CONTAINER, ); return null; } @@ -483,12 +493,7 @@ export default class NewAccountFormController { }) .catch((err) => { this.submitError = err; - // 400 with an address selected = stale PPF address (RG6): warn the - // field and refresh the rules - if (err?.status === 400 && this.model.einvoicingBillingAddress) { - this.$scope.$broadcast('einvoicing.staleAddress'); - this.updateRules(); - } + this.refreshEinvoicingAddressOnError(err); const isPrivateIndividual = this.model.legalform === USER_TYPE_INDIVIDUAL; const genericError = isPrivateIndividual @@ -510,7 +515,7 @@ export default class NewAccountFormController { this.Alerter.alertFromSWS( `${genericError}${apiError}`, 'ERROR', - 'InfoErrors', + INFO_ERRORS_CONTAINER, ); }) .finally(() => { @@ -560,6 +565,38 @@ export default class NewAccountFormController { } // absent when the PPF directory doesn't know the SIRET + /** + * A submit error never names the field the API rejected. Refresh the rules so + * the directory's current view is loaded, but leave the selected address + * alone: only a refreshed list that offers other addresses without this one is + * evidence against it (RG6). An error raised by any other field — a company + * address the API refuses, typically — must not cost the customer a choice the + * API never pointed at. An answer with no address at all is not evidence + * either, see the picker's getAddresses. + */ + refreshEinvoicingAddressOnError(err) { + const submitted = this.model[EINVOICING_FIELD_NAME]; + if (err?.status !== 400 || !submitted) { + return null; + } + return this.updateRules().then(() => { + const offered = (this.getEinvoicingRule()?.in || []).filter(Boolean); + if (offered.length && !offered.includes(submitted)) { + this.$scope.$broadcast('einvoicing.staleAddress'); + } + }); + } + + /** + * Drops the errors the API raised against data the customer has since + * replaced: the banner it pushed to the alert container, and the inline + * message the form renders from submitError. + */ + clearApiErrors() { + this.submitError = null; + this.Alerter.resetMessage(INFO_ERRORS_CONTAINER); + } + getEinvoicingRule() { return (this.rules || []).find( (rule) => rule.fieldName === EINVOICING_FIELD_NAME, @@ -579,15 +616,27 @@ export default class NewAccountFormController { if (!newRules) { return; } + // The rules are refetched on every field change. An answer that drops + // the e-invoicing entry — because another field is being refused — + // would take the picker off the screen and delete the selected address + // with it, for a field the API never pointed at: carry the entry over. + // A company change brings a fresh entry instead, and the picker hides + // itself anyway once the account stops being eligible. + const previousEinvoicingRule = this.getEinvoicingRule(); + const rules = + previousEinvoicingRule && + !newRules.find((rule) => rule.fieldName === EINVOICING_FIELD_NAME) + ? [...newRules, previousEinvoicingRule] + : newRules; (this.rules || []).forEach((rule) => { - if (!newRules.find((value) => value.fieldName === rule.fieldName)) { + if (!rules.find((value) => value.fieldName === rule.fieldName)) { delete this.model[rule.fieldName]; } }); - this.rules = newRules; + this.rules = rules; if (this.siretFieldIsAvailable()) { - this.formatSiretRules(newRules); + this.formatSiretRules(rules); } }) .catch(angular.noop); @@ -687,6 +736,12 @@ export default class NewAccountFormController { return this.updateRules(); } + // Sends the customer back to the SIRET lookup modal, which lives inside the + // siret component (a descendant scope), from an error message rendered here. + openSiretSearch() { + this.$scope.$broadcast('siret:openSearchModal'); + } + isFrenchAssociation() { return ( this.model?.legalform === USER_TYPE_ASSOCIATION && diff --git a/packages/manager/modules/account/src/user/components/newAccountForm/new-account-form-controller.spec.js b/packages/manager/modules/account/src/user/components/newAccountForm/new-account-form-controller.spec.js new file mode 100644 index 000000000000..abf3f26dbef4 --- /dev/null +++ b/packages/manager/modules/account/src/user/components/newAccountForm/new-account-form-controller.spec.js @@ -0,0 +1,213 @@ +import { describe, expect, it, vi } from 'vitest'; + +import NewAccountFormCtrl from './new-account-form-controller'; + +// The app bundle provides angular as a global; the controller relies on it +// rather than importing it. Only these three helpers are reached from here. +global.angular = { + noop: () => {}, + copy: (value) => JSON.parse(JSON.stringify(value ?? null)), + equals: (a, b) => JSON.stringify(a) === JSON.stringify(b), +}; + +const ADDRESS = 'FR:SIRET:42476141900045'; +const OTHER = 'FR:SIRET:42476141900099'; + +const einvoicingRule = (addresses) => ({ + fieldName: 'einvoicingBillingAddress', + in: addresses, +}); +const otherRule = { fieldName: 'organisation', in: null }; + +// Only the collaborators these two decisions reach: the controller takes twelve +// injected services, none of the others are involved. +const build = ({ address = ADDRESS, rules } = {}) => { + const broadcasts = []; + const listeners = {}; + const $scope = { + $broadcast: (name) => broadcasts.push(name), + $on: (name, fn) => { + listeners[name] = fn; + }, + }; + const alerter = { alertFromSWS: vi.fn(), resetMessage: vi.fn() }; + const ctrl = new NewAccountFormCtrl( + { resolve: (v) => Promise.resolve(v) }, + {}, + (fn) => fn(), + {}, + { trackClick: vi.fn() }, + { getUser: () => ({}), getUserLocale: () => 'fr_FR' }, + alerter, + { instant: (k) => k }, + vi.fn(), + $scope, + // nothing of $onInit past the listener registration is exercised here + { checkFeatureAvailability: () => new Promise(() => {}) }, + {}, + ); + ctrl.model = { einvoicingBillingAddress: address, organisation: 'OVH' }; + ctrl.rules = rules || [otherRule, einvoicingRule([ADDRESS, OTHER])]; + ctrl.siretFieldIsAvailable = () => false; + return { ctrl, broadcasts, listeners, alerter }; +}; + +// stands in for the /newAccount/rules refresh +const refreshReturning = (ctrl, newRules) => { + ctrl.fetchRules = vi.fn(() => Promise.resolve(newRules)); +}; + +const staleWasFlagged = (broadcasts) => + broadcasts.includes('einvoicing.staleAddress'); + +describe('a submit error the directory does not confirm', () => { + // the reported regression: an error forced on the company data emptied a field + // the API had never pointed at + it('keeps the address when the directory still offers it', async () => { + const { ctrl, broadcasts } = build(); + refreshReturning(ctrl, [otherRule, einvoicingRule([ADDRESS, OTHER])]); + + await ctrl.refreshEinvoicingAddressOnError({ status: 400 }); + + expect(ctrl.model.einvoicingBillingAddress).toBe(ADDRESS); + expect(staleWasFlagged(broadcasts)).toBe(false); + }); + + it('keeps it when the refresh brings no address at all', async () => { + const { ctrl, broadcasts } = build(); + refreshReturning(ctrl, [otherRule, einvoicingRule([''])]); + + await ctrl.refreshEinvoicingAddressOnError({ status: 400 }); + + expect(ctrl.model.einvoicingBillingAddress).toBe(ADDRESS); + expect(staleWasFlagged(broadcasts)).toBe(false); + }); + + it('keeps it when the refresh drops the entry entirely', async () => { + const { ctrl, broadcasts } = build(); + refreshReturning(ctrl, [otherRule]); + + await ctrl.refreshEinvoicingAddressOnError({ status: 400 }); + + expect(ctrl.model.einvoicingBillingAddress).toBe(ADDRESS); + expect(staleWasFlagged(broadcasts)).toBe(false); + }); +}); + +describe('a submit error the directory does confirm', () => { + it('flags the address as stale when other addresses replaced it', async () => { + const { ctrl, broadcasts } = build(); + refreshReturning(ctrl, [otherRule, einvoicingRule([OTHER])]); + + await ctrl.refreshEinvoicingAddressOnError({ status: 400 }); + + expect(staleWasFlagged(broadcasts)).toBe(true); + }); +}); + +describe('errors that must not reach the field', () => { + it.each([ + [{ status: 500 }, 'a server error'], + [{ status: 403 }, 'a forbidden'], + [undefined, 'no error object'], + ])('does nothing on %p (%s)', async (err) => { + const { ctrl, broadcasts } = build(); + refreshReturning(ctrl, [otherRule]); + + await ctrl.refreshEinvoicingAddressOnError(err); + + expect(ctrl.fetchRules).not.toHaveBeenCalled(); + expect(ctrl.model.einvoicingBillingAddress).toBe(ADDRESS); + expect(staleWasFlagged(broadcasts)).toBe(false); + }); + + it('does nothing when no address was selected', async () => { + const { ctrl } = build({ address: null }); + refreshReturning(ctrl, [otherRule]); + + await ctrl.refreshEinvoicingAddressOnError({ status: 400 }); + + expect(ctrl.fetchRules).not.toHaveBeenCalled(); + }); +}); + +describe('a rules refresh that omits the e-invoicing entry', () => { + it('carries the entry over so the picker stays on screen', async () => { + const { ctrl } = build(); + refreshReturning(ctrl, [otherRule]); + + await ctrl.updateRules(); + + expect(ctrl.getEinvoicingRule()).toEqual(einvoicingRule([ADDRESS, OTHER])); + expect(ctrl.model.einvoicingBillingAddress).toBe(ADDRESS); + }); + + it('takes the fresh entry when the refresh brings one', async () => { + const { ctrl } = build(); + refreshReturning(ctrl, [otherRule, einvoicingRule([OTHER])]); + + await ctrl.updateRules(); + + expect(ctrl.getEinvoicingRule()).toEqual(einvoicingRule([OTHER])); + }); + + // the generic behaviour must stay untouched for every other field + it('still drops the model value of any other vanished rule', async () => { + const { ctrl } = build(); + refreshReturning(ctrl, [einvoicingRule([ADDRESS, OTHER])]); + + await ctrl.updateRules(); + + expect('organisation' in ctrl.model).toBe(false); + }); + + it('carries nothing over when there was no entry to begin with', async () => { + const { ctrl } = build({ rules: [otherRule] }); + refreshReturning(ctrl, [otherRule]); + + await ctrl.updateRules(); + + expect(ctrl.getEinvoicingRule()).toBeUndefined(); + }); +}); + +describe('the API errors the form displays', () => { + // validating a company replaces the data those errors were about + const validateCompany = () => { + const built = build(); + built.ctrl.$onInit(); + built.ctrl.submitError = { status: 400, data: { message: 'nope' } }; + built.listeners['siret:companySelected'](); + return built; + }; + + it('are dropped when the customer validates a company', () => { + const { ctrl } = validateCompany(); + + expect(ctrl.submitError).toBe(null); + }); + + it('drops the alert banner too, not just the inline message', () => { + const { alerter } = validateCompany(); + + expect(alerter.resetMessage).toHaveBeenCalledWith('InfoErrors'); + }); + + it('listens for the company the siret component hands over', () => { + const { ctrl, listeners } = build(); + + ctrl.$onInit(); + + expect(listeners['siret:companySelected']).toBeTypeOf('function'); + }); + + it('clears both on demand', () => { + const { ctrl, alerter } = build(); + ctrl.submitError = { status: 400 }; + + ctrl.clearApiErrors(); + + expect(ctrl.submitError).toBe(null); + expect(alerter.resetMessage).toHaveBeenCalledWith('InfoErrors'); + }); +}); diff --git a/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_de_DE.json b/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_de_DE.json index 644c577ab8f3..bbe6cbc8d2fa 100644 --- a/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_de_DE.json +++ b/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_de_DE.json @@ -790,5 +790,7 @@ "signup_legalform_other_invalid": "Die Kategorie „Sonstige“ ist nicht gültig. Bitte wählen Sie eine andere Kategorie aus.", "signup_legalform_other_save_blocked": "Speichern mit der Kategorie „Sonstige“ nicht möglich. Bitte wählen Sie eine gültige Kategorie aus, bevor Sie fortfahren.", "signup_legalform_other_switch_company": "Sie haben Ihre Kategorie geändert. Bitte aktualisieren Sie die Daten Ihrer {{ category }}, bevor Sie speichern.", - "signup_legalform_other_switch_company_cta": "Meine Unternehmensdaten aktualisieren" + "signup_legalform_other_switch_company_cta": "Meine Unternehmensdaten aktualisieren", + "signup_account_info_siret_lookup_hint": "Ihre Unternehmensinformationen sind möglicherweise veraltet.", + "signup_account_info_siret_lookup_cta": "Aktualisieren Sie diese mit meiner SIRET-Nummer" } diff --git a/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_en_GB.json b/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_en_GB.json index 04c364e284e9..dcabcb0369ab 100644 --- a/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_en_GB.json +++ b/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_en_GB.json @@ -790,5 +790,7 @@ "signup_legalform_other_invalid": "The \"Other\" category is not valid. Please select another category.", "signup_legalform_other_save_blocked": "Unable to save with the \"Other\" category. Please select a valid category before continuing.", "signup_legalform_other_switch_company": "You have changed your category. Please update your {{ category }} data before saving.", - "signup_legalform_other_switch_company_cta": "Update my company data" + "signup_legalform_other_switch_company_cta": "Update my company data", + "signup_account_info_siret_lookup_hint": "Your business information may be out of date.", + "signup_account_info_siret_lookup_cta": "Update them with my SIRET number" } diff --git a/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_es_ES.json b/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_es_ES.json index 426f19b1823f..f1e6085e3df0 100644 --- a/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_es_ES.json +++ b/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_es_ES.json @@ -790,5 +790,7 @@ "signup_legalform_other_invalid": "La categoría «Otro» no es válida. Seleccione otra categoría.", "signup_legalform_other_save_blocked": "No es posible guardar con la categoría «Otro». Seleccione una categoría válida antes de continuar.", "signup_legalform_other_switch_company": "Ha modificado su categoría. Actualice los datos de su {{ category }} antes de guardar.", - "signup_legalform_other_switch_company_cta": "Actualizar mis datos de empresa" + "signup_legalform_other_switch_company_cta": "Actualizar mis datos de empresa", + "signup_account_info_siret_lookup_hint": "Es posible que la información de vuestra empresa esté obsoleta.", + "signup_account_info_siret_lookup_cta": "Actualizadla con mi número de SIRET" } diff --git a/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_fr_CA.json b/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_fr_CA.json index fae04e35ce2d..0aedc6856b1b 100644 --- a/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_fr_CA.json +++ b/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_fr_CA.json @@ -789,5 +789,7 @@ "signup_legalform_other_invalid": "La catégorie « Autre » n'est pas valide. Veuillez sélectionner une autre catégorie.", "signup_legalform_other_save_blocked": "Impossible d'enregistrer avec la catégorie « Autre ». Veuillez sélectionner une catégorie valide avant de continuer.", "signup_legalform_other_switch_company": "Vous avez modifié votre catégorie. Veuillez mettre à jour les données de votre {{ category }} avant d'enregistrer.", - "signup_legalform_other_switch_company_cta": "Mettre à jour mes données société" + "signup_legalform_other_switch_company_cta": "Mettre à jour mes données société", + "signup_account_info_siret_lookup_hint": "Vos informations d'entreprise sont peut-être obsolètes.", + "signup_account_info_siret_lookup_cta": "Les mettre à jour avec mon numéro de SIRET" } diff --git a/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_fr_FR.json b/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_fr_FR.json index fae04e35ce2d..0aedc6856b1b 100644 --- a/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_fr_FR.json +++ b/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_fr_FR.json @@ -789,5 +789,7 @@ "signup_legalform_other_invalid": "La catégorie « Autre » n'est pas valide. Veuillez sélectionner une autre catégorie.", "signup_legalform_other_save_blocked": "Impossible d'enregistrer avec la catégorie « Autre ». Veuillez sélectionner une catégorie valide avant de continuer.", "signup_legalform_other_switch_company": "Vous avez modifié votre catégorie. Veuillez mettre à jour les données de votre {{ category }} avant d'enregistrer.", - "signup_legalform_other_switch_company_cta": "Mettre à jour mes données société" + "signup_legalform_other_switch_company_cta": "Mettre à jour mes données société", + "signup_account_info_siret_lookup_hint": "Vos informations d'entreprise sont peut-être obsolètes.", + "signup_account_info_siret_lookup_cta": "Les mettre à jour avec mon numéro de SIRET" } diff --git a/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_it_IT.json b/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_it_IT.json index bebe629b9dda..7693adfb9186 100644 --- a/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_it_IT.json +++ b/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_it_IT.json @@ -790,5 +790,7 @@ "signup_legalform_other_invalid": "La categoria \"Altro\" non è valida. Seleziona un'altra categoria.", "signup_legalform_other_save_blocked": "Impossibile salvare con la categoria \"Altro\". Seleziona una categoria valida prima di continuare.", "signup_legalform_other_switch_company": "Hai modificato la tua categoria. Aggiorna i dati della tua {{ category }} prima di salvare.", - "signup_legalform_other_switch_company_cta": "Aggiorna i miei dati aziendali" + "signup_legalform_other_switch_company_cta": "Aggiorna i miei dati aziendali", + "signup_account_info_siret_lookup_hint": "Le informazioni sulla tua azienda potrebbero essere obsolete.", + "signup_account_info_siret_lookup_cta": "Aggiornale con il mio numero SIRET" } diff --git a/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_pl_PL.json b/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_pl_PL.json index 8c7e10821156..d33b7a345c13 100644 --- a/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_pl_PL.json +++ b/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_pl_PL.json @@ -790,5 +790,7 @@ "signup_legalform_other_invalid": "Kategoria „Inne” jest nieprawidłowa. Wybierz inną kategorię.", "signup_legalform_other_save_blocked": "Zapisanie z kategorią „Inne” jest niemożliwe. Przed kontynuowaniem wybierz prawidłową kategorię.", "signup_legalform_other_switch_company": "Zmieniono kategorię. Przed zapisaniem zaktualizuj dane {{ category }}.", - "signup_legalform_other_switch_company_cta": "Zaktualizuj moje dane firmy" + "signup_legalform_other_switch_company_cta": "Zaktualizuj moje dane firmy", + "signup_account_info_siret_lookup_hint": "Twoje informacje firmowe mogą być nieaktualne.", + "signup_account_info_siret_lookup_cta": "Zaktualizuj je za pomocą mojego numeru SIRET" } diff --git a/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_pt_PT.json b/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_pt_PT.json index e328fdc9fc82..94f826903900 100644 --- a/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_pt_PT.json +++ b/packages/manager/modules/account/src/user/components/newAccountForm/translations/Messages_pt_PT.json @@ -790,5 +790,7 @@ "signup_legalform_other_invalid": "A categoria «Outro» não é válida. Queira selecionar outra categoria.", "signup_legalform_other_save_blocked": "Impossível guardar com a categoria «Outro». Queira selecionar uma categoria válida antes de continuar.", "signup_legalform_other_switch_company": "Alterou a sua categoria. Queira atualizar os dados da sua {{ category }} antes de guardar.", - "signup_legalform_other_switch_company_cta": "Atualizar os meus dados de empresa" + "signup_legalform_other_switch_company_cta": "Atualizar os meus dados de empresa", + "signup_account_info_siret_lookup_hint": "As suas informações de empresa podem estar obsoletas.", + "signup_account_info_siret_lookup_cta": "Atualizá-las com o meu número de SIRET" } diff --git a/packages/manager/modules/account/src/user/infos/user-infos.controller.js b/packages/manager/modules/account/src/user/infos/user-infos.controller.js index 83550f50485e..c75be91dae27 100644 --- a/packages/manager/modules/account/src/user/infos/user-infos.controller.js +++ b/packages/manager/modules/account/src/user/infos/user-infos.controller.js @@ -145,6 +145,10 @@ export default class UserAccountInfosController { onProfileUpdate() { this.isUpdated = true; this.user = null; + // the form is destroyed and rebuilt below: consume the deep-link focus so a + // ?fieldToFocus=siretForm landing does not reopen the SIRET lookup modal + // right after a successful save + this.fieldToFocus = null; return this.$q.all({ loadUserInfos: this.loadUserInfos(), getTaskEmailChange: this.getTaskEmailChange(), diff --git a/packages/manager/modules/account/vitest.config.js b/packages/manager/modules/account/vitest.config.js new file mode 100644 index 000000000000..f8bc7327c0fb --- /dev/null +++ b/packages/manager/modules/account/vitest.config.js @@ -0,0 +1,16 @@ +import { + createConfig, + mergeConfig, + sharedConfig, +} from '@ovh-ux/manager-tests-setup'; + +export default mergeConfig( + sharedConfig, + createConfig({ + test: { + coverage: { + include: ['src/user/components/newAccountForm/**/*.js'], + }, + }, + }), +); diff --git a/packages/manager/modules/sign-up/package.json b/packages/manager/modules/sign-up/package.json index bc295068c79d..a2fcdf9a8127 100644 --- a/packages/manager/modules/sign-up/package.json +++ b/packages/manager/modules/sign-up/package.json @@ -18,7 +18,9 @@ "dev:watch": "rollup -c --environment BUILD:development --watch", "lint": "manager-legacy-lint --kinds tsx,js,css,html,md --continue", "lint:fix": "manager-legacy-lint --kinds tsx,js,css,html,md --fix --continue", - "prepare": "rollup -c --environment BUILD:production" + "prepare": "rollup -c --environment BUILD:production", + "test": "manager-test run", + "test:coverage": "manager-test run --coverage" }, "dependencies": { "@ovh-ux/manager-config": "^8.9.0", @@ -27,7 +29,8 @@ }, "devDependencies": { "@ovh-ux/component-rollup-config": "^13.2.0", - "@ovh-ux/manager-static-analysis-legacy-kit": "^0.1.0" + "@ovh-ux/manager-static-analysis-legacy-kit": "^0.1.0", + "@ovh-ux/manager-tests-setup": "^0.8.0" }, "peerDependencies": { "@ovh-ux/manager-core": "^12.0.0 || ^13.0.0", diff --git a/packages/manager/modules/sign-up/src/components/siret/index.js b/packages/manager/modules/sign-up/src/components/siret/index.js index 48ba5d0fb576..7de1b3f64e4c 100644 --- a/packages/manager/modules/sign-up/src/components/siret/index.js +++ b/packages/manager/modules/sign-up/src/components/siret/index.js @@ -4,6 +4,7 @@ import '@ovh-ux/ui-kit'; import '@ovh-ux/ng-at-internet'; import component from './siret.component'; +import searchModalComponent from './searchModal/siret-search-modal.component'; import service from './siret.service'; import './siret.scss'; @@ -19,6 +20,7 @@ angular ]) .run(/* @ngTranslationsInject:json ./translations */) .component('siretComponent', component) + .component('siretSearchModal', searchModalComponent) .service('SiretService', service); export default moduleName; diff --git a/packages/manager/modules/sign-up/src/components/siret/searchModal/siret-search-modal.component.js b/packages/manager/modules/sign-up/src/components/siret/searchModal/siret-search-modal.component.js new file mode 100644 index 000000000000..4f29ce694f8d --- /dev/null +++ b/packages/manager/modules/sign-up/src/components/siret/searchModal/siret-search-modal.component.js @@ -0,0 +1,19 @@ +import template from './siret-search-modal.html'; +import controller from './siret-search-modal.controller'; + +export default { + template, + controller, + bindings: { + country: '<', + // SIRET the account already holds: looked up automatically on open + initialSearch: ' { + this.searching = false; + if (this.shouldDiscardResponse()) { + return; + } + if (suggest.error) { + this.trackPage('error'); + } else { + this.trackPage(suggest.entryList?.length > 0 ? 'list' : 'no-result'); + } + this.suggest = suggest; + // a SIRET matches a single establishment: preselect it for review + if (suggest.entryList?.length === 1) { + [this.selected] = suggest.entryList; + } + }) + .catch(() => { + this.searching = false; + if (this.shouldDiscardResponse()) { + return; + } + this.suggest = { error: true, entryList: [] }; + }); + } + + selectSuggest(suggestSelected) { + this.selected = suggestSelected || null; + } + + validate() { + if (!this.selected) { + return; + } + this.trackClick('validate'); + this.onValidate({ suggestion: this.selected }); + } + + cancel() { + this.trackClick('cancel'); + this.onCancel(); + } + + hasSearched() { + return !this.searching && Boolean(this.suggest); + } + + hasError() { + return Boolean(this.suggest?.error); + } + + hasEntries() { + return this.suggest?.entryList?.length > 0; + } + + hasNoResult() { + return Boolean(this.suggest) && !this.hasError() && !this.hasEntries(); + } + + // Several establishments can only show up on a non-SIRET search; keep the + // picker so the customer stays in control if the API ever returns more. + hasManyEntries() { + return this.suggest?.entryList?.length > 1; + } + + // Validating an incomplete company lands the customer on fields they must fill + // in themselves, so the button says so instead of promising a plain "Validate". + getValidateLabelKey() { + return this.isNonDiffusible() + ? 'siret_modal_complete' + : 'siret_modal_validate'; + } + + // Once a company has been found the search button becomes the retry loop. + getSearchButtonLabelKey() { + return this.suggest ? 'siret_modal_search_again' : 'siret_search_button'; + } + + // "Update my company / association / administration information" + getHeadingKey() { + return updateSearchAssistantLabelKey(this.legalForm); + } + + getCompanyName() { + return fromSuggestion(this.selected?.name, ''); + } + + getCompanyAddress() { + return [ + fromSuggestion(this.selected?.address, ''), + fromSuggestion(this.selected?.zipCode, ''), + fromSuggestion(this.selected?.city, ''), + ] + .filter(Boolean) + .join(' '); + } + + // A company that does not disclose its data comes back with [ND] tokens or + // empty values: warn that those details will have to be filled in by hand + // rather than leaving the review block silently showing dashes. + isNonDiffusible() { + return hasMissingValues(this.selected); + } + + /** + * The values the customer will have to type in themselves, because the + * directory withheld them or simply has none. Labelled with the same wording + * as the form fields they will land in, so the list and the form read as one. + * Memoized on the selection: ng-repeat must not get a fresh array per digest. + */ + getMissingFieldLabelKeys() { + if (this.missingFieldsSource !== this.selected) { + this.missingFieldsSource = this.selected; + this.missingFieldLabelKeys = [ + { value: this.selected?.name, key: companyNameLabelKey(this.legalForm) }, + { + value: this.selected?.address, + key: 'siret_modal_non_diffusible_field_address', + }, + { + value: this.selected?.zipCode, + key: 'siret_modal_non_diffusible_field_zip', + }, + { + value: this.selected?.city, + key: 'siret_modal_non_diffusible_field_city', + }, + ] + .filter(({ value }) => isMissingValue(value)) + .map(({ key }) => key); + } + return this.missingFieldLabelKeys; + } + + trackClick(hit) { + this.atInternet.trackClick({ + name: `${this.trackingPrefix}${hit}`, + type: 'action', + }); + } + + trackPage(hit) { + this.atInternet.trackPage({ + name: `${this.trackingPrefix}${hit}`, + type: 'navigation', + }); + } +} diff --git a/packages/manager/modules/sign-up/src/components/siret/searchModal/siret-search-modal.controller.spec.js b/packages/manager/modules/sign-up/src/components/siret/searchModal/siret-search-modal.controller.spec.js new file mode 100644 index 000000000000..b0473bfb2bde --- /dev/null +++ b/packages/manager/modules/sign-up/src/components/siret/searchModal/siret-search-modal.controller.spec.js @@ -0,0 +1,453 @@ +import { describe, expect, it, vi } from 'vitest'; + +import SiretSearchModalCtrl from './siret-search-modal.controller'; + +// Verbatim payload of a non-disclosed company, provider +// DATA_GOUV_RECHERCHE_ENTREPRISES: the withheld values come back as empty +// strings, not as [ND] tokens. +const ND_ENTRY = { + address: '', + area: '32', + city: 'COURRIERES', + creationDate: '2024-02-24', + legalFormCode: '1000', + name: '', + primaryCNIN: '984715045', + secondaryCNIN: '98471504500014', + vatID: 'FR59984715045', + zipCode: '', +}; + +const FULL_ENTRY = { + address: '2 rue Kellermann', + city: 'ROUBAIX', + legalFormCode: '5710', + name: 'OVH', + primaryCNIN: '424761419', + secondaryCNIN: '42476141900045', + vatID: 'FR22424761419', + zipCode: '59100', +}; + +// `init: false` for the tests that drive $onInit themselves — everywhere else +// the controller is initialised like the component would, so the tracking prefix +// is set before any hit is emitted. +const build = ({ getSiret, init = true, ...bindings } = {}) => { + const atInternet = { trackClick: vi.fn(), trackPage: vi.fn() }; + const siretService = { + getSiret: getSiret || (() => Promise.resolve({ entryList: [] })), + }; + const ctrl = new SiretSearchModalCtrl(atInternet, siretService); + Object.assign(ctrl, { + country: 'FR', + legalForm: 'corporation', + trackingMode: 'modification', + onValidate: vi.fn(), + onCancel: vi.fn(), + ...bindings, + }); + if (init) { + ctrl.$onInit(); + } + return { ctrl, atInternet, siretService }; +}; + +describe('auto-search on open', () => { + it('searches the SIRET the account already holds', async () => { + const getSiret = vi.fn(() => Promise.resolve({ entryList: [FULL_ENTRY] })); + const { ctrl, atInternet } = build({ + getSiret, + init: false, + initialSearch: '98471504500014', + }); + + ctrl.$onInit(); + await Promise.resolve(); + + expect(getSiret).toHaveBeenCalledWith({ + country: 'FR', + identifier: '98471504500014', + }); + // nothing was clicked, so no click hit — the outcome is still tracked + expect(atInternet.trackClick).not.toHaveBeenCalled(); + expect(atInternet.trackPage).toHaveBeenCalledWith( + expect.objectContaining({ name: expect.stringContaining('::list') }), + ); + }); + + it('strips spaces out of the SIRET it was given', async () => { + const getSiret = vi.fn(() => Promise.resolve({ entryList: [] })); + const { ctrl } = build({ + getSiret, + init: false, + initialSearch: '984 715 045 00014', + }); + + ctrl.$onInit(); + + expect(getSiret).toHaveBeenCalledWith({ + country: 'FR', + identifier: '98471504500014', + }); + }); + + it.each([ + ['984715045', 'a SIREN'], + ['', 'nothing'], + [undefined, 'no binding at all'], + ['9847150450001A', 'a malformed value'], + ])('does not search when the account holds %p (%s)', (initialSearch) => { + const getSiret = vi.fn(); + const { ctrl } = build({ getSiret, init: false, initialSearch }); + + ctrl.$onInit(); + + expect(getSiret).not.toHaveBeenCalled(); + }); + + // Regression: the field used to be filled in only when the value was a + // searchable 14-digit SIRET, so a customer whose account held anything else + // opened the modal on an empty field and had to retype what we already knew. + it.each([ + ['984715045', 'a SIREN'], + ['9847150450001A', 'a malformed value'], + ['984 715 045 0001', 'an incomplete formatted value'], + ])('still fills the field with %p (%s)', (initialSearch) => { + const getSiret = vi.fn(); + const { ctrl } = build({ getSiret, init: false, initialSearch }); + + ctrl.$onInit(); + + expect(ctrl.search).toBe(initialSearch.replace(/\s/g, '')); + expect(getSiret).not.toHaveBeenCalled(); + }); + + it('fills the field with the SIRET it searches', () => { + const { ctrl } = build({ init: false, initialSearch: '984 715 045 00014' }); + + ctrl.$onInit(); + + expect(ctrl.search).toBe('98471504500014'); + }); + + it.each([ + ['', 'nothing'], + [undefined, 'no binding at all'], + ])('leaves the field empty when the account holds %p (%s)', (initialSearch) => { + const { ctrl } = build({ init: false, initialSearch }); + + ctrl.$onInit(); + + expect(ctrl.search).toBe(''); + }); +}); + +describe('stale responses', () => { + it('drops a response for a SIRET the customer has since edited', async () => { + let resolveSearch; + const getSiret = () => + new Promise((resolve) => { + resolveSearch = resolve; + }); + const { ctrl } = build({ getSiret, initialSearch: '98471504500014' }); + + ctrl.$onInit(); + // the customer retypes while the auto-search is still in flight + ctrl.search = '42476141900045'; + ctrl.onSearchChange(); + resolveSearch({ entryList: [FULL_ENTRY], type: 'siret' }); + await Promise.resolve(); + await Promise.resolve(); + + expect(ctrl.selected).toBeNull(); + expect(ctrl.suggest).toBeNull(); + // the retry loop is available again + expect(ctrl.canSearch()).toBe(true); + expect(ctrl.getSearchButtonLabelKey()).toBe('siret_search_button'); + }); + + it('drops a response that lands after the modal was closed', async () => { + let resolveSearch; + const getSiret = () => + new Promise((resolve) => { + resolveSearch = resolve; + }); + const { ctrl, atInternet } = build({ + getSiret, + init: false, + initialSearch: '98471504500014', + }); + + ctrl.$onInit(); + ctrl.$onDestroy(); + resolveSearch({ entryList: [FULL_ENTRY], type: 'siret' }); + await Promise.resolve(); + await Promise.resolve(); + + expect(ctrl.suggest).toBeNull(); + // no navigation hit for a screen nobody saw + expect(atInternet.trackPage).not.toHaveBeenCalled(); + }); + + it('keeps a response whose SIRET is still the one on screen', async () => { + const { ctrl } = build({ + getSiret: () => Promise.resolve({ entryList: [FULL_ENTRY], type: 'siret' }), + init: false, + initialSearch: '42476141900045', + }); + + ctrl.$onInit(); + await Promise.resolve(); + await Promise.resolve(); + + expect(ctrl.selected).toEqual(FULL_ENTRY); + }); +}); + +describe('search state', () => { + it('refuses to search a SIRET that is not 14 digits', () => { + const { ctrl } = build(); + ctrl.search = '9847150450001'; + expect(ctrl.canSearch()).toBe(false); + }); + + it('refuses to search while a search runs', () => { + const { ctrl } = build(); + ctrl.search = '98471504500014'; + ctrl.searching = true; + expect(ctrl.canSearch()).toBe(false); + }); + + it('only flags an invalid SIRET once the field was left', () => { + const { ctrl } = build(); + ctrl.search = '9847'; + expect(ctrl.isSearchInvalid()).toBe(false); + ctrl.onSearchBlur(); + expect(ctrl.isSearchInvalid()).toBe(true); + }); + + it('does not flag an empty field', () => { + const { ctrl } = build(); + ctrl.onSearchBlur(); + ctrl.search = ''; + expect(ctrl.isSearchInvalid()).toBe(false); + }); + + it('searches on Enter and stops the keypress reaching the outer form', () => { + const getSiret = vi.fn(() => Promise.resolve({ entryList: [] })); + const { ctrl } = build({ getSiret }); + ctrl.search = '98471504500014'; + const event = { key: 'Enter', preventDefault: vi.fn() }; + + ctrl.onSearchKeyDown(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(getSiret).toHaveBeenCalled(); + }); + + it('ignores other keys', () => { + const getSiret = vi.fn(); + const { ctrl } = build({ getSiret }); + ctrl.search = '98471504500014'; + const event = { key: 'a', preventDefault: vi.fn() }; + + ctrl.onSearchKeyDown(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(getSiret).not.toHaveBeenCalled(); + }); +}); + +describe('non-disclosed company', () => { + it('names every value the customer has to complete', () => { + const { ctrl } = build(); + ctrl.selected = ND_ENTRY; + + expect(ctrl.isNonDiffusible()).toBe(true); + // city came back, so it is not listed + expect(ctrl.getMissingFieldLabelKeys()).toEqual([ + 'siret_manual_company_name_corporation', + 'siret_modal_non_diffusible_field_address', + 'siret_modal_non_diffusible_field_zip', + ]); + }); + + it('names the missing company after the legal form', () => { + const { ctrl } = build({ legalForm: 'association' }); + ctrl.selected = ND_ENTRY; + + expect(ctrl.getMissingFieldLabelKeys()[0]).toBe( + 'siret_manual_company_name_association', + ); + }); + + it('treats withheld [ND] tokens the same as empty values', () => { + const { ctrl } = build(); + ctrl.selected = { ...FULL_ENTRY, address: '[ND]' }; + + expect(ctrl.isNonDiffusible()).toBe(true); + expect(ctrl.getMissingFieldLabelKeys()).toEqual([ + 'siret_modal_non_diffusible_field_address', + ]); + }); + + it('lists nothing for a fully disclosed company', () => { + const { ctrl } = build(); + ctrl.selected = FULL_ENTRY; + + expect(ctrl.isNonDiffusible()).toBe(false); + expect(ctrl.getMissingFieldLabelKeys()).toEqual([]); + }); + + it('asks to complete rather than to validate', () => { + const { ctrl } = build(); + ctrl.selected = ND_ENTRY; + expect(ctrl.getValidateLabelKey()).toBe('siret_modal_complete'); + + ctrl.selected = FULL_ENTRY; + expect(ctrl.getValidateLabelKey()).toBe('siret_modal_validate'); + }); + + it('reuses the same array while the selection does not change', () => { + // ng-repeat must not get a fresh array on every digest + const { ctrl } = build(); + ctrl.selected = ND_ENTRY; + expect(ctrl.getMissingFieldLabelKeys()).toBe( + ctrl.getMissingFieldLabelKeys(), + ); + }); +}); + +describe('wording of the intro', () => { + it('asks for the SIRET before anything was found', () => { + const { ctrl } = build(); + expect(ctrl.getIntroKey()).toBe('siret_update_search_assistant_info'); + }); + + it('asks to check the information once a company is on screen', () => { + const { ctrl } = build({ legalForm: 'administration' }); + ctrl.selected = FULL_ENTRY; + expect(ctrl.getIntroKey()).toBe( + 'siret_modal_review_intro_administration', + ); + }); +}); + +describe('result shapes', () => { + it('preselects the single establishment a SIRET matches', async () => { + const { ctrl } = build({ + getSiret: () => Promise.resolve({ entryList: [FULL_ENTRY], type: 'siret' }), + }); + ctrl.search = '42476141900045'; + + await ctrl.submitSearch(); + + expect(ctrl.selected).toEqual(FULL_ENTRY); + expect(ctrl.hasEntries()).toBe(true); + expect(ctrl.hasNoResult()).toBe(false); + }); + + it('reports no result on the 404 shape', async () => { + const { ctrl, atInternet } = build({ + getSiret: () => + Promise.resolve({ error: false, searched: '98471504500014', entryList: [] }), + }); + ctrl.search = '98471504500014'; + + await ctrl.submitSearch(); + + expect(ctrl.hasNoResult()).toBe(true); + expect(ctrl.selected).toBeNull(); + expect(atInternet.trackPage).toHaveBeenCalledWith( + expect.objectContaining({ name: expect.stringContaining('::no-result') }), + ); + }); + + it('reports an error payload that carries no entryList', async () => { + const { ctrl } = build({ + getSiret: () => Promise.resolve({ error: true, message: 'boom' }), + }); + ctrl.search = '98471504500014'; + + await ctrl.submitSearch(); + + expect(ctrl.hasError()).toBe(true); + expect(ctrl.hasEntries()).toBe(false); + expect(ctrl.hasNoResult()).toBe(false); + }); + + it('survives a rejected lookup', async () => { + const { ctrl } = build({ getSiret: () => Promise.reject(new Error('net')) }); + ctrl.search = '98471504500014'; + + await ctrl.submitSearch(); + + expect(ctrl.hasError()).toBe(true); + expect(ctrl.searching).toBe(false); + }); + + it('lets the customer pick when several establishments come back', async () => { + const second = { ...FULL_ENTRY, secondaryCNIN: '42476141900046' }; + const { ctrl } = build({ + getSiret: () => + Promise.resolve({ entryList: [FULL_ENTRY, second], type: 'siret' }), + }); + ctrl.search = '42476141900045'; + + await ctrl.submitSearch(); + + expect(ctrl.hasManyEntries()).toBe(true); + // nothing preselected: the customer chooses + expect(ctrl.selected).toBeNull(); + ctrl.selectSuggest(second); + expect(ctrl.selected).toEqual(second); + }); +}); + +describe('validate and cancel', () => { + it('hands the confirmed company back to the caller', () => { + const { ctrl } = build(); + ctrl.selected = FULL_ENTRY; + + ctrl.validate(); + + expect(ctrl.onValidate).toHaveBeenCalledWith({ suggestion: FULL_ENTRY }); + }); + + it('cannot validate without a company', () => { + const { ctrl } = build(); + + ctrl.validate(); + + expect(ctrl.onValidate).not.toHaveBeenCalled(); + }); + + it('cancels without touching the caller', () => { + const { ctrl } = build(); + + ctrl.cancel(); + + expect(ctrl.onCancel).toHaveBeenCalled(); + expect(ctrl.onValidate).not.toHaveBeenCalled(); + }); +}); + +describe('company review', () => { + it('blanks withheld values instead of showing the token', () => { + const { ctrl } = build(); + ctrl.selected = { ...FULL_ENTRY, name: '[ND]' }; + expect(ctrl.getCompanyName()).toBe(''); + }); + + it('joins the address parts that came back', () => { + const { ctrl } = build(); + ctrl.selected = FULL_ENTRY; + expect(ctrl.getCompanyAddress()).toBe('2 rue Kellermann 59100 ROUBAIX'); + }); + + it('keeps only the known parts of a partial address', () => { + const { ctrl } = build(); + ctrl.selected = ND_ENTRY; + expect(ctrl.getCompanyAddress()).toBe('COURRIERES'); + }); +}); diff --git a/packages/manager/modules/sign-up/src/components/siret/searchModal/siret-search-modal.html b/packages/manager/modules/sign-up/src/components/siret/searchModal/siret-search-modal.html new file mode 100644 index 000000000000..ce3289c8006a --- /dev/null +++ b/packages/manager/modules/sign-up/src/components/siret/searchModal/siret-search-modal.html @@ -0,0 +1,178 @@ + +
diff --git a/packages/manager/modules/sign-up/src/components/siret/siret.constants.js b/packages/manager/modules/sign-up/src/components/siret/siret.constants.js index 2f6038e58967..b02daa4c6ea5 100644 --- a/packages/manager/modules/sign-up/src/components/siret/siret.constants.js +++ b/packages/manager/modules/sign-up/src/components/siret/siret.constants.js @@ -40,6 +40,33 @@ export const COMPANY_NAME_LABEL_LEGAL_FORMS = [ export const UPDATE_SEARCH_ASSISTANT_LABEL_DEFAULT = 'siret_update_search_assistant'; +export const MODAL_REVIEW_INTRO_DEFAULT = 'siret_modal_review_intro'; + +/** + * Appends the legal form to a translation key when a dedicated wording exists + * (entreprise / association / administration), otherwise keeps the base key. + */ +export function byLegalForm(baseKey, legalForm) { + return COMPANY_NAME_LABEL_LEGAL_FORMS.includes(legalForm) + ? `${baseKey}_${legalForm}` + : baseKey; +} + +/** "Nom de l'entreprise / l'association / l'administration" */ +export function getCompanyNameLabelKey(legalForm) { + return byLegalForm(COMPANY_NAME_LABEL_DEFAULT, legalForm); +} + +/** "Mettre à jour mes informations d'entreprise / d'association / d'administration" */ +export function getUpdateSearchAssistantLabelKey(legalForm) { + return byLegalForm(UPDATE_SEARCH_ASSISTANT_LABEL_DEFAULT, legalForm); +} + +/** "Vérifiez vos informations d'entreprise / d'association / d'administration" */ +export function getModalReviewIntroKey(legalForm) { + return byLegalForm(MODAL_REVIEW_INTRO_DEFAULT, legalForm); +} + // Maps the search-assistant field aliases (used for enabling/disabling inputs) // to their matching key in the rules object. export const SIRET_RULE_FIELD = { @@ -82,7 +109,13 @@ export function calculateFRVATNumber(siren) { export const SIRET_SEARCH_REGEXP = /^(?:\d\s*){14}$/; export const SIRET_FOCUS_PARAM = 'siretForm'; -export const SIRET_SEARCH_ASSISTANT_ANCHOR = 'siret-search-assistant'; + +/** + * Broadcast from an ancestor scope to (re)open the SIRET lookup modal, so an + * error message rendered outside the siret component can send the customer + * straight to the company lookup. + */ +export const OPEN_SEARCH_MODAL_EVENT = 'siret:openSearchModal'; export const NON_DISCLOSED_VALUE = '[ND]'; @@ -103,6 +136,27 @@ export function fromSuggestion(value, previous) { return isNdValue(value) ? '' : value || previous; } +/** + * Suggestion values the search assistant fills the form with. A withheld value + * comes back in one of two shapes depending on the provider: the [ND] token, or + * an empty string (DATA_GOUV_RECHERCHE_ENTREPRISES answers "" for a company that + * does not disclose its data). Both mean the customer must type it in. + * The address is only usable when street, postcode AND city are all present. + */ +export const ASSISTANT_FILLED_VALUES = ['name', 'address', 'zipCode', 'city']; + +/** True when the directory returned nothing usable for a value. */ +export function isMissingValue(value) { + return !fromSuggestion(value, ''); +} + +/** True when at least one value the assistant fills came back empty or withheld. */ +export function hasMissingValues(suggestion) { + return ASSISTANT_FILLED_VALUES.some((key) => + isMissingValue(suggestion?.[key]), + ); +} + export default { LEGAL_FORM, PREFIX_TRANSLATION_LEGAL_FORM, @@ -117,5 +171,5 @@ export default { SIRET_RULE_FIELD, SIRET_SEARCH_REGEXP, SIRET_FOCUS_PARAM, - SIRET_SEARCH_ASSISTANT_ANCHOR, + OPEN_SEARCH_MODAL_EVENT, }; diff --git a/packages/manager/modules/sign-up/src/components/siret/siret.constants.spec.js b/packages/manager/modules/sign-up/src/components/siret/siret.constants.spec.js new file mode 100644 index 000000000000..2807dd32b215 --- /dev/null +++ b/packages/manager/modules/sign-up/src/components/siret/siret.constants.spec.js @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest'; + +import { + byLegalForm, + calculateFRVATNumber, + fromSuggestion, + getCompanyNameLabelKey, + getLegalFormFromCode, + getModalReviewIntroKey, + getUpdateSearchAssistantLabelKey, + hasMissingValues, + isMissingValue, + isNdValue, + SIRET_SEARCH_REGEXP, +} from './siret.constants'; + +describe('isNdValue', () => { + it.each([['[ND]'], ['[nd]'], ['[ND] [ND]'], [' [ND] ']])( + 'detects the withheld token %p', + (value) => { + expect(isNdValue(value)).toBe(true); + }, + ); + + it.each([[''], [null], [undefined]])('is false for %p', (value) => { + // an empty value is not a [ND] token: that distinction is why isMissingValue + // exists, and why empty values used to go unnoticed + expect(isNdValue(value)).toBe(false); + }); + + it('is false for a real value', () => { + expect(isNdValue('COURRIERES')).toBe(false); + }); +}); + +describe('fromSuggestion', () => { + it('blanks a withheld value instead of falling back', () => { + expect(fromSuggestion('[ND]', 'previous')).toBe(''); + }); + + it('keeps a real value', () => { + expect(fromSuggestion('COURRIERES', 'previous')).toBe('COURRIERES'); + }); + + it('falls back on the previous value when asked to', () => { + expect(fromSuggestion('', 'previous')).toBe('previous'); + }); + + it('blanks an empty value when no fallback is given', () => { + expect(fromSuggestion('', '')).toBe(''); + }); +}); + +describe('isMissingValue', () => { + // both shapes the directory answers with, per provider + it.each([ + ['[ND]', 'withheld token'], + ['[nd]', 'lowercase token'], + ['[ND] [ND]', 'repeated token'], + ['', 'empty string'], + [null, 'null'], + [undefined, 'absent'], + [' ', 'blanks only'], + ])('treats %p (%s) as missing', (value) => { + expect(isMissingValue(value)).toBe(true); + }); + + it.each([['COURRIERES'], ['12 rue de la Paix'], ['62710']])( + 'treats %p as present', + (value) => { + expect(isMissingValue(value)).toBe(false); + }, + ); +}); + +describe('hasMissingValues', () => { + const complete = { + name: 'OVH', + address: '2 rue Kellermann', + zipCode: '59100', + city: 'ROUBAIX', + }; + + it('is false when the directory returned everything', () => { + expect(hasMissingValues(complete)).toBe(false); + }); + + it.each(['name', 'address', 'zipCode', 'city'])( + 'is true when %s alone is empty', + (key) => { + expect(hasMissingValues({ ...complete, [key]: '' })).toBe(true); + }, + ); + + it.each(['name', 'address', 'zipCode', 'city'])( + 'is true when %s alone is withheld', + (key) => { + expect(hasMissingValues({ ...complete, [key]: '[ND]' })).toBe(true); + }, + ); + + it('is true on the real payload of a non-disclosed company', () => { + // verbatim from GET /me/suggest/company, provider DATA_GOUV_RECHERCHE_ENTREPRISES + expect( + hasMissingValues({ + address: '', + area: '32', + city: 'COURRIERES', + legalFormCode: '1000', + name: '', + primaryCNIN: '984715045', + secondaryCNIN: '98471504500014', + vatID: 'FR59984715045', + zipCode: '', + }), + ).toBe(true); + }); + + it('does not blow up without a suggestion', () => { + expect(hasMissingValues(undefined)).toBe(true); + }); + + it('ignores values the assistant does not fill', () => { + // area and creationDate are never written to the form + expect(hasMissingValues({ ...complete, area: '', creationDate: '' })).toBe( + false, + ); + }); +}); + +describe('byLegalForm', () => { + it.each([ + ['corporation', 'base_corporation'], + ['association', 'base_association'], + ['administration', 'base_administration'], + ])('suffixes the key for %s', (legalForm, expected) => { + expect(byLegalForm('base', legalForm)).toBe(expected); + }); + + it.each([['individual'], [undefined], [null], ['']])( + 'keeps the base key for %p', + (legalForm) => { + expect(byLegalForm('base', legalForm)).toBe('base'); + }, + ); +}); + +describe('legal-form aware label keys', () => { + it('names the company after its legal form', () => { + expect(getCompanyNameLabelKey('association')).toBe( + 'siret_manual_company_name_association', + ); + expect(getCompanyNameLabelKey('individual')).toBe( + 'siret_manual_company_name', + ); + }); + + it('adapts the update button label', () => { + expect(getUpdateSearchAssistantLabelKey('administration')).toBe( + 'siret_update_search_assistant_administration', + ); + }); + + it('adapts the modal review intro', () => { + expect(getModalReviewIntroKey('corporation')).toBe( + 'siret_modal_review_intro_corporation', + ); + expect(getModalReviewIntroKey(undefined)).toBe('siret_modal_review_intro'); + }); +}); + +describe('getLegalFormFromCode', () => { + it.each([ + ['9220', 'association'], + ['4110', 'administration'], + ['7220', 'administration'], + ['8110', 'administration'], + ['1000', 'corporation'], + ['5710', 'corporation'], + ])('maps INSEE code %s to %s', (code, expected) => { + expect(getLegalFormFromCode(code)).toBe(expected); + }); + + it('returns null without a code', () => { + expect(getLegalFormFromCode(undefined)).toBeNull(); + }); +}); + +describe('calculateFRVATNumber', () => { + it('computes the VAT number from a 9-digit SIREN', () => { + // key = (12 + 3 * (siren % 97)) % 97 + expect(calculateFRVATNumber('984715045')).toBe('FR59984715045'); + }); + + it.each([['12345678'], ['1234567890'], ['abcdefghi'], [''], [undefined]])( + 'refuses %p', + (siren) => { + expect(calculateFRVATNumber(siren)).toBeNull(); + }, + ); +}); + +describe('SIRET_SEARCH_REGEXP', () => { + it('accepts 14 digits, spaced or not', () => { + expect(SIRET_SEARCH_REGEXP.test('98471504500014')).toBe(true); + expect(SIRET_SEARCH_REGEXP.test('984 715 045 00014')).toBe(true); + }); + + it.each([['9847150450001'], ['984715045000145'], ['9847150450001A'], ['']])( + 'rejects %p', + (value) => { + expect(SIRET_SEARCH_REGEXP.test(value)).toBe(false); + }, + ); +}); diff --git a/packages/manager/modules/sign-up/src/components/siret/siret.controller.js b/packages/manager/modules/sign-up/src/components/siret/siret.controller.js index 68604362d509..05cd0d6eb69c 100644 --- a/packages/manager/modules/sign-up/src/components/siret/siret.controller.js +++ b/packages/manager/modules/sign-up/src/components/siret/siret.controller.js @@ -7,16 +7,15 @@ import { LEGAL_FORM_ENTERPRISE, LEGAL_FORM_ASSOCIATION, VAT_CHECKBOX_LABEL_BY_LEGAL_FORM, - COMPANY_NAME_LABEL_DEFAULT, - COMPANY_NAME_LABEL_LEGAL_FORMS, - UPDATE_SEARCH_ASSISTANT_LABEL_DEFAULT, SIRET_RULE_FIELD, SIRET_SEARCH_REGEXP, SIRET_FOCUS_PARAM, - SIRET_SEARCH_ASSISTANT_ANCHOR, + OPEN_SEARCH_MODAL_EVENT, fromSuggestion, - isNdValue, + hasMissingValues, getLegalFormFromCode, + getCompanyNameLabelKey as companyNameLabelKey, + getUpdateSearchAssistantLabelKey as updateSearchAssistantLabelKey, calculateFRVATNumber, } from './siret.constants'; @@ -28,19 +27,21 @@ export default class SiretCtrl { SiretService, coreConfig, $rootScope, + $scope, $timeout, - $anchorScroll, + $element, ) { this.$translate = $translate; this.atInternet = atInternet; this.siretService = SiretService; this.$rootScope = $rootScope; + this.$scope = $scope; this.$timeout = $timeout; - this.$anchorScroll = $anchorScroll; + this.$element = $element; this.search = ''; this.isFirstSearch = true; this.displayManualForm = false; - this.showUpdateSiretInfo = false; + this.searchModalOpen = false; this.activeSelectSuggest = null; this.assistantUsed = false; this.assistantEmptyFields = {}; @@ -59,12 +60,16 @@ export default class SiretCtrl { this.mode === 'modification' ? SIRET_SEARCH_REGEXP : undefined; if (this.mode === 'modification') { + // The edition form is never swapped out: the SIRET lookup happens in a + // modal, so the customer keeps their current data in sight until they + // validate the company found. + this.isFirstSearch = false; + this.displayManualForm = true; // Deep-links (container CompanyInformationModal / hub SiretBanner) carry - // fieldToFocus=siretForm to land the user directly on the search assistant; - // otherwise the manual edition form stays the default. - const openSearchAssistant = this.fieldToFocus === SIRET_FOCUS_PARAM; - this.isFirstSearch = openSearchAssistant; - this.displayManualForm = !openSearchAssistant; + // fieldToFocus=siretForm to land the customer straight on the lookup. + this.searchModalOpen = this.fieldToFocus === SIRET_FOCUS_PARAM; + // an error message rendered by the surrounding form can reopen the modal + this.$scope.$on(OPEN_SEARCH_MODAL_EVENT, () => this.openSearchModal()); if (this.shouldApplyFrenchAssociationRules()) { this.assistantUsed = true; @@ -82,9 +87,6 @@ export default class SiretCtrl { this.$timeout(() => { this.setAddressAutocompleteActive(true); - if (openSearchAssistant) { - this.$anchorScroll(SIRET_SEARCH_ASSISTANT_ANCHOR); - } }); } @@ -158,14 +160,29 @@ export default class SiretCtrl { : suggestSelected.secondaryCNIN; return this.submitSearch(false); } + this.suggest = { ...this.suggest, entryList: [suggestSelected] }; + this.applySuggestion(suggestSelected); + return null; + } + + /** + * Writes a company picked through the search assistant into the shared model. + * Called from the inline suggestion list (creation) and from the lookup modal + * once the customer validated the company found (modification) — never + * before, so a dismissed modal changes nothing. + */ + applySuggestion(suggestSelected) { + this.model = this.model || {}; this.model.companyNationalIdentificationNumber = suggestSelected.secondaryCNIN; - const isNonDiffusible = - isNdValue(suggestSelected.name) || isNdValue(suggestSelected.address); - this.model.organisation = fromSuggestion( - suggestSelected.name, - this.model.organisation, - ); + // Withheld data comes back as [ND] or as an empty string depending on the + // provider, and any of the values we fill can be missing — not just the name + // and the address. + const isNonDiffusible = hasMissingValues(suggestSelected); + // Blank rather than keep the previous value: after a search the previous + // value belongs to a DIFFERENT company, and keeping it both showed wrong data + // and left the field locked (assistantEmptyFields saw it as filled in). + this.model.organisation = fromSuggestion(suggestSelected.name, ''); this.lastVatValue = fromSuggestion(suggestSelected.vatID, ''); this.noVat = !this.lastVatValue; this.model.vat = this.noVat ? null : this.lastVatValue; @@ -199,12 +216,10 @@ export default class SiretCtrl { vat: this.isAssistantValueInvalid('vat', this.model.vat), }; this.isNonDiffusible = isNonDiffusible; - this.suggest = { ...this.suggest, entryList: [suggestSelected] }; - if (this.mode === 'modification') { - this.isFirstSearch = false; - this.displayManualForm = true; - } - return null; + // The confirmation checkbox is bound to controller state, which outlives the + // teardown of its own scope: untick it explicitly so freshly fetched company + // data always has to be confirmed again. + this.informationConfirmed = false; } // Detects the account type from the selected company legalFormCode and, for @@ -241,12 +256,64 @@ export default class SiretCtrl { this.displayManualForm = true; } + onSearchAssistantClick() { + return this.mode === 'modification' + ? this.openSearchModal() + : this.goToSearchMode(); + } + + /** + * Opens the SIRET lookup modal. Deliberately touches nothing but the modal + * visibility: the edition form and the shared model must stay intact until + * the customer validates a company (or dismisses the modal). + */ + openSearchModal() { + this.trackClick('search-assistant'); + this.searchModalOpen = true; + } + + closeSearchModal() { + this.searchModalOpen = false; + } + + onSearchModalValidate(suggestion) { + this.searchModalOpen = false; + if (!suggestion) { + return; + } + this.applySuggestion(suggestion); + // The customer was just asked to complete their information: take them to + // the first field to fill instead of leaving them to hunt for it. + if (this.isNonDiffusible) { + this.focusFirstInvalidField(); + } + } + + /** + * Focuses the first invalid control of the surrounding form, in document + * order. Runs in a $timeout so the blanked values have been written and their + * ng-invalid class applied. The fields to complete sit in the contact section, + * rendered ABOVE the activity section the modal was opened from, hence the + * scroll that focus() brings along. + */ + focusFirstInvalidField() { + this.$timeout(() => { + const root = this.$element[0].closest('form') || this.$element[0]; + const field = root.querySelector( + 'input.ng-invalid, select.ng-invalid, textarea.ng-invalid', + ); + if (field) { + field.focus(); + } + }); + } + goToSearchMode() { this.trackClick('search-assistant'); this.isFirstSearch = true; this.displayManualForm = false; - this.showUpdateSiretInfo = this.mode === 'modification'; this.isValid = false; + this.informationConfirmed = false; this.assistantUsed = false; this.assistantInvalidFields = {}; this.isNonDiffusible = false; @@ -382,17 +449,11 @@ export default class SiretCtrl { // "Nom de l'entreprise / l'association / l'administration" depending on the legal form getCompanyNameLabelKey() { - const legalForm = this.getLegalForm(); - return COMPANY_NAME_LABEL_LEGAL_FORMS.includes(legalForm) - ? `${COMPANY_NAME_LABEL_DEFAULT}_${legalForm}` - : COMPANY_NAME_LABEL_DEFAULT; + return companyNameLabelKey(this.getLegalForm()); } getUpdateSearchAssistantLabelKey() { - const legalForm = this.getLegalForm(); - return COMPANY_NAME_LABEL_LEGAL_FORMS.includes(legalForm) - ? `${UPDATE_SEARCH_ASSISTANT_LABEL_DEFAULT}_${legalForm}` - : UPDATE_SEARCH_ASSISTANT_LABEL_DEFAULT; + return updateSearchAssistantLabelKey(this.getLegalForm()); } onNoVatChange(noVat) { diff --git a/packages/manager/modules/sign-up/src/components/siret/siret.controller.spec.js b/packages/manager/modules/sign-up/src/components/siret/siret.controller.spec.js new file mode 100644 index 000000000000..e277ebc789ad --- /dev/null +++ b/packages/manager/modules/sign-up/src/components/siret/siret.controller.spec.js @@ -0,0 +1,334 @@ +import { describe, expect, it, vi } from 'vitest'; + +import SiretCtrl from './siret.controller'; + +// Verbatim payload of a non-disclosed company (provider +// DATA_GOUV_RECHERCHE_ENTREPRISES): withheld values are empty strings. +const ND_ENTRY = { + address: '', + area: '32', + city: 'COURRIERES', + legalFormCode: '1000', + name: '', + primaryCNIN: '984715045', + secondaryCNIN: '98471504500014', + vatID: 'FR59984715045', + zipCode: '', +}; + +const FULL_ENTRY = { + address: '2 rue Kellermann', + city: 'ROUBAIX', + legalFormCode: '5710', + name: 'OVH', + primaryCNIN: '424761419', + secondaryCNIN: '42476141900045', + vatID: 'FR22424761419', + zipCode: '59100', +}; + +// mode: null for the creation flow — undefined would trigger the default below, +// exactly as AngularJS leaves the binding unset when the attribute is absent. +const build = ({ model = {}, mode = 'modification', ...bindings } = {}) => { + const broadcasts = []; + const $rootScope = { + $broadcast: (name, payload) => broadcasts.push({ name, payload }), + }; + const $scope = { $on: vi.fn() }; + const $timeout = (fn) => fn(); + const $element = [document.createElement('div')]; + const coreConfig = { getUser: () => ({ legalform: 'corporation' }) }; + const $translate = { instant: (key) => key }; + const siretService = { getSiret: vi.fn() }; + + const ctrl = new SiretCtrl( + { trackClick: vi.fn(), trackPage: vi.fn() }, + $translate, + siretService, + coreConfig, + $rootScope, + $scope, + $timeout, + $element, + ); + Object.assign(ctrl, { + mode, + country: 'FR', + trackingMode: mode === 'modification' ? 'modification' : 'creation', + model, + rules: {}, + ...bindings, + }); + ctrl.$onInit(); + return { ctrl, broadcasts, $element }; +}; + +const broadcastOf = (broadcasts, name) => + broadcasts.filter((b) => b.name === name).pop(); + +describe('$onInit in modification mode', () => { + it('keeps the edition form mounted so the customer never loses their data', () => { + const { ctrl } = build(); + expect(ctrl.displayManualForm).toBe(true); + expect(ctrl.isFirstSearch).toBe(false); + expect(ctrl.searchModalOpen).toBe(false); + }); + + it('opens the modal straight away on a deep link', () => { + const { ctrl } = build({ fieldToFocus: 'siretForm' }); + expect(ctrl.searchModalOpen).toBe(true); + }); + + it('listens for a request to reopen the modal', () => { + const { ctrl } = build(); + expect(ctrl.$scope.$on).toHaveBeenCalledWith( + 'siret:openSearchModal', + expect.any(Function), + ); + }); +}); + +describe('opening the modal', () => { + it('changes nothing but the modal visibility', () => { + const model = { + organisation: 'PREVIOUS COMPANY', + companyNationalIdentificationNumber: '42476141900045', + vat: 'FR22424761419', + }; + const { ctrl, broadcasts } = build({ model }); + const before = { ...model }; + + ctrl.openSearchModal(); + + expect(ctrl.searchModalOpen).toBe(true); + // the whole promise of the modal: dismissing must be a no-op + expect(ctrl.model).toEqual(before); + expect(broadcastOf(broadcasts, 'siret:companySelected')).toBeUndefined(); + }); + + it('leaves the model untouched when the modal is dismissed', () => { + const model = { organisation: 'PREVIOUS COMPANY' }; + const { ctrl } = build({ model }); + + ctrl.openSearchModal(); + ctrl.closeSearchModal(); + + expect(ctrl.searchModalOpen).toBe(false); + expect(ctrl.model).toEqual({ organisation: 'PREVIOUS COMPANY' }); + }); +}); + +describe('applySuggestion on a fully disclosed company', () => { + it('writes the company found into the shared model', () => { + const { ctrl, broadcasts } = build({ model: { organisation: 'OLD' } }); + + ctrl.applySuggestion(FULL_ENTRY); + + expect(ctrl.model.organisation).toBe('OVH'); + expect(ctrl.model.companyNationalIdentificationNumber).toBe( + '42476141900045', + ); + expect(ctrl.model.vat).toBe('FR22424761419'); + expect(ctrl.isNonDiffusible).toBe(false); + expect(broadcastOf(broadcasts, 'siret:companySelected').payload).toEqual({ + address: '2 rue Kellermann', + city: 'ROUBAIX', + zip: '59100', + }); + }); + + it('unticks the confirmation checkbox so fresh data is confirmed again', () => { + const { ctrl } = build(); + ctrl.informationConfirmed = true; + + ctrl.applySuggestion(FULL_ENTRY); + + expect(ctrl.informationConfirmed).toBe(false); + }); + + it('locks the fields the assistant filled in', () => { + const { ctrl } = build(); + + ctrl.applySuggestion(FULL_ENTRY); + + expect(ctrl.isOrganisationDisabled()).toBe(true); + expect(ctrl.isSiretDisabled()).toBe(true); + }); +}); + +describe('applySuggestion on a non-disclosed company', () => { + // this is the regression: the customer first searches a disclosed company, + // then searches a company whose data the directory withholds + const previous = { + organisation: 'PREVIOUS COMPANY', + companyNationalIdentificationNumber: '42476141900045', + vat: 'FR22424761419', + }; + + it('does not keep the name of the company searched before', () => { + const { ctrl } = build({ model: { ...previous } }); + + ctrl.applySuggestion(ND_ENTRY); + + expect(ctrl.model.organisation).toBe(''); + }); + + it('unlocks the fields the customer now has to fill in', () => { + const { ctrl } = build({ model: { ...previous } }); + + ctrl.applySuggestion(ND_ENTRY); + + // blanked, therefore editable + expect(ctrl.isOrganisationDisabled()).toBe(false); + }); + + it('flags the company as non-disclosed even though nothing carries [ND]', () => { + const { ctrl } = build({ model: { ...previous } }); + + ctrl.applySuggestion(ND_ENTRY); + + expect(ctrl.isNonDiffusible).toBe(true); + }); + + it('blanks the address parts the directory withheld, keeping the city', () => { + const { ctrl, broadcasts } = build({ model: { ...previous } }); + + ctrl.applySuggestion(ND_ENTRY); + + expect(broadcastOf(broadcasts, 'siret:companySelected').payload).toEqual({ + address: '', + city: 'COURRIERES', + zip: '', + }); + }); + + it('still applies the SIRET and the VAT the directory did return', () => { + const { ctrl } = build({ model: { ...previous } }); + + ctrl.applySuggestion(ND_ENTRY); + + expect(ctrl.model.companyNationalIdentificationNumber).toBe( + '98471504500014', + ); + expect(ctrl.model.vat).toBe('FR59984715045'); + // the SIRET was found, so it stays locked + expect(ctrl.isSiretDisabled()).toBe(true); + }); + + it('treats a withheld [ND] token exactly like an empty value', () => { + const { ctrl } = build({ model: { ...previous } }); + + ctrl.applySuggestion({ ...FULL_ENTRY, name: '[ND]' }); + + expect(ctrl.model.organisation).toBe(''); + expect(ctrl.isNonDiffusible).toBe(true); + expect(ctrl.isOrganisationDisabled()).toBe(false); + }); +}); + +describe('validating from the modal', () => { + it('closes the modal and applies the company', () => { + const { ctrl } = build(); + ctrl.searchModalOpen = true; + + ctrl.onSearchModalValidate(FULL_ENTRY); + + expect(ctrl.searchModalOpen).toBe(false); + expect(ctrl.model.organisation).toBe('OVH'); + }); + + it('applies nothing when no company came back', () => { + const { ctrl } = build({ model: { organisation: 'KEEP ME' } }); + ctrl.searchModalOpen = true; + + ctrl.onSearchModalValidate(undefined); + + expect(ctrl.searchModalOpen).toBe(false); + expect(ctrl.model.organisation).toBe('KEEP ME'); + }); + + it('focuses the first field to fill on an incomplete company', () => { + const form = document.createElement('form'); + const untouched = document.createElement('input'); + const invalid = document.createElement('input'); + invalid.className = 'ng-invalid'; + form.append(untouched, invalid); + document.body.append(form); + const { ctrl } = build(); + // the component lives inside the surrounding form + form.append(ctrl.$element[0]); + + ctrl.onSearchModalValidate(ND_ENTRY); + + expect(document.activeElement).toBe(invalid); + form.remove(); + }); + + it('does not move the focus for a fully disclosed company', () => { + const form = document.createElement('form'); + const invalid = document.createElement('input'); + invalid.className = 'ng-invalid'; + form.append(invalid); + document.body.append(form); + const { ctrl } = build(); + form.append(ctrl.$element[0]); + + ctrl.onSearchModalValidate(FULL_ENTRY); + + expect(document.activeElement).not.toBe(invalid); + form.remove(); + }); +}); + +describe('the account type detected from the company', () => { + it('switches the account type and tells the parent to refetch its rules', () => { + const onLegalFormChange = vi.fn(); + const { ctrl } = build({ + model: { legalform: 'corporation' }, + onLegalFormChange, + }); + + // INSEE category 9xxx is an association + ctrl.applySuggestion({ ...FULL_ENTRY, legalFormCode: '9220' }); + + expect(ctrl.model.legalform).toBe('association'); + expect(onLegalFormChange).toHaveBeenCalledWith({ + legalform: 'association', + }); + }); + + it('computes the FR VAT number of a corporation that has none', () => { + const { ctrl } = build({ model: { legalform: 'corporation' } }); + + ctrl.applySuggestion({ ...FULL_ENTRY, vatID: '' }); + + expect(ctrl.model.vat).toBe('FR22424761419'); + }); +}); + +describe('creation mode keeps the inline assistant', () => { + it('does not mount the edition form nor the modal', () => { + const { ctrl } = build({ mode: null }); + expect(ctrl.displayManualForm).toBe(false); + expect(ctrl.isFirstSearch).toBe(true); + expect(ctrl.searchModalOpen).toBe(false); + }); + + it('routes the assistant button to the inline search, not the modal', () => { + const { ctrl } = build({ mode: null }); + + ctrl.onSearchAssistantClick(); + + expect(ctrl.searchModalOpen).toBe(false); + expect(ctrl.displayManualForm).toBe(false); + expect(ctrl.isFirstSearch).toBe(true); + }); + + it('routes the assistant button to the modal in modification', () => { + const { ctrl } = build(); + + ctrl.onSearchAssistantClick(); + + expect(ctrl.searchModalOpen).toBe(true); + }); +}); diff --git a/packages/manager/modules/sign-up/src/components/siret/siret.html b/packages/manager/modules/sign-up/src/components/siret/siret.html index 3108730eba7f..07b7944b7f38 100644 --- a/packages/manager/modules/sign-up/src/components/siret/siret.html +++ b/packages/manager/modules/sign-up/src/components/siret/siret.html @@ -10,14 +10,6 @@ > - - - -