fix(dashboards): lock the import folder while importing and keep the home pin's folder current after a move (#16295)

Co-authored-by: ktx-vaidehi <vaidehi.akhani@kiara.tech>
Co-authored-by: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com>
This commit is contained in:
Neha P
2026-11-04 08:37:36 +00:00
committed by GitHub
co-authored by ktx-vaidehi ktx-vaidehi
parent 70dcb67537
commit 6f7f4a5d2c
10 changed files with 418 additions and 50 deletions
@@ -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);
@@ -24,6 +24,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:options="folderOptions"
data-test="index-dropdown-stream_type"
labelKey="label"
:disabled="disabled"
class="flex-1"
>
<template v-if="selectedFolder" #icon-left>
@@ -37,6 +38,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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();
+164 -24
View File
@@ -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();
+2 -2
View File
@@ -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 () => {
@@ -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) });
+12 -1
View File
@@ -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 () => {
@@ -47,6 +47,7 @@ vi.mock("@/utils/dashboard/panelValidation", () => ({
vi.mock("@/components/dashboards/SelectFolderDropdown.vue", () => ({
default: {
name: "SelectFolderDropdown",
props: ["activeFolderId", "disabled"],
template: '<div data-test="select-folder-dropdown"></div>',
},
}));
@@ -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).
+11 -6
View File
@@ -102,6 +102,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<SelectFolderDropdown
@folder-selected="selectedFolder = $event"
:activeFolderId="selectedFolder.value"
:disabled="!!isLoading"
/>
</div>
</div>
@@ -140,6 +141,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<SelectFolderDropdown
@folder-selected="selectedFolder = $event"
:activeFolderId="selectedFolder.value"
:disabled="!!isLoading"
/>
</div>
</div>
@@ -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 = "";
+65 -14
View File
@@ -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({
+3 -3
View File
@@ -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;
}