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: '',
+ // drives the wording (company / association / administration)
+ legalForm: '',
+ trackingMode: '',
+ // called with the company the customer confirmed: { suggestion }
+ onValidate: '&',
+ // dismissing the modal must leave the caller untouched
+ onCancel: '&',
+ },
+};
diff --git a/packages/manager/modules/sign-up/src/components/siret/searchModal/siret-search-modal.controller.js b/packages/manager/modules/sign-up/src/components/siret/searchModal/siret-search-modal.controller.js
new file mode 100644
index 000000000000..fe425faa200e
--- /dev/null
+++ b/packages/manager/modules/sign-up/src/components/siret/searchModal/siret-search-modal.controller.js
@@ -0,0 +1,281 @@
+import {
+ TRACKING_PREFIX,
+ SIRET_SEARCH_REGEXP,
+ fromSuggestion,
+ getCompanyNameLabelKey as companyNameLabelKey,
+ getModalReviewIntroKey as modalReviewIntroKey,
+ getUpdateSearchAssistantLabelKey as updateSearchAssistantLabelKey,
+ hasMissingValues,
+ isMissingValue,
+} from '../siret.constants';
+
+/**
+ * SIRET lookup modal: searches a company by SIRET, shows the company found for
+ * review and hands it back to the caller through `onValidate` only once the
+ * customer has confirmed it. Nothing outside this component is mutated, so a
+ * dismissed modal leaves the surrounding form exactly as it was.
+ */
+export default class SiretSearchModalCtrl {
+ /* @ngInject */
+ constructor(atInternet, SiretService) {
+ this.atInternet = atInternet;
+ this.siretService = SiretService;
+ this.search = '';
+ this.searching = false;
+ this.suggest = null;
+ this.selected = null;
+ }
+
+ $onInit() {
+ this.trackingPrefix = TRACKING_PREFIX[this.trackingMode || 'modification'];
+ // Seed the field with whatever SIRET the account already holds, so the
+ // customer never retypes what we know — a SIREN or a value the directory
+ // would reject still gives them a head start over an empty field.
+ const initial = (this.initialSearch || '').replace(/\s/g, '');
+ this.search = initial;
+ // Only a usable 14-digit SIRET is looked up on open: searching a value we
+ // know to be incomplete would greet the customer with an error they did not
+ // ask for. needTracking=false — nothing was clicked, but the outcome is
+ // still tracked.
+ if (SIRET_SEARCH_REGEXP.test(initial)) {
+ this.submitSearch(false);
+ }
+ }
+
+ // The lookup is by SIRET only (14 digits), so spaces are never meaningful
+ // (MANAGER-21817).
+ onSearchChange() {
+ if (this.search) {
+ this.search = this.search.replace(/\s/g, '');
+ }
+ // Editing the SIRET invalidates the company found: drop it so the customer
+ // cannot validate a company that no longer matches what they typed.
+ if (this.suggest && this.search !== this.searchedValue) {
+ this.suggest = null;
+ this.selected = null;
+ }
+ }
+
+ // The modal is rendered inside the surrounding form: intercepting Enter here
+ // keeps it from bubbling up as an implicit submit of that form.
+ onSearchKeyDown(event) {
+ if ((event.key || '').toLowerCase() !== 'enter') {
+ return;
+ }
+ event.preventDefault();
+ if (this.canSearch()) {
+ this.submitSearch();
+ }
+ }
+
+ // Validated in the controller rather than with ngModel validators: an invalid
+ // control here would propagate to the surrounding form and lock its submit.
+ canSearch() {
+ return !this.searching && SIRET_SEARCH_REGEXP.test(this.search || '');
+ }
+
+ onSearchBlur() {
+ this.searchTouched = true;
+ }
+
+ // Drives the oui-field error state. Only once the customer left a non-empty
+ // field: an empty field is already covered by the disabled search button, and
+ // flagging every half-typed SIRET would flash an error on each keystroke.
+ isSearchInvalid() {
+ return (
+ Boolean(this.searchTouched) &&
+ Boolean(this.search) &&
+ !SIRET_SEARCH_REGEXP.test(this.search)
+ );
+ }
+
+ // Before a company is on screen the intro tells the customer to enter their
+ // SIRET; once one is displayed it asks them to check it instead.
+ getIntroKey() {
+ return this.selected
+ ? modalReviewIntroKey(this.legalForm)
+ : 'siret_update_search_assistant_info';
+ }
+
+ $onDestroy() {
+ this.destroyed = true;
+ }
+
+ /**
+ * A lookup response is worthless in two cases, both reachable because the
+ * auto-search starts as soon as the modal opens — exactly when a customer who
+ * came to CHANGE their SIRET starts typing, or gives up and closes it:
+ * - the SIRET in the field is no longer the one we searched: showing the old
+ * company next to the new number would let Validate apply the wrong one;
+ * - the modal is gone: nothing to render, and no hit worth reporting.
+ */
+ shouldDiscardResponse() {
+ return (
+ this.destroyed ||
+ this.searchedValue !== (this.search || '').replace(/\s/g, '')
+ );
+ }
+
+ submitSearch(needTracking = true) {
+ if (this.searching) {
+ return null;
+ }
+ if (needTracking) {
+ this.trackClick('search');
+ }
+ this.searching = true;
+ this.suggest = null;
+ this.selected = null;
+ this.searchedValue = (this.search || '').replace(/\s/g, '');
+ return this.siretService
+ .getSiret({
+ country: (this.country || '').toUpperCase(),
+ identifier: this.searchedValue,
+ })
+ .then((suggest = {}) => {
+ 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 @@
>
-
-
-
-