diff --git a/my_compassion/static/src/css/global.css b/my_compassion/static/src/css/global.css index 783205fb2..7155b09a0 100644 --- a/my_compassion/static/src/css/global.css +++ b/my_compassion/static/src/css/global.css @@ -116,3 +116,16 @@ h1 { outline: none; /* Remove default outline */ box-shadow: 0 0 10px rgba(74, 166, 246, 0.5); /* Glow effect */ } + +/* + Global form validation styles + Applied to all form fields marked as invalid (`.is-invalid`) + within `.form-field-component` across the entire MyCompassion app. + */ +.form-field-component .is-invalid { + border: 1px solid var(--mid-orange); + border-left: 5px solid var(--mid-orange); + /* override bootstrap invalid style */ + background-image: none; + padding-right: 0.75rem; +} diff --git a/my_compassion/static/src/css/my2_new_sponsorship_wizard.css b/my_compassion/static/src/css/my2_new_sponsorship_wizard.css index d1b3c53e1..4bed910f4 100644 --- a/my_compassion/static/src/css/my2_new_sponsorship_wizard.css +++ b/my_compassion/static/src/css/my2_new_sponsorship_wizard.css @@ -50,15 +50,6 @@ border: 0.65rem solid; } -.new-sponsorship-wizard-form input { - width: 100%; - height: 2.6rem; - border-radius: 5px; - border: 1px solid var(--mid-grey); - padding-left: 10px; - padding-right: 10px; -} - .new-sponsorship-wizard-form input[type="checkbox"] { width: 1.4rem; height: 1.4rem; @@ -90,11 +81,6 @@ margin-right: auto; } -.new-sponsorship-wizard-form .is-invalid { - border: 1px solid var(--mid-orange); - border-left: 5px solid var(--mid-orange); -} - .thank-you-child-profile { max-width: 350px; } diff --git a/my_compassion/static/src/css/user_settings.css b/my_compassion/static/src/css/user_settings.css index 3f3385205..297a56c83 100644 --- a/my_compassion/static/src/css/user_settings.css +++ b/my_compassion/static/src/css/user_settings.css @@ -113,3 +113,29 @@ border-top-right-radius: 25px; border-bottom-right-radius: 25px; } + +/* Spinner styling */ +#user-settings-loader { + background: rgba(255, 255, 255, 0.5); + position: absolute; + width: 100%; + top: 0; + height: fit-content; + display: flex; +} + +.spinner-rotate { + animation: custom-spin 1.5s infinite linear !important; + margin: auto; +} + +@keyframes custom-spin { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} diff --git a/my_compassion/static/src/js/my2_new_sponsorship_wizard.js b/my_compassion/static/src/js/my2_new_sponsorship_wizard.js index 3da9f91cc..b23c31e3d 100644 --- a/my_compassion/static/src/js/my2_new_sponsorship_wizard.js +++ b/my_compassion/static/src/js/my2_new_sponsorship_wizard.js @@ -107,33 +107,15 @@ document.addEventListener("DOMContentLoaded", function (event) { */ _validateForm: function () { var isValid = true; - // Remove previous error messages and styles - this.$(".input-invalid-hint").remove(); - this.$("input.is-invalid").removeClass("is-invalid"); - - // Find all required inputs within the current step that are visible - this.$("input[required]:visible, select[required]:visible").each(function () { - var $input = $(this); - if (!$input.val()) { + + this.$(".form-field-component:visible").each(function () { + var fieldWidget = $(this).data("widget"); + + if (fieldWidget && !fieldWidget.validate()) { isValid = false; - // Add the 'is-invalid' class - $input.addClass("is-invalid"); - - // Add a small text hint above the input field - var $errorHint = $( - '
This field is required.
' - ); - var $select_container = $input.parent(".SelectComponent"); - - if ($select_container.length > 0) { - // If the input is a select component, place the hint before the container - $select_container.before($errorHint); - } else { - // Otherwise, it's a standard input, so place the hint before the input itself - $input.before($errorHint); - } } }); + return isValid; }, diff --git a/my_compassion/static/src/js/my2_user_settings.js b/my_compassion/static/src/js/my2_user_settings.js index 0d693ed12..bbab041fe 100644 --- a/my_compassion/static/src/js/my2_user_settings.js +++ b/my_compassion/static/src/js/my2_user_settings.js @@ -17,11 +17,6 @@ document.addEventListener("DOMContentLoaded", () => { * Main setup function to initialize all event listeners. */ function initializeUserSettings() { - // Hides the error messages of the select components - document.querySelectorAll(".invalid-hint").forEach((hint) => { - hint.style.display = "none"; - }); - initTabNavigation(); initCommunicationSettings(); initAgreementsForm(); @@ -149,28 +144,6 @@ document.addEventListener("DOMContentLoaded", () => { }); } - /** - * Attaches a standardized event listener to an agreement checkbox. - * When checked, it calls a specific RPC route. - */ - - function attachAgreementListener(checkbox, route) { - checkbox.addEventListener("change", function () { - // Only proceed if the checkbox is being checked - if (!this.checked) return; - - rpc.query({ route, params: {} }) - .then(() => { - window.location.reload(); - }) - .catch((err) => { - console.error("RPC Error:", err); - this.checked = false; // Revert the checkbox state on error - Dialog.alert(null, "Could not save your confirmation. Please try again."); - }); - }); - } - /** * Initializes the privacy checkbox. */ @@ -289,36 +262,62 @@ document.addEventListener("DOMContentLoaded", () => { let originalValues = {}; + const getFieldWidget = (fieldName) => { + const input = form.querySelector(`[name="${fieldName}"]`); + if (!input) return null; + + return $(input).closest(".form-field-component").data("widget"); + }; + + function toggleLoader(isLoading) { + const loader = document.getElementById("user-settings-loader"); + + if (isLoading) { + loader?.classList.remove("d-none"); + } else { + loader?.classList.add("d-none"); + } + } + + const showBackendErrors = (errors) => { + for (const fieldName in errors) { + const widget = getFieldWidget(fieldName); + if (widget) { + widget.showError(errors[fieldName]); + } else { + console.warn(`No widget found for field ${fieldName} to display error: ${errors[fieldName]}`); + } + } + }; + const clearErrors = () => { - form.querySelectorAll(".is-invalid").forEach((input) => { - input.classList.remove("is-invalid"); - }); - form.querySelectorAll(".invalid-hint").forEach((hint) => { - hint.style.display = "none"; + fields.forEach((field) => { + const widget = getFieldWidget(field); + if (widget) { + widget.clearError(); + } }); }; - const showErrors = (errors) => { - for (const fieldName in errors) { - const input = form.querySelector(`[name="${fieldName}"]`); - if (input) { - input.classList.add("is-invalid"); - const container = input.closest(".form-field-container"); - if (container) { - const hintEl = container.querySelector(".invalid-hint"); - if (hintEl) { - hintEl.textContent = errors[fieldName]; - hintEl.style.display = "block"; - } + const validateForm = () => { + let isValid = true; + + fields.forEach((field) => { + const widget = getFieldWidget(field); + if (widget) { + if (!widget.validate()) { + isValid = false; } } - } + }); + + return isValid; }; const storeOriginalValues = () => { fields.forEach((field) => { const input = form.querySelector(`[name="${field}"]`); - if (input) originalValues[field] = input.value; + if (input) originalValues[field] = input.type === "checkbox" ? input.checked : input.value; }); }; @@ -326,30 +325,61 @@ document.addEventListener("DOMContentLoaded", () => { fields.forEach((field) => { const input = form.querySelector(`[name="${field}"]`); if (input && originalValues[field] !== undefined) { - input.value = originalValues[field]; + if (input.type === "checkbox") { + input.checked = originalValues[field]; + } else { + input.value = originalValues[field]; + } } }); }; - editButton.addEventListener("click", () => { - storeOriginalValues(); + const anyValueUpdated = () => { + return fields.some((field) => { + const input = form.querySelector(`[name="${field}"]`); + if (input) { + const valueToCheck = input.type === "checkbox" ? input.checked : input.value; + return valueToCheck !== originalValues[field]; + } + }); + }; + + function toggleEdit(isEditing) { clearErrors(); - form.classList.add("is-editing"); + form.classList.toggle("is-editing", isEditing); + } + + editButton.addEventListener("click", (e) => { + e.preventDefault(); + storeOriginalValues(); + toggleEdit(true); }); - cancelButton.addEventListener("click", () => { + cancelButton.addEventListener("click", (e) => { + e.preventDefault(); restoreOriginalValues(); - clearErrors(); - form.classList.remove("is-editing"); + toggleEdit(false); }); - saveButton.addEventListener("click", () => { + saveButton.addEventListener("click", (e) => { + e.preventDefault(); clearErrors(); + + if (!validateForm()) return; + toggleLoader(true); + + if (!anyValueUpdated()) { + toggleLoader(false); + toggleEdit(false); + return; + } + const payload = {}; - fields.forEach((field) => { + for (const field of fields) { const input = form.querySelector(`[name="${field}"]`); - if (input) payload[field] = input.value; - }); + if (!input || input.offsetParent === null) continue; // Skip hidden/missing inputs + payload[field] = input.type === "checkbox" ? input.checked : input.value; + } rpc.query({ route: endpoint, params: payload }) .then((response) => { @@ -362,16 +392,19 @@ document.addEventListener("DOMContentLoaded", () => { displayEl.textContent = input.tagName === "SELECT" ? input.options[input.selectedIndex].text : input.value; }); - form.classList.remove("is-editing"); + toggleEdit(false); + toggleLoader(false); } else { if (response.errors) { - showErrors(response.errors); + showBackendErrors(response.errors); } + toggleLoader(false); } }) .catch((err) => { console.error("RPC Error:", err); Dialog.alert(null, "An unexpected error occurred. Please try again later."); + toggleLoader(false); }); }); } diff --git a/my_compassion/templates/pages/my2_new_sponsorship_wizard.xml b/my_compassion/templates/pages/my2_new_sponsorship_wizard.xml index 2e53e43fb..e98221074 100644 --- a/my_compassion/templates/pages/my2_new_sponsorship_wizard.xml +++ b/my_compassion/templates/pages/my2_new_sponsorship_wizard.xml @@ -97,35 +97,71 @@ Page: My Compassion 2 New Sponsorship Wizard Page Surname - + Firstname - + Date of birth - + - E-mail + - + - Phone number + - +
@@ -134,7 +170,14 @@ Page: My Compassion 2 New Sponsorship Wizard Page Street - +
@@ -146,6 +189,7 @@ Page: My Compassion 2 New Sponsorship Wizard Page id="street_number" type="text" name="street_number" + class="form-control" t-att-value="wizard.street_number" required="" /> @@ -159,7 +203,7 @@ Page: My Compassion 2 New Sponsorship Wizard Page Code - +
@@ -167,7 +211,14 @@ Page: My Compassion 2 New Sponsorship Wizard Page Locality - +
diff --git a/my_compassion/templates/pages/my2_user_settings.xml b/my_compassion/templates/pages/my2_user_settings.xml index a2d25d31b..19913e050 100644 --- a/my_compassion/templates/pages/my2_user_settings.xml +++ b/my_compassion/templates/pages/my2_user_settings.xml @@ -71,7 +71,11 @@ Injected data:
-
+
My reference @@ -85,13 +89,14 @@ Injected data: Title
- +
+ - + @@ -125,6 +130,8 @@ Injected data: class="form-control" t-att-value="partner.lastname" t-att-data-original-value="partner.lastname" + data-validate-type="name" + required="" />
@@ -144,6 +151,8 @@ Injected data: class="form-control" t-att-value="partner.firstname" t-att-data-original-value="partner.firstname" + data-validate-type="name" + required="" />
@@ -165,6 +174,7 @@ Injected data: class="form-control" t-att-value="partner.street" t-att-data-original-value="partner.street" + required="" />
@@ -186,6 +196,8 @@ Injected data: class="form-control" t-att-value="partner.zip" t-att-data-original-value="partner.zip" + data-validate-type="zip" + required="" />
@@ -206,6 +218,7 @@ Injected data: class="form-control" t-att-value="partner.city" t-att-data-original-value="partner.city" + required="" />
@@ -255,6 +268,8 @@ Injected data: class="form-control" t-att-value="partner.email" t-att-data-original-value="partner.email" + data-validate-type="email" + required="" />
@@ -275,6 +290,7 @@ Injected data: class="form-control" t-att-value="partner.phone" t-att-data-original-value="partner.phone" + data-validate-type="phone" />
@@ -294,12 +310,16 @@ Injected data: class="form-control" t-att-value="partner.mobile" t-att-data-original-value="partner.mobile" + data-validate-type="phone" />
-
diff --git a/theme_compassion_2025/static/src/scss/components/_form_field.scss b/theme_compassion_2025/static/src/scss/components/_form_field.scss index a2be128e4..93b7f2490 100644 --- a/theme_compassion_2025/static/src/scss/components/_form_field.scss +++ b/theme_compassion_2025/static/src/scss/components/_form_field.scss @@ -7,3 +7,11 @@ display: block; } } + +.form-field-component .required-asterisk { + display: none; +} + +.is-editing .form-field-component .required-asterisk { + display: inline; +} diff --git a/theme_compassion_2025/static/src/utils/form_field_validator.js b/theme_compassion_2025/static/src/utils/form_field_validator.js new file mode 100644 index 000000000..74dd46b28 --- /dev/null +++ b/theme_compassion_2025/static/src/utils/form_field_validator.js @@ -0,0 +1,148 @@ +/* + * Form Field Validator Widget + * + * Provides client-side validation for form fields inside + * `.form-field-component` wrappers. It validates: + * - required fields + * - email format + * - phone number format + * + * Features: + * - Displays inline error messages below/above inputs + * - Marks invalid fields with the `is-invalid` class + * - Appends an asterisk (*) to required field labels + * + * + * ------------------------------------------------------------------------------- + */ +odoo.define("my_compassion.form_field_validator", function (require) { + "use strict"; + + const publicWidget = require("web.public.widget"); + + const validationConfig = { + required: { + suffix: '*', + defaultErrorMessage: "This field is required.", + }, + email: { + regex: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, + defaultErrorMessage: "Please enter a valid email address.", + }, + phone: { + regex: /^\+?(\d[\d\s-]{5,15}\d)$/, + defaultErrorMessage: "Please enter a valid phone number.", + }, + name: { + // Allow international letters, space, dot, apostrophe, hyphen. + // Length: 2 to 50 characters. + regex: /^[\p{L} .'-]{2,50}$/u, + defaultErrorMessage: "Please enter a valid name (2-50 characters, no numbers).", + }, + zip: { + // Allow ZIP codes to start with at most 2 letters, max 15 characters. + // Allowed 4802 (CH), 10115 (DE, FR, IT), SW1A 1AA (UK) + // Not allowed: rhiq9rq4q4, DEC1234 + regex: /^([0-9]|[a-zA-Z]{1,2}[0-9\s-])[a-zA-Z0-9\s-]{1,14}$/, + defaultErrorMessage: "Please enter a valid ZIP/Postal code. (enter 0000 if not applicable)", + }, + }; + + publicWidget.registry.FormFieldValidator = publicWidget.Widget.extend({ + selector: ".form-field-component", + events: { + "blur input": "_onBlur", + "change select": "_onBlur", + }, + + init: function () { + this._super.apply(this, arguments); + this.validationType = null; + this.config = {}; + }, + + /** + * The start method is called after the widget's DOM element + * has been rendered and is available. This is the right place for DOM manipulations. + */ + start: function () { + this._super.apply(this, arguments); + this.$el.data("widget", this); + + this.$input = this.$("input, select"); + this.isRequired = this.$input.prop("required"); + this.validationType = this.$input.data("validateType"); + + if (this.isRequired) { + this.$el.find("label").append(validationConfig.required.suffix); + } + + if (validationConfig[this.validationType]) { + this.config = validationConfig[this.validationType]; + } + + this.errorMessages = { + required: this.$input.data("errorRequired") || validationConfig.required.defaultErrorMessage, + format: this.$input.data("errorFormat") || (this.config && this.config.defaultErrorMessage), + }; + }, + + /** + * Validates the field when it loses focus. + */ + _onBlur: function () { + this.validate(); + }, + + /** + * The central validation function. Can also be called externally. + * @returns {Boolean} + */ + validate: function () { + this.clearError(); + const value = this.$input.val(); + + if (this.isRequired && !value) { + this.showError(this.errorMessages.required); + return false; + } + + if (this.validationType && value && this.config.regex) { + if (!this.config.regex.test(value)) { + this.showError(this.errorMessages.format); + return false; + } + } + + return true; + }, + /** + * Removes existing error messages. + */ + clearError: function () { + this.$el.find(".input-invalid-hint").remove(); + this.$input.removeClass("is-invalid"); + }, + /** + * Displays an error message. + */ + showError: function (message) { + this.$input.addClass("is-invalid"); + + const isDark = this.$input.hasClass("dark-bg"); + const colorClass = isDark ? "text-pure-white" : "text-mid-orange"; + const classes = `input-invalid-hint ${colorClass} tiny-text mt-2`; + + const $errorHint = $(`
`).text(message); + + const $selectContainer = this.$input.closest(".SelectComponent"); + if ($selectContainer.length > 0) { + $selectContainer.after($errorHint); + } else { + this.$input.after($errorHint); + } + }, + }); + + return publicWidget.registry.FormFieldValidator; +}); diff --git a/theme_compassion_2025/templates/components/FormField.xml b/theme_compassion_2025/templates/components/FormField.xml index 601bafc7a..839595dc1 100644 --- a/theme_compassion_2025/templates/components/FormField.xml +++ b/theme_compassion_2025/templates/components/FormField.xml @@ -7,27 +7,19 @@ Props: - label (string): The text seen on top of the component. - - invalid_hint (string): Hint to show when the input is given the 'is-invalid' class. - input_id (string): ID of the linked input - top_class (string): class for the outer div element wrapping everything --> diff --git a/theme_compassion_2025/templates/components/Select.xml b/theme_compassion_2025/templates/components/Select.xml index 3bd152f2b..b11844c42 100644 --- a/theme_compassion_2025/templates/components/Select.xml +++ b/theme_compassion_2025/templates/components/Select.xml @@ -17,6 +17,9 @@ - custom-select: Applied to the