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 @@ > - - - -