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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions packages/manager/modules/account/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export default class NewAccountFormEinvoicingController {
constructor($scope) {
this.$scope = $scope;
this.staleAddress = false;
this.companyChanged = false;
}

$onInit() {
Expand All @@ -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()) {
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,23 @@ <h3 data-translate="{{ :: 'signup_section_' + section }}"></h3>
<!-- ALERT -->
<div data-ovh-alert="InfoErrors"></div>

<!-- The alert is rendered with ng-bind-html, so the way back to the SIRET
lookup has to be a sibling of it rather than a link inside it. -->
<oui-message
class="mb-2"
type="error"
data-ng-if="$ctrl.submitError && $ctrl.isSiretAvailable"
>
<span data-translate="signup_account_info_siret_lookup_hint"></span>
<button
type="button"
class="oui-button oui-button_link"
data-ng-click="$ctrl.openSiretSearch()"
>
<span data-translate="signup_account_info_siret_lookup_cta"></span>
</button>
</oui-message>

<!-- FOOTER BUTTONS -->
<div class="mb-5">
<!-- RG4: switching from "Autre" to a company category prompts a company data check -->
Expand All @@ -120,16 +137,15 @@ <h3 data-translate="{{ :: 'signup_section_' + section }}"></h3>
<oui-message
class="mb-2"
type="warning"
data-ng-if="ovhSignupForm.$invalid && $ctrl.user.ovhSubsidiary === 'FR' && !ovhSignupForm.form_part_activity.searchForm"
data-ng-if="ovhSignupForm.$invalid && $ctrl.user.ovhSubsidiary === 'FR'"
>
<span data-translate="new_account_content_check_validation"></span>
</oui-message>
<button
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"
Expand Down
Loading
Loading