This is an automated email from the ASF dual-hosted git repository.

yasithdev pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airavata-portals.git


The following commit(s) were added to refs/heads/main by this push:
     new 6bf029967 fix(portal): experiment editor — kill 'Leave site?' popup, 
fix experiment creation, token-derived account link (#231)
6bf029967 is described below

commit 6bf0299675809fc73ae7be2a59f4fa4aa7dd5507
Author: Yasith Jayawardana <[email protected]>
AuthorDate: Sun Jun 14 21:06:53 2026 -0400

    fix(portal): experiment editor — kill 'Leave site?' popup, fix experiment 
creation, token-derived account link (#231)
    
    * fix(workspace): don't prompt 'Leave site?' on app-initiated navigation
    
    UnsavedChangesGuard's beforeunload handler fired the native 'Leave site?'
    dialog whenever the experiment editor redirected after a save (Save / Save 
and
    Launch), because those redirects are full-page window.location navigations. 
Add
    an intentional-navigation latch (utils.navigateTo / isIntentionalNavigation)
    that the guard consults, and route the workspace urls.js navigations 
through it.
    Accidental leaves with unsaved edits (tab close, back button, unrelated 
links)
    still warn.
    
    * fix(auth): derive Keycloak account console URL from the user's token
    
    Generate the User Settings link from the iss claim of the signed-in user's
    access token (the realm's OIDC discovery issuer) as <issuer>/account/, 
rather
    than a separately configured setting, so it always points at the realm the 
user
    authenticated against. Falls back to KEYCLOAK_ACCOUNT_CONSOLE_URL when the 
token
    carries no issuer.
    
    * fix(ui): default shadcn Button to type=button (was implicitly submit)
    
    The shadcn Button renders a native <button>, which defaults to type=submit. 
Inside
    a <form>, a bare <Button @click=...> therefore ALSO submitted the form on 
click,
    triggering a full-page navigation and the browser's 'Leave site?' 
unsaved-changes
    guard before the @click handler could run — the actual cause of the popup 
on the
    experiment editor's Save / Save and Launch buttons (the navigateTo latch 
alone
    could not help, since the submit-navigation fired first). Default type to 
'button'
    (matching the prior Bootstrap-Vue <b-button> default); real submit buttons 
pass
    type='submit' explicitly (verified: the 3 native-submit forms are 
unaffected).
    
    * fix(workspace): bind experiment scheduling editors via Vue 3 v-model
    
    Experiment creation 500'd ('ComputeResourceDescription ... crd is null') 
because
    the allocation/scheduling editors were never migrated to the Vue 3 v-model
    convention. The parent binds them with v-model (modelValue + 
update:modelValue),
    but the children still used the Vue 2 value/input pair:
    
    - GroupResourceProfileSelector: prop value + emit('input') -> the 
auto-selected
      group resource profile never propagated, so group_resource_profile_id 
stayed
      null (and the scheduling editor, gated on it, never rendered).
    - ComputationalResourceSchedulingEditor / QueueSettingsEditor: used 
VModelMixin
      (modelValue/data) yet still declared a value prop and read this.value, 
which is
      undefined under Vue 3 -> crashed in data()/mounted() once they rendered.
    
    Switch all three to modelValue + update:modelValue (the latter two rely on 
the
    mixin's data working-copy). Allocation, compute resource, and queue 
settings now
    bind, the form validates, and create + launch succeed.
---
 .../ComputationalResourceSchedulingEditor.vue       | 20 ++++++++------------
 .../experiment/GroupResourceProfileSelector.vue     | 15 ++++++++++-----
 .../components/experiment/QueueSettingsEditor.vue   | 10 +++++-----
 .../django_airavata_workspace/js/utils/urls.js      | 12 +++++++-----
 .../django_airavata/context_processors.py           | 21 ++++++++++++++++++++-
 .../common/js/components/UnsavedChangesGuard.vue    |  6 +++++-
 .../common/js/components/ui/button/Button.vue       |  8 ++++++++
 .../django_airavata/static/common/js/utils.js       | 19 +++++++++++++++++++
 8 files changed, 82 insertions(+), 29 deletions(-)

diff --git 
a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/ComputationalResourceSchedulingEditor.vue
 
b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/ComputationalResourceSchedulingEditor.vue
index bc2e7e28f..8784751bb 100644
--- 
a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/ComputationalResourceSchedulingEditor.vue
+++ 
b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/ComputationalResourceSchedulingEditor.vue
@@ -50,22 +50,18 @@
 
 <script>
 import QueueSettingsEditor from "./QueueSettingsEditor.vue";
-import {
-  errors,
-  models,
-  services,
-  utils as apiUtils,
-} from "django-airavata-api";
+import { errors, services, utils as apiUtils } from "django-airavata-api";
 import { mixins, utils } from "django-airavata-common-ui";
 import { NATIVE_SELECT_CLASS } from "../../lib/utils";
 
 export default {
   name: "computational-resource-scheduling-editor",
+  // VModelMixin supplies the `modelValue` prop and the `data` working copy, 
and
+  // emits `update:modelValue` for the parent's v-model. (This component used 
to
+  // also declare a Vue 2 `value` prop and emit `input`, which the parent 
ignored
+  // — and `this.value` was undefined under Vue 3, crashing data() below.)
   mixins: [mixins.VModelMixin],
   props: {
-    value: {
-      type: models.ComputationalResourceSchedulingModel,
-    },
     appModuleId: {
       type: String,
       required: true,
@@ -80,7 +76,7 @@ export default {
       computeResources: {},
       applicationDeployments: [],
       selectedGroupResourceProfileData: null,
-      resourceHostId: this.value.resource_host_id,
+      resourceHostId: this.modelValue.resource_host_id,
       invalidQueueSettings: false,
       workspacePreferences: null,
     };
@@ -233,7 +229,7 @@ export default {
       // whenever it changes
       this.localComputationalResourceScheduling.resource_host_id =
         this.resourceHostId;
-      this.$emit("input", this.data);
+      this.$emit("update:modelValue", this.data);
     },
     queueSettingsValidityChanged(valid) {
       this.invalidQueueSettings = !valid;
@@ -248,7 +244,7 @@ export default {
     },
     emitValueChanged: function () {
       this.validate();
-      this.$emit("input", this.localComputationalResourceScheduling);
+      this.$emit("update:modelValue", 
this.localComputationalResourceScheduling);
     },
     getValidationFeedback: function (properties) {
       return utils.getProperty(this.validation, properties);
diff --git 
a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/GroupResourceProfileSelector.vue
 
b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/GroupResourceProfileSelector.vue
index 587c30781..9865876c8 100644
--- 
a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/GroupResourceProfileSelector.vue
+++ 
b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/GroupResourceProfileSelector.vue
@@ -29,13 +29,18 @@ import { NATIVE_SELECT_CLASS } from "../../lib/utils";
 export default {
   name: "group-resource-profile-selector",
   props: {
-    value: {
+    // Vue 3 v-model: the parent binds with `v-model`, which passes 
`modelValue`
+    // and listens for `update:modelValue`. (Was the Vue 2 `value`/`input` 
pair,
+    // which the parent's v-model silently ignored — so the auto-selected
+    // allocation never propagated and experiments were created with a null
+    // group_resource_profile_id, 500ing server-side.)
+    modelValue: {
       type: String,
     },
   },
   data() {
     return {
-      groupResourceProfileId: this.value,
+      groupResourceProfileId: this.modelValue,
       groupResourceProfiles: [],
       workspacePreferences: null,
     };
@@ -78,7 +83,7 @@ export default {
         (groupResourceProfiles) => {
           this.groupResourceProfiles = groupResourceProfiles;
           if (
-            (!this.value ||
+            (!this.modelValue ||
               !this.selectedValueInGroupResourceProfileList(
                 groupResourceProfiles,
               )) &&
@@ -105,13 +110,13 @@ export default {
     },
     emitValueChanged: function () {
       this.validate();
-      this.$emit("input", this.groupResourceProfileId);
+      this.$emit("update:modelValue", this.groupResourceProfileId);
     },
     selectedValueInGroupResourceProfileList(groupResourceProfiles) {
       return (
         groupResourceProfiles
           .map((grp) => grp.group_resource_profile_id)
-          .indexOf(this.value) >= 0
+          .indexOf(this.modelValue) >= 0
       );
     },
     validate() {
diff --git 
a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/QueueSettingsEditor.vue
 
b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/QueueSettingsEditor.vue
index 1c0fcb6fc..8f381b775 100644
--- 
a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/QueueSettingsEditor.vue
+++ 
b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/components/experiment/QueueSettingsEditor.vue
@@ -261,11 +261,11 @@ import { NATIVE_SELECT_CLASS } from "../../lib/utils";
 export default {
   name: "queue-settings-editor",
   components: { Info, Lock, LockOpen, X },
+  // VModelMixin supplies the `modelValue` prop and the `data` working copy and
+  // emits `update:modelValue`; the parent binds with v-model. (Was a leftover
+  // Vue 2 `value` prop — undefined under Vue 3, crashing mounted() below.)
   mixins: [mixins.VModelMixin],
   props: {
-    value: {
-      type: models.ComputationalResourceSchedulingModel,
-    },
     appDeploymentId: {
       type: String,
       required: true,
@@ -586,7 +586,7 @@ export default {
         this.setDefaultQueue();
       }
     },
-    value: {
+    modelValue: {
       // Rerun validation whenever the queue settings change, which can from
       // outside the component, for example when a queue settings calculator
       // provides values
@@ -608,7 +608,7 @@ export default {
     this.loadAppDeploymentQueues().then(() => {
       // For brand new queue settings (no queueName specified) load the default
       // queue and its default values and apply them
-      if (!this.value.queue_name) {
+      if (!this.modelValue.queue_name) {
         this.setDefaultQueue();
       }
     });
diff --git 
a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/utils/urls.js
 
b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/utils/urls.js
index 9fb23774d..97100f419 100644
--- 
a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/utils/urls.js
+++ 
b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/utils/urls.js
@@ -1,3 +1,5 @@
+import { utils } from "django-airavata-common-ui";
+
 export default {
   editExperiment(experiment) {
     return (
@@ -7,13 +9,13 @@ export default {
     );
   },
   navigateToEditExperiment(experiment) {
-    window.location.assign(this.editExperiment(experiment));
+    utils.navigateTo(this.editExperiment(experiment));
   },
   experimentsList() {
     return "/workspace/experiments";
   },
   navigateToExperimentsList() {
-    window.location.assign(this.experimentsList());
+    utils.navigateTo(this.experimentsList());
   },
   viewExperiment(experiment, { launching = false } = {}) {
     return (
@@ -24,7 +26,7 @@ export default {
     );
   },
   navigateToViewExperiment(experiment, { launching = false } = {}) {
-    window.location.assign(
+    utils.navigateTo(
       this.viewExperiment(experiment, { launching: launching }),
     );
   },
@@ -36,7 +38,7 @@ export default {
     );
   },
   navigateToCreateExperiment(appModule) {
-    window.location.assign(this.createExperiment(appModule));
+    utils.navigateTo(this.createExperiment(appModule));
   },
   editProject(project) {
     return (
@@ -47,7 +49,7 @@ export default {
     return "/workspace/projects";
   },
   navigateToProjectsList() {
-    window.location.assign(this.projectsList());
+    utils.navigateTo(this.projectsList());
   },
   viewGroupResourceProfile(groupResourceProfile) {
     return `/admin/group-resource-profiles/${encodeURIComponent(
diff --git a/airavata-django-portal/django_airavata/context_processors.py 
b/airavata-django-portal/django_airavata/context_processors.py
index c6b04fa59..9d8d0feb7 100644
--- a/airavata-django-portal/django_airavata/context_processors.py
+++ b/airavata-django-portal/django_airavata/context_processors.py
@@ -222,6 +222,25 @@ def _safe_reverse(name):
         return "#"
 
 
+def _account_console_url(request):
+    """Keycloak account console URL for the signed-in user's own profile.
+
+    Generated from the ``iss`` (issuer) claim of the user's own access token —
+    i.e. the realm they actually authenticated against, which is exactly the
+    ``issuer`` advertised by that realm's OIDC discovery (``.well-known``)
+    document. The account console lives at ``<issuer>/account/`` by Keycloak
+    convention (the discovery document itself exposes no account endpoint). 
This
+    keeps the link correct per-user/per-realm without depending on a separately
+    configured URL. Falls back to the configured 
``KEYCLOAK_ACCOUNT_CONSOLE_URL``
+    setting if the token carries no issuer.
+    """
+    claims = getattr(getattr(request, "user", None), "claims", None) or {}
+    issuer = claims.get("iss")
+    if issuer:
+        return issuer.rstrip("/") + "/account/"
+    return getattr(settings, "KEYCLOAK_ACCOUNT_CONSOLE_URL", "")
+
+
 def shell_data(request):
     """Assemble the page-shell data the Vue app shell (AppShell.vue) renders.
 
@@ -302,7 +321,7 @@ def shell_data(request):
             "username": getattr(request.user, "username", ""),
             "email": getattr(request.user, "email", ""),
         }
-        data["accountUrl"] = getattr(settings, "KEYCLOAK_ACCOUNT_CONSOLE_URL", 
"")
+        data["accountUrl"] = _account_console_url(request)
         data["logoutUrl"] = _safe_reverse("django_airavata_auth:logout")
         notifications = get_notifications(request)
         data["notices"] = json.loads(notifications.get("notifications") or 
"[]")
diff --git 
a/airavata-django-portal/django_airavata/static/common/js/components/UnsavedChangesGuard.vue
 
b/airavata-django-portal/django_airavata/static/common/js/components/UnsavedChangesGuard.vue
index 18f8a9c9e..d98c6c680 100644
--- 
a/airavata-django-portal/django_airavata/static/common/js/components/UnsavedChangesGuard.vue
+++ 
b/airavata-django-portal/django_airavata/static/common/js/components/UnsavedChangesGuard.vue
@@ -3,6 +3,8 @@
 </template>
 
 <script>
+import { isIntentionalNavigation } from "../utils";
+
 export default {
   name: "unsaved-changes-guard",
   props: {
@@ -19,7 +21,9 @@ export default {
   },
   methods: {
     onBeforeUnload(event) {
-      if (this.dirty) {
+      // Don't warn when the portal itself is navigating (e.g. after a save).
+      // Only genuinely accidental leaves with unsaved edits should prompt.
+      if (this.dirty && !isIntentionalNavigation()) {
         event.preventDefault();
         // Have to return a message for some browsers in order to trigger popup
         // asking user if they want to leave the page. I don't think any 
browser
diff --git 
a/airavata-django-portal/django_airavata/static/common/js/components/ui/button/Button.vue
 
b/airavata-django-portal/django_airavata/static/common/js/components/ui/button/Button.vue
index 0996d8e09..344385f2c 100644
--- 
a/airavata-django-portal/django_airavata/static/common/js/components/ui/button/Button.vue
+++ 
b/airavata-django-portal/django_airavata/static/common/js/components/ui/button/Button.vue
@@ -13,6 +13,13 @@ const props = defineProps({
   },
   asChild: { type: Boolean, required: false },
   as: { type: null, required: false, default: "button" },
+  // Default to a non-submitting button, matching the prior Bootstrap-Vue
+  // <b-button> default. A native <button> defaults to type="submit", so 
inside a
+  // <form> a bare <Button @click="…"> would also submit the form on click —
+  // triggering a full-page navigation and the browser's "Leave site?" unsaved-
+  // changes guard before the click handler runs. Pass type="submit" explicitly
+  // for real submit buttons.
+  type: { type: String, required: false, default: "button" },
 });
 </script>
 
@@ -23,6 +30,7 @@ const props = defineProps({
     :data-size="size"
     :as="as"
     :as-child="asChild"
+    :type="as === 'button' && !asChild ? type : undefined"
     :class="cn(buttonVariants({ variant, size }), props.class)"
   >
     <slot />
diff --git a/airavata-django-portal/django_airavata/static/common/js/utils.js 
b/airavata-django-portal/django_airavata/static/common/js/utils.js
index 2f5b00f4c..bcd3798ec 100644
--- a/airavata-django-portal/django_airavata/static/common/js/utils.js
+++ b/airavata-django-portal/django_airavata/static/common/js/utils.js
@@ -24,3 +24,22 @@ export const dateFormatters = {
     timeZoneName: "short",
   }),
 };
+
+// Tracks app-initiated (intentional) navigations so UnsavedChangesGuard does 
not
+// pop the native "Leave site?" dialog when the portal itself sends the user to
+// another page (e.g. after Save / Save and Launch). The guard should still 
warn
+// on genuinely accidental leaves (closing the tab, browser back/forward, 
editing
+// the URL bar, following an unrelated link) while there are unsaved edits.
+let intentionalNavigation = false;
+
+export function isIntentionalNavigation() {
+  return intentionalNavigation;
+}
+
+// Send the browser to `url` as an intentional, app-initiated navigation. Use 
this
+// instead of window.location.assign for in-portal navigations (e.g. 
redirecting
+// after a successful save) so the unsaved-changes guard stays silent.
+export function navigateTo(url) {
+  intentionalNavigation = true;
+  window.location.assign(url);
+}

Reply via email to