mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
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:
co-authored by
ktx-vaidehi
ktx-vaidehi
parent
70dcb67537
commit
6f7f4a5d2c
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user