AIRAVATA-3565 Better handling of extended user profile validation
diff --git a/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileEditor.vue b/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileEditor.vue
index d1a6df0..0b0b08d 100644
--- a/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileEditor.vue
+++ b/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileEditor.vue
@@ -6,6 +6,8 @@
         :key="extendedUserProfileField.id"
         :is="getEditor(extendedUserProfileField)"
         :extended-user-profile-field="extendedUserProfileField"
+        @valid="recordValidChildComponent(extendedUserProfileField.id)"
+        @invalid="recordInvalidChildComponent(extendedUserProfileField.id)"
       />
     </template>
   </div>
@@ -17,13 +19,13 @@
 import ExtendedUserProfileSingleChoiceFieldEditor from "./ExtendedUserProfileSingleChoiceFieldEditor.vue";
 import ExtendedUserProfileTextFieldEditor from "./ExtendedUserProfileTextFieldEditor.vue";
 import ExtendedUserProfileUserAgreementFieldEditor from "./ExtendedUserProfileUserAgreementFieldEditor.vue";
+import { mixins } from "django-airavata-common-ui";
 export default {
+  mixins: [mixins.ValidationParent],
   computed: {
     ...mapGetters("extendedUserProfile", ["extendedUserProfileFields"]),
     valid() {
-      return this.$refs.extendedUserProfileFieldComponents.every(
-        (c) => c.valid
-      );
+      return this.childComponentsAreValid;
     },
   },
   methods: {
@@ -45,6 +47,9 @@
         );
       }
     },
+    touch() {
+      this.$refs.extendedUserProfileFieldComponents.forEach((c) => c.touch());
+    },
   },
 };
 </script>
diff --git a/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileFieldEditor.vue b/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileFieldEditor.vue
index 1115ea7..13b7a66 100644
--- a/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileFieldEditor.vue
+++ b/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileFieldEditor.vue
@@ -3,6 +3,14 @@
     :label="extendedUserProfileField.name"
     :description="extendedUserProfileField.help_text"
   >
+    <template #label>
+      {{ extendedUserProfileField.name }}
+      <small
+        v-if="!extendedUserProfileField.required"
+        class="text-muted text-small"
+        >(Optional)</small
+      >
+    </template>
     <b-card
       v-for="link in extendedUserProfileField.links"
       :key="link.id"
diff --git a/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileMultiChoiceFieldEditor.vue b/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileMultiChoiceFieldEditor.vue
index ff02eea..17faef6 100644
--- a/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileMultiChoiceFieldEditor.vue
+++ b/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileMultiChoiceFieldEditor.vue
@@ -136,6 +136,17 @@
     },
     validateState: errors.vuelidateHelpers.validateState,
     validateStateErrorOnly: errors.vuelidateHelpers.validateStateErrorOnly,
+    touch() {
+      this.$v.$touch();
+    },
+  },
+  watch: {
+    valid: {
+      handler(valid) {
+        this.$emit(valid ? "valid" : "invalid");
+      },
+      immediate: true,
+    },
   },
 };
 </script>
diff --git a/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileSingleChoiceFieldEditor.vue b/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileSingleChoiceFieldEditor.vue
index 4087208..61c672f 100644
--- a/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileSingleChoiceFieldEditor.vue
+++ b/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileSingleChoiceFieldEditor.vue
@@ -138,6 +138,17 @@
     },
     validateState: errors.vuelidateHelpers.validateState,
     validateStateErrorOnly: errors.vuelidateHelpers.validateStateErrorOnly,
+    touch() {
+      this.$v.$touch();
+    },
+  },
+  watch: {
+    valid: {
+      handler(valid) {
+        this.$emit(valid ? "valid" : "invalid");
+      },
+      immediate: true,
+    },
   },
 };
 </script>
diff --git a/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileTextFieldEditor.vue b/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileTextFieldEditor.vue
index 37b451d..3e2a8e6 100644
--- a/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileTextFieldEditor.vue
+++ b/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileTextFieldEditor.vue
@@ -45,6 +45,17 @@
   methods: {
     ...mapMutations("extendedUserProfile", ["setTextValue"]),
     validateState: errors.vuelidateHelpers.validateState,
+    touch() {
+      this.$v.$touch();
+    },
+  },
+  watch: {
+    valid: {
+      handler(valid) {
+        this.$emit(valid ? "valid" : "invalid");
+      },
+      immediate: true,
+    },
   },
 };
 </script>
diff --git a/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileUserAgreementFieldEditor.vue b/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileUserAgreementFieldEditor.vue
index 487d6b0..d0fe40a 100644
--- a/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileUserAgreementFieldEditor.vue
+++ b/django_airavata/apps/auth/static/django_airavata_auth/js/components/ExtendedUserProfileUserAgreementFieldEditor.vue
@@ -65,6 +65,17 @@
     },
     validateState: errors.vuelidateHelpers.validateState,
     validateStateErrorOnly: errors.vuelidateHelpers.validateStateErrorOnly,
+    touch() {
+      this.$v.$touch();
+    },
+  },
+  watch: {
+    valid: {
+      handler(valid) {
+        this.$emit(valid ? "valid" : "invalid");
+      },
+      immediate: true,
+    },
   },
 };
 </script>
diff --git a/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue b/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
index 86fbb39..3e8d101 100644
--- a/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
+++ b/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
@@ -105,14 +105,7 @@
           })
         );
       } else {
-        // TODO: make sure to highlight which fields are invalid
-        notifications.NotificationList.add(
-          new notifications.Notification({
-            type: "WARNING",
-            message: "The form is invalid. Please fix and try again.",
-            duration: 5,
-          })
-        );
+        this.$refs.extendedUserProfileEditor.touch();
       }
     },
     async handleResendEmailVerification() {
diff --git a/django_airavata/static/common/js/index.js b/django_airavata/static/common/js/index.js
index b4bcc5e..68c9719 100644
--- a/django_airavata/static/common/js/index.js
+++ b/django_airavata/static/common/js/index.js
@@ -31,6 +31,7 @@
 
 import ListLayout from "./layouts/ListLayout.vue";
 
+import ValidationParent from "./mixins/ValidationParent";
 import VModelMixin from "./mixins/VModelMixin";
 
 import Notification from "./notifications/Notification";
@@ -80,6 +81,7 @@
 };
 
 const mixins = {
+  ValidationParent,
   VModelMixin,
 };
 
diff --git a/django_airavata/static/common/js/mixins/ValidationParent.js b/django_airavata/static/common/js/mixins/ValidationParent.js
new file mode 100644
index 0000000..6e89563
--- /dev/null
+++ b/django_airavata/static/common/js/mixins/ValidationParent.js
@@ -0,0 +1,30 @@
+/**
+ * Aggregate validation state of child components. Child components should
+ * dispatch 'valid' and 'invalid' events and component using this mixin should
+ * call recordValidChildComponent or recordInvalidChildComponent, respectively.
+ */
+export default {
+  data: function () {
+    return {
+      invalidChildComponents: [],
+    };
+  },
+  computed: {
+    childComponentsAreValid() {
+      return this.invalidChildComponents.length === 0;
+    },
+  },
+  methods: {
+    recordInvalidChildComponent(childComponentId) {
+      if (!this.invalidChildComponents.includes(childComponentId)) {
+        this.invalidChildComponents.push(childComponentId);
+      }
+    },
+    recordValidChildComponent(childComponentId) {
+      if (this.invalidChildComponents.includes(childComponentId)) {
+        const index = this.invalidChildComponents.indexOf(childComponentId);
+        this.invalidChildComponents.splice(index, 1);
+      }
+    },
+  },
+};