diff --git a/web/src/components/dashboards/SelectFolderDropdown.spec.ts b/web/src/components/dashboards/SelectFolderDropdown.spec.ts
index 3717026c9b..a5eb995f49 100644
--- a/web/src/components/dashboards/SelectFolderDropdown.spec.ts
+++ b/web/src/components/dashboards/SelectFolderDropdown.spec.ts
@@ -16,6 +16,7 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { mount } from "@vue/test-utils";
import SelectFolderDropdown from "./SelectFolderDropdown.vue";
+import OSelect from "@/lib/forms/Select/OSelect.vue";
import i18n from "@/locales";
import { createStore } from "vuex";
import { createRouter, createWebHistory } from "vue-router";
@@ -341,6 +342,26 @@ describe("SelectFolderDropdown", () => {
});
});
+ describe("disabled prop", () => {
+ it("locks the folder select and the new-folder button when disabled", () => {
+ const wrapper = createWrapper({ disabled: true }, store, router);
+
+ expect(wrapper.findComponent(OSelect).props("disabled")).toBe(true);
+ expect(
+ wrapper.find('[data-test="dashboard-folder-move-new-add"]').attributes("disabled"),
+ ).toBeDefined();
+ });
+
+ it("leaves both controls enabled by default", () => {
+ const wrapper = createWrapper({}, store, router);
+
+ expect(wrapper.findComponent(OSelect).props("disabled")).toBe(false);
+ expect(
+ wrapper.find('[data-test="dashboard-folder-move-new-add"]').attributes("disabled"),
+ ).toBeUndefined();
+ });
+ });
+
describe("Reactive folder list", () => {
it("should refresh selectedFolder when the store folders list changes and current id no longer exists", async () => {
const wrapper = createWrapper({ activeFolderId: "folder1" }, store, router);
diff --git a/web/src/components/dashboards/SelectFolderDropdown.vue b/web/src/components/dashboards/SelectFolderDropdown.vue
index 3bd442a7a0..99059a241a 100644
--- a/web/src/components/dashboards/SelectFolderDropdown.vue
+++ b/web/src/components/dashboards/SelectFolderDropdown.vue
@@ -24,6 +24,7 @@ along with this program. If not, see .
:options="folderOptions"
data-test="index-dropdown-stream_type"
labelKey="label"
+ :disabled="disabled"
class="flex-1"
>
@@ -37,6 +38,7 @@ along with this program. If not, see .
variant="outline"
size="icon-xs-sq"
class="h-8! w-8!"
+ :disabled="disabled"
@mousedown.prevent
@click="
() => {
@@ -90,6 +92,10 @@ export default defineComponent({
type: String as PropType<"inside" | "outside">,
default: "outside",
},
+ disabled: {
+ type: Boolean,
+ default: false,
+ },
},
setup(props, { emit }) {
const store: any = useStore();
diff --git a/web/src/test/unit/HomeView.spec.ts b/web/src/test/unit/HomeView.spec.ts
index 92dd225c53..bee9ec9e4a 100644
--- a/web/src/test/unit/HomeView.spec.ts
+++ b/web/src/test/unit/HomeView.spec.ts
@@ -22,6 +22,7 @@ import i18n from "@/locales";
import { nextTick } from "vue";
import { useHomeDashboard } from "@/composables/useHomeDashboard";
import { queryClient } from "@/composables/query/queryClient";
+import { settingKeys } from "@/services/settings.querykeys";
vi.mock("../../aws-exports", () => ({
default: {
@@ -367,6 +368,7 @@ describe("HomeView org home dashboard tab", () => {
vi.clearAllTimers();
localStorage.clear();
useHomeDashboard(gt).homeDashboard.value = null;
+ queryClient.clear();
});
const createWrapper = () => {
@@ -438,56 +440,50 @@ describe("HomeView org home dashboard tab", () => {
};
wrapper = createWrapper();
wrapper.vm.activeHomeTab = "dash:default:abc";
- await wrapper.vm.$nextTick();
+ await flushPromises();
expect(wrapper.findComponent({ name: "PinnedDashboardTab" }).exists()).toBe(true);
});
- it("clears the home dashboard and falls back to first tab on unavailable", async () => {
+ const pinIn = (folderId: string, dashboardId = "abc") => ({
+ data: { setting_value: { dashboardId, folderId, label: "X" } },
+ });
+
+ it("clears the home dashboard and falls back to first tab when a fresh read confirms the pin", async () => {
const hd = useHomeDashboard(gt);
- hd.homeDashboard.value = {
- dashboardId: "abc",
- folderId: "default",
- label: "Payments Health",
- };
+ hd.homeDashboard.value = { dashboardId: "abc", folderId: "default", label: "Payments Health" };
+ (settingsService.getSetting as any).mockResolvedValue(pinIn("default"));
wrapper = createWrapper();
wrapper.vm.activeHomeTab = "dash:default:abc";
- await wrapper.vm.$nextTick();
- wrapper.findComponent({ name: "PinnedDashboardTab" }).vm.$emit("unavailable", "abc");
- await wrapper.vm.$nextTick();
+ await flushPromises();
+ wrapper.findComponent({ name: "PinnedDashboardTab" }).vm.$emit("unavailable", "abc", "default");
+ await flushPromises();
expect(hd.homeDashboard.value).toBeNull();
expect(wrapper.vm.activeHomeTab).not.toBe("dash:default:abc");
});
it("toasts when the pinned dashboard is reported unavailable", async () => {
- const hd = useHomeDashboard(gt);
- hd.homeDashboard.value = {
+ useHomeDashboard(gt).homeDashboard.value = {
dashboardId: "abc",
folderId: "default",
label: "Payments Health",
};
+ (settingsService.getSetting as any).mockResolvedValue(pinIn("default"));
wrapper = createWrapper();
wrapper.vm.activeHomeTab = "dash:default:abc";
- await wrapper.vm.$nextTick();
+ await flushPromises();
toastSpy.mockClear();
- wrapper.findComponent({ name: "PinnedDashboardTab" }).vm.$emit("unavailable", "abc");
- await wrapper.vm.$nextTick();
+ wrapper.findComponent({ name: "PinnedDashboardTab" }).vm.$emit("unavailable", "abc", "default");
+ await flushPromises();
expect(toastSpy).toHaveBeenCalledWith(expect.objectContaining({ variant: "error" }));
});
it("re-syncs the home_dashboard setting when the pinned tab is activated", async () => {
(settingsService.getSetting as any).mockClear();
- (settingsService.getSetting as any).mockResolvedValue({
- data: { setting_value: { dashboardId: "abc", folderId: "A", label: "X" } },
- });
- useHomeDashboard(gt).homeDashboard.value = {
- dashboardId: "abc",
- folderId: "B",
- label: "X",
- };
+ (settingsService.getSetting as any).mockResolvedValue(pinIn("A"));
+ useHomeDashboard(gt).homeDashboard.value = { dashboardId: "abc", folderId: "B", label: "X" };
wrapper = createWrapper();
wrapper.vm.activeHomeTab = "dash:B:abc";
await flushPromises();
- // load(org) re-reads the authoritative setting via getSetting(org, key).
expect(settingsService.getSetting).toHaveBeenCalledWith(
"test-org",
"home_dashboard",
@@ -495,6 +491,135 @@ describe("HomeView org home dashboard tab", () => {
);
});
+ it("re-reads the pin in the background on each switch without holding the tab back", async () => {
+ const pin = { dashboardId: "abc", folderId: "A", label: "X" };
+ queryClient.setQueryData(settingKeys.one("test-org", "home_dashboard"), pin);
+ useHomeDashboard(gt).homeDashboard.value = { ...pin };
+ (settingsService.getSetting as any).mockResolvedValue(pinIn("A"));
+ wrapper = createWrapper();
+ await flushPromises();
+ (settingsService.getSetting as any).mockClear();
+ for (let i = 0; i < 3; i++) {
+ wrapper.vm.activeHomeTab = "overview";
+ await flushPromises();
+ wrapper.vm.activeHomeTab = "dash:A:abc";
+ await nextTick();
+ expect(wrapper.findComponent({ name: "PinnedDashboardTab" }).exists()).toBe(true);
+ await flushPromises();
+ }
+ expect(settingsService.getSetting).toHaveBeenCalledTimes(3);
+ });
+
+ it("drops a pin cleared on another device when Home is revisited on another tab", async () => {
+ useHomeDashboard(gt).homeDashboard.value = { dashboardId: "abc", folderId: "A", label: "X" };
+ (settingsService.getSetting as any).mockRejectedValue({ response: { status: 404 } });
+ wrapper = createWrapper();
+ await flushPromises();
+
+ expect(wrapper.vm.activeHomeTab).toBe("overview");
+ expect(wrapper.find('[data-test^="home-tab-dash:"]').exists()).toBe(false);
+ });
+
+ it("ignores a late failure from the tab of the folder the pin moved out of", async () => {
+ const pin = { dashboardId: "abc", folderId: "A", label: "X" };
+ useHomeDashboard(gt).homeDashboard.value = { ...pin };
+ (settingsService.getSetting as any).mockResolvedValue(pinIn("A"));
+ wrapper = createWrapper();
+ wrapper.vm.activeHomeTab = "dash:A:abc";
+ await flushPromises();
+ toastSpy.mockClear();
+
+ wrapper.findComponent({ name: "PinnedDashboardTab" }).vm.$emit("unavailable", "abc", "B");
+ await flushPromises();
+
+ expect(useHomeDashboard(gt).homeDashboard.value?.folderId).toBe("A");
+ expect(wrapper.vm.activeHomeTab).toBe("dash:A:abc");
+ expect(settingsService.deleteOrgSetting).not.toHaveBeenCalled();
+ expect(toastSpy).not.toHaveBeenCalled();
+ });
+
+ it("follows a pin moved elsewhere when the cached folder fails, instead of deleting it", async () => {
+ const stale = { dashboardId: "abc", folderId: "B", label: "X" };
+ queryClient.setQueryData(settingKeys.one("test-org", "home_dashboard"), stale);
+ (settingsService.getSetting as any).mockResolvedValue(pinIn("A"));
+ useHomeDashboard(gt).homeDashboard.value = { ...stale };
+ wrapper = createWrapper();
+ wrapper.vm.activeHomeTab = "dash:B:abc";
+ await flushPromises();
+ toastSpy.mockClear();
+
+ wrapper.findComponent({ name: "PinnedDashboardTab" }).vm.$emit("unavailable", "abc", "B");
+ await flushPromises();
+
+ expect(useHomeDashboard(gt).homeDashboard.value?.folderId).toBe("A");
+ expect(wrapper.vm.activeHomeTab).toBe("dash:A:abc");
+ expect(wrapper.findComponent({ name: "PinnedDashboardTab" }).props("folderId")).toBe("A");
+ expect(settingsService.deleteOrgSetting).not.toHaveBeenCalled();
+ expect(toastSpy).not.toHaveBeenCalled();
+ });
+
+ it("leaves the pinned tab without deleting anything when the pin was cleared elsewhere", async () => {
+ const pin = { dashboardId: "abc", folderId: "A", label: "X" };
+ useHomeDashboard(gt).homeDashboard.value = { ...pin };
+ (settingsService.getSetting as any).mockRejectedValue({ response: { status: 404 } });
+ wrapper = createWrapper();
+ wrapper.vm.activeHomeTab = "dash:A:abc";
+ await flushPromises();
+
+ expect(useHomeDashboard(gt).homeDashboard.value).toBeNull();
+ expect(wrapper.vm.activeHomeTab).toBe("overview");
+ expect(wrapper.findComponent({ name: "PinnedDashboardTab" }).exists()).toBe(false);
+ expect(settingsService.deleteOrgSetting).not.toHaveBeenCalled();
+ });
+
+ it("leaves the pinned tab when the pin was replaced by another dashboard elsewhere", async () => {
+ useHomeDashboard(gt).homeDashboard.value = { dashboardId: "abc", folderId: "A", label: "X" };
+ (settingsService.getSetting as any).mockResolvedValue(pinIn("A", "xyz"));
+ wrapper = createWrapper();
+ wrapper.vm.activeHomeTab = "dash:A:abc";
+ await flushPromises();
+
+ expect(wrapper.vm.activeHomeTab).toBe("overview");
+ expect(wrapper.find('[data-test="home-tab-dash:A:xyz"]').exists()).toBe(true);
+ });
+
+ describe("a saved pinned tab restored on mount", () => {
+ const loadPin = async (folderId: string) => {
+ queryClient.clear();
+ (settingsService.getSetting as any).mockResolvedValueOnce(pinIn(folderId));
+ await useHomeDashboard(gt).load("test-org");
+ };
+
+ it("opens the pin's current folder after the dashboard moved", async () => {
+ await loadPin("A");
+ (settingsService.getSetting as any).mockResolvedValue(pinIn("A"));
+ localStorage.setItem("o2_home_active_tab", "dash:B:abc");
+ wrapper = createWrapper();
+ await flushPromises();
+
+ expect(wrapper.vm.activeHomeTab).toBe("dash:A:abc");
+ expect(wrapper.findComponent({ name: "PinnedDashboardTab" }).props("folderId")).toBe("A");
+ });
+
+ it("recovers from an out-of-band move once the stale folder fails, keeping the pin", async () => {
+ await loadPin("B");
+ let answer: (v: unknown) => void = () => {};
+ (settingsService.getSetting as any).mockReturnValue(new Promise((r) => (answer = r)));
+ localStorage.setItem("o2_home_active_tab", "dash:B:abc");
+ wrapper = createWrapper();
+ await flushPromises();
+ expect(wrapper.vm.activeHomeTab).toBe("dash:B:abc");
+
+ wrapper.findComponent({ name: "PinnedDashboardTab" }).vm.$emit("unavailable", "abc", "B");
+ answer(pinIn("A"));
+ await flushPromises();
+
+ expect(wrapper.vm.activeHomeTab).toBe("dash:A:abc");
+ expect(useHomeDashboard(gt).homeDashboard.value?.folderId).toBe("A");
+ expect(settingsService.deleteOrgSetting).not.toHaveBeenCalled();
+ });
+ });
+
it("does NOT toast when the pin is closed deliberately via the ×", async () => {
const hd = useHomeDashboard(gt);
hd.homeDashboard.value = {
@@ -567,6 +692,21 @@ describe("HomeView org home dashboard tab", () => {
expect(wrapper.findComponent({ name: "PinnedDashboardTab" }).exists()).toBe(true);
});
+ it("opens the pin's new folder when the pending pin arrives moved", async () => {
+ localStorage.setItem("o2_home_active_tab", "dash:old:abc");
+ await startPendingLoad();
+ wrapper = createWrapper();
+ await flushPromises();
+ expect(wrapper.vm.activeHomeTab).toBe("dash:old:abc");
+
+ answer({ data: { setting_value: PINNED } });
+ await flushPromises();
+ expect(wrapper.vm.activeHomeTab).toBe("dash:default:abc");
+ expect(wrapper.findComponent({ name: "PinnedDashboardTab" }).props("folderId")).toBe(
+ "default",
+ );
+ });
+
it("falls back to the first tab once the org turns out to have no pin", async () => {
localStorage.setItem("o2_home_active_tab", "dash:default:abc");
await startPendingLoad();
diff --git a/web/src/test/unit/PinnedDashboardTab.spec.ts b/web/src/test/unit/PinnedDashboardTab.spec.ts
index 493482b6a6..05cc581f0d 100644
--- a/web/src/test/unit/PinnedDashboardTab.spec.ts
+++ b/web/src/test/unit/PinnedDashboardTab.spec.ts
@@ -182,7 +182,7 @@ describe("PinnedDashboardTab", () => {
(getDashboard as any).mockRejectedValue({ response: { status: 404 } });
wrapper = factory();
await flushPromises();
- expect(wrapper.emitted("unavailable")?.[0]).toEqual(["abc"]);
+ expect(wrapper.emitted("unavailable")?.[0]).toEqual(["abc", "default"]);
});
it("emits unavailable when getDashboard returns an empty object (deleted)", async () => {
@@ -190,7 +190,7 @@ describe("PinnedDashboardTab", () => {
(getDashboard as any).mockResolvedValue({});
wrapper = factory();
await flushPromises();
- expect(wrapper.emitted("unavailable")?.[0]).toEqual(["abc"]);
+ expect(wrapper.emitted("unavailable")?.[0]).toEqual(["abc", "default"]);
});
it("shows skeleton while loading", async () => {
diff --git a/web/src/views/Dashboards/Dashboards.spec.ts b/web/src/views/Dashboards/Dashboards.spec.ts
index 0277d87ce7..d64fa7bddc 100644
--- a/web/src/views/Dashboards/Dashboards.spec.ts
+++ b/web/src/views/Dashboards/Dashboards.spec.ts
@@ -80,6 +80,8 @@ vi.mock("@/services/settings", async (importOriginal) => {
import settingsService from "@/services/settings";
import * as commons from "@/utils/commons";
import { useFavoriteDashboards } from "@/composables/useFavoriteDashboards";
+import { useHomeDashboard } from "@/composables/useHomeDashboard";
+import { gt } from "@/types/i18n";
// Mock DOM methods to prevent errors from missing DOM APIs
Object.defineProperty(Element.prototype, "removeAttribute", {
@@ -1091,6 +1093,47 @@ describe("Dashboards.vue", () => {
});
});
+ describe("Home pin after a move", () => {
+ const pinIn = (folderId: string) => ({
+ data: { setting_value: { dashboardId: "d1", folderId, label: "Pinned" } },
+ });
+
+ afterEach(() => {
+ useHomeDashboard(gt).homeDashboard.value = null;
+ });
+
+ const mountWithWarmPin = async () => {
+ vi.mocked(settingsService.getSetting).mockResolvedValue(pinIn("B") as any);
+ wrapper = shallowMount(Dashboards, { global: buildGlobalConfig(store, router, i18n) });
+ await flushPromises();
+ expect(useHomeDashboard(gt).homeDashboard.value?.folderId).toBe("B");
+ vi.mocked(settingsService.getSetting).mockClear();
+ vi.mocked(settingsService.getSetting).mockResolvedValue(pinIn("A") as any);
+ };
+
+ it("re-reads the pin past a warm cache when the pinned dashboard is moved", async () => {
+ await mountWithWarmPin();
+
+ wrapper.vm.selectedDashboardIdToMove = ["d1"];
+ await wrapper.findComponent({ name: "MoveDashboardToAnotherFolder" }).vm.$emit("updated");
+ await flushPromises();
+
+ expect(useHomeDashboard(gt).homeDashboard.value?.folderId).toBe("A");
+ expect(wrapper.vm.showMoveDashboardDialog).toBe(false);
+ });
+
+ it("does not re-read the pin when a different dashboard is moved", async () => {
+ await mountWithWarmPin();
+
+ wrapper.vm.selectedDashboardIdToMove = ["other"];
+ await wrapper.findComponent({ name: "MoveDashboardToAnotherFolder" }).vm.$emit("updated");
+ await flushPromises();
+
+ expect(settingsService.getSetting).not.toHaveBeenCalled();
+ expect(useHomeDashboard(gt).homeDashboard.value?.folderId).toBe("B");
+ });
+ });
+
describe("product analytics", () => {
const mountDashboards = async () => {
wrapper = shallowMount(Dashboards, { global: buildGlobalConfig(store, router, i18n) });
diff --git a/web/src/views/Dashboards/Dashboards.vue b/web/src/views/Dashboards/Dashboards.vue
index 2578763f6c..2c6d30f82d 100644
--- a/web/src/views/Dashboards/Dashboards.vue
+++ b/web/src/views/Dashboards/Dashboards.vue
@@ -788,7 +788,13 @@ export default defineComponent({
const { showPositiveNotification, showErrorNotification } = useNotifications();
- const { isHome, setHomeDashboard, clearHomeDashboard, homeDashboard } = useHomeDashboard(t);
+ const {
+ isHome,
+ setHomeDashboard,
+ clearHomeDashboard,
+ homeDashboard,
+ load: loadHomeDashboard,
+ } = useHomeDashboard(t);
// Per-user favorites — heart toggle on each row + a folder-independent
// favorites view.
@@ -1573,6 +1579,11 @@ export default defineComponent({
const handleDashboardMoved = async () => {
showMoveDashboardDialog.value = false;
+ const org = store.state.selectedOrganization?.identifier;
+ // The server rewrites the pin's folder on move; the cached copy would keep routing to the old one.
+ if (org && selectedDashboardIdToMove.value?.some((id) => isHome(id))) {
+ await loadHomeDashboard(org, true);
+ }
};
const deleteFolder = async () => {
diff --git a/web/src/views/Dashboards/ImportDashboard.spec.ts b/web/src/views/Dashboards/ImportDashboard.spec.ts
index 1c1ef6d6bc..7adfe290b1 100644
--- a/web/src/views/Dashboards/ImportDashboard.spec.ts
+++ b/web/src/views/Dashboards/ImportDashboard.spec.ts
@@ -47,6 +47,7 @@ vi.mock("@/utils/dashboard/panelValidation", () => ({
vi.mock("@/components/dashboards/SelectFolderDropdown.vue", () => ({
default: {
name: "SelectFolderDropdown",
+ props: ["activeFolderId", "disabled"],
template: '',
},
}));
@@ -385,6 +386,96 @@ describe("ImportDashboard.vue", () => {
]);
});
+ it.each(["import_json_file", "import_json_url"])(
+ "locks the folder picker on %s while an import is running",
+ async (tab) => {
+ wrapper = mountComponent();
+ wrapper.vm.activeTab = tab;
+ await nextTick();
+ const picker = () => wrapper.findComponent({ name: "SelectFolderDropdown" });
+ expect(picker().props("disabled")).toBe(false);
+
+ wrapper.vm.isLoading = tab === "import_json_url" ? "url" : "files";
+ await nextTick();
+ expect(picker().props("disabled")).toBe(true);
+
+ wrapper.vm.isLoading = false;
+ await nextTick();
+ expect(picker().props("disabled")).toBe(false);
+ },
+ );
+
+ it("keeps the folder picker locked until a pending create settles", async () => {
+ const dashboardService = (await import("@/services/dashboards")).default;
+ let settle: (v: unknown) => void = () => {};
+ (dashboardService.create as any).mockReturnValue(new Promise((resolve) => (settle = resolve)));
+ wrapper = mountComponent();
+ await nextTick();
+ wrapper.vm.jsonStr = JSON.stringify({ title: "Pending", tabs: [{ tabId: "t1", panels: [] }] });
+
+ const run = wrapper.vm.importFromJsonStr();
+ await flushPromises();
+ expect(wrapper.findComponent({ name: "SelectFolderDropdown" }).props("disabled")).toBe(true);
+
+ settle({ data: {} });
+ await run;
+ await flushPromises();
+ expect(wrapper.findComponent({ name: "SelectFolderDropdown" }).props("disabled")).toBe(false);
+ });
+
+ it("keeps the folder picker locked while uploaded files are importing", async () => {
+ const dashboardService = (await import("@/services/dashboards")).default;
+ let settle: (v: unknown) => void = () => {};
+ (dashboardService.create as any).mockReturnValue(new Promise((resolve) => (settle = resolve)));
+ const content = JSON.stringify({ title: "Pending", tabs: [{ tabId: "t1", panels: [] }] });
+ wrapper = mountComponent();
+ await nextTick();
+ wrapper.vm.form.setFieldValue("jsonFiles", [
+ new File([content], "a.json", { type: "application/json" }),
+ ]);
+ await flushPromises();
+ wrapper.vm.jsonStr = JSON.stringify([JSON.parse(content)]);
+ const picker = () => wrapper.findComponent({ name: "SelectFolderDropdown" });
+
+ void wrapper.vm.importFiles();
+ await flushPromises();
+ expect(picker().props("disabled")).toBe(true);
+
+ settle({ data: {} });
+ await flushPromises();
+ expect(picker().props("disabled")).toBe(false);
+ });
+
+ it("imports every uploaded dashboard into the folder chosen when the import started", async () => {
+ const dashboardService = (await import("@/services/dashboards")).default;
+ const settles: Array<(v: unknown) => void> = [];
+ (dashboardService.create as any).mockImplementation(
+ () => new Promise((resolve) => settles.push(resolve)),
+ );
+ const dash = (title: string) => ({ title, tabs: [{ tabId: "t1", panels: [] }] });
+ wrapper = mountComponent();
+ await nextTick();
+ wrapper.vm.selectedFolder = { label: "A", value: "folder-a" };
+ wrapper.vm.form.setFieldValue("jsonFiles", [
+ new File([JSON.stringify([dash("One"), dash("Two")])], "a.json", {
+ type: "application/json",
+ }),
+ ]);
+ await flushPromises();
+ wrapper.vm.jsonStr = JSON.stringify([[dash("One"), dash("Two")]]);
+
+ void wrapper.vm.importFiles();
+ await flushPromises();
+ wrapper.vm.selectedFolder = { label: "B", value: "folder-b" };
+ settles.shift()?.({ data: {} });
+ await flushPromises();
+ settles.shift()?.({ data: {} });
+ await flushPromises();
+
+ const folders = (dashboardService.create as any).mock.calls.map((c: any[]) => c[2]);
+ expect(folders).toEqual(["folder-a", "folder-a"]);
+ });
+
// Real-OForm validation wiring (playbook §5): the OForm covers only the
// url/jsonFiles input collection. The URL-format rule is the one field check
// (non-required, so the paste-JSON flow is preserved).
diff --git a/web/src/views/Dashboards/ImportDashboard.vue b/web/src/views/Dashboards/ImportDashboard.vue
index a755f6cbcb..dae310f86e 100644
--- a/web/src/views/Dashboards/ImportDashboard.vue
+++ b/web/src/views/Dashboards/ImportDashboard.vue
@@ -102,6 +102,7 @@ along with this program. If not, see .
@@ -140,6 +141,7 @@ along with this program. If not, see .
@@ -541,6 +543,7 @@ export default defineComponent({
}
createdInAction = 0;
+ const importFolder = selectedFolder.value;
const data = jsonStr.value.map((parsedContent, fileIndex) => {
return new Promise((resolve, reject) => {
(async () => {
@@ -575,7 +578,7 @@ export default defineComponent({
continue;
}
- const res = await importDashboardFromJSON(convertedSchema, selectedFolder.value);
+ const res = await importDashboardFromJSON(convertedSchema, importFolder);
results.push({ index: i + 1, result: res });
} catch (e) {
results.push({ index: i + 1, error: e });
@@ -613,7 +616,7 @@ export default defineComponent({
const successfulImports = results.filter((r) => r.status === "fulfilled").length;
if (results.length === successfulImports) {
- await resetAndRefresh(ImportType.FILES, selectedFolder.value);
+ await resetAndRefresh(ImportType.FILES, importFolder);
}
if (successfulImports) {
@@ -666,6 +669,7 @@ export default defineComponent({
const importFromUrl = async () => {
isLoading.value = ImportType.URL;
createdInAction = 0;
+ const importFolder = selectedFolder.value;
try {
const urlData = url.value.trim();
@@ -691,7 +695,7 @@ export default defineComponent({
return Promise.reject({ index, error: new Error(errorMessage) });
}
- return importDashboardFromJSON(converted, selectedFolder.value);
+ return importDashboardFromJSON(converted, importFolder);
} catch (e) {
return Promise.reject({ index, error: e });
}
@@ -706,7 +710,7 @@ export default defineComponent({
}
if (successCount > 0) {
- await resetAndRefresh(ImportType.URL, selectedFolder.value);
+ await resetAndRefresh(ImportType.URL, importFolder);
showPositiveNotification(
t("dashboard.importDashboardPage.dashboardsImportedSuccessfully", { n: successCount }),
);
@@ -733,6 +737,7 @@ export default defineComponent({
const importFromJsonStr = async () => {
isLoading.value = ImportType.JSON_STRING;
createdInAction = 0;
+ const importFolder = selectedFolder.value;
try {
// get the dashboard
@@ -749,8 +754,8 @@ export default defineComponent({
return;
}
- await importDashboardFromJSON(convertedSchema, selectedFolder.value).then((_res) => {
- resetAndRefresh(ImportType.JSON_STRING, selectedFolder.value);
+ await importDashboardFromJSON(convertedSchema, importFolder).then((_res) => {
+ resetAndRefresh(ImportType.JSON_STRING, importFolder);
filesImportResults.value = [];
jsonStr.value = "";
diff --git a/web/src/views/HomeView.vue b/web/src/views/HomeView.vue
index 60fc7ef732..c3bb635fec 100644
--- a/web/src/views/HomeView.vue
+++ b/web/src/views/HomeView.vue
@@ -260,7 +260,8 @@ export default defineComponent({
const isEnterpriseOrCloud = config.isEnterprise === "true" || config.isCloud === "true";
- const { homeDashboard, clearHomeDashboard, updateLabel, isLoadedFor } = useHomeDashboard(t);
+ const { homeDashboard, clearHomeDashboard, updateLabel, isLoadedFor, load } =
+ useHomeDashboard(t);
const DEFAULT_TABS = computed(() => {
const tabs: { id: string; label: string; closable?: boolean }[] = [];
@@ -304,9 +305,29 @@ export default defineComponent({
}
}
+ let verifyingPin = false;
+
// The pinned dashboard could not be loaded (deleted / inaccessible). Clear
// the pin and tell the user why — distinct from a deliberate close.
- function onPinnedUnavailable() {
+ async function onPinnedUnavailable(dashboardId: string, folderId: string) {
+ const org = store.state.selectedOrganization?.identifier;
+ if (!org || verifyingPin) return;
+ verifyingPin = true;
+ try {
+ await load(org, true);
+ } finally {
+ verifyingPin = false;
+ }
+ const failed = `dash:${folderId}:${dashboardId}`;
+ const pin = homeDashboard.value;
+ if (pin?.dashboardId !== dashboardId) return;
+ const pinTab = `dash:${pin.folderId}:${pin.dashboardId}`;
+ // The pin is org-wide, so it is only deleted when a fresh read still names the folder that failed; a late failure from a replaced tab must not.
+ if (pinTab !== failed) {
+ if (activeHomeTab.value === failed) activeHomeTab.value = pinTab;
+ return;
+ }
+ if (activeHomeTab.value !== failed) return;
removeHomePin();
toast({
variant: "error",
@@ -354,7 +375,16 @@ export default defineComponent({
},
);
- const savedActiveTab = localStorage.getItem(LS_ACTIVE_TAB_KEY);
+ // The tab id carries the folder, so a saved tab for the pinned dashboard takes the pin's current folder.
+ function pinTabFor(id: string) {
+ const pin = homeDashboard.value;
+ if (!pin || !id.startsWith("dash:")) return id;
+ if (parsePinnedTabId(id).dashboardId !== pin.dashboardId) return id;
+ return `dash:${pin.folderId}:${pin.dashboardId}`;
+ }
+
+ const storedActiveTab = localStorage.getItem(LS_ACTIVE_TAB_KEY);
+ const savedActiveTab = storedActiveTab && pinTabFor(storedActiveTab);
const isAvailableTab = (id: string) => DEFAULT_TABS.value.some((tb) => tb.id === id);
const pinnedTabPending = ref(
!!savedActiveTab?.startsWith("dash:") &&
@@ -369,13 +399,15 @@ export default defineComponent({
if (pinnedTabPending.value) {
const stopHold = watch(
() => [
- isAvailableTab(activeHomeTab.value),
+ isAvailableTab(pinTabFor(activeHomeTab.value)),
isLoadedFor(store.state.selectedOrganization?.identifier),
],
([available, loaded]) => {
if (!available && !loaded) return;
pinnedTabPending.value = false;
- if (!available) activeHomeTab.value = tabOrder.value[0]?.id ?? "usage";
+ activeHomeTab.value = available
+ ? pinTabFor(activeHomeTab.value)
+ : (tabOrder.value[0]?.id ?? "usage");
stopHold();
},
);
@@ -383,17 +415,36 @@ export default defineComponent({
watch(activeHomeTab, (val) => localStorage.setItem(LS_ACTIVE_TAB_KEY, val));
- // When the user opens the pinned dashboard tab, re-read the authoritative
- // home_dashboard org setting so a snapshot that went stale on another system
- // (e.g. the dashboard was moved to a different folder elsewhere) self-corrects
- // before we render / navigate with its folderId.
- watch(activeHomeTab, (val) => {
- if (val.startsWith("dash:")) {
- const org = store.state.selectedOrganization?.identifier;
- if (org) useHomeDashboard(t).load(org);
- }
+ // The cached pin can be stale from another device, so it is re-read without holding the tab; a stale folder is caught by onPinnedUnavailable.
+ function revalidatePin() {
+ const org = store.state.selectedOrganization?.identifier;
+ if (org) void load(org, true);
+ }
+
+ watch(activeHomeTab, (val, prev) => {
+ if (!val.startsWith("dash:")) return;
+ // Only the folder changed, by the watch below, which already applied the fresh pin.
+ if (
+ prev?.startsWith("dash:") &&
+ parsePinnedTabId(prev).dashboardId === parsePinnedTabId(val).dashboardId
+ )
+ return;
+ revalidatePin();
});
+ // The tab id carries the folder, so a re-read that moved, replaced or dropped the pin must move the open tab with it.
+ watch(homeDashboard, (pin) => {
+ if (pinnedTabPending.value || !activeHomeTab.value.startsWith("dash:")) return;
+ if (!pin || pin.dashboardId !== parsePinnedTabId(activeHomeTab.value).dashboardId) {
+ activeHomeTab.value = tabOrder.value[0]?.id ?? "usage";
+ return;
+ }
+ const nextId = `dash:${pin.folderId}:${pin.dashboardId}`;
+ if (activeHomeTab.value !== nextId) activeHomeTab.value = nextId;
+ });
+
+ if (homeDashboard.value && !pinnedTabPending.value) revalidatePin();
+
// Drag-to-reorder — OTabs reports the move (dragged id → target id + which
// side of the target) and we apply it to our own ordered list, then persist.
function onTabReorder({
diff --git a/web/src/views/PinnedDashboardTab.vue b/web/src/views/PinnedDashboardTab.vue
index a0e07ff99a..699bbfc34c 100644
--- a/web/src/views/PinnedDashboardTab.vue
+++ b/web/src/views/PinnedDashboardTab.vue
@@ -109,7 +109,7 @@ const { isMobile } = useBreakpoint();
const props = defineProps<{ dashboardId: string; folderId: string }>();
const emit = defineEmits<{
(e: "update-label", label: string): void;
- (e: "unavailable", dashboardId: string): void;
+ (e: "unavailable", dashboardId: string, folderId: string): void;
}>();
const store = useStore();
@@ -217,7 +217,7 @@ const load = async () => {
// getDashboard returns {} (not a throw) for a missing/deleted dashboard.
if (!dashboard || typeof dashboard !== "object" || !dashboard.title) {
- emit("unavailable", props.dashboardId);
+ emit("unavailable", props.dashboardId, props.folderId);
return;
}
@@ -230,7 +230,7 @@ const load = async () => {
await nextTick();
setTime();
} catch {
- emit("unavailable", props.dashboardId);
+ emit("unavailable", props.dashboardId, props.folderId);
} finally {
isLoading.value = false;
}