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);
+}