Merge pull request #9643 from tchak/feat-autosave-validation

feat(dossier): validate on change and revalidate on input
This commit is contained in:
Paul Chavard 2023-10-31 18:03:20 +00:00 committed by GitHub
commit 13eadb93bf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 247 additions and 334 deletions

View file

@ -61,7 +61,13 @@ module Administrateurs
def apercu def apercu
@dossier = procedure_without_control.draft_revision.dossier_for_preview(current_user) @dossier = procedure_without_control.draft_revision.dossier_for_preview(current_user)
DossierPreloader.load_one(@dossier)
@tab = apercu_tab @tab = apercu_tab
if @tab == 'dossier'
@dossier.validate(:champs_public_value)
else
@dossier.validate(:champs_private_value)
end
end end
def new def new

View file

@ -292,9 +292,8 @@ module Instructeurs
if dossier.champs_private_all.any?(&:changed?) if dossier.champs_private_all.any?(&:changed?)
dossier.last_champ_private_updated_at = Time.zone.now dossier.last_champ_private_updated_at = Time.zone.now
end end
if !dossier.save(context: :annotations)
flash.now.alert = dossier.errors.full_messages dossier.save(context: :champs_private_value)
end
respond_to do |format| respond_to do |format|
format.turbo_stream do format.turbo_stream do

View file

@ -204,7 +204,7 @@ module Users
session.delete(:prefill_token) session.delete(:prefill_token)
session.delete(:prefill_params) session.delete(:prefill_params)
@dossier = dossier_with_champs @dossier = dossier_with_champs
@dossier.valid?(context: :prefilling) @dossier.validate(:champs_public_value)
end end
def submit_brouillon def submit_brouillon
@ -519,27 +519,28 @@ module Users
end end
def update_dossier_and_compute_errors def update_dossier_and_compute_errors
errors = []
@dossier.assign_attributes(champs_public_params) @dossier.assign_attributes(champs_public_params)
if @dossier.champs_public_all.any?(&:changed_for_autosave?) if @dossier.champs_public_all.any?(&:changed_for_autosave?)
@dossier.last_champ_updated_at = Time.zone.now @dossier.last_champ_updated_at = Time.zone.now
end end
if !@dossier.save(**validation_options) # We save the dossier without validating fields, and if it is successful and the client
errors = @dossier.errors # requests it, we ask for field validation errors.
if @dossier.save && params[:validate].present?
@dossier.valid?(:champs_public_value)
end end
errors @dossier.errors
end end
def submit_dossier_and_compute_errors def submit_dossier_and_compute_errors
@dossier.valid?(**submit_validation_options) @dossier.validate(:champs_public_value)
errors = @dossier.errors errors = @dossier.errors
@dossier.check_mandatory_and_visible_champs.map do |error_on_champ| @dossier.check_mandatory_and_visible_champs.map do |error_on_champ|
errors.import(error_on_champ) errors.import(error_on_champ)
end end
errors errors
end end
@ -588,20 +589,5 @@ module Users
def commentaire_params def commentaire_params
params.require(:commentaire).permit(:body, :piece_jointe) params.require(:commentaire).permit(:body, :piece_jointe)
end end
def submit_validation_options
# rubocop:disable Lint/BooleanSymbol
# Force ActiveRecord to re-validate associated records.
{ context: :false }
# rubocop:enable Lint/BooleanSymbol
end
def validation_options
if dossier.brouillon?
{ context: :brouillon }
else
submit_validation_options
end
end
end end
end end

View file

