This is an automated email from the ASF dual-hosted git repository. yasithdev pushed a commit to branch feat/generic-experiment-launcher in repository https://gitbox.apache.org/repos/asf/airavata-portals.git
commit 2e5af0f95cc738b8f418dc97e19efca7ff178597 Author: yasithdev <[email protected]> AuthorDate: Fri Apr 24 23:04:43 2026 -0400 feat(launcher): localStorage draft persistence + hydrate() --- .../js/containers/LaunchContainer.vue | 3 ++ .../tests/unit/stores/launch.spec.ts | 34 ++++++++++++++++++++++ .../static/common/js/stores/launch.ts | 27 ++++++++++++++++- airavata-django-portal/tooling/vitest-setup.ts | 16 ++++++++++ airavata-django-portal/tooling/vitest.config.js | 1 + 5 files changed, 80 insertions(+), 1 deletion(-) diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/LaunchContainer.vue b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/LaunchContainer.vue index d9a1d0809..2904afa27 100644 --- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/LaunchContainer.vue +++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/js/containers/LaunchContainer.vue @@ -18,6 +18,7 @@ <script setup lang="ts"> import { onMounted, ref } from "vue"; import { launcherService } from "django-airavata-common-ui/js/services/launcherService"; +import { useLaunchStore } from "django-airavata-common-ui/js/stores/launch"; import ExperimentMetaHeader from "../components/launch/ExperimentMetaHeader.vue"; import WizardTabs from "../components/launch/WizardTabs.vue"; import Tab1ApplicationInputs from "../components/launch/Tab1ApplicationInputs.vue"; @@ -26,6 +27,7 @@ import Tab3ReviewLaunch from "../components/launch/Tab3ReviewLaunch.vue"; const active = ref<1 | 2 | 3>(1); const projects = ref<Array<{ project_id: string; name: string }>>([]); +const store = useLaunchStore(); function onChangeTab(n: 1 | 2 | 3) { active.value = n; @@ -35,6 +37,7 @@ function onChangeTab(n: 1 | 2 | 3) { } onMounted(async () => { + store.hydrate(); const url = new URL(window.location.href); const t = Number(url.searchParams.get("tab")); if (t === 1 || t === 2 || t === 3) active.value = t as 1 | 2 | 3; diff --git a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/stores/launch.spec.ts b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/stores/launch.spec.ts index b064e646f..ad073e075 100644 --- a/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/stores/launch.spec.ts +++ b/airavata-django-portal/django_airavata/apps/workspace/static/django_airavata_workspace/tests/unit/stores/launch.spec.ts @@ -102,3 +102,37 @@ describe("setters and reset", () => { expect(s.tab1Valid).toBe(true); }); }); + +describe("draft persistence", () => { + beforeEach(() => { + setActivePinia(createPinia()); + localStorage.clear(); + }); + + it("saves draft to localStorage on every change", () => { + const s = useLaunchStore(); + s.setMeta({ name: "abc", project_id: "p1", description: "" }); + const stored = localStorage.getItem("launch-draft"); + expect(stored).not.toBeNull(); + expect(JSON.parse(stored!).name).toBe("abc"); + }); + + it("restores draft from localStorage on hydrate()", () => { + localStorage.setItem("launch-draft", JSON.stringify({ + name: "restored", project_id: "p1", description: "", + app_id: null, interface_name: null, inputs: {}, outputs: {}, + runtime: { compute_resource_id: null, partition: null, + walltime: "01:00:00", nodes: 1, cpus_per_node: 1 }, + })); + const s = useLaunchStore(); + s.hydrate(); + expect(s.draft.name).toBe("restored"); + }); + + it("reset() clears localStorage", () => { + const s = useLaunchStore(); + s.setMeta({ name: "abc", project_id: "p1", description: "" }); + s.reset(); + expect(localStorage.getItem("launch-draft")).toBeNull(); + }); +}); diff --git a/airavata-django-portal/django_airavata/static/common/js/stores/launch.ts b/airavata-django-portal/django_airavata/static/common/js/stores/launch.ts index 5e34c91b8..d769e0017 100644 --- a/airavata-django-portal/django_airavata/static/common/js/stores/launch.ts +++ b/airavata-django-portal/django_airavata/static/common/js/stores/launch.ts @@ -1,5 +1,5 @@ import { defineStore } from "pinia"; -import { computed, reactive, ref } from "vue"; +import { computed, reactive, ref, watch } from "vue"; import type { Application, ExperimentDraft, @@ -142,6 +142,29 @@ export const useLaunchStore = defineStore("launch", () => { profile.value = p; } + const STORAGE_KEY = "launch-draft"; + + function persist(draftValue: ExperimentDraft) { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(draftValue)); } catch { /* ignore quota */ } + } + + function clearPersisted() { + try { localStorage.removeItem(STORAGE_KEY); } catch { /* noop */ } + } + + function hydrate() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return; + const parsed = JSON.parse(raw) as Partial<ExperimentDraft>; + Object.assign(draft, makeDraft(), parsed); + } catch { + /* ignore corrupted draft */ + } + } + + watch(draft, (d) => persist(d), { deep: true, flush: "sync" }); + function reset() { Object.assign(draft, makeDraft()); pickedApp.value = null; @@ -150,6 +173,7 @@ export const useLaunchStore = defineStore("launch", () => { previewError.value = null; previewLoading.value = false; lastPreviewedHash.value = null; + clearPersisted(); } return { @@ -173,6 +197,7 @@ export const useLaunchStore = defineStore("launch", () => { setRuntime, setStorages, setProfile, + hydrate, reset, }; }); diff --git a/airavata-django-portal/tooling/vitest-setup.ts b/airavata-django-portal/tooling/vitest-setup.ts index 4b4e4ece3..a553a6d4a 100644 --- a/airavata-django-portal/tooling/vitest-setup.ts +++ b/airavata-django-portal/tooling/vitest-setup.ts @@ -17,3 +17,19 @@ if (typeof globalThis.ResizeObserver === "undefined") { disconnect() {} }; } + +// Node 25 ships a built-in `localStorage` stub that lacks the full Storage API +// (e.g. `.clear()` is missing). Replace it with a simple in-memory +// implementation so tests that rely on localStorage work correctly in jsdom. +(function installLocalStorage() { + const store: Record<string, string> = {}; + const impl = { + get length() { return Object.keys(store).length; }, + getItem(k: string) { return Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null; }, + setItem(k: string, v: string) { store[k] = String(v); }, + removeItem(k: string) { delete store[k]; }, + clear() { for (const k of Object.keys(store)) delete store[k]; }, + key(n: number) { return Object.keys(store)[n] ?? null; }, + }; + Object.defineProperty(globalThis, "localStorage", { value: impl, writable: true, configurable: true }); +})(); diff --git a/airavata-django-portal/tooling/vitest.config.js b/airavata-django-portal/tooling/vitest.config.js index c2dab779e..04a6e7d7e 100644 --- a/airavata-django-portal/tooling/vitest.config.js +++ b/airavata-django-portal/tooling/vitest.config.js @@ -19,6 +19,7 @@ export function defineVitestConfig({ srcDir, overrides = {} }) { test: { globals: true, environment: "jsdom", + environmentOptions: { jsdom: { url: "http://localhost/" } }, setupFiles: [resolve(import.meta.dirname, "./vitest-setup.ts")], include: ["**/*.{test,spec}.{js,ts,mjs}", "**/tests/**/*.{test,spec}.{js,ts}"], exclude: ["**/node_modules/**", "**/dist/**", "**/tests/e2e/**"],