@ -1,11 +1,5 @@
import { import { httpRequest, ResponseError, getConfig } from '@utils';
httpRequest, import { matchInputElement, isButtonElement } from '@coldwired/utils';
ResponseError,
isSelectElement,
isCheckboxOrRadioInputElement,
isTextInputElement,
getConfig
} from '@utils';
import { ApplicationController } from './application_controller'; import { ApplicationController } from './application_controller';
import { AutoUpload } from '../shared/activestorage/auto-upload'; import { AutoUpload } from '../shared/activestorage/auto-upload';
@ -54,66 +48,84 @@ export class AutosaveController extends ApplicationController {
} }
onClickRetryButton(event: Event) { onClickRetryButton(event: Event) {
const target = event.target as HTMLButtonElement; const target = event.target;
const inputTargetSelector = target.dataset.inputTarget; if (isButtonElement(target)) {
if (inputTargetSelector) { const inputTargetSelector = target.dataset.inputTarget;
const target = if (inputTargetSelector) {
this.element.querySelector<HTMLInputElement>(inputTargetSelector); const target =
if ( this.element.querySelector<HTMLInputElement>(inputTargetSelector);
target && if (
target.type == 'file' && target &&
target.dataset.autoAttachUrl && target.type == 'file' &&
target.files?.length target.dataset.autoAttachUrl &&
) { target.files?.length
this.enqueueAutouploadRequest(target, target.files[0]); ) {
this.enqueueAutouploadRequest(target, target.files[0]);
}
} }
} }
} }
private onChange(event: Event) { private onChange(event: Event) {
const target = event.target as HTMLInputElement; matchInputElement(event.target, {
if (!target.disabled) { file: (target) => {
if (target.type == 'file') {
if (target.dataset.autoAttachUrl && target.files?.length) { if (target.dataset.autoAttachUrl && target.files?.length) {
this.globalDispatch('autosave:input'); this.globalDispatch('autosave:input');
this.enqueueAutouploadRequest(target, target.files[0]); this.enqueueAutouploadRequest(target, target.files[0]);
} }
} else if (target.type == 'hidden') { },
this.globalDispatch('autosave:input'); changeable: (target) => {
// In React comboboxes we dispatch a "change" event on hidden inputs to trigger autosave.
// We want to debounce them.
this.debounce(this.enqueueAutosaveRequest, AUTOSAVE_DEBOUNCE_DELAY);
} else if (
isSelectElement(target) ||
isCheckboxOrRadioInputElement(target)
) {
this.globalDispatch('autosave:input'); this.globalDispatch('autosave:input');
// Wait next tick so champs having JS can interact // Wait next tick so champs having JS can interact
// with form elements before extracting form data. // with form elements before extracting form data.
setTimeout(() => { setTimeout(() => {
this.enqueueAutosaveRequest(); this.enqueueAutosaveRequest();
this.showConditionnalSpinner(target); this.showConditionnalSpinner(target);
}, 0); }, 0);
},
inputable: (target) => this.enqueueOnInput(target, true),
hidden: (target) => {
// In comboboxes we dispatch a "change" event on hidden inputs to trigger autosave.
// We want to debounce them.
this.enqueueOnInput(target, true);
} }
} });
} }
private onInput(event: Event) { private onInput(event: Event) {
const target = event.target as HTMLInputElement; matchInputElement(event.target, {
if ( inputable: (target) => {
!target.disabled && // Ignore input from React comboboxes. We trigger "change" events on them when selection is changed.
// Ignore input from React comboboxes. We trigger "change" events on them when selection is changed. if (target.getAttribute('role') != 'combobox') {
target.getAttribute('role') != 'combobox' && const validate = this.needsValidation(target);
isTextInputElement(target) this.enqueueOnInput(target, validate);
) { }
this.globalDispatch('autosave:input'); }
this.debounce(this.enqueueAutosaveRequest, AUTOSAVE_DEBOUNCE_DELAY); });
this.showConditionnalSpinner(target);
}
} }
private showConditionnalSpinner(target: HTMLInputElement) { private enqueueOnInput(
target: HTMLInputElement | HTMLTextAreaElement,
validate: boolean
) {
this.globalDispatch('autosave:input');
const callback = validate
? this.enqueueAutosaveWithValidationRequest
: this.enqueueAutosaveRequest;
this.debounce(callback, AUTOSAVE_DEBOUNCE_DELAY);
this.showConditionnalSpinner(target);
}
private needsValidation(target: HTMLElement) {
return target.getAttribute('aria-invalid') == 'true';
}
private showConditionnalSpinner(
target: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
) {
const champWrapperElement = target.closest( const champWrapperElement = target.closest(
'.editable-champ[data-dependent-conditions]' '.editable-champ[data-dependent-conditions]'
); );
@ -198,9 +210,18 @@ export class AutosaveController extends ApplicationController {
this.didEnqueue(); this.didEnqueue();
} }
private enqueueAutosaveWithValidationRequest() {
this.#latestPromise = this.#latestPromise.finally(() =>
this.sendAutosaveRequest(true)
.then(() => this.didSucceed())
.catch((error) => this.didFail(error))
);
this.didEnqueue();
}
// Create a fetch request that saves the form. // Create a fetch request that saves the form.
// Returns a promise fulfilled when the request completes. // Returns a promise fulfilled when the request completes.
private sendAutosaveRequest(): Promise<void> { private sendAutosaveRequest(validate = false): Promise<void> {
this.#abortController = new AbortController(); this.#abortController = new AbortController();
const { form, inputs } = this; const { form, inputs } = this;
@ -222,6 +243,9 @@ export class AutosaveController extends ApplicationController {
formData.append(input.name, input.value); formData.append(input.name, input.value);
} }
} }
if (validate) {
formData.append('validate', 'true');
}
this.#pendingPromiseCount++; this.#pendingPromiseCount++;

View file

@ -1,10 +1,10 @@
import { isButtonElement } from '@utils'; import { isButtonElement } from '@coldwired/utils';
import { ApplicationController } from './application_controller'; import { ApplicationController } from './application_controller';
export class AutosaveSubmitController extends ApplicationController { export class AutosaveSubmitController extends ApplicationController {
#isSaving = false; #isSaving = false;
#shouldSubmit = true; #shouldSubmit = false;
#buttonText?: string; #buttonText?: string;
connect(): void { connect(): void {
@ -46,15 +46,20 @@ export class AutosaveSubmitController extends ApplicationController {
private disableButton() { private disableButton() {
if (isButtonElement(this.element)) { if (isButtonElement(this.element)) {
this.#buttonText = this.element.value; const disableWith = this.element.dataset.disableWith ?? '';
this.element.value = this.element.dataset.disableWith ?? ''; if (disableWith) {
this.#buttonText = this.element.textContent ?? undefined;
this.element.textContent = disableWith;
}
this.element.disabled = true; this.element.disabled = true;
} }
} }
private enableButton() { private enableButton() {
if (isButtonElement(this.element) && this.#buttonText) { if (isButtonElement(this.element)) {
this.element.value = this.#buttonText; if (this.#buttonText) {
this.element.textContent = this.#buttonText;
}
this.element.disabled = false; this.element.disabled = false;
} }
} }

View file

@ -1,10 +1,4 @@
import { import { isFormInputElement, matchInputElement } from '@coldwired/utils';
isSelectElement,
isCheckboxOrRadioInputElement,
isTextInputElement,
isDateInputElement
} from '@utils';
import { isFormInputElement } from '@coldwired/utils';
import { ApplicationController } from './application_controller'; import { ApplicationController } from './application_controller';
@ -28,14 +22,9 @@ export class AutosubmitController extends ApplicationController {
private onChange(event: Event) { private onChange(event: Event) {
const target = this.findTargetElement(event); const target = this.findTargetElement(event);
if (!target) return;
if ( matchInputElement(target, {
isSelectElement(target) || date: (target) => {
isCheckboxOrRadioInputElement(target) ||
isTextInputElement(target)
) {
if (isDateInputElement(target)) {
if (target.value.trim() == '' || !isNaN(Date.parse(target.value))) { if (target.value.trim() == '' || !isNaN(Date.parse(target.value))) {
this.#dateTimeChangedInputs.add(target); this.#dateTimeChangedInputs.add(target);
this.debounce(this.submit, AUTOSUBMIT_DATE_DEBOUNCE_DELAY); this.debounce(this.submit, AUTOSUBMIT_DATE_DEBOUNCE_DELAY);
@ -43,34 +32,34 @@ export class AutosubmitController extends ApplicationController {
this.#dateTimeChangedInputs.delete(target); this.#dateTimeChangedInputs.delete(target);
this.cancelDebounce(this.submit); this.cancelDebounce(this.submit);
} }
} else { },
this.cancelDebounce(this.submit); text: () => this.submitNow(),
this.submit(); changeable: () => this.submitNow()
} });
}
} }
private onInput(event: Event) { private onInput(event: Event) {
const target = this.findTargetElement(event); const target = this.findTargetElement(event);
if (!target) return;
if (!isDateInputElement(target) && isTextInputElement(target)) { matchInputElement(target, {
this.debounce(this.submit, AUTOSUBMIT_DEBOUNCE_DELAY); date: () => {},
} inputable: () => this.debounce(this.submit, AUTOSUBMIT_DEBOUNCE_DELAY)
});
} }
private onBlur(event: Event) { private onBlur(event: Event) {
const target = this.findTargetElement(event); const target = this.findTargetElement(event);
if (!target) return; if (!target) return;
if (isDateInputElement(target)) { matchInputElement(target, {
Promise.resolve().then(() => { date: () => {
if (this.#dateTimeChangedInputs.has(target)) { Promise.resolve().then(() => {
this.cancelDebounce(this.submit); if (this.#dateTimeChangedInputs.has(target)) {
this.submit(); this.submitNow();
} }
}); });
} }
});
} }
private findTargetElement(event: Event) { private findTargetElement(event: Event) {
@ -111,6 +100,11 @@ export class AutosubmitController extends ApplicationController {
return eventTypes.length == 0 ? true : eventTypes; return eventTypes.length == 0 ? true : eventTypes;
} }
private submitNow() {
this.cancelDebounce(this.submit);
this.submit();
}
private submit() { private submit() {
const submitter = this.hasSubmitterTarget ? this.submitterTarget : null; const submitter = this.hasSubmitterTarget ? this.submitterTarget : null;
const form = const form =

View file

@ -1,12 +1,8 @@
/* eslint-disable react-hooks/rules-of-hooks */ /* eslint-disable react-hooks/rules-of-hooks */
import { ActionEvent } from '@hotwired/stimulus'; import { ActionEvent } from '@hotwired/stimulus';
import { import { httpRequest, getConfig } from '@utils';
httpRequest, import { matchInputElement } from '@coldwired/utils';
isSelectElement,
isCheckboxOrRadioInputElement,
isTextInputElement,
getConfig
} from '@utils';
import { AutoUpload } from '../shared/activestorage/auto-upload'; import { AutoUpload } from '../shared/activestorage/auto-upload';
import { ApplicationController } from './application_controller'; import { ApplicationController } from './application_controller';
@ -59,29 +55,26 @@ export class TypeDeChampEditorController extends ApplicationController {
} }
private onChange(event: Event) { private onChange(event: Event) {
const target = event.target as HTMLElement & { form?: HTMLFormElement }; matchInputElement(event.target, {
file: (target) => {
if ( if (target.files?.length) {
target.form && const autoupload = new AutoUpload(target, target.files[0]);
(isSelectElement(target) || isCheckboxOrRadioInputElement(target)) autoupload.start();
) { }
this.save(target.form); },
} changeable: (target) => this.save(target.form)
});
} }
private onInput(event: Event) { private onInput(event: Event) {
const target = event.target as HTMLInputElement; matchInputElement(event.target, {
inputable: (target) => {
// mark input as touched so we know to not overwrite it's value with next re-render if (target.form) {
target.setAttribute('data-touched', 'true'); this.#dirtyForms.add(target.form);
this.debounce(this.save, AUTOSAVE_DEBOUNCE_DELAY);
if (target.form && isTextInputElement(target)) { }
this.#dirtyForms.add(target.form); }
this.debounce(this.save, AUTOSAVE_DEBOUNCE_DELAY); });
} else if (target.form && target.type == 'file' && target.files?.length) {
const autoupload = new AutoUpload(target, target.files[0]);
autoupload.start();
}
} }
private onSortableEnd(event: CustomEvent<{ position: number }>) { private onSortableEnd(event: CustomEvent<{ position: number }>) {

View file

@ -1,14 +1,6 @@
import { suite, test, expect } from 'vitest'; import { suite, test, expect } from 'vitest';
import { import { show, hide, toggle, toggleExpandIcon } from './utils';
show,
hide,
toggle,
toggleExpandIcon,
isSelectElement,
isTextInputElement,
isCheckboxOrRadioInputElement
} from './utils';
suite('@utils', () => { suite('@utils', () => {
test('show', () => { test('show', () => {
@ -46,79 +38,4 @@ suite('@utils', () => {
expect(icon.classList.contains('fr-icon-arrow-down-s-line')).toBeTruthy(); expect(icon.classList.contains('fr-icon-arrow-down-s-line')).toBeTruthy();
expect(icon.classList.contains('fr-icon-arrow-up-s-line')).toBeFalsy(); expect(icon.classList.contains('fr-icon-arrow-up-s-line')).toBeFalsy();
}); });
test('isSelectElement', () => {
const select = document.createElement('select');
const input = document.createElement('input');
const textarea = document.createElement('textarea');
expect(isSelectElement(select)).toBeTruthy();
expect(isSelectElement(input)).toBeFalsy();
expect(isSelectElement(textarea)).toBeFalsy();
input.type = 'text';
expect(isSelectElement(input)).toBeFalsy();
input.type = 'email';
expect(isSelectElement(input)).toBeFalsy();
input.type = 'checkbox';
expect(isSelectElement(input)).toBeFalsy();
input.type = 'radio';
expect(isSelectElement(input)).toBeFalsy();
input.type = 'file';
expect(isSelectElement(input)).toBeFalsy();
});
test('isTextInputElement', () => {
const select = document.createElement('select');
const input = document.createElement('input');
const textarea = document.createElement('textarea');
expect(isTextInputElement(select)).toBeFalsy();
expect(isTextInputElement(input)).toBeTruthy();
expect(isTextInputElement(textarea)).toBeTruthy();
input.type = 'text';
expect(isTextInputElement(input)).toBeTruthy();
input.type = 'email';
expect(isTextInputElement(input)).toBeTruthy();
input.type = 'checkbox';
expect(isTextInputElement(input)).toBeFalsy();
input.type = 'radio';
expect(isTextInputElement(input)).toBeFalsy();
input.type = 'file';
expect(isTextInputElement(input)).toBeFalsy();
});
test('isCheckboxOrRadioInputElement', () => {
const select = document.createElement('select');
const input = document.createElement('input');
const textarea = document.createElement('textarea');
expect(isCheckboxOrRadioInputElement(select)).toBeFalsy();
expect(isCheckboxOrRadioInputElement(input)).toBeFalsy();
expect(isCheckboxOrRadioInputElement(textarea)).toBeFalsy();
input.type = 'text';
expect(isCheckboxOrRadioInputElement(input)).toBeFalsy();
input.type = 'email';
expect(isCheckboxOrRadioInputElement(input)).toBeFalsy();
input.type = 'checkbox';
expect(isCheckboxOrRadioInputElement(input)).toBeTruthy();
input.type = 'radio';
expect(isCheckboxOrRadioInputElement(input)).toBeTruthy();
input.type = 'file';
expect(isCheckboxOrRadioInputElement(input)).toBeFalsy();
});
}); });

View file

@ -269,50 +269,6 @@ export function isNumeric(s: string) {
return !isNaN(n) && isFinite(n); return !isNaN(n) && isFinite(n);
} }
export function isButtonElement(
element: Element
): element is HTMLButtonElement {
return (
element.tagName == 'BUTTON' ||
(element.tagName == 'INPUT' &&
(element as HTMLInputElement).type == 'submit')
);
}
export function isSelectElement(
element: HTMLElement
): element is HTMLSelectElement {
return element.tagName == 'SELECT';
}
export function isCheckboxOrRadioInputElement(
element: HTMLElement & { type?: string }
): element is HTMLInputElement {
return (
element.tagName == 'INPUT' &&
(element.type == 'checkbox' || element.type == 'radio')
);
}
export function isDateInputElement(
element: HTMLElement & { type?: string }
): element is HTMLInputElement {
return (
element.tagName == 'INPUT' &&
(element.type == 'date' || element.type == 'datetime-local')
);
}
export function isTextInputElement(
element: HTMLElement & { type?: string }
): element is HTMLInputElement {
return (
['INPUT', 'TEXTAREA'].includes(element.tagName) &&
typeof element.type == 'string' &&
!['checkbox', 'radio', 'file'].includes(element.type)
);
}
export function fire<T>(obj: EventTarget, name: string, data?: T) { export function fire<T>(obj: EventTarget, name: string, data?: T) {
const event = new CustomEvent(name, { const event = new CustomEvent(name, {
bubbles: true, bubbles: true,

View file

@ -250,8 +250,25 @@ class Champ < ApplicationRecord
public? && dossier.champ_forked_with_changes?(self) public? && dossier.champ_forked_with_changes?(self)
end end
protected
def valid_champ_value?
valid?(public? ? :champs_public_value : :champs_private_value)
end
private private
def validate_champ_value?
case validation_context
when :champs_public_value
public?
when :champs_private_value
private?
else
false
end
end
def html_id def html_id
"champ-#{stable_id}-#{id}" "champ-#{stable_id}-#{id}"
end end

View file

@ -1,7 +1,7 @@
class Champs::CnafChamp < Champs::TextChamp class Champs::CnafChamp < Champs::TextChamp
# see https://github.com/betagouv/api-particulier/blob/master/src/presentation/middlewares/cnaf-input-validation.middleware.ts # see https://github.com/betagouv/api-particulier/blob/master/src/presentation/middlewares/cnaf-input-validation.middleware.ts
validates :numero_allocataire, format: { with: /\A\d{1,7}\z/ }, if: -> { code_postal.present? && validation_context != :brouillon } validates :numero_allocataire, format: { with: /\A\d{1,7}\z/ }, if: -> { code_postal.present? && validate_champ_value? }
validates :code_postal, format: { with: /\A\w{5}\z/ }, if: -> { numero_allocataire.present? && validation_context != :brouillon } validates :code_postal, format: { with: /\A\w{5}\z/ }, if: -> { numero_allocataire.present? && validate_champ_value? }
store_accessor :value_json, :numero_allocataire, :code_postal store_accessor :value_json, :numero_allocataire, :code_postal
@ -14,14 +14,14 @@ class Champs::CnafChamp < Champs::TextChamp
end end
def fetch_external_data def fetch_external_data
if valid? return unless valid_champ_value?
APIParticulier::CnafAdapter.new(
procedure.api_particulier_token, APIParticulier::CnafAdapter.new(
numero_allocataire, procedure.api_particulier_token,
code_postal, numero_allocataire,
procedure.api_particulier_sources code_postal,
).to_params procedure.api_particulier_sources
end ).to_params
end end
def external_id def external_id

View file

@ -25,7 +25,7 @@ class Champs::DecimalNumberChamp < Champ
end end
def processed_value def processed_value
return if invalid? return unless valid_champ_value?
value&.to_f value&.to_f
end end

View file

@ -1,7 +1,7 @@
class Champs::DgfipChamp < Champs::TextChamp class Champs::DgfipChamp < Champs::TextChamp
# see https://github.com/betagouv/api-particulier/blob/master/src/presentation/middlewares/dgfip-input-validation.middleware.ts # see https://github.com/betagouv/api-particulier/blob/master/src/presentation/middlewares/dgfip-input-validation.middleware.ts
validates :numero_fiscal, format: { with: /\A\w{13,14}\z/ }, if: -> { reference_avis.present? && validation_context != :brouillon } validates :numero_fiscal, format: { with: /\A\w{13,14}\z/ }, if: -> { reference_avis.present? && validate_champ_value? }
validates :reference_avis, format: { with: /\A\w{13,14}\z/ }, if: -> { numero_fiscal.present? && validation_context != :brouillon } validates :reference_avis, format: { with: /\A\w{13,14}\z/ }, if: -> { numero_fiscal.present? && validate_champ_value? }
store_accessor :value_json, :numero_fiscal, :reference_avis store_accessor :value_json, :numero_fiscal, :reference_avis
@ -14,14 +14,14 @@ class Champs::DgfipChamp < Champs::TextChamp
end end
def fetch_external_data def fetch_external_data
if valid? return unless valid_champ_value?
APIParticulier::DgfipAdapter.new(
procedure.api_particulier_token, APIParticulier::DgfipAdapter.new(
numero_fiscal, procedure.api_particulier_token,
reference_avis, numero_fiscal,
procedure.api_particulier_sources reference_avis,
).to_params procedure.api_particulier_sources
end ).to_params
end end
def external_id def external_id

View file

@ -1,3 +1,3 @@
class Champs::ExpressionReguliereChamp < Champ class Champs::ExpressionReguliereChamp < Champ
validates_with ExpressionReguliereValidator, if: -> { validation_context != :brouillon } validates_with ExpressionReguliereValidator, if: :validate_champ_value?
end end

View file

@ -1,5 +1,5 @@
class Champs::IbanChamp < Champ class Champs::IbanChamp < Champ
validates_with IbanValidator, if: -> { validation_context != :brouillon } validates_with IbanValidator, if: :validate_champ_value?
after_validation :format_iban after_validation :format_iban
def for_api def for_api

View file

@ -20,7 +20,7 @@ class Champs::IntegerNumberChamp < Champ
private private
def processed_value def processed_value
return if invalid? return unless valid_champ_value?
value&.to_i value&.to_i
end end

View file

@ -11,7 +11,7 @@ class Champs::MesriChamp < Champs::TextChamp
end end
def fetch_external_data def fetch_external_data
return if !valid? return unless valid_champ_value?
APIParticulier::MesriAdapter.new( APIParticulier::MesriAdapter.new(
procedure.api_particulier_token, procedure.api_particulier_token,

View file

@ -11,7 +11,7 @@ class Champs::PoleEmploiChamp < Champs::TextChamp
end end
def fetch_external_data def fetch_external_data
return if !valid? return unless valid_champ_value?
APIParticulier::PoleEmploiAdapter.new( APIParticulier::PoleEmploiAdapter.new(
procedure.api_particulier_token, procedure.api_particulier_token,

View file

@ -3,7 +3,7 @@ class Champs::RNAChamp < Champ
validates :value, allow_blank: true, format: { validates :value, allow_blank: true, format: {
with: /\AW[0-9]{9}\z/, message: I18n.t(:not_a_rna, scope: 'activerecord.errors.messages') with: /\AW[0-9]{9}\z/, message: I18n.t(:not_a_rna, scope: 'activerecord.errors.messages')
}, if: -> { validation_context != :brouillon } }, if: :validate_champ_value?
delegate :id, to: :procedure, prefix: true delegate :id, to: :procedure, prefix: true

View file

@ -7,7 +7,7 @@ module RNAChampAssociationFetchableConcern
self.value = rna self.value = rna
return clear_association!(:empty) if rna.empty? return clear_association!(:empty) if rna.empty?
return clear_association!(:invalid) unless valid? return clear_association!(:invalid) unless valid?(:champs_public_value)
return clear_association!(:not_found) if (data = APIEntreprise::RNAAdapter.new(rna, procedure_id).to_params).blank? return clear_association!(:not_found) if (data = APIEntreprise::RNAAdapter.new(rna, procedure_id).to_params).blank?
update!(data: data) update!(data: data)
@ -21,7 +21,7 @@ module RNAChampAssociationFetchableConcern
def clear_association!(error) def clear_association!(error)
@association_fetch_error_key = error @association_fetch_error_key = error
self.data = nil self.data = nil
save!(context: :brouillon) save!
false false
end end
end end

View file

@ -440,7 +440,7 @@ class Dossier < ApplicationRecord
validates :user, presence: true, if: -> { deleted_user_email_never_send.nil? }, unless: -> { prefilled } validates :user, presence: true, if: -> { deleted_user_email_never_send.nil? }, unless: -> { prefilled }
validates :individual, presence: true, if: -> { revision.procedure.for_individual? } validates :individual, presence: true, if: -> { revision.procedure.for_individual? }
validates_associated :prefilled_champs_public, on: :prefilling validates_associated :prefilled_champs_public, on: :champs_public_value
def types_de_champ_public def types_de_champ_public
types_de_champ types_de_champ

View file

@ -2,7 +2,7 @@
"dependencies": { "dependencies": {
"@coldwired/actions": "^0.11.2", "@coldwired/actions": "^0.11.2",
"@coldwired/turbo-stream": "^0.11.1", "@coldwired/turbo-stream": "^0.11.1",
"@coldwired/utils": "^0.11.1", "@coldwired/utils": "^0.11.4",
"@gouvfr/dsfr": "^1.10.1", "@gouvfr/dsfr": "^1.10.1",
"@graphiql/plugin-explorer": "^0.3.4", "@graphiql/plugin-explorer": "^0.3.4",
"@graphiql/toolkit": "^0.9.1", "@graphiql/toolkit": "^0.9.1",

View file

@ -411,7 +411,7 @@ describe Users::DossiersController, type: :controller do
render_views render_views
let(:error_message) { 'nop' } let(:error_message) { 'nop' }
before do before do
expect_any_instance_of(Dossier).to receive(:valid?).and_return(false) expect_any_instance_of(Dossier).to receive(:validate).and_return(false)
expect_any_instance_of(Dossier).to receive(:errors).and_return( expect_any_instance_of(Dossier).to receive(:errors).and_return(
[double(inner_error: double(base: first_champ), message: 'nop')] [double(inner_error: double(base: first_champ), message: 'nop')]
) )
@ -518,7 +518,7 @@ describe Users::DossiersController, type: :controller do
render_views render_views
before do before do
expect_any_instance_of(Dossier).to receive(:valid?).and_return(false) expect_any_instance_of(Dossier).to receive(:validate).and_return(false)
expect_any_instance_of(Dossier).to receive(:errors).and_return( expect_any_instance_of(Dossier).to receive(:errors).and_return(
[double(inner_error: double(base: first_champ), message: 'nop')] [double(inner_error: double(base: first_champ), message: 'nop')]
) )

View file

@ -38,7 +38,7 @@ describe Champs::CnafChamp, type: :model do
let(:numero_allocataire) { '1234567' } let(:numero_allocataire) { '1234567' }
let(:code_postal) { '12345' } let(:code_postal) { '12345' }
let(:champ) { described_class.new(dossier: create(:dossier), type_de_champ: create(:type_de_champ_cnaf)) } let(:champ) { described_class.new(dossier: create(:dossier), type_de_champ: create(:type_de_champ_cnaf)) }
let(:validation_context) { :create } let(:validation_context) { :champs_public_value }
subject { champ.valid?(validation_context) } subject { champ.valid?(validation_context) }

View file

@ -38,7 +38,7 @@ describe Champs::DgfipChamp, type: :model do
let(:numero_fiscal) { '1122299999092' } let(:numero_fiscal) { '1122299999092' }
let(:reference_avis) { 'FC22299999092' } let(:reference_avis) { 'FC22299999092' }
let(:champ) { described_class.new(dossier: create(:dossier), type_de_champ: create(:type_de_champ_dgfip)) } let(:champ) { described_class.new(dossier: create(:dossier), type_de_champ: create(:type_de_champ_dgfip)) }
let(:validation_context) { :create } let(:validation_context) { :champs_public_value }
subject { champ.valid?(validation_context) } subject { champ.valid?(validation_context) }

View file

@ -1,17 +1,17 @@
describe Champs::IbanChamp do describe Champs::IbanChamp do
describe '#valid?' do describe '#valid?' do
it do it do
expect(build(:champ_iban, value: nil)).to be_valid expect(build(:champ_iban, value: nil).valid?(:champs_public_value)).to be_truthy
expect(build(:champ_iban, value: "FR35 KDSQFDJQSMFDQMFDQ")).to_not be_valid expect(build(:champ_iban, value: "FR35 KDSQFDJQSMFDQMFDQ").valid?(:champs_public_value)).to be_falsey
expect(build(:champ_iban, value: "FR7630006000011234567890189")).to be_valid expect(build(:champ_iban, value: "FR7630006000011234567890189").valid?(:champs_public_value)).to be_truthy
expect(build(:champ_iban, value: "FR76 3000 6000 0112 3456 7890 189")).to be_valid expect(build(:champ_iban, value: "FR76 3000 6000 0112 3456 7890 189").valid?(:champs_public_value)).to be_truthy
expect(build(:champ_iban, value: "FR76 3000 6000 0112 3456 7890 189DSF")).to_not be_valid expect(build(:champ_iban, value: "FR76 3000 6000 0112 3456 7890 189DSF").valid?(:champs_public_value)).to be_falsey
expect(build(:champ_iban, value: "FR76 3000 6000 0112 3456 7890 189")).to be_valid expect(build(:champ_iban, value: "FR76 3000 6000 0112 3456 7890 189").valid?(:champs_public_value)).to be_truthy
end end
it 'format value after validation' do it 'format value after validation' do
champ = build(:champ_iban, value: "FR76 3000 6000 0112 3456 7890 189") champ = build(:champ_iban, value: "FR76 3000 6000 0112 3456 7890 189")
champ.valid? champ.valid?(:champs_public_value)
expect(champ.value).to eq("FR76 3000 6000 0112 3456 7890 189") expect(champ.value).to eq("FR76 3000 6000 0112 3456 7890 189")
end end
end end

View file

@ -2,11 +2,11 @@ describe Champs::RNAChamp do
let(:champ) { create(:champ_rna, value: "W182736273") } let(:champ) { create(:champ_rna, value: "W182736273") }
describe '#valid?' do describe '#valid?' do
it { expect(build(:champ_rna, value: nil)).to be_valid } it { expect(build(:champ_rna, value: nil).valid?(:champs_public_value)).to be_truthy }
it { expect(build(:champ_rna, value: "2736251627")).to_not be_valid } it { expect(build(:champ_rna, value: "2736251627").valid?(:champs_public_value)).to be_falsey }
it { expect(build(:champ_rna, value: "A172736283")).to_not be_valid } it { expect(build(:champ_rna, value: "A172736283").valid?(:champs_public_value)).to be_falsey }
it { expect(build(:champ_rna, value: "W1827362718")).to_not be_valid } it { expect(build(:champ_rna, value: "W1827362718").valid?(:champs_public_value)).to be_falsey }
it { expect(build(:champ_rna, value: "W182736273")).to be_valid } it { expect(build(:champ_rna, value: "W182736273").valid?(:champs_public_value)).to be_truthy }
end end
describe "#export" do describe "#export" do

View file

@ -1589,7 +1589,7 @@ describe Dossier, type: :model do
before do before do
champ = dossier.champs_public.first champ = dossier.champs_public.first
champ.value = expression_reguliere_exemple_text champ.value = expression_reguliere_exemple_text
dossier.save dossier.save(context: :champs_public_value)
end end
it 'should have errors' do it 'should have errors' do

View file

@ -262,7 +262,7 @@ describe 'fetch API Particulier Data', js: true, retry: 3 do
before { login_as user, scope: :user } before { login_as user, scope: :user }
context 'CNAF' do context 'CNAF' do
scenario 'it can fill an cnaf champ' do scenario 'it can fill an cnaf champ', vcr: { cassette_name: 'api_particulier/success/composition_familiale' } do
visit commencer_path(path: procedure.path) visit commencer_path(path: procedure.path)
click_on 'Commencer la démarche' click_on 'Commencer la démarche'
@ -274,7 +274,6 @@ describe 'fetch API Particulier Data', js: true, retry: 3 do
fill_in 'Le numéro dallocataire CAF', with: numero_allocataire fill_in 'Le numéro dallocataire CAF', with: numero_allocataire
fill_in 'Le code postal', with: 'wrong_code' fill_in 'Le code postal', with: 'wrong_code'
wait_for_autosave
dossier = Dossier.last dossier = Dossier.last
cnaf_champ = dossier.champs_public.find(&:cnaf?) cnaf_champ = dossier.champs_public.find(&:cnaf?)
@ -284,12 +283,12 @@ describe 'fetch API Particulier Data', js: true, retry: 3 do
click_on 'Déposer le dossier' click_on 'Déposer le dossier'
expect(page).to have_content("cnaf doit posséder 5 caractères") expect(page).to have_content("cnaf doit posséder 5 caractères")
VCR.use_cassette('api_particulier/success/composition_familiale') do fill_in 'Le code postal', with: code_postal
fill_in 'Le code postal', with: code_postal wait_until { cnaf_champ.reload.external_id.present? }
wait_for_autosave
click_on 'Déposer le dossier' click_on 'Déposer le dossier'
perform_enqueued_jobs perform_enqueued_jobs
end
expect(page).to have_current_path(merci_dossier_path(Dossier.last)) expect(page).to have_current_path(merci_dossier_path(Dossier.last))
perform_enqueued_jobs perform_enqueued_jobs
@ -321,7 +320,7 @@ describe 'fetch API Particulier Data', js: true, retry: 3 do
context 'Pôle emploi' do context 'Pôle emploi' do
let(:api_particulier_token) { '06fd8675601267d2988cbbdef56ecb0de1d45223' } let(:api_particulier_token) { '06fd8675601267d2988cbbdef56ecb0de1d45223' }
scenario 'it can fill a Pôle emploi field' do scenario 'it can fill a Pôle emploi field', vcr: { cassette_name: 'api_particulier/success/situation_pole_emploi' } do
visit commencer_path(path: procedure.path) visit commencer_path(path: procedure.path)
click_on 'Commencer la démarche' click_on 'Commencer la démarche'
@ -332,7 +331,6 @@ describe 'fetch API Particulier Data', js: true, retry: 3 do
click_button('Continuer') click_button('Continuer')
fill_in "Identifiant", with: 'wrong code' fill_in "Identifiant", with: 'wrong code'
wait_for_autosave
dossier = Dossier.last dossier = Dossier.last
pole_emploi_champ = dossier.champs_public.find(&:pole_emploi?) pole_emploi_champ = dossier.champs_public.find(&:pole_emploi?)
@ -342,12 +340,12 @@ describe 'fetch API Particulier Data', js: true, retry: 3 do
clear_enqueued_jobs clear_enqueued_jobs
pole_emploi_champ.update(external_id: nil, identifiant: nil) pole_emploi_champ.update(external_id: nil, identifiant: nil)
VCR.use_cassette('api_particulier/success/situation_pole_emploi') do fill_in "Identifiant", with: identifiant
fill_in "Identifiant", with: identifiant wait_until { pole_emploi_champ.reload.external_id.present? }
wait_until { pole_emploi_champ.reload.external_id.present? }
click_on 'Déposer le dossier' click_on 'Déposer le dossier'
perform_enqueued_jobs perform_enqueued_jobs
end
expect(page).to have_current_path(merci_dossier_path(Dossier.last)) expect(page).to have_current_path(merci_dossier_path(Dossier.last))
perform_enqueued_jobs perform_enqueued_jobs
@ -406,7 +404,6 @@ describe 'fetch API Particulier Data', js: true, retry: 3 do
click_button('Continuer') click_button('Continuer')
fill_in "INE", with: 'wrong code' fill_in "INE", with: 'wrong code'
wait_for_autosave
dossier = Dossier.last dossier = Dossier.last
mesri_champ = dossier.champs_public.find(&:mesri?) mesri_champ = dossier.champs_public.find(&:mesri?)
@ -471,7 +468,6 @@ describe 'fetch API Particulier Data', js: true, retry: 3 do
fill_in 'Le numéro fiscal', with: numero_fiscal fill_in 'Le numéro fiscal', with: numero_fiscal
fill_in "La référence davis dimposition", with: 'wrong_code' fill_in "La référence davis dimposition", with: 'wrong_code'
wait_for_autosave
dossier = Dossier.last dossier = Dossier.last
dgfip_champ = dossier.champs_public.find(&:dgfip?) dgfip_champ = dossier.champs_public.find(&:dgfip?)
@ -482,7 +478,8 @@ describe 'fetch API Particulier Data', js: true, retry: 3 do
expect(page).to have_content(/dgfip doit posséder 13 ou 14 caractères/) expect(page).to have_content(/dgfip doit posséder 13 ou 14 caractères/)
fill_in "La référence davis dimposition", with: reference_avis fill_in "La référence davis dimposition", with: reference_avis
wait_for_autosave wait_until { dgfip_champ.reload.external_id.present? }
click_on 'Déposer le dossier' click_on 'Déposer le dossier'
perform_enqueued_jobs perform_enqueued_jobs

View file

@ -148,7 +148,9 @@ describe 'The user' do
create(:procedure, :published, :for_individual, types_de_champ_public: [ create(:procedure, :published, :for_individual, types_de_champ_public: [
{ mandatory: true, libelle: 'texte obligatoire' }, { mandatory: false, libelle: 'texte optionnel' }, { mandatory: true, libelle: 'texte obligatoire' }, { mandatory: false, libelle: 'texte optionnel' },
{ mandatory: false, libelle: "nombre entier", type: :integer_number }, { mandatory: false, libelle: "nombre entier", type: :integer_number },
{ mandatory: false, libelle: "nombre décimal", type: :decimal_number } { mandatory: false, libelle: "nombre décimal", type: :decimal_number },
{ mandatory: false, libelle: 'address', type: :address },
{ mandatory: false, libelle: 'IBAN', type: :iban }
]) ])
} }
@ -162,6 +164,17 @@ describe 'The user' do
expect(page).to have_current_path(brouillon_dossier_path(user_dossier)) expect(page).to have_current_path(brouillon_dossier_path(user_dossier))
fill_in('IBAN', with: 'FR')
wait_until { champ_value_for('IBAN') == 'FR' }
expect(page).not_to have_content 'nest pas au format IBAN'
blur
expect(page).to have_content 'nest pas au format IBAN'
fill_in('IBAN', with: 'FR7630006000011234567890189')
wait_until { champ_value_for('IBAN') == 'FR76 3000 6000 0112 3456 7890 189' }
expect(page).not_to have_content 'nest pas au format IBAN'
# Check an incomplete dossier cannot be submitted when mandatory fields are missing # Check an incomplete dossier cannot be submitted when mandatory fields are missing
click_on 'Déposer le dossier' click_on 'Déposer le dossier'
expect(user_dossier.reload.brouillon?).to be(true) expect(user_dossier.reload.brouillon?).to be(true)
@ -169,15 +182,15 @@ describe 'The user' do
# Check a dossier can be submitted when all mandatory fields are filled # Check a dossier can be submitted when all mandatory fields are filled
fill_in('texte obligatoire', with: 'super texte') fill_in('texte obligatoire', with: 'super texte')
wait_until { champ_value_for('texte obligatoire') == 'super texte' }
click_on 'Déposer le dossier' click_on 'Déposer le dossier'
wait_until { user_dossier.reload.en_construction? } wait_until { user_dossier.reload.en_construction? }
expect(champ_value_for('texte obligatoire')).to eq('super texte')
expect(page).to have_current_path(merci_dossier_path(user_dossier)) expect(page).to have_current_path(merci_dossier_path(user_dossier))
end end
scenario 'fill address not in BAN', js: true, retry: 3 do scenario 'fill address not in BAN', js: true, retry: 3 do
log_in(user, procedure) log_in(user, simple_procedure)
fill_individual fill_individual
fill_in('address', with: '2 rue de la paix, 92094 Belgique') fill_in('address', with: '2 rue de la paix, 92094 Belgique')

View file

@ -12,6 +12,7 @@ RSpec.shared_examples 'the user can edit the submitted demande' do
fill_in('Texte obligatoire', with: 'Nouveau texte') fill_in('Texte obligatoire', with: 'Nouveau texte')
click_on 'Déposer les modifications' click_on 'Déposer les modifications'
expect(page).to have_current_path(dossier_path(dossier))
click_on 'Demande' click_on 'Demande'
expect(page).to have_current_path(demande_dossier_path(dossier)) expect(page).to have_current_path(demande_dossier_path(dossier))

View file

@ -1218,6 +1218,11 @@
resolved "https://registry.yarnpkg.com/@coldwired/utils/-/utils-0.11.1.tgz#d126246ab66591467e9e4c765769f133f6236ddb" resolved "https://registry.yarnpkg.com/@coldwired/utils/-/utils-0.11.1.tgz#d126246ab66591467e9e4c765769f133f6236ddb"
integrity sha512-vuW1hVhD5U4NX/0dl+fT4RL92T5fEITwc9l/DnaBoP5SAuCAVRnHCWcdyROGz55E4WvSXFqAHD6qkxRwOKG2og== integrity sha512-vuW1hVhD5U4NX/0dl+fT4RL92T5fEITwc9l/DnaBoP5SAuCAVRnHCWcdyROGz55E4WvSXFqAHD6qkxRwOKG2og==
"@coldwired/utils@^0.11.4":
version "0.11.4"
resolved "https://registry.yarnpkg.com/@coldwired/utils/-/utils-0.11.4.tgz#4889103dc73c1d86eff7d21574a0bde37b1f3c03"
integrity sha512-JvYosEc++zcuZmyfHAKG1cZ/jEIBM8ciJsD2ZcgFdzjSla1wtG+p6GOYiIVXZpCKO2oWGR4EmiYFYpBNIy3WDw==
"@emotion/is-prop-valid@^0.8.2": "@emotion/is-prop-valid@^0.8.2":
version "0.8.8" version "0.8.8"
resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a" resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a"