mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
fix: restore list filters, sort and page size when returning from detail pages (#15269)
This commit is contained in:
@@ -923,6 +923,7 @@ export default defineComponent({
|
||||
path: "/alerts",
|
||||
query: {
|
||||
folder: alertForm.activeFolderId.value ?? "default",
|
||||
tab: alertForm.router.currentRoute.value.query.tab,
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -542,7 +542,7 @@ import { useQuery } from "@tanstack/vue-query";
|
||||
import { alertHistoryQuery, alertsListQuery } from "@/services/alerts.queries";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import { ref, onMounted, watch, nextTick } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { useStore } from "vuex";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import { formatDate } from "@/utils/date";
|
||||
@@ -569,6 +569,7 @@ import { COL } from "@/lib/core/Table/OTable.types";
|
||||
const { t } = useI18nTyped();
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
// Data
|
||||
// The query shape this page is currently reading (range + page + sort +
|
||||
@@ -957,9 +958,10 @@ const closeErrorDialog = () => {
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
const { folder, tab } = route.query;
|
||||
router.push({
|
||||
name: "alertList",
|
||||
query: { org_identifier: store.state.selectedOrganization.identifier },
|
||||
query: { org_identifier: store.state.selectedOrganization.identifier, folder, tab },
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -449,9 +449,10 @@ const fetchAlerts = async () => {
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
const { folder, tab } = route.query;
|
||||
router.push({
|
||||
name: "alertList",
|
||||
query: { org_identifier: store.state.selectedOrganization.identifier },
|
||||
query: { org_identifier: store.state.selectedOrganization.identifier, folder, tab },
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ import { alertKeys } from "@/services/alerts.querykeys";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import TemplateService from "@/services/alert_templates";
|
||||
import DestinationService from "@/services/alert_destination";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
const node = document.createElement("div");
|
||||
node.setAttribute("id", "app");
|
||||
@@ -285,11 +286,7 @@ beforeEach(() => {
|
||||
];
|
||||
(store.state as any).organizationData.allAlertsListByFolderId = {};
|
||||
// Reset alert list filters to prevent leaking between tests
|
||||
(store.state as any).alertListFilters = {
|
||||
searchQuery: "",
|
||||
filterQuery: "",
|
||||
searchAcrossFolders: false,
|
||||
};
|
||||
resetListViewState();
|
||||
|
||||
alertsDB = [
|
||||
makeAlert(1, {
|
||||
@@ -945,13 +942,7 @@ describe("AlertList - pagination restoration", () => {
|
||||
// second, spurious autoResetPageIndex after the legitimate one has already settled.
|
||||
(store.state as any).organizationData.allAlertsListByFolderId = {};
|
||||
alertsDB = Array.from({ length: 15 }, (_, i) => makeAlert(i + 1));
|
||||
(store.state as any).alertListFilters = {
|
||||
searchQuery: "",
|
||||
filterQuery: "",
|
||||
searchAcrossFolders: false,
|
||||
perPage: 5,
|
||||
currentPage: 3,
|
||||
};
|
||||
Object.assign(useListViewState("alerts", {}), { perPage: 5, currentPage: 3 });
|
||||
const wrapper: any = await mountAlertList();
|
||||
await flushPromises();
|
||||
await wrapper.vm.$nextTick();
|
||||
@@ -965,13 +956,7 @@ describe("AlertList - pagination restoration", () => {
|
||||
it("resets the persisted page on a folder switch so it is not restored onto the new folder", async () => {
|
||||
(store.state as any).organizationData.allAlertsListByFolderId = {};
|
||||
alertsDB = Array.from({ length: 15 }, (_, i) => makeAlert(i + 1));
|
||||
(store.state as any).alertListFilters = {
|
||||
searchQuery: "",
|
||||
filterQuery: "",
|
||||
searchAcrossFolders: false,
|
||||
perPage: 5,
|
||||
currentPage: 3,
|
||||
};
|
||||
Object.assign(useListViewState("alerts", {}), { perPage: 5, currentPage: 3 });
|
||||
const wrapper: any = await mountAlertList();
|
||||
await flushPromises();
|
||||
expect(wrapper.vm.currentPage).toBe(3);
|
||||
@@ -980,7 +965,59 @@ describe("AlertList - pagination restoration", () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.vm.currentPage).toBe(1);
|
||||
expect((store.state as any).alertListFilters.currentPage).toBe(1);
|
||||
expect(useListViewState("alerts", {}).currentPage).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AlertList - view state restoration", () => {
|
||||
it("restores the stat-tile chip, sort and page size saved before navigating away", async () => {
|
||||
Object.assign(useListViewState("alerts", {}), {
|
||||
stateFilter: "paused",
|
||||
sortBy: "name",
|
||||
sortOrder: "desc",
|
||||
perPage: 50,
|
||||
});
|
||||
const wrapper: any = await mountAlertList();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.vm.stateFilter).toBe("paused");
|
||||
expect(wrapper.vm.sortBy).toBe("name");
|
||||
expect(wrapper.vm.sortOrder).toBe("desc");
|
||||
expect(wrapper.vm.pageSize).toBe(50);
|
||||
});
|
||||
|
||||
it("saves chip, sort and page size changes for the next visit", async () => {
|
||||
const wrapper: any = await mountAlertList();
|
||||
await flushPromises();
|
||||
|
||||
wrapper.vm.stateFilter = "active";
|
||||
wrapper.vm.sortBy = "name";
|
||||
wrapper.vm.sortOrder = "asc";
|
||||
wrapper.vm.pageSize = 100;
|
||||
await flushPromises();
|
||||
|
||||
expect(useListViewState("alerts", {})).toMatchObject({
|
||||
stateFilter: "active",
|
||||
sortBy: "name",
|
||||
sortOrder: "asc",
|
||||
perPage: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("carries the active tab to the alert detail page so back lands on it", async () => {
|
||||
const wrapper: any = await mountAlertList();
|
||||
await flushPromises();
|
||||
const push = vi.spyOn(wrapper.vm.$router, "push");
|
||||
|
||||
wrapper.vm.activeTab = "scheduled";
|
||||
wrapper.vm.triggerExpand({ alert_id: "a1", folder_id: "default" });
|
||||
|
||||
expect(push).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "alertDetail",
|
||||
query: expect.objectContaining({ tab: "scheduled" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -113,6 +113,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:page-size-options="pageSizeOptions"
|
||||
:current-page="currentPage"
|
||||
@update:current-page="onPageChange"
|
||||
@update:page-size="pageSize = $event"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
width="100%"
|
||||
:show-global-filter="false"
|
||||
:default-columns="false"
|
||||
@@ -967,6 +972,7 @@ import FolderList from "../common/sidebar/FolderList.vue";
|
||||
import MoveAcrossFolders from "../common/sidebar/MoveAcrossFolders.vue";
|
||||
import { invalidateDependencyGraphCache } from "@/composables/alerts/useDependencyGraph";
|
||||
import useBreakpoint from "@/composables/useBreakpoint";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import SelectFolderDropDown from "../common/sidebar/SelectFolderDropDown.vue";
|
||||
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
|
||||
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
|
||||
@@ -1071,7 +1077,16 @@ export default defineComponent({
|
||||
// Flag to skip clearing search state on the first folder emission after mount.
|
||||
// When returning from add/edit screens, the FolderList emits the active folder
|
||||
// which would normally clear restored search filters. This flag prevents that.
|
||||
const savedAlertListFilters = store.state.alertListFilters || {};
|
||||
const savedAlertListFilters = useListViewState("alerts", {
|
||||
searchQuery: "",
|
||||
filterQuery: "",
|
||||
searchAcrossFolders: false,
|
||||
stateFilter: null as "active" | "paused" | "failed" | "recent" | null,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
perPage: 20,
|
||||
currentPage: 1,
|
||||
});
|
||||
const isRestoringState = ref(
|
||||
!!savedAlertListFilters.searchQuery ||
|
||||
!!savedAlertListFilters.filterQuery ||
|
||||
@@ -1208,6 +1223,7 @@ export default defineComponent({
|
||||
query: {
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
folder: row.folder_id || router.currentRoute.value.query.folder || "default",
|
||||
tab: activeTab.value,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1495,7 +1511,9 @@ export default defineComponent({
|
||||
// Clicking a summary tile filters the table to that state (toggle); the
|
||||
// "total" tile clears it. Counts on the tiles stay full (from filteredResults)
|
||||
// so you always see the folder totals; only the table rows are filtered.
|
||||
const stateFilter = ref<"active" | "paused" | "failed" | "recent" | null>(null);
|
||||
const stateFilter = ref<"active" | "paused" | "failed" | "recent" | null>(
|
||||
savedAlertListFilters.stateFilter,
|
||||
);
|
||||
const displayedAlerts = computed(() => {
|
||||
const rows = filteredResults.value || [];
|
||||
const f = stateFilter.value;
|
||||
@@ -2475,6 +2493,8 @@ export default defineComponent({
|
||||
const onPageChange = (page: number) => {
|
||||
currentPage.value = page;
|
||||
};
|
||||
const sortBy = ref<string>(savedAlertListFilters.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(savedAlertListFilters.sortOrder);
|
||||
// No timezone suffix in this table, unlike the other lists.
|
||||
const convertUnixToDateFormat = (unixMicroseconds: any) =>
|
||||
convertUnixToFormat(unixMicroseconds, "YYYY-MM-DD HH:mm:ss");
|
||||
@@ -3034,6 +3054,8 @@ export default defineComponent({
|
||||
name: "alertInsights",
|
||||
query: {
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
folder: activeFolderId.value,
|
||||
tab: activeTab.value,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -3043,6 +3065,8 @@ export default defineComponent({
|
||||
name: "alertHistory",
|
||||
query: {
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
folder: activeFolderId.value,
|
||||
tab: activeTab.value,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -3349,15 +3373,40 @@ export default defineComponent({
|
||||
}
|
||||
});
|
||||
// Persist filter state to Vuex so it survives navigation to add/edit screens
|
||||
watch([searchQuery, filterQuery, searchAcrossFolders, pageSize, currentPage], () => {
|
||||
store.commit("setAlertListFilters", {
|
||||
searchQuery: searchQuery.value || "",
|
||||
filterQuery: filterQuery.value || "",
|
||||
searchAcrossFolders: !!searchAcrossFolders.value,
|
||||
perPage: pageSize.value,
|
||||
currentPage: currentPage.value,
|
||||
});
|
||||
// Closing the editor remounts FolderList, whose first emit would otherwise clear the active search as a same-folder click.
|
||||
watch([showAddAlertDialog, showImportAlertDialog], ([adding, importing]) => {
|
||||
if (
|
||||
!adding &&
|
||||
!importing &&
|
||||
(searchQuery.value || filterQuery.value || searchAcrossFolders.value)
|
||||
) {
|
||||
isRestoringState.value = true;
|
||||
}
|
||||
});
|
||||
watch(
|
||||
[
|
||||
searchQuery,
|
||||
filterQuery,
|
||||
searchAcrossFolders,
|
||||
stateFilter,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
pageSize,
|
||||
currentPage,
|
||||
],
|
||||
() => {
|
||||
Object.assign(savedAlertListFilters, {
|
||||
searchQuery: searchQuery.value || "",
|
||||
filterQuery: filterQuery.value || "",
|
||||
searchAcrossFolders: !!searchAcrossFolders.value,
|
||||
stateFilter: stateFilter.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
perPage: pageSize.value,
|
||||
currentPage: currentPage.value,
|
||||
});
|
||||
},
|
||||
);
|
||||
watch(activeTab, async (newVal) => {
|
||||
//here we are resetting the filterQuery when the activeTab is changed
|
||||
//this is done because we need to trigger again the filterQuery watch
|
||||
@@ -3755,6 +3804,8 @@ export default defineComponent({
|
||||
refreshList,
|
||||
pageSize,
|
||||
pageSizeOptions,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
currentPage,
|
||||
onPageChange,
|
||||
oTableRef,
|
||||
|
||||
@@ -57,6 +57,7 @@ vi.mock("@/composables/usePrebuiltDestinations", () => ({
|
||||
}));
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import AlertsDestinationList from "./AlertsDestinationList.vue";
|
||||
import destinationService from "@/services/alert_destination";
|
||||
@@ -174,6 +175,7 @@ describe("AlertsDestinationList", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetListViewState();
|
||||
|
||||
// Reset router state
|
||||
(router as any).currentRoute.value.query = {};
|
||||
@@ -667,4 +669,36 @@ describe("AlertsDestinationList", () => {
|
||||
expect(pushedQuery.action).toBe("import");
|
||||
});
|
||||
});
|
||||
|
||||
describe("view state restore", () => {
|
||||
it("restores search, tab, page size and sort", () => {
|
||||
useListViewState("alertDestinations", {
|
||||
filterQuery: "slack",
|
||||
tab: "custom",
|
||||
pageSize: 50,
|
||||
sortBy: "name",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
wrapper = mountComponent();
|
||||
const vm = wrapper.vm as any;
|
||||
expect(vm.filterQuery).toBe("slack");
|
||||
expect(vm.activeTab).toBe("custom");
|
||||
expect(vm.pageSize).toBe(50);
|
||||
expect(vm.sortBy).toBe("name");
|
||||
expect(vm.sortOrder).toBe("desc");
|
||||
});
|
||||
|
||||
it("saves changes to the view state", async () => {
|
||||
wrapper = mountComponent();
|
||||
const vm = wrapper.vm as any;
|
||||
vm.filterQuery = "abc";
|
||||
vm.activeTab = "prebuilt";
|
||||
vm.pageSize = 100;
|
||||
await vm.$nextTick();
|
||||
const saved: any = useListViewState("alertDestinations", {});
|
||||
expect(saved.filterQuery).toBe("abc");
|
||||
expect(saved.tab).toBe("prebuilt");
|
||||
expect(saved.pageSize).toBe(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,10 +62,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:selected-ids="selectedDestinationIds"
|
||||
selection="multiple"
|
||||
pagination="client"
|
||||
:page-size="20"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[5, 10, 20, 50, 100]"
|
||||
:current-page="currentPage"
|
||||
@update:page-size="pageSize = $event"
|
||||
sorting="client"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
:default-columns="false"
|
||||
:enable-column-resize="true"
|
||||
:persist-columns="true"
|
||||
@@ -348,6 +353,7 @@ import { templatesQuery } from "@/services/alert_templates.queries";
|
||||
import { useStore } from "vuex";
|
||||
import ConfirmDialog from "../ConfirmDialog.vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import type { DestinationPayload } from "@/ts/interfaces";
|
||||
import { usePrebuiltDestinations } from "@/composables/usePrebuiltDestinations";
|
||||
import type { Template } from "@/ts/interfaces/index";
|
||||
@@ -505,7 +511,17 @@ export default defineComponent({
|
||||
const showImportDestination = ref(false);
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const filterQuery = ref("");
|
||||
const viewState = useListViewState("alertDestinations", {
|
||||
filterQuery: "",
|
||||
tab: "all" as "all" | "prebuilt" | "custom",
|
||||
pageSize: 20,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const filterQuery = ref(viewState.filterQuery);
|
||||
const pageSize = ref(viewState.pageSize);
|
||||
const sortBy = ref(viewState.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(viewState.sortOrder);
|
||||
const deletingDestinations = ref(new Set<string>());
|
||||
const oTableRef: any = ref(null);
|
||||
|
||||
@@ -858,7 +874,16 @@ export default defineComponent({
|
||||
// (Slack/Opsgenie/PagerDuty/ServiceNow/etc., identified via the
|
||||
// `prebuilt_type` metadata or URL/template pattern); "custom" is the
|
||||
// negation, capturing user-defined HTTP/Email destinations.
|
||||
const activeTab = ref<"all" | "prebuilt" | "custom">("all");
|
||||
const activeTab = ref<"all" | "prebuilt" | "custom">(viewState.tab);
|
||||
watch([filterQuery, activeTab, pageSize, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
filterQuery: filterQuery.value,
|
||||
tab: activeTab.value,
|
||||
pageSize: pageSize.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
|
||||
const visibleRows = computed(() => {
|
||||
const base = destinations.value || [];
|
||||
@@ -1006,6 +1031,9 @@ export default defineComponent({
|
||||
forbidden,
|
||||
conformDeleteDestination,
|
||||
filterQuery,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
filterData,
|
||||
editingDestination,
|
||||
templates,
|
||||
|
||||
@@ -51,6 +51,7 @@ vi.mock("@/composables/useConfirmDialog", () => ({
|
||||
}));
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
import { mount, flushPromises, VueWrapper } from "@vue/test-utils";
|
||||
import { nextTick } from "vue";
|
||||
import IncidentList from "./IncidentList.vue";
|
||||
@@ -140,6 +141,7 @@ describe("IncidentList.vue", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetListViewState();
|
||||
|
||||
store.state.selectedOrganization = {
|
||||
label: "default Organization",
|
||||
@@ -672,4 +674,30 @@ describe("IncidentList.vue", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("View state restore", () => {
|
||||
it("restores severity, page size and sort", () => {
|
||||
useListViewState("incidents", {
|
||||
severityFilter: "P2",
|
||||
pageSize: 50,
|
||||
sortBy: "title",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
wrapper = createWrapper();
|
||||
expect(wrapper.vm.severityFilter).toBe("P2");
|
||||
expect(wrapper.vm.pageSize).toBe(50);
|
||||
expect(wrapper.vm.sortBy).toBe("title");
|
||||
expect(wrapper.vm.sortOrder).toBe("desc");
|
||||
});
|
||||
|
||||
it("saves changes to the view state", async () => {
|
||||
wrapper = createWrapper();
|
||||
wrapper.vm.severityFilter = "P1";
|
||||
wrapper.vm.pageSize = 100;
|
||||
await flushPromises();
|
||||
const saved: any = useListViewState("incidents", {});
|
||||
expect(saved.severityFilter).toBe("P1");
|
||||
expect(saved.pageSize).toBe(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:page-size-options="[20, 50, 100, 250, 500]"
|
||||
:current-page="currentPage"
|
||||
@update:current-page="onPageChange"
|
||||
@update:page-size="pageSize = $event"
|
||||
sorting="client"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
filter-mode="client"
|
||||
:default-columns="false"
|
||||
show-index
|
||||
@@ -293,6 +298,7 @@ import { useStore } from "vuex";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { formatToReadable } from "@/utils/date";
|
||||
import incidentsService, { Incident } from "@/services/incidents";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
@@ -393,7 +399,15 @@ export default defineComponent({
|
||||
: "active",
|
||||
);
|
||||
const isRestoringState = ref(false);
|
||||
const pageSize = ref(20);
|
||||
const viewState = useListViewState("incidents", {
|
||||
severityFilter: "all",
|
||||
pageSize: 20,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const pageSize = ref(viewState.pageSize);
|
||||
const sortBy = ref(viewState.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(viewState.sortOrder);
|
||||
// Restored from the Vuex incidents store (same mechanism as searchQuery/statusFilter) so returning from a detail view lands back on the same page.
|
||||
const currentPage = ref(1);
|
||||
const onPageChange = (page: number) => {
|
||||
@@ -401,7 +415,15 @@ export default defineComponent({
|
||||
savePageState();
|
||||
};
|
||||
// Severity facet — independent of status ("all" | "P1".."P4").
|
||||
const severityFilter = ref("all");
|
||||
const severityFilter = ref(viewState.severityFilter);
|
||||
watch([severityFilter, pageSize, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
severityFilter: severityFilter.value,
|
||||
pageSize: pageSize.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
|
||||
const columns: OTableColumnDef[] = [
|
||||
{
|
||||
@@ -660,7 +682,7 @@ export default defineComponent({
|
||||
store.dispatch("incidents/setIncidents", {
|
||||
searchQuery: searchQuery.value,
|
||||
statusFilter: statusFilter.value,
|
||||
pagination: { page: currentPage.value, rowsPerPage: 20 },
|
||||
pagination: { page: currentPage.value, rowsPerPage: pageSize.value },
|
||||
organizationIdentifier: store.state.selectedOrganization.identifier,
|
||||
});
|
||||
|
||||
@@ -677,7 +699,7 @@ export default defineComponent({
|
||||
store.dispatch("incidents/setIncidents", {
|
||||
searchQuery: searchQuery.value,
|
||||
statusFilter: statusFilter.value,
|
||||
pagination: { page: currentPage.value, rowsPerPage: 20 },
|
||||
pagination: { page: currentPage.value, rowsPerPage: pageSize.value },
|
||||
organizationIdentifier: store.state.selectedOrganization.identifier,
|
||||
});
|
||||
};
|
||||
@@ -886,7 +908,7 @@ export default defineComponent({
|
||||
store.dispatch("incidents/setIncidents", {
|
||||
searchQuery: searchQuery.value,
|
||||
statusFilter: statusFilter.value,
|
||||
pagination: { page: currentPage.value, rowsPerPage: 20 },
|
||||
pagination: { page: currentPage.value, rowsPerPage: pageSize.value },
|
||||
organizationIdentifier: store.state.selectedOrganization.identifier,
|
||||
});
|
||||
}
|
||||
@@ -935,6 +957,8 @@ export default defineComponent({
|
||||
clearFilters,
|
||||
columns,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
currentPage,
|
||||
onPageChange,
|
||||
loadIncidents,
|
||||
|
||||
@@ -49,6 +49,7 @@ vi.mock("@/lib/feedback/Toast/useToast", () => ({
|
||||
toast: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { nextTick } from "vue";
|
||||
@@ -137,6 +138,8 @@ function mountComponent() {
|
||||
|
||||
// ── tests ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => resetListViewState());
|
||||
|
||||
describe("PipelinesDestinationList", () => {
|
||||
let wrapper: ReturnType<typeof mountComponent> | null = null;
|
||||
|
||||
@@ -599,3 +602,40 @@ describe("PipelinesDestinationList", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("list view state", () => {
|
||||
it("restores search, page size, page and sort from the saved view", async () => {
|
||||
Object.assign(useListViewState("pipeline-destinations", {}), {
|
||||
filterQuery: "abc",
|
||||
pageSize: 50,
|
||||
currentPage: 2,
|
||||
sortBy: "name",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
const w: any = mountComponent();
|
||||
await flushPromises();
|
||||
expect(w.vm.filterQuery).toBe("abc");
|
||||
expect(w.vm.pageSize).toBe(50);
|
||||
expect(w.vm.currentPage).toBe(2);
|
||||
expect(w.vm.sortBy).toBe("name");
|
||||
expect(w.vm.sortOrder).toBe("desc");
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("saves list state so it survives unmount", async () => {
|
||||
const w: any = mountComponent();
|
||||
await flushPromises();
|
||||
w.vm.filterQuery = "xyz";
|
||||
w.vm.pageSize = 100;
|
||||
w.vm.sortBy = "name";
|
||||
w.vm.sortOrder = "desc";
|
||||
await flushPromises();
|
||||
w.unmount();
|
||||
expect(useListViewState("pipeline-destinations", {})).toMatchObject({
|
||||
filterQuery: "xyz",
|
||||
pageSize: 100,
|
||||
sortBy: "name",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
<div class="bg-card-glass-bg min-h-0 flex-1 overflow-hidden">
|
||||
<OTable
|
||||
ref="oTableRef"
|
||||
:frame="false"
|
||||
data-test="alert-destinations-list-table"
|
||||
:data="visibleRows"
|
||||
@@ -44,8 +45,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:selected-ids="selectedDestinationIds"
|
||||
selection="multiple"
|
||||
pagination="client"
|
||||
:page-size="20"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[5, 10, 20, 50, 100]"
|
||||
:current-page="currentPage"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:page-size="pageSize = $event"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
sorting="client"
|
||||
:default-columns="false"
|
||||
:enable-column-resize="true"
|
||||
@@ -219,6 +227,7 @@ import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import ORefreshButton from "@/lib/core/RefreshButton/ORefreshButton.vue";
|
||||
import { useShortcuts } from "@/lib/vue-shortcut-manager";
|
||||
import { isInputFocused } from "@/utils/keyboardShortcuts";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OTag from "@/lib/core/Badge/OTag.vue";
|
||||
import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
|
||||
@@ -336,7 +345,37 @@ export default defineComponent({
|
||||
const selectedDestinations = ref<any[]>([]);
|
||||
const showDestinationEditor = ref(false);
|
||||
const router = useRouter();
|
||||
const filterQuery = ref("");
|
||||
const viewState = useListViewState("pipeline-destinations", {
|
||||
filterQuery: "",
|
||||
pageSize: 20,
|
||||
currentPage: 1,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const filterQuery = ref(viewState.filterQuery);
|
||||
const pageSize = ref(viewState.pageSize);
|
||||
const currentPage = ref(viewState.currentPage);
|
||||
const sortBy = ref(viewState.sortBy);
|
||||
const sortOrder = ref(viewState.sortOrder);
|
||||
const oTableRef = ref<any>(null);
|
||||
|
||||
watch([filterQuery, pageSize, currentPage, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
filterQuery: filterQuery.value,
|
||||
pageSize: pageSize.value,
|
||||
currentPage: currentPage.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
// setTimeout(0) runs after TanStack's deferred auto-reset-on-data-change, so the restored page wins.
|
||||
watch(
|
||||
destinations,
|
||||
() => {
|
||||
setTimeout(() => oTableRef.value?.restorePage?.(currentPage.value), 0);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
const selectedDestinationIds = computed(() =>
|
||||
selectedDestinations.value.map((d: any) => d.name),
|
||||
@@ -722,6 +761,11 @@ export default defineComponent({
|
||||
refreshDestinations,
|
||||
forbidden,
|
||||
filterQuery,
|
||||
pageSize,
|
||||
currentPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
oTableRef,
|
||||
filterData,
|
||||
editingDestination,
|
||||
templates,
|
||||
|
||||
@@ -22,6 +22,7 @@ import alertsFixture from "@/test/unit/mockData/alerts";
|
||||
import router from "@/test/unit/helpers/router";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import i18n from "@/locales";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
const node = document.createElement("div");
|
||||
node.setAttribute("id", "app");
|
||||
@@ -313,3 +314,46 @@ describe("TemplateList pagination persistence", () => {
|
||||
expect(pushedQuery.name).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("alertTemplates view state restore", () => {
|
||||
let viewWrapper: any;
|
||||
const mountComponent = () =>
|
||||
mount(TemplateList, {
|
||||
attachTo: "#app",
|
||||
global: { provide: { store }, plugins: [i18n, router] },
|
||||
});
|
||||
|
||||
beforeEach(() => resetListViewState());
|
||||
|
||||
afterEach(() => viewWrapper?.unmount());
|
||||
|
||||
it("restores search, tab, page size and sort", () => {
|
||||
useListViewState("alertTemplates", {
|
||||
filterQuery: "slack",
|
||||
tab: "custom",
|
||||
pageSize: 50,
|
||||
sortBy: "name",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
viewWrapper = mountComponent();
|
||||
const vm = viewWrapper.vm as any;
|
||||
expect(vm.filterQuery).toBe("slack");
|
||||
expect(vm.activeTab).toBe("custom");
|
||||
expect(vm.pageSize).toBe(50);
|
||||
expect(vm.sortBy).toBe("name");
|
||||
expect(vm.sortOrder).toBe("desc");
|
||||
});
|
||||
|
||||
it("saves changes to the view state", async () => {
|
||||
viewWrapper = mountComponent();
|
||||
const vm = viewWrapper.vm as any;
|
||||
vm.filterQuery = "abc";
|
||||
vm.activeTab = "prebuilt";
|
||||
vm.pageSize = 100;
|
||||
await flushPromises();
|
||||
const saved: any = useListViewState("alertTemplates", {});
|
||||
expect(saved.filterQuery).toBe("abc");
|
||||
expect(saved.tab).toBe("prebuilt");
|
||||
expect(saved.pageSize).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,10 +62,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
selection="multiple"
|
||||
:is-row-selectable="isTemplateRowSelectable"
|
||||
pagination="client"
|
||||
:page-size="20"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[5, 10, 20, 50, 100]"
|
||||
:current-page="currentPage"
|
||||
@update:page-size="pageSize = $event"
|
||||
sorting="client"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
filter-mode="client"
|
||||
:default-columns="false"
|
||||
show-index
|
||||
@@ -344,6 +349,7 @@ import ConfirmDialog from "../ConfirmDialog.vue";
|
||||
import type { TemplateData, Template } from "@/ts/interfaces";
|
||||
import { useStore } from "vuex";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import ORefreshButton from "@/lib/core/RefreshButton/ORefreshButton.vue";
|
||||
@@ -426,10 +432,29 @@ const selectedTemplates: Ref<any[]> = ref([]);
|
||||
const confirmBulkDelete = ref(false);
|
||||
const bulkDeleteLoading = ref(false);
|
||||
const deletingTemplates = ref(new Set<string>());
|
||||
const filterQuery = ref("");
|
||||
const viewState = useListViewState("alertTemplates", {
|
||||
filterQuery: "",
|
||||
tab: "all" as "all" | "prebuilt" | "custom",
|
||||
pageSize: 20,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const filterQuery = ref(viewState.filterQuery);
|
||||
const pageSize = ref(viewState.pageSize);
|
||||
const sortBy = ref(viewState.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(viewState.sortOrder);
|
||||
// Top-right tab filter — mirrors the alerts list pattern. "prebuilt" shows
|
||||
// system templates (name starts with `prebuilt_`), "custom" shows the rest.
|
||||
const activeTab = ref<"all" | "prebuilt" | "custom">("all");
|
||||
const activeTab = ref<"all" | "prebuilt" | "custom">(viewState.tab);
|
||||
watch([filterQuery, activeTab, pageSize, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
filterQuery: filterQuery.value,
|
||||
tab: activeTab.value,
|
||||
pageSize: pageSize.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
const oTableRef: any = ref(null);
|
||||
|
||||
// URL-synced so returning from add/edit/import (Back/Update/Cancel) lands on the same page instead of resetting to page 1.
|
||||
|
||||
@@ -5,6 +5,10 @@ import streamService from "@/services/stream";
|
||||
import analytics from "@/services/product_analytics";
|
||||
import i18n from "@/locales";
|
||||
|
||||
vi.mock("@/stores", () => ({
|
||||
default: { state: { selectedOrganization: { identifier: "test-org" } } },
|
||||
}));
|
||||
|
||||
// Mock zincutils
|
||||
vi.mock("@/utils/zincutils", () => ({
|
||||
formatSizeFromMB: vi.fn((size) => `${size} MB`),
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { createStore } from "vuex";
|
||||
import i18n from "@/locales";
|
||||
@@ -68,6 +69,10 @@ vi.mock("@/services/stream", async (importOriginal) => {
|
||||
|
||||
vi.mock("@/services/product_analytics", () => ({ default: { track: vi.fn() } }));
|
||||
vi.mock("@/services/reodotdev_analytics", () => ({ useReo: () => ({ track: vi.fn() }) }));
|
||||
vi.mock("@/stores", () => ({
|
||||
default: { state: { selectedOrganization: { identifier: "test-org" } } },
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/zincutils", () => ({
|
||||
formatSizeFromMB: vi.fn((v) => v + " MB"),
|
||||
getImageURL: vi.fn((u) => u),
|
||||
@@ -166,6 +171,7 @@ const mountComponent = () =>
|
||||
describe("EnrichmentTableList", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetListViewState();
|
||||
mockGetStreams.mockResolvedValue({ list: [] });
|
||||
mockGetAllEnrichmentTableStatuses.mockResolvedValue({ data: {} });
|
||||
store = makeStore();
|
||||
@@ -703,4 +709,38 @@ describe("EnrichmentTableList", () => {
|
||||
expect(mockGetStreams).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("view state restore", () => {
|
||||
it("restores search, tab, page, size and sort", async () => {
|
||||
useListViewState("enrichmentTables", {
|
||||
filterQuery: "geo",
|
||||
filter: "uploaded",
|
||||
pageSize: 50,
|
||||
currentPage: 2,
|
||||
sortBy: "name",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
const wrapper = mountComponent();
|
||||
const vm = wrapper.vm as any;
|
||||
expect(vm.currentPage).toBe(2);
|
||||
await flushPromises();
|
||||
expect(vm.filterQuery).toBe("geo");
|
||||
expect(vm.selectedFilter).toBe("uploaded");
|
||||
expect(vm.selectedPerPage).toBe(50);
|
||||
expect(vm.sortBy).toBe("name");
|
||||
expect(vm.sortOrder).toBe("desc");
|
||||
});
|
||||
|
||||
it("saves changes to the view state", async () => {
|
||||
const wrapper = mountComponent();
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as any;
|
||||
vm.filterQuery = "abc";
|
||||
vm.selectedFilter = "file_url";
|
||||
await flushPromises();
|
||||
const saved: any = useListViewState("enrichmentTables", {});
|
||||
expect(saved.filterQuery).toBe("abc");
|
||||
expect(saved.filter).toBe("file_url");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +54,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:page-size-options="perPageOptionsList"
|
||||
:current-page="currentPage"
|
||||
@update:current-page="onPageChange"
|
||||
@update:page-size="selectedPerPage = $event"
|
||||
sorting="client"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
filter-mode="client"
|
||||
show-index
|
||||
:show-global-filter="false"
|
||||
@@ -475,9 +480,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import { streamKeys } from "@/services/stream.querykeys";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import { enrichmentTableStatusesQuery } from "@/services/jstransform.queries";
|
||||
import { computed, defineComponent, onBeforeMount, onMounted, ref } from "vue";
|
||||
import { computed, defineComponent, onBeforeMount, onMounted, ref, watch } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
|
||||
import AddEnrichmentTable from "./AddEnrichmentTable.vue";
|
||||
@@ -551,14 +557,22 @@ export default defineComponent({
|
||||
const showEnrichmentSchema = ref<boolean>(false);
|
||||
const showUrlJobsDialogState = ref<boolean>(false);
|
||||
const selectedTableForUrlJobs = ref<any>(null);
|
||||
const filterQuery = ref("");
|
||||
const viewState = useListViewState("enrichmentTables", {
|
||||
filterQuery: "",
|
||||
filter: "all",
|
||||
pageSize: 20,
|
||||
currentPage: 1,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const filterQuery = ref(viewState.filterQuery);
|
||||
const loading = ref(false);
|
||||
// Request in flight with rows still on screen — the refresh button's
|
||||
// spinner. `loading` is the skeleton, for a cold read only.
|
||||
const fetching = ref(false);
|
||||
const lastUpdatedAt = ref<number | null>(null);
|
||||
const forbidden = ref(false);
|
||||
const currentPage = ref(1);
|
||||
const currentPage = ref(viewState.currentPage);
|
||||
const onPageChange = (page: number) => {
|
||||
currentPage.value = page;
|
||||
};
|
||||
@@ -824,9 +838,22 @@ export default defineComponent({
|
||||
};
|
||||
|
||||
const maxRecordToReturn = ref<number>(100);
|
||||
const selectedPerPage = ref<number>(20);
|
||||
const selectedPerPage = ref<number>(viewState.pageSize);
|
||||
const selectedEnrichmentTable = ref<any>(null);
|
||||
const selectedFilter = ref<string>("all");
|
||||
const selectedFilter = ref<string>(viewState.filter);
|
||||
const sortBy = ref(viewState.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(viewState.sortOrder);
|
||||
watch([filterQuery, selectedFilter], () => (currentPage.value = 1));
|
||||
watch([filterQuery, selectedFilter, selectedPerPage, currentPage, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
filterQuery: filterQuery.value,
|
||||
filter: selectedFilter.value,
|
||||
pageSize: selectedPerPage.value,
|
||||
currentPage: currentPage.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
|
||||
const changePagination = (val: { label: string; value: any }) => {
|
||||
selectedPerPage.value = val.value;
|
||||
@@ -1198,6 +1225,8 @@ export default defineComponent({
|
||||
bulkDeleteEnrichmentTables,
|
||||
bulkDeleteLoading,
|
||||
selectedFilter,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
showUrlJobsDialog,
|
||||
showUrlJobsDialogState,
|
||||
selectedTableForUrlJobs,
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import { functionKeys } from "@/services/jstransform.querykeys";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { nextTick } from "vue";
|
||||
import FunctionList from "./FunctionList.vue";
|
||||
@@ -72,6 +73,10 @@ vi.mock("@/services/product_analytics", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/stores", () => ({
|
||||
default: { state: { selectedOrganization: { identifier: "test-org" } } },
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/zincutils", () => ({
|
||||
getImageURL: vi.fn((url: string) => url),
|
||||
verifyOrganizationStatus: vi.fn(),
|
||||
@@ -130,6 +135,7 @@ describe("FunctionList", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetListViewState();
|
||||
|
||||
mockJsTransformList.mockResolvedValue(mockFunctionData);
|
||||
mockJsTransformDelete.mockResolvedValue({
|
||||
@@ -1216,4 +1222,39 @@ describe("FunctionList", () => {
|
||||
expect(mockToastFn).not.toHaveBeenCalledWith(expect.objectContaining({ variant: "success" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("View state restore", () => {
|
||||
const mountList = () =>
|
||||
mount(FunctionList, {
|
||||
global: { plugins: [i18n, store, router], stubs: globalStubs },
|
||||
});
|
||||
|
||||
it("restores search, page, size and sort", () => {
|
||||
useListViewState("functions", {
|
||||
filterQuery: "func",
|
||||
pageSize: 50,
|
||||
currentPage: 2,
|
||||
sortBy: "name",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
const wrapper = mountList();
|
||||
expect(wrapper.vm.filterQuery).toBe("func");
|
||||
expect(wrapper.vm.pageSize).toBe(50);
|
||||
expect(wrapper.vm.currentPage).toBe(2);
|
||||
expect(wrapper.vm.sortBy).toBe("name");
|
||||
expect(wrapper.vm.sortOrder).toBe("desc");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("saves changes to the view state", async () => {
|
||||
const wrapper = mountList();
|
||||
wrapper.vm.filterQuery = "js";
|
||||
wrapper.vm.sortOrder = "desc";
|
||||
await nextTick();
|
||||
const saved: any = useListViewState("functions", {});
|
||||
expect(saved.filterQuery).toBe("js");
|
||||
expect(saved.sortOrder).toBe("desc");
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
@update:current-page="onPageChange"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
@update:page-size="pageSize = $event"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
selection="multiple"
|
||||
v-model:selected-ids="selectedFunctionIds"
|
||||
show-index
|
||||
@@ -297,6 +302,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import { defineAsyncComponent, defineComponent, ref, computed, watch } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import { useI18nTyped, raw } from "@/types/i18n";
|
||||
|
||||
import OTable from "@/lib/core/Table/OTable.vue";
|
||||
@@ -367,7 +373,14 @@ export default defineComponent({
|
||||
const { searchObj } = searchState();
|
||||
const pipelineList = ref([]);
|
||||
const selectedPipeline = ref("");
|
||||
const filterQuery = ref("");
|
||||
const viewState = useListViewState("functions", {
|
||||
filterQuery: "",
|
||||
pageSize: 20,
|
||||
currentPage: 1,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const filterQuery = ref(viewState.filterQuery);
|
||||
const { track } = useReo();
|
||||
const columns: OTableColumnDef[] = [
|
||||
{
|
||||
@@ -408,8 +421,8 @@ export default defineComponent({
|
||||
|
||||
const orgId = useOrgId();
|
||||
|
||||
// Plain ref, not URL/store-backed: only the OTable v-if branch unmounts on add/edit, not FunctionList itself.
|
||||
const currentPage = ref(1);
|
||||
// View-state seeded because the import route destroys the whole list, not just the OTable branch.
|
||||
const currentPage = ref(viewState.currentPage);
|
||||
const onPageChange = (page: number) => {
|
||||
currentPage.value = page;
|
||||
};
|
||||
@@ -506,7 +519,19 @@ export default defineComponent({
|
||||
// Bound to the refresh button: always reaches the server.
|
||||
const refreshJSTransforms = () => functions.refetch();
|
||||
|
||||
const pageSize = ref(20);
|
||||
const pageSize = ref(viewState.pageSize);
|
||||
const sortBy = ref(viewState.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(viewState.sortOrder);
|
||||
watch(filterQuery, () => (currentPage.value = 1));
|
||||
watch([filterQuery, pageSize, currentPage, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
filterQuery: filterQuery.value,
|
||||
pageSize: pageSize.value,
|
||||
currentPage: currentPage.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
const pageSizeOptions = [20, 50, 100, 250, 500];
|
||||
|
||||
const selectedFunctionIds = ref<string[]>([]);
|
||||
@@ -939,6 +964,8 @@ export default defineComponent({
|
||||
refreshList,
|
||||
pageSize,
|
||||
pageSizeOptions,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
addTransform,
|
||||
deleteFn,
|
||||
isUpdated,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
@@ -114,6 +115,8 @@ const createMockAxiosResponse = (data: any) => ({
|
||||
config: {} as any,
|
||||
});
|
||||
|
||||
beforeEach(() => resetListViewState());
|
||||
|
||||
describe("AppGroups Component", () => {
|
||||
let wrapper: any;
|
||||
|
||||
@@ -687,3 +690,40 @@ describe("AppGroups - quick-assign via ?member=", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("list view state", () => {
|
||||
it("restores search, page size, page and sort from the saved view", async () => {
|
||||
Object.assign(useListViewState("iam-groups", {}), {
|
||||
filterQuery: "abc",
|
||||
pageSize: 50,
|
||||
currentPage: 2,
|
||||
sortBy: "group_name",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
const w: any = mount(AppGroups, { global: { provide: { store }, plugins: [i18n, router] } });
|
||||
await flushPromises();
|
||||
expect(w.vm.filterQuery).toBe("abc");
|
||||
expect(w.vm.pageSize).toBe(50);
|
||||
expect(w.vm.currentPage).toBe(2);
|
||||
expect(w.vm.sortBy).toBe("group_name");
|
||||
expect(w.vm.sortOrder).toBe("desc");
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("saves list state so it survives unmount", async () => {
|
||||
const w: any = mount(AppGroups, { global: { provide: { store }, plugins: [i18n, router] } });
|
||||
await flushPromises();
|
||||
w.vm.filterQuery = "xyz";
|
||||
w.vm.pageSize = 100;
|
||||
w.vm.sortBy = "group_name";
|
||||
w.vm.sortOrder = "desc";
|
||||
await flushPromises();
|
||||
w.unmount();
|
||||
expect(useListViewState("iam-groups", {})).toMatchObject({
|
||||
filterQuery: "xyz",
|
||||
pageSize: 100,
|
||||
sortBy: "group_name",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<div class="min-h-0 w-full flex-1 overflow-hidden">
|
||||
<div class="bg-card-glass-bg h-full">
|
||||
<OTable
|
||||
ref="oTableRef"
|
||||
:frame="false"
|
||||
data-test="iam-groups-table-section"
|
||||
:data="rows"
|
||||
@@ -62,14 +63,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
v-model:global-filter="filterQuery"
|
||||
:show-global-filter="false"
|
||||
pagination="client"
|
||||
:page-size="20"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[20, 50, 100, 250, 500]"
|
||||
:current-page="currentPage"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
sorting="client"
|
||||
selection="multiple"
|
||||
filter-mode="client"
|
||||
:default-columns="false"
|
||||
show-index
|
||||
@update:selected-ids="handleSelectedIdsUpdate"
|
||||
@update:page-size="pageSize = $event"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
>
|
||||
<template #toolbar>
|
||||
<div class="flex w-full min-w-0 items-center gap-2 max-md:contents">
|
||||
@@ -246,6 +254,7 @@ import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import { useShortcuts } from "@/lib/vue-shortcut-manager";
|
||||
import { focusSearchInput, isInputFocused } from "@/utils/keyboardShortcuts";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
const showAddGroup = ref(false);
|
||||
|
||||
@@ -331,7 +340,29 @@ const assignMemberToGroup = async (group: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
const filterQuery = ref("");
|
||||
const viewState = useListViewState("iam-groups", {
|
||||
filterQuery: "",
|
||||
pageSize: 20,
|
||||
currentPage: 1,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const filterQuery = ref(viewState.filterQuery);
|
||||
const pageSize = ref(viewState.pageSize);
|
||||
const currentPage = ref(viewState.currentPage);
|
||||
const sortBy = ref(viewState.sortBy);
|
||||
const sortOrder = ref(viewState.sortOrder);
|
||||
const oTableRef = ref<any>(null);
|
||||
|
||||
watch([filterQuery, pageSize, currentPage, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
filterQuery: filterQuery.value,
|
||||
pageSize: pageSize.value,
|
||||
currentPage: currentPage.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
|
||||
const deleteConformDialog = ref({
|
||||
show: false,
|
||||
@@ -442,6 +473,15 @@ const groupsList = useQuery(() =>
|
||||
// Bound to the query rather than hand-managed: `isPending` is the cold read,
|
||||
// `isFetching` is any request in flight.
|
||||
const loading = groupsList.isPending;
|
||||
// setTimeout(0) runs after TanStack's deferred auto-reset-on-data-change, so the restored page wins.
|
||||
watch(
|
||||
loading,
|
||||
(isLoading) => {
|
||||
if (isLoading) return;
|
||||
setTimeout(() => oTableRef.value?.restorePage?.(currentPage.value), 0);
|
||||
},
|
||||
{ once: true, immediate: true },
|
||||
);
|
||||
// A request in flight while rows stay on screen — the refresh button's spinner.
|
||||
// `loading` is the skeleton, which only a cold read wants.
|
||||
const fetching = groupsList.isFetching;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import i18n from "@/locales";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
@@ -74,6 +75,8 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
// 1. Rendering
|
||||
beforeEach(() => resetListViewState());
|
||||
|
||||
describe("AppRoles - rendering", () => {
|
||||
it("renders the section title", async () => {
|
||||
const wrapper = await mountAppRoles();
|
||||
@@ -639,3 +642,40 @@ describe("AppRoles - quick-assign via ?member=", () => {
|
||||
expect(router.currentRoute.value.query.member).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("list view state", () => {
|
||||
it("restores search, page size, page and sort from the saved view", async () => {
|
||||
Object.assign(useListViewState("iam-roles", {}), {
|
||||
filterQuery: "abc",
|
||||
pageSize: 50,
|
||||
currentPage: 2,
|
||||
sortBy: "role_name",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
const w: any = await mountAppRoles();
|
||||
await flushPromises();
|
||||
expect(w.vm.filterQuery).toBe("abc");
|
||||
expect(w.vm.pageSize).toBe(50);
|
||||
expect(w.vm.currentPage).toBe(2);
|
||||
expect(w.vm.sortBy).toBe("role_name");
|
||||
expect(w.vm.sortOrder).toBe("desc");
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("saves list state so it survives unmount", async () => {
|
||||
const w: any = await mountAppRoles();
|
||||
await flushPromises();
|
||||
w.vm.filterQuery = "xyz";
|
||||
w.vm.pageSize = 100;
|
||||
w.vm.sortBy = "role_name";
|
||||
w.vm.sortOrder = "desc";
|
||||
await flushPromises();
|
||||
w.unmount();
|
||||
expect(useListViewState("iam-roles", {})).toMatchObject({
|
||||
filterQuery: "xyz",
|
||||
pageSize: 100,
|
||||
sortBy: "role_name",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,12 +51,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<div class="min-h-0 w-full flex-1 overflow-hidden">
|
||||
<div class="bg-card-glass-bg h-full">
|
||||
<RoleTable
|
||||
ref="roleTableRef"
|
||||
data-test="iam-roles-table-section"
|
||||
:data="rows"
|
||||
:loading="loading"
|
||||
:forbidden="forbidden"
|
||||
:action-loading="bulkDeleteLoading"
|
||||
v-model:global-filter="filterQuery"
|
||||
v-model:page-size="pageSize"
|
||||
v-model:current-page="currentPage"
|
||||
v-model:sort-by="sortBy"
|
||||
v-model:sort-order="sortOrder"
|
||||
:selected-ids="selectedRoleNames"
|
||||
:assign-target="assignTarget"
|
||||
:assigning-role-name="assigningRoleName"
|
||||
@@ -129,12 +134,35 @@ import { useReo } from "@/services/reodotdev_analytics";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import { useShortcuts } from "@/lib/vue-shortcut-manager";
|
||||
import { focusSearchInput, isInputFocused } from "@/utils/keyboardShortcuts";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
|
||||
const { track } = useReo();
|
||||
|
||||
const filterQuery = ref("");
|
||||
const viewState = useListViewState("iam-roles", {
|
||||
filterQuery: "",
|
||||
pageSize: 20,
|
||||
currentPage: 1,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const filterQuery = ref(viewState.filterQuery);
|
||||
const pageSize = ref(viewState.pageSize);
|
||||
const currentPage = ref(viewState.currentPage);
|
||||
const sortBy = ref(viewState.sortBy);
|
||||
const sortOrder = ref(viewState.sortOrder);
|
||||
const roleTableRef = ref<any>(null);
|
||||
|
||||
watch([filterQuery, pageSize, currentPage, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
filterQuery: filterQuery.value,
|
||||
pageSize: pageSize.value,
|
||||
currentPage: currentPage.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
|
||||
const showAddGroup = ref(false);
|
||||
|
||||
@@ -278,6 +306,15 @@ const rolesList = useQuery(() =>
|
||||
// Bound to the query rather than hand-managed: `isPending` is the cold read,
|
||||
// `isFetching` is any request in flight.
|
||||
const loading = rolesList.isPending;
|
||||
// setTimeout(0) runs after TanStack's deferred auto-reset-on-data-change, so the restored page wins.
|
||||
watch(
|
||||
loading,
|
||||
(isLoading) => {
|
||||
if (isLoading) return;
|
||||
setTimeout(() => roleTableRef.value?.restorePage?.(currentPage.value), 0);
|
||||
},
|
||||
{ once: true, immediate: true },
|
||||
);
|
||||
// A request in flight while rows stay on screen — the refresh button's spinner.
|
||||
// `loading` is the skeleton, which only a cold read wants.
|
||||
const fetching = rolesList.isFetching;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- Copyright 2026 OpenObserve Inc. -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import OTable from "@/lib/core/Table/OTable.vue";
|
||||
import { COL, type OTableColumnDef } from "@/lib/core/Table/OTable.types";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
@@ -22,6 +22,10 @@ const props = defineProps<{
|
||||
actionLoading?: boolean;
|
||||
selectedIds?: string[];
|
||||
globalFilter?: string;
|
||||
pageSize?: number;
|
||||
currentPage?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
/** Member email being assigned to a role (from the "Assign a role" quick-link).
|
||||
* Presence alone turns on the Assign column — not gated separately. */
|
||||
assignTarget?: string;
|
||||
@@ -35,6 +39,10 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
"update:selectedIds": [ids: string[]];
|
||||
"update:globalFilter": [value: string];
|
||||
"update:pageSize": [value: number];
|
||||
"update:currentPage": [value: number];
|
||||
"update:sortBy": [value: string];
|
||||
"update:sortOrder": [value: "asc" | "desc"];
|
||||
edit: [row: any];
|
||||
delete: [row: any];
|
||||
"bulk-delete": [];
|
||||
@@ -42,6 +50,9 @@ const emit = defineEmits<{
|
||||
assign: [row: any];
|
||||
}>();
|
||||
|
||||
const oTableRef = ref<any>(null);
|
||||
defineExpose({ restorePage: (page: number) => oTableRef.value?.restorePage?.(page) });
|
||||
|
||||
const onEmptyStateAction = (id?: string) => {
|
||||
if (id === "clear-filters") {
|
||||
emit("update:globalFilter", "");
|
||||
@@ -115,6 +126,7 @@ const isAssigned = (row: any): boolean => (props.assignedRoleNames ?? []).includ
|
||||
|
||||
<template>
|
||||
<OTable
|
||||
ref="oTableRef"
|
||||
:frame="false"
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
@@ -124,8 +136,11 @@ const isAssigned = (row: any): boolean => (props.assignedRoleNames ?? []).includ
|
||||
:global-filter="globalFilter"
|
||||
:show-global-filter="false"
|
||||
pagination="client"
|
||||
:page-size="20"
|
||||
:page-size="pageSize ?? 20"
|
||||
:page-size-options="[20, 50, 100, 250, 500]"
|
||||
:current-page="currentPage ?? 1"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
sorting="client"
|
||||
selection="multiple"
|
||||
row-key="role_name"
|
||||
@@ -134,6 +149,10 @@ const isAssigned = (row: any): boolean => (props.assignedRoleNames ?? []).includ
|
||||
show-index
|
||||
@update:selected-ids="emit('update:selectedIds', $event)"
|
||||
@update:global-filter="emit('update:globalFilter', $event)"
|
||||
@update:page-size="emit('update:pageSize', $event)"
|
||||
@update:current-page="emit('update:currentPage', $event)"
|
||||
@update:sort-by="emit('update:sortBy', $event)"
|
||||
@update:sort-order="emit('update:sortOrder', $event)"
|
||||
>
|
||||
<template #toolbar>
|
||||
<div class="flex w-full min-w-0 items-center gap-2 max-md:contents">
|
||||
|
||||
@@ -18,6 +18,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import OnCallMyDeliveries from "@/components/oncall/OnCallMyDeliveries.vue";
|
||||
import i18n from "@/locales";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
import oncallService from "@/services/oncall";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import type { MyDelivery } from "@/ts/interfaces/oncall";
|
||||
@@ -34,8 +35,8 @@ const service = vi.mocked(oncallService);
|
||||
const stubs = {
|
||||
OTable: {
|
||||
name: "OTable",
|
||||
props: ["data", "columns"],
|
||||
emits: ["row-click"],
|
||||
props: ["data", "columns", "currentPage", "pageSize"],
|
||||
emits: ["row-click", "update:currentPage", "update:pageSize"],
|
||||
template: `<div>
|
||||
<div v-for="(row, i) in (data || [])" :key="i" data-test="row" @click="$emit('row-click', row)">
|
||||
<slot v-for="c in (columns || [])" :key="c.id" :name="'cell-' + c.id" :row="row" />
|
||||
@@ -103,6 +104,7 @@ function render(props: Record<string, unknown> = {}) {
|
||||
describe("OnCallMyDeliveries", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetListViewState();
|
||||
service.myDeliveries.mockResolvedValue({
|
||||
data: { total: 0, unread: 0, deliveries: [] },
|
||||
} as any);
|
||||
@@ -363,4 +365,40 @@ describe("OnCallMyDeliveries", () => {
|
||||
const button = wrapper.find('[data-test="oncall-my-delivery-toggle-ev_88"]');
|
||||
expect(button.find(".max-md\\:sr-only").text()).toBe("Mark read");
|
||||
});
|
||||
|
||||
describe("list view state", () => {
|
||||
const defaults = { unreadOnly: false, currentPage: 1, pageSize: 20 };
|
||||
const rows = {
|
||||
data: { total: 1, unread: 1, deliveries: [delivery({ event_id: "ev_1" })] },
|
||||
} as any;
|
||||
|
||||
it("restores page, page size and the unread-only read", async () => {
|
||||
Object.assign(useListViewState("oncall-my-deliveries", defaults), {
|
||||
unreadOnly: true,
|
||||
currentPage: 2,
|
||||
pageSize: 50,
|
||||
});
|
||||
service.myDeliveries.mockResolvedValue(rows);
|
||||
const wrapper = render();
|
||||
await flushPromises();
|
||||
|
||||
expect(service.myDeliveries).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ unread_only: true }),
|
||||
);
|
||||
const table = wrapper.findComponent({ name: "OTable" });
|
||||
expect(table.props("currentPage")).toBe(2);
|
||||
expect(table.props("pageSize")).toBe(50);
|
||||
});
|
||||
|
||||
it("saves page changes", async () => {
|
||||
service.myDeliveries.mockResolvedValue(rows);
|
||||
const wrapper = render();
|
||||
await flushPromises();
|
||||
wrapper.findComponent({ name: "OTable" }).vm.$emit("update:currentPage", 3);
|
||||
await flushPromises();
|
||||
|
||||
const saved = useListViewState("oncall-my-deliveries", defaults) as Record<string, unknown>;
|
||||
expect(saved.currentPage).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,16 +60,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<OTable
|
||||
v-if="rows.length || loading"
|
||||
ref="oTableRef"
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
row-key="event_id"
|
||||
:frame="false"
|
||||
:loading="loading"
|
||||
pagination="client"
|
||||
:page-size="pageSize"
|
||||
:current-page="currentPage"
|
||||
:show-global-filter="false"
|
||||
table-id="oncall-my-deliveries"
|
||||
data-test="oncall-my-deliveries-table"
|
||||
@row-click="openPage"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:page-size="pageSize = $event"
|
||||
>
|
||||
<template #cell-page="{ row }">
|
||||
<span class="flex min-w-0 flex-col gap-0.5 max-lg:py-1.5">
|
||||
@@ -143,7 +148,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useStore } from "vuex";
|
||||
|
||||
@@ -159,6 +164,7 @@ import OCheckbox from "@/lib/forms/Checkbox/OCheckbox.vue";
|
||||
import type { CheckboxModelValue } from "@/lib/forms/Checkbox/OCheckbox.types";
|
||||
import { useMutation } from "@tanstack/vue-query";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import { markDeliveriesReadMutation, myDeliveriesQuery } from "@/services/oncall.queries";
|
||||
import type { MyDeliveries, MyDelivery } from "@/ts/interfaces/oncall";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
@@ -183,7 +189,33 @@ const rows = ref<MyDelivery[]>([]);
|
||||
const unread = ref(0);
|
||||
const loading = ref(false);
|
||||
const marking = ref(false);
|
||||
const unreadOnly = ref(false);
|
||||
const savedView = useListViewState("oncall-my-deliveries", {
|
||||
unreadOnly: false,
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
const unreadOnly = ref(savedView.unreadOnly);
|
||||
const currentPage = ref(savedView.currentPage);
|
||||
const pageSize = ref(savedView.pageSize);
|
||||
const oTableRef = ref<any>(null);
|
||||
|
||||
watch(unreadOnly, () => {
|
||||
currentPage.value = 1;
|
||||
});
|
||||
watch([unreadOnly, currentPage, pageSize], () => {
|
||||
Object.assign(savedView, {
|
||||
unreadOnly: unreadOnly.value,
|
||||
currentPage: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
});
|
||||
});
|
||||
// The inbox loads after mount and TanStack resets to page 0 on its own deferred queue; setTimeout(0) runs after it so the restored page wins.
|
||||
let pageRestored = false;
|
||||
watch(loading, (isLoading) => {
|
||||
if (isLoading || pageRestored) return;
|
||||
pageRestored = true;
|
||||
setTimeout(() => oTableRef.value?.restorePage?.(currentPage.value), 0);
|
||||
});
|
||||
|
||||
/// This is the reader's own inbox, so it is rendered in the reader's own clock
|
||||
/// — unlike a schedule, which belongs to its team's zone.
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { describe, expect, it, beforeEach, afterEach, vi, type MockedFunction } from "vitest";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
import PipelinesList from "@/components/pipeline/PipelinesList.vue";
|
||||
import i18n from "@/locales";
|
||||
import { nextTick, reactive } from "vue";
|
||||
@@ -222,6 +223,7 @@ describe("PipelinesList", () => {
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
resetListViewState();
|
||||
store = createStore({
|
||||
state: {
|
||||
theme: "light",
|
||||
@@ -1047,4 +1049,36 @@ describe("PipelinesList", () => {
|
||||
expect(wrapper.vm.activeTab).toBe("scheduled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("view state restore", () => {
|
||||
it("restores search, tab, state facet, size and sort", () => {
|
||||
resetListViewState();
|
||||
useListViewState("pipelines", {
|
||||
filterQuery: "etl",
|
||||
tab: "scheduled",
|
||||
stateFilter: "paused",
|
||||
pageSize: 50,
|
||||
sortBy: "name",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
const restored = createWrapper();
|
||||
const vm = restored.vm as any;
|
||||
expect(vm.filterQuery).toBe("etl");
|
||||
expect(vm.activeTab).toBe("scheduled");
|
||||
expect(vm.stateFilter).toBe("paused");
|
||||
expect(vm.pageSize).toBe(50);
|
||||
expect(vm.sortBy).toBe("name");
|
||||
expect(vm.sortOrder).toBe("desc");
|
||||
restored.unmount();
|
||||
});
|
||||
|
||||
it("saves changes to the view state", async () => {
|
||||
wrapper.vm.filterQuery = "abc";
|
||||
wrapper.vm.pageSize = 100;
|
||||
await nextTick();
|
||||
const saved: any = useListViewState("pipelines", {});
|
||||
expect(saved.filterQuery).toBe("abc");
|
||||
expect(saved.pageSize).toBe(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,10 +34,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:forbidden="forbidden"
|
||||
:global-filter="filterQuery"
|
||||
:show-global-filter="false"
|
||||
:page-size="20"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[20, 50, 100, 250, 500]"
|
||||
:current-page="currentPage"
|
||||
@update:current-page="onPageChange"
|
||||
@update:page-size="pageSize = $event"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
selection="multiple"
|
||||
:enable-column-resize="true"
|
||||
:persist-columns="true"
|
||||
@@ -511,6 +516,7 @@ import { normalizeNodeErrorMessages } from "@/utils/pipelines/nodeErrors";
|
||||
import { MarkerType } from "@vue-flow/core";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import StreamSelection from "./StreamSelection.vue";
|
||||
import pipelineService from "@/services/pipelines";
|
||||
import { useStore } from "vuex";
|
||||
@@ -556,7 +562,18 @@ interface PipelineRow {
|
||||
sql_query?: string;
|
||||
}
|
||||
|
||||
const filterQuery = ref("");
|
||||
const viewState = useListViewState("pipelines", {
|
||||
filterQuery: "",
|
||||
tab: "all",
|
||||
stateFilter: null as "errored" | "paused" | "active" | null,
|
||||
pageSize: 20,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const filterQuery = ref(viewState.filterQuery);
|
||||
const pageSize = ref(viewState.pageSize);
|
||||
const sortBy = ref(viewState.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(viewState.sortOrder);
|
||||
|
||||
const showCreatePipeline = ref(false);
|
||||
|
||||
@@ -676,7 +693,7 @@ const confirmDialogMeta: any = ref({
|
||||
data: null,
|
||||
onConfirm: () => {},
|
||||
});
|
||||
const activeTab = ref("all");
|
||||
const activeTab = ref(viewState.tab);
|
||||
// Derived, not assigned: a copy written only by updateActiveTab() is empty on a cold mount.
|
||||
const filteredPipelines = computed<any[]>(() =>
|
||||
activeTab.value === "all"
|
||||
@@ -769,7 +786,17 @@ const pipelineRowStyle = (row: any): Record<string, string> => {
|
||||
// ── State facet + summary strip ─────────────────────────────────────────────
|
||||
// Counts run over the TYPE-filtered rows (not the state-filtered ones) so the
|
||||
// tiles keep their totals while a facet is active.
|
||||
const stateFilter = ref<"errored" | "paused" | "active" | null>(null);
|
||||
const stateFilter = ref<"errored" | "paused" | "active" | null>(viewState.stateFilter);
|
||||
watch([filterQuery, activeTab, stateFilter, pageSize, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
filterQuery: filterQuery.value,
|
||||
tab: activeTab.value,
|
||||
stateFilter: stateFilter.value,
|
||||
pageSize: pageSize.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
|
||||
const displayedPipelines = computed(() => {
|
||||
const rows = filteredPipelines.value || [];
|
||||
|
||||
@@ -675,6 +675,61 @@ describe("CreateReport", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("return after save/cancel", () => {
|
||||
const setBack = (router: any, back: string) => {
|
||||
router.options = { history: { state: { back } } };
|
||||
};
|
||||
|
||||
it("goes back to the list it was opened from so its filters are restored", async () => {
|
||||
({ wrapper, mockRouter } = mountComponent({ folder: "default" }));
|
||||
await flushPromises();
|
||||
setBack(mockRouter, "/web/reports?folder=default");
|
||||
|
||||
(wrapper.vm as any).goToReports();
|
||||
|
||||
expect(mockRouter.back).toHaveBeenCalledTimes(1);
|
||||
expect(mockRouter.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("goes back to the dashboard view when opened from its scheduled-reports drawer", async () => {
|
||||
({ wrapper, mockRouter } = mountComponent({ dashboardId: "d1" }));
|
||||
await flushPromises();
|
||||
setBack(mockRouter, "/web/dashboards/view?dashboard=d1");
|
||||
|
||||
(wrapper.vm as any).goToReports();
|
||||
|
||||
expect(mockRouter.back).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to the reports route when it was not opened from a list", async () => {
|
||||
({ wrapper, mockRouter } = mountComponent());
|
||||
await flushPromises();
|
||||
setBack(mockRouter, "/web/logs");
|
||||
|
||||
(wrapper.vm as any).goToReports();
|
||||
|
||||
expect(mockRouter.back).not.toHaveBeenCalled();
|
||||
expect(mockRouter.push).toHaveBeenCalledWith(expect.objectContaining({ name: "reports" }));
|
||||
});
|
||||
|
||||
it("lands on the chosen folder when it differs from the one the form was opened from", async () => {
|
||||
({ wrapper, mockRouter } = mountComponent({ folder: "default" }));
|
||||
await flushPromises();
|
||||
setBack(mockRouter, "/web/reports?folder=default");
|
||||
(wrapper.vm as any).selectedReportFolderId = "other";
|
||||
|
||||
(wrapper.vm as any).goToReports();
|
||||
|
||||
expect(mockRouter.back).not.toHaveBeenCalled();
|
||||
expect(mockRouter.replace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "reports",
|
||||
query: expect.objectContaining({ folder: "other" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Cached report toggle ─────────────────────────────────────────────────
|
||||
|
||||
describe("cached report toggle", () => {
|
||||
|
||||
@@ -641,6 +641,7 @@ import reports from "@/services/reports";
|
||||
import { saveReportMutation } from "@/services/reports.queries";
|
||||
import { useMutation } from "@tanstack/vue-query";
|
||||
import { useOrgId } from "@/composables/query";
|
||||
import { useListBackNavigation } from "@/composables/useListBackNavigation";
|
||||
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import { convertDateToTimestamp } from "@/utils/date";
|
||||
@@ -1363,14 +1364,28 @@ const saveReport = async (value: CreateReportForm) => {
|
||||
});
|
||||
};
|
||||
|
||||
const reportsListRoute = () => ({
|
||||
name: "reports",
|
||||
query: {
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
folder: selectedReportFolderId.value || "default",
|
||||
},
|
||||
});
|
||||
|
||||
const goBackToOrigin = useListBackNavigation({
|
||||
// The dashboard view hosts the scheduled-reports drawer that also opens this form.
|
||||
isListPath: (path) => path.endsWith("/reports") || path.endsWith("/dashboards/view"),
|
||||
fallback: reportsListRoute,
|
||||
});
|
||||
|
||||
const goToReports = () => {
|
||||
router.replace({
|
||||
name: "reports",
|
||||
query: {
|
||||
org_identifier: store.state.selectedOrganization.identifier,
|
||||
folder: selectedReportFolderId.value || "default",
|
||||
},
|
||||
});
|
||||
const entryFolder = router.currentRoute.value.query.folder;
|
||||
// Saving into another folder must land on that folder, not the one the form was opened from.
|
||||
if (entryFolder && selectedReportFolderId.value && entryFolder !== selectedReportFolderId.value) {
|
||||
router.replace(reportsListRoute());
|
||||
return;
|
||||
}
|
||||
goBackToOrigin();
|
||||
};
|
||||
|
||||
const setupEditingReport = async (report: any) => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import i18n from "@/locales";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import reports from "@/services/reports";
|
||||
import ReportList from "./ReportList.vue";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
// ─── Module mocks (hoisted) ──────────────────────────────────────────────────
|
||||
|
||||
@@ -228,6 +229,7 @@ describe("ReportList", () => {
|
||||
let mockRouter: { push: ReturnType<typeof vi.fn>; replace: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(async () => {
|
||||
resetListViewState();
|
||||
vi.mocked(reports.listByFolderId).mockResolvedValue({
|
||||
data: [REPORT_SCHEDULED, REPORT_CACHED],
|
||||
} as any);
|
||||
@@ -850,3 +852,83 @@ describe("ReportList", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ReportList view state persistence", () => {
|
||||
let wrapper: VueWrapper;
|
||||
const defaults = {
|
||||
activeTab: "shared",
|
||||
filterQuery: "",
|
||||
searchQuery: "",
|
||||
searchAcrossFolders: false,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
pageSize: 20,
|
||||
currentPage: 1,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetListViewState();
|
||||
vi.mocked(reports.listByFolderId).mockResolvedValue({
|
||||
data: [REPORT_SCHEDULED, REPORT_CACHED],
|
||||
} as any);
|
||||
store.state.selectedOrganization = { identifier: "test-org", name: "Test Org" };
|
||||
store.state.theme = "light";
|
||||
store.state.organizationData.allReportsListByFolderId = {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
resetListViewState();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("seeds tab, filter, sort and paging from the saved view", async () => {
|
||||
Object.assign(useListViewState("reports", defaults), {
|
||||
activeTab: "cached",
|
||||
filterQuery: "daily",
|
||||
sortBy: "name",
|
||||
sortOrder: "desc",
|
||||
pageSize: 50,
|
||||
currentPage: 2,
|
||||
});
|
||||
wrapper = mountComponent().wrapper;
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm as any;
|
||||
expect(vm.activeTab).toBe("cached");
|
||||
expect(vm.filterQuery).toBe("daily");
|
||||
expect(vm.sortBy).toBe("name");
|
||||
expect(vm.sortOrder).toBe("desc");
|
||||
expect(vm.pageSize).toBe(50);
|
||||
expect(vm.currentPage).toBe(2);
|
||||
});
|
||||
|
||||
it("saves filter, sort and page changes into the view state", async () => {
|
||||
wrapper = mountComponent().wrapper;
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as any;
|
||||
|
||||
vm.filterQuery = "weekly";
|
||||
vm.sortBy = "name";
|
||||
vm.currentPage = 3;
|
||||
await flushPromises();
|
||||
|
||||
expect(useListViewState("reports", defaults)).toMatchObject({
|
||||
filterQuery: "weekly",
|
||||
sortBy: "name",
|
||||
currentPage: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("resets the page when the tab changes", async () => {
|
||||
Object.assign(useListViewState("reports", defaults), { currentPage: 3 });
|
||||
wrapper = mountComponent().wrapper;
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as any;
|
||||
|
||||
vm.activeTab = "cached";
|
||||
await flushPromises();
|
||||
|
||||
expect(vm.currentPage).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<div class="h-full min-w-0 flex-1 max-md:h-auto max-md:min-h-0">
|
||||
<div class="bg-card-glass-bg h-full">
|
||||
<OTable
|
||||
ref="oTableRef"
|
||||
data-test="report-list-table"
|
||||
:data="visibleRows"
|
||||
:columns="columns"
|
||||
@@ -67,6 +68,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:enable-column-resize="true"
|
||||
:persist-columns="true"
|
||||
:default-columns="false"
|
||||
:page-size="pageSize"
|
||||
:current-page="currentPage"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:page-size="pageSize = $event"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
show-index
|
||||
table-id="reports-report-list"
|
||||
>
|
||||
@@ -371,6 +380,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useOrgId } from "@/composables/query/useOrgId";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import type { ReportListFilters } from "@/services/reports";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import { reportsQuery } from "@/services/reports.queries";
|
||||
@@ -422,7 +432,17 @@ const store = useStore();
|
||||
|
||||
// ── Folder state ──────────────────────────────────────────────────────────────
|
||||
const activeFolderId = ref<string>((router.currentRoute.value.query.folder as string) ?? "default");
|
||||
const searchAcrossFolders = ref(false);
|
||||
const savedView = useListViewState("reports", {
|
||||
activeTab: "shared",
|
||||
filterQuery: "",
|
||||
searchQuery: "",
|
||||
searchAcrossFolders: false,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
pageSize: 20,
|
||||
currentPage: 1,
|
||||
});
|
||||
const searchAcrossFolders = ref(savedView.searchAcrossFolders);
|
||||
|
||||
const showMoveDialog = ref(false);
|
||||
const activeFolderToMove = ref("default");
|
||||
@@ -462,10 +482,58 @@ const forbidden = computed(() => {
|
||||
const isLoadingReports = ref(true);
|
||||
const fetching = reportsList.isFetching;
|
||||
const lastUpdatedAt = reportsList.dataUpdatedAt;
|
||||
const activeTab = ref("shared");
|
||||
const filterQuery = ref(""); // client-side filter within current folder
|
||||
const searchQuery = ref(""); // API search across all folders
|
||||
const activeTab = ref(savedView.activeTab);
|
||||
const filterQuery = ref(savedView.filterQuery); // client-side filter within current folder
|
||||
const searchQuery = ref(savedView.searchQuery); // API search across all folders
|
||||
const sortBy = ref<string>(savedView.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(savedView.sortOrder);
|
||||
const pageSize = ref<number>(savedView.pageSize);
|
||||
const currentPage = ref<number>(savedView.currentPage);
|
||||
const oTableRef = ref<any>(null);
|
||||
|
||||
watch(
|
||||
[
|
||||
activeTab,
|
||||
filterQuery,
|
||||
searchQuery,
|
||||
searchAcrossFolders,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
pageSize,
|
||||
currentPage,
|
||||
],
|
||||
() => {
|
||||
Object.assign(savedView, {
|
||||
activeTab: activeTab.value,
|
||||
filterQuery: filterQuery.value,
|
||||
searchQuery: searchQuery.value,
|
||||
searchAcrossFolders: searchAcrossFolders.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
pageSize: pageSize.value,
|
||||
currentPage: currentPage.value,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// A tab switch starts a new dataset; the stored page must not carry over.
|
||||
watch(activeTab, () => {
|
||||
currentPage.value = 1;
|
||||
});
|
||||
|
||||
// setTimeout(0) runs after TanStack's deferred auto-reset on the first data load, so the restored page wins.
|
||||
watch(
|
||||
isLoadingReports,
|
||||
(isLoading) => {
|
||||
if (isLoading) return;
|
||||
setTimeout(() => {
|
||||
(oTableRef.value as any)?.restorePage?.(currentPage.value);
|
||||
}, 0);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
const cachedFolderReports = ref<any[]>([]); // current folder's reports before cross-folder search
|
||||
let folderRowsCached = false;
|
||||
|
||||
const dynamicQueryModel = computed({
|
||||
get() {
|
||||
@@ -584,7 +652,10 @@ const shapeReports = (rows: any[]) =>
|
||||
|
||||
const renderReports = (rows: any[]) => {
|
||||
const mapped = shapeReports(rows);
|
||||
if (!readNameQuery.value) cachedFolderReports.value = mapped;
|
||||
if (!readNameQuery.value) {
|
||||
cachedFolderReports.value = mapped;
|
||||
folderRowsCached = true;
|
||||
}
|
||||
staticReportsList.value = mapped;
|
||||
filterReports();
|
||||
};
|
||||
@@ -669,7 +740,10 @@ const filterReports = () => {
|
||||
onBeforeMount(async () => {
|
||||
// A folder-list 403 must not abort the load below, or the skeleton never clears.
|
||||
await getFoldersListByType(store, "reports").catch(() => null);
|
||||
await loadReports(activeFolderId.value);
|
||||
await loadReports(
|
||||
activeFolderId.value,
|
||||
searchAcrossFolders.value && searchQuery.value ? searchQuery.value : undefined,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Folder watchers (mirrors AlertList pattern) ───────────────────────────────
|
||||
@@ -690,6 +764,7 @@ watch(
|
||||
async (newVal) => {
|
||||
selectedReports.value = [];
|
||||
if (newVal === router.currentRoute.value.query.folder) return;
|
||||
currentPage.value = 1;
|
||||
|
||||
if (searchAcrossFolders.value) {
|
||||
searchAcrossFolders.value = false;
|
||||
@@ -722,11 +797,20 @@ watch(searchAcrossFolders, (enabled) => {
|
||||
// Restore the cached folder data without an API call
|
||||
searchQuery.value = "";
|
||||
filterQuery.value = "";
|
||||
staticReportsList.value = cachedFolderReports.value;
|
||||
filterReports();
|
||||
restoreFolderRows();
|
||||
}
|
||||
});
|
||||
|
||||
// A view restored straight into a cross-folder search never cached the folder's own rows, so those must be read again.
|
||||
const restoreFolderRows = () => {
|
||||
if (!folderRowsCached) {
|
||||
loadReports(activeFolderId.value);
|
||||
return;
|
||||
}
|
||||
staticReportsList.value = cachedFolderReports.value;
|
||||
filterReports();
|
||||
};
|
||||
|
||||
// Named handler: a refresh keeps whatever the user is searching for. Passing
|
||||
// `undefined` here reset the name query, so the rows came back unfiltered while
|
||||
// the search box still showed the term.
|
||||
@@ -743,8 +827,7 @@ watch(searchQuery, async (newVal) => {
|
||||
if (!searchAcrossFolders.value) return;
|
||||
if (!newVal) {
|
||||
// User cleared the search — restore cached folder data
|
||||
staticReportsList.value = cachedFolderReports.value;
|
||||
filterReports();
|
||||
restoreFolderRows();
|
||||
} else {
|
||||
await debouncedSearch(newVal);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
|
||||
import { createRouter, createWebHistory, type Router } from "vue-router";
|
||||
import { createStore } from "vuex";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
const { confirmMock, toastMock } = vi.hoisted(() => ({
|
||||
confirmMock: vi.fn(async (_opts: Record<string, unknown>) => true),
|
||||
@@ -109,6 +110,7 @@ describe("NamedEventsListPage (AC-44)", () => {
|
||||
resetNamedEvents();
|
||||
resetSavedFunnels();
|
||||
resetProductAnalytics();
|
||||
resetListViewState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -464,4 +466,15 @@ describe("NamedEventsListPage (AC-44)", () => {
|
||||
expect(api.service.listFunnels.mock.calls.length).toBe(funnels + 1);
|
||||
expect(rowText()).toEqual(["Added elsewhere", "Event 0"]);
|
||||
});
|
||||
|
||||
it("restores the saved search and saves search changes", async () => {
|
||||
seed(3);
|
||||
useListViewState("rumNamedEvents", { filter: "Event 1" });
|
||||
await mountList();
|
||||
expect(rowText()).toEqual(["Event 1"]);
|
||||
wrapper!.findComponent(NamedEventsListPage).vm.$.setupState.filter = "Event 2";
|
||||
await flushPromises();
|
||||
const saved: any = useListViewState("rumNamedEvents", {});
|
||||
expect(saved.filter).toBe("Event 2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</OBanner>
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<OTable
|
||||
ref="oTableRef"
|
||||
v-model:selected-ids="selectedIds"
|
||||
:frame="false"
|
||||
:data="filtered"
|
||||
@@ -62,9 +63,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
selection="multiple"
|
||||
:is-row-selectable="() => canWrite"
|
||||
pagination="client"
|
||||
:page-size="20"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[20, 50]"
|
||||
:current-page="currentPage"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:page-size="pageSize = $event"
|
||||
sorting="client"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
:default-columns="false"
|
||||
:show-global-filter="false"
|
||||
:persist-columns="true"
|
||||
@@ -250,6 +258,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRouter, type HistoryState } from "vue-router";
|
||||
import { useStore } from "vuex";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import OTable from "@/lib/core/Table/OTable.vue";
|
||||
import OTimeCell from "@/lib/core/Table/cells/OTimeCell.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
@@ -285,13 +294,46 @@ const savedFunnels = useSavedFunnels();
|
||||
const { confirm } = useConfirmDialog();
|
||||
const { events, permission, invalidCount, loading } = ne;
|
||||
|
||||
const filter = ref("");
|
||||
const viewState = useListViewState("rumNamedEvents", {
|
||||
filter: "",
|
||||
pageSize: 20,
|
||||
currentPage: 1,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const filter = ref(viewState.filter);
|
||||
const pageSize = ref(viewState.pageSize);
|
||||
const currentPage = ref(viewState.currentPage);
|
||||
const sortBy = ref(viewState.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(viewState.sortOrder);
|
||||
const oTableRef = ref<any>(null);
|
||||
watch([filter, pageSize, currentPage, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
filter: filter.value,
|
||||
pageSize: pageSize.value,
|
||||
currentPage: currentPage.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
watch(filter, () => (currentPage.value = 1));
|
||||
const selectedIds = ref<string[]>([]);
|
||||
const bulkDeleting = ref(false);
|
||||
|
||||
const org = (): string => store.state.selectedOrganization?.identifier ?? "";
|
||||
const app = computed(() => pa.state.app);
|
||||
const status = computed(() => ne.status(org(), app.value));
|
||||
let pageRestored = false;
|
||||
watch(
|
||||
() => status.value === "ready",
|
||||
(ready) => {
|
||||
if (!ready || pageRestored) return;
|
||||
pageRestored = true;
|
||||
// Macrotask so the restored page wins over the table's own reset on data change.
|
||||
setTimeout(() => oTableRef.value?.restorePage?.(currentPage.value), 0);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const canWrite = computed(() => permission.value === "write");
|
||||
const canCreate = computed(() => canWrite.value && events.value.length < MAX_EVENTS_PER_APP);
|
||||
const readOnlyTip = computed(() => t("rum.analytics.events.readOnly"));
|
||||
|
||||
@@ -19,6 +19,7 @@ import { DOMWrapper, flushPromises, mount, type VueWrapper } from "@vue/test-uti
|
||||
import { createMemoryHistory, createRouter, RouterView, type Router } from "vue-router";
|
||||
import { createStore } from "vuex";
|
||||
import i18n from "@/locales";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
const { confirmMock, toastMock } = vi.hoisted(() => ({
|
||||
confirmMock: vi.fn(async (_o: Record<string, unknown>) => true),
|
||||
@@ -204,6 +205,7 @@ describe("SavedFunnelsPage (AC-67, AC-68, AC-69)", () => {
|
||||
resetSavedFunnels();
|
||||
resetNamedEvents();
|
||||
resetProductAnalytics();
|
||||
resetListViewState();
|
||||
confirmMock.mockReset().mockResolvedValue(true);
|
||||
toastMock.mockReset();
|
||||
window.localStorage.clear();
|
||||
@@ -685,4 +687,16 @@ describe("SavedFunnelsPage (AC-67, AC-68, AC-69)", () => {
|
||||
await settle();
|
||||
expect(names()).toEqual(["Alpha"]);
|
||||
});
|
||||
|
||||
it("restores the saved search and saves search changes", async () => {
|
||||
api.seedFunnel("web", "Alpha", PARAM);
|
||||
api.seedFunnel("web", "Beta", PARAM);
|
||||
useListViewState("rumSavedFunnels", { filter: "Alpha" });
|
||||
await mountPage();
|
||||
expect(names()).toEqual([expect.stringContaining("Alpha")]);
|
||||
wrapper!.findComponent(SavedFunnelsPage).vm.$.setupState.filter = "Beta";
|
||||
await flushPromises();
|
||||
const saved: any = useListViewState("rumSavedFunnels", {});
|
||||
expect(saved.filter).toBe("Beta");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<OTable
|
||||
ref="oTableRef"
|
||||
v-model:selected-ids="selectedIds"
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
@@ -53,9 +54,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:error="listStatus === 'failed' ? t('rum.analytics.saved.loadFailed') : null"
|
||||
:selection="canWrite ? 'multiple' : 'none'"
|
||||
pagination="client"
|
||||
:page-size="20"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[20, 50, 100]"
|
||||
:current-page="currentPage"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:page-size="pageSize = $event"
|
||||
sorting="client"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
:default-columns="false"
|
||||
:show-global-filter="false"
|
||||
:frame="false"
|
||||
@@ -251,6 +259,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, ref, watch } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import OTable from "@/lib/core/Table/OTable.vue";
|
||||
import OTimeCell from "@/lib/core/Table/cells/OTimeCell.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
@@ -295,7 +304,29 @@ const draft = useFunnelDraft();
|
||||
const { permission, invalidCount } = sf;
|
||||
|
||||
const dialogs = ref<InstanceType<typeof SavedFunnelDialogs> | null>(null);
|
||||
const filter = ref("");
|
||||
const viewState = useListViewState("rumSavedFunnels", {
|
||||
filter: "",
|
||||
pageSize: 20,
|
||||
currentPage: 1,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const filter = ref(viewState.filter);
|
||||
const pageSize = ref(viewState.pageSize);
|
||||
const currentPage = ref(viewState.currentPage);
|
||||
const sortBy = ref(viewState.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(viewState.sortOrder);
|
||||
const oTableRef = ref<any>(null);
|
||||
watch([filter, pageSize, currentPage, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
filter: filter.value,
|
||||
pageSize: pageSize.value,
|
||||
currentPage: currentPage.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
watch(filter, () => (currentPage.value = 1));
|
||||
const selectedIds = ref<string[]>([]);
|
||||
const refreshing = ref(false);
|
||||
let activatedOnce = false;
|
||||
@@ -307,6 +338,17 @@ const { entries, entriesLoading, entriesFailed, recent, loadEntries, refreshRece
|
||||
|
||||
const org = () => pa.toQuery().org_identifier as string;
|
||||
const listStatus = computed(() => sf.status(org(), pa.state.app));
|
||||
let pageRestored = false;
|
||||
watch(
|
||||
() => listStatus.value === "ready",
|
||||
(ready) => {
|
||||
if (!ready || pageRestored) return;
|
||||
pageRestored = true;
|
||||
// Macrotask so the restored page wins over the table's own reset on data change.
|
||||
setTimeout(() => oTableRef.value?.restorePage?.(currentPage.value), 0);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
// Another app's read time must not stand for this list while it loads.
|
||||
const lastUpdatedAt = computed(() =>
|
||||
listStatus.value === "loading" ? undefined : (sf.loadedAt.value ?? undefined),
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import AiToolsets from "./AiToolsets.vue";
|
||||
import i18n from "@/locales";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import router from "@/test/unit/helpers/router";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
vi.mock("@/services/ai_toolsets", () => ({
|
||||
default: { list: vi.fn(async () => ({ data: { toolsets: [] } })), delete: vi.fn() },
|
||||
}));
|
||||
|
||||
const mountPage = () =>
|
||||
mount(AiToolsets, {
|
||||
global: {
|
||||
plugins: [i18n, store, router],
|
||||
provide: { store },
|
||||
stubs: { AddAiToolset: true, ConfirmDialog: true, OTable: true },
|
||||
},
|
||||
});
|
||||
|
||||
describe("AiToolsets list view state", () => {
|
||||
beforeEach(() => resetListViewState());
|
||||
|
||||
it("restores saved search, page size, page and sort", async () => {
|
||||
Object.assign(useListViewState("ai-toolsets", {}), {
|
||||
filterQuery: "abc",
|
||||
pageSize: 50,
|
||||
currentPage: 2,
|
||||
sortBy: "name",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
const w: any = mountPage();
|
||||
await flushPromises();
|
||||
expect([
|
||||
w.vm.filterQuery,
|
||||
w.vm.pageSize,
|
||||
w.vm.currentPage,
|
||||
w.vm.sortBy,
|
||||
w.vm.sortOrder,
|
||||
]).toEqual(["abc", 50, 2, "name", "desc"]);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("saves changes so they survive unmount", async () => {
|
||||
const w: any = mountPage();
|
||||
await flushPromises();
|
||||
w.vm.filterQuery = "xyz";
|
||||
w.vm.pageSize = 100;
|
||||
await flushPromises();
|
||||
w.unmount();
|
||||
expect(useListViewState("ai-toolsets", {})).toMatchObject({
|
||||
filterQuery: "xyz",
|
||||
pageSize: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -38,14 +38,22 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<!-- Table -->
|
||||
<div class="bg-card-glass-bg mt-2.5 overflow-hidden">
|
||||
<OTable
|
||||
ref="oTableRef"
|
||||
:forbidden="forbidden"
|
||||
:frame="false"
|
||||
:data="visibleRows"
|
||||
:columns="columns"
|
||||
row-key="id"
|
||||
pagination="client"
|
||||
:page-size="20"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[20, 50, 100]"
|
||||
:current-page="currentPage"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:page-size="pageSize = $event"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
sorting="client"
|
||||
:default-columns="false"
|
||||
show-index
|
||||
@@ -181,6 +189,7 @@ import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import AddAiToolset from "@/components/ai_toolsets/AddAiToolset.vue";
|
||||
import { useShortcuts } from "@/lib/vue-shortcut-manager";
|
||||
import { isInputFocused } from "@/utils/keyboardShortcuts";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import aiToolsetsService from "@/services/ai_toolsets";
|
||||
|
||||
export default defineComponent({
|
||||
@@ -229,7 +238,38 @@ export default defineComponent({
|
||||
const e: any = toolsetsList.error.value;
|
||||
return e?.status === 403 || e?.response?.status === 403;
|
||||
});
|
||||
const filterQuery = ref("");
|
||||
const viewState = useListViewState("ai-toolsets", {
|
||||
filterQuery: "",
|
||||
pageSize: 20,
|
||||
currentPage: 1,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const filterQuery = ref(viewState.filterQuery);
|
||||
const pageSize = ref(viewState.pageSize);
|
||||
const currentPage = ref(viewState.currentPage);
|
||||
const sortBy = ref(viewState.sortBy);
|
||||
const sortOrder = ref(viewState.sortOrder);
|
||||
const oTableRef = ref<any>(null);
|
||||
|
||||
watch([filterQuery, pageSize, currentPage, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
filterQuery: filterQuery.value,
|
||||
pageSize: pageSize.value,
|
||||
currentPage: currentPage.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
// setTimeout(0) runs after TanStack's deferred auto-reset-on-data-change, so the restored page wins.
|
||||
watch(
|
||||
loading,
|
||||
(v) => {
|
||||
if (v) return;
|
||||
setTimeout(() => oTableRef.value?.restorePage?.(currentPage.value), 0);
|
||||
},
|
||||
{ once: true, immediate: true },
|
||||
);
|
||||
|
||||
const columns: OTableColumnDef[] = [
|
||||
{
|
||||
@@ -463,6 +503,11 @@ export default defineComponent({
|
||||
showAddDialog,
|
||||
resultTotal,
|
||||
filterQuery,
|
||||
pageSize,
|
||||
currentPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
oTableRef,
|
||||
visibleRows,
|
||||
confirmDelete,
|
||||
addToolset,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
|
||||
import CipherKeys from "./CipherKeys.vue";
|
||||
@@ -43,7 +44,8 @@ vi.mock("@/services/product_analytics", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/zincutils", () => ({
|
||||
vi.mock("@/utils/zincutils", async (importOriginal) => ({
|
||||
...(await importOriginal<any>()),
|
||||
convertToTitleCase: vi.fn((str) => str),
|
||||
}));
|
||||
|
||||
@@ -199,6 +201,8 @@ const createWrapperAndWait = async (options = {}) => {
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
beforeEach(() => resetListViewState());
|
||||
|
||||
describe("CipherKeys", () => {
|
||||
const mockCipherKeysData = {
|
||||
data: {
|
||||
@@ -685,3 +689,40 @@ describe("CipherKeys", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("list view state", () => {
|
||||
it("restores search, page size, page and sort from the saved view", async () => {
|
||||
Object.assign(useListViewState("cipher-keys", {}), {
|
||||
filterQuery: "abc",
|
||||
pageSize: 50,
|
||||
currentPage: 2,
|
||||
sortBy: "name",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
const w: any = await createWrapperAndWait();
|
||||
await flushPromises();
|
||||
expect(w.vm.filterQuery).toBe("abc");
|
||||
expect(w.vm.pageSize).toBe(50);
|
||||
expect(w.vm.currentPage).toBe(2);
|
||||
expect(w.vm.sortBy).toBe("name");
|
||||
expect(w.vm.sortOrder).toBe("desc");
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("saves list state so it survives unmount", async () => {
|
||||
const w: any = await createWrapperAndWait();
|
||||
await flushPromises();
|
||||
w.vm.filterQuery = "xyz";
|
||||
w.vm.pageSize = 100;
|
||||
w.vm.sortBy = "name";
|
||||
w.vm.sortOrder = "desc";
|
||||
await flushPromises();
|
||||
w.unmount();
|
||||
expect(useListViewState("cipher-keys", {})).toMatchObject({
|
||||
filterQuery: "xyz",
|
||||
pageSize: 100,
|
||||
sortBy: "name",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
<div class="bg-card-glass-bg min-h-0 flex-1 overflow-hidden">
|
||||
<OTable
|
||||
ref="oTableRef"
|
||||
:frame="false"
|
||||
:data="visibleRows"
|
||||
:columns="columns"
|
||||
@@ -40,8 +41,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:selected-ids="selectedKeyIds"
|
||||
selection="multiple"
|
||||
pagination="client"
|
||||
:page-size="20"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[20, 50, 100, 250, 500]"
|
||||
:current-page="currentPage"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:page-size="pageSize = $event"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
sorting="client"
|
||||
filter-mode="client"
|
||||
:default-columns="false"
|
||||
@@ -192,6 +200,7 @@ import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
|
||||
import { COL } from "@/lib/core/Table/OTable.types";
|
||||
import { useShortcuts } from "@/lib/vue-shortcut-manager";
|
||||
import { isInputFocused } from "@/utils/keyboardShortcuts";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
export default defineComponent({
|
||||
name: "PageCipherKeys",
|
||||
@@ -229,7 +238,38 @@ export default defineComponent({
|
||||
const e: any = cipherKeysList.error.value;
|
||||
return e?.status === 403 || e?.response?.status === 403;
|
||||
});
|
||||
const filterQuery = ref("");
|
||||
const viewState = useListViewState("cipher-keys", {
|
||||
filterQuery: "",
|
||||
pageSize: 20,
|
||||
currentPage: 1,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const filterQuery = ref(viewState.filterQuery);
|
||||
const pageSize = ref(viewState.pageSize);
|
||||
const currentPage = ref(viewState.currentPage);
|
||||
const sortBy = ref(viewState.sortBy);
|
||||
const sortOrder = ref(viewState.sortOrder);
|
||||
const oTableRef = ref<any>(null);
|
||||
|
||||
watch([filterQuery, pageSize, currentPage, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
filterQuery: filterQuery.value,
|
||||
pageSize: pageSize.value,
|
||||
currentPage: currentPage.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
// setTimeout(0) runs after TanStack's deferred auto-reset-on-data-change, so the restored page wins.
|
||||
watch(
|
||||
loading,
|
||||
(v) => {
|
||||
if (v) return;
|
||||
setTimeout(() => oTableRef.value?.restorePage?.(currentPage.value), 0);
|
||||
},
|
||||
{ once: true, immediate: true },
|
||||
);
|
||||
const columns: OTableColumnDef[] = [
|
||||
{
|
||||
id: "name",
|
||||
@@ -563,6 +603,11 @@ export default defineComponent({
|
||||
addCipherKey,
|
||||
getData,
|
||||
filterQuery,
|
||||
pageSize,
|
||||
currentPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
oTableRef,
|
||||
hideAddDialog,
|
||||
cancelDeleteCipherKey,
|
||||
confirmDeleteCipherKey,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import LlmProvidersSettings from "./LlmProvidersSettings.vue";
|
||||
import i18n from "@/locales";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import router from "@/test/unit/helpers/router";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
vi.mock("@/services/online-evals.service.queries", () => ({
|
||||
providersQuery: (org: string) => ({
|
||||
queryKey: ["llm-providers-spec", org],
|
||||
queryFn: async () => [],
|
||||
}),
|
||||
deleteProviderMutation: () => ({ mutationFn: async () => ({}) }),
|
||||
}));
|
||||
|
||||
const mountPage = () =>
|
||||
mount(LlmProvidersSettings, {
|
||||
global: {
|
||||
plugins: [i18n, store, router],
|
||||
provide: { store },
|
||||
stubs: { ProviderFormPage: true, ConfirmDialog: true, OTable: true },
|
||||
},
|
||||
});
|
||||
|
||||
describe("LlmProvidersSettings list view state", () => {
|
||||
beforeEach(() => resetListViewState());
|
||||
|
||||
it("restores saved search, page size, page and sort", async () => {
|
||||
Object.assign(useListViewState("llm-providers", {}), {
|
||||
searchQuery: "abc",
|
||||
pageSize: 50,
|
||||
currentPage: 2,
|
||||
sortBy: "name",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
const w: any = mountPage();
|
||||
await flushPromises();
|
||||
expect([
|
||||
w.vm.searchQuery,
|
||||
w.vm.pageSize,
|
||||
w.vm.currentPage,
|
||||
w.vm.sortBy,
|
||||
w.vm.sortOrder,
|
||||
]).toEqual(["abc", 50, 2, "name", "desc"]);
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("saves changes so they survive unmount", async () => {
|
||||
const w: any = mountPage();
|
||||
await flushPromises();
|
||||
w.vm.searchQuery = "xyz";
|
||||
w.vm.pageSize = 100;
|
||||
await flushPromises();
|
||||
w.unmount();
|
||||
expect(useListViewState("llm-providers", {})).toMatchObject({
|
||||
searchQuery: "xyz",
|
||||
pageSize: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,7 @@
|
||||
|
||||
<div v-else class="min-h-0 flex-1">
|
||||
<OTable
|
||||
ref="oTableRef"
|
||||
data-test="llm-providers-table"
|
||||
:data="filteredProviders"
|
||||
:columns="columns"
|
||||
@@ -63,8 +64,15 @@
|
||||
:enable-column-resize="true"
|
||||
:persist-columns="true"
|
||||
table-id="settings-llm-providers"
|
||||
:page-size="20"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[20, 50, 100]"
|
||||
:current-page="currentPage"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:page-size="pageSize = $event"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
width="100%"
|
||||
class="h-full w-full"
|
||||
@row-click="(row: any) => openEdit(row)"
|
||||
@@ -206,13 +214,36 @@ import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
|
||||
import { COL } from "@/lib/core/Table/OTable.types";
|
||||
import { useShortcuts } from "@/lib/vue-shortcut-manager";
|
||||
import { isInputFocused } from "@/utils/keyboardShortcuts";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const searchQuery = ref("");
|
||||
const viewState = useListViewState("llm-providers", {
|
||||
searchQuery: "",
|
||||
pageSize: 20,
|
||||
currentPage: 1,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const searchQuery = ref(viewState.searchQuery);
|
||||
const pageSize = ref(viewState.pageSize);
|
||||
const currentPage = ref(viewState.currentPage);
|
||||
const sortBy = ref(viewState.sortBy);
|
||||
const sortOrder = ref(viewState.sortOrder);
|
||||
const oTableRef = ref<any>(null);
|
||||
|
||||
watch([searchQuery, pageSize, currentPage, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
searchQuery: searchQuery.value,
|
||||
pageSize: pageSize.value,
|
||||
currentPage: currentPage.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
const formPage = ref<{ mode: "create" | "edit"; row: Provider | null } | null>(null);
|
||||
|
||||
const confirmDeleteOpen = ref(false);
|
||||
@@ -226,6 +257,15 @@ const providersList = useQuery(() =>
|
||||
);
|
||||
const providers = computed<Provider[]>(() => providersList.data.value ?? []);
|
||||
const isLoading = providersList.isPending;
|
||||
// setTimeout(0) runs after TanStack's deferred auto-reset-on-data-change, so the restored page wins.
|
||||
watch(
|
||||
isLoading,
|
||||
(v) => {
|
||||
if (v) return;
|
||||
setTimeout(() => oTableRef.value?.restorePage?.(currentPage.value), 0);
|
||||
},
|
||||
{ once: true, immediate: true },
|
||||
);
|
||||
const isFetching = providersList.isFetching;
|
||||
// A 403 lands in the query's error rather than a loader's catch, so derive the no-access state from it.
|
||||
const forbidden = computed(() => {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
|
||||
import { nextTick } from "vue";
|
||||
@@ -53,7 +54,8 @@ vi.mock("vue-router", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/zincutils", () => ({
|
||||
vi.mock("@/utils/zincutils", async (importOriginal) => ({
|
||||
...(await importOriginal<any>()),
|
||||
getImageURL: (path: string) => `mock://${path}`,
|
||||
}));
|
||||
|
||||
@@ -345,6 +347,8 @@ function mountComponent(extra: { store?: any; query?: Record<string, any> } = {}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => resetListViewState());
|
||||
|
||||
describe("ModelPricingList.vue", () => {
|
||||
let wrapper: VueWrapper;
|
||||
|
||||
@@ -1332,3 +1336,42 @@ describe("ModelPricingList.vue", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("list view state", () => {
|
||||
it("restores search, page size, page and sort from the saved view", async () => {
|
||||
Object.assign(useListViewState("model-pricing", {}), {
|
||||
filterQuery: "abc",
|
||||
selectedTab: "org",
|
||||
pageSize: 50,
|
||||
currentPage: 2,
|
||||
sortBy: "name",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
const w: any = mountComponent();
|
||||
await flushPromises();
|
||||
expect(w.vm.filterQuery).toBe("abc");
|
||||
expect(w.vm.selectedTab).toBe("org");
|
||||
expect(w.vm.pageSize).toBe(50);
|
||||
expect(w.vm.currentPage).toBe(2);
|
||||
expect(w.vm.sortBy).toBe("name");
|
||||
expect(w.vm.sortOrder).toBe("desc");
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("saves list state so it survives unmount", async () => {
|
||||
const w: any = mountComponent();
|
||||
await flushPromises();
|
||||
w.vm.filterQuery = "xyz";
|
||||
w.vm.pageSize = 100;
|
||||
w.vm.sortBy = "name";
|
||||
w.vm.sortOrder = "desc";
|
||||
await flushPromises();
|
||||
w.unmount();
|
||||
expect(useListViewState("model-pricing", {})).toMatchObject({
|
||||
filterQuery: "xyz",
|
||||
pageSize: 100,
|
||||
sortBy: "name",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,8 +94,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:selected-ids="selectedIds"
|
||||
selection="multiple"
|
||||
pagination="client"
|
||||
:page-size="20"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[20, 50, 100, 250, 500]"
|
||||
:current-page="currentPage"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
sorting="client"
|
||||
filter-mode="client"
|
||||
:default-columns="false"
|
||||
@@ -109,6 +112,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
(row: any) => !!(row.children?.length && shadowingParentNames.has(row.name))
|
||||
"
|
||||
@update:selected-ids="handleSelectedIdsUpdate"
|
||||
@update:page-size="pageSize = $event"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
>
|
||||
<!-- Toolbar: Built-in/Custom tabs + search -->
|
||||
<template #toolbar>
|
||||
@@ -658,6 +665,7 @@ import { operatorSymbol, formatUtcWindows, formatUtcWindowsInTz } from "@/utils/
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import { useShortcuts } from "@/lib/vue-shortcut-manager";
|
||||
import { isInputFocused } from "@/utils/keyboardShortcuts";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
const store = useStore();
|
||||
@@ -675,6 +683,15 @@ const modelsQuery = useQuery(() =>
|
||||
// repaints these rows with no wiring here.
|
||||
const models = computed(() => modelsQuery.data.value ?? []);
|
||||
const loading = modelsQuery.isPending;
|
||||
// setTimeout(0) runs after TanStack's deferred auto-reset-on-data-change, so the restored page wins.
|
||||
watch(
|
||||
loading,
|
||||
(isLoading) => {
|
||||
if (isLoading) return;
|
||||
setTimeout(() => qTableRef.value?.restorePage?.(currentPage.value), 0);
|
||||
},
|
||||
{ once: true, immediate: true },
|
||||
);
|
||||
// Request in flight, with rows still on screen — the refresh button's
|
||||
// spinner. `loading` stays for the skeleton, which only a cold read wants.
|
||||
const fetching = modelsQuery.isFetching;
|
||||
@@ -706,12 +723,35 @@ const confirmDialogMeta = ref({
|
||||
const resetConfirmDialog = () => {
|
||||
confirmDialogMeta.value.show = false;
|
||||
};
|
||||
const filterQuery = ref("");
|
||||
const viewState = useListViewState("model-pricing", {
|
||||
filterQuery: "",
|
||||
selectedTab: "all",
|
||||
pageSize: 20,
|
||||
currentPage: 1,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const filterQuery = ref(viewState.filterQuery);
|
||||
const pageSize = ref(viewState.pageSize);
|
||||
const currentPage = ref(viewState.currentPage);
|
||||
const sortBy = ref(viewState.sortBy);
|
||||
const sortOrder = ref(viewState.sortOrder);
|
||||
const showImportModelPricingPage = ref(false);
|
||||
const showTestMatchDialog = ref(false);
|
||||
const selectedIds = ref<string[]>([]);
|
||||
const bulkDeleteLoading = ref(false);
|
||||
const selectedTab = ref("all");
|
||||
const selectedTab = ref(viewState.selectedTab);
|
||||
|
||||
watch([filterQuery, selectedTab, pageSize, currentPage, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
filterQuery: filterQuery.value,
|
||||
selectedTab: selectedTab.value,
|
||||
pageSize: pageSize.value,
|
||||
currentPage: currentPage.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
|
||||
const tabOptions = computed(() => [
|
||||
{ label: t("modelPricing.tabAll"), value: "all", icon: "format-list-bulleted" },
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { ref, nextTick } from "vue";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
@@ -139,6 +140,8 @@ const mockNotify = {
|
||||
notify: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => resetListViewState());
|
||||
|
||||
describe("RegexPatternList.vue Component Logic", () => {
|
||||
const mockRegexPatterns = [
|
||||
{
|
||||
@@ -1293,4 +1296,27 @@ describe("RegexPatternList.vue - ODrawer Migration", () => {
|
||||
await flushPromises();
|
||||
expect(w.vm.showAddRegexPatternDialog.show).toBe(false);
|
||||
});
|
||||
|
||||
it("restores saved list state and writes changes back", async () => {
|
||||
Object.assign(useListViewState("regex-patterns", {}), {
|
||||
filterQuery: "abc",
|
||||
pageSize: 50,
|
||||
currentPage: 2,
|
||||
sortBy: "name",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
const { wrapper } = await mountComponent();
|
||||
const vm: any = wrapper.vm;
|
||||
expect([vm.filterQuery, vm.pageSize, vm.currentPage, vm.sortBy, vm.sortOrder]).toEqual([
|
||||
"abc",
|
||||
50,
|
||||
2,
|
||||
"name",
|
||||
"desc",
|
||||
]);
|
||||
vm.pageSize = 100;
|
||||
await vm.$nextTick();
|
||||
expect(useListViewState("regex-patterns", {}).pageSize).toBe(100);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
<div class="bg-card-glass-bg min-h-0 flex-1 overflow-hidden">
|
||||
<OTable
|
||||
ref="oTableRef"
|
||||
:frame="false"
|
||||
data-test="regex-pattern-list-table"
|
||||
:data="visibleRows"
|
||||
@@ -50,8 +51,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:selected-ids="selectedPatternIds"
|
||||
selection="multiple"
|
||||
pagination="client"
|
||||
:page-size="20"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[10, 20, 50, 100]"
|
||||
:current-page="currentPage"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:page-size="pageSize = $event"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
sorting="client"
|
||||
filter-mode="client"
|
||||
:default-columns="false"
|
||||
@@ -258,6 +266,7 @@ import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
|
||||
import { COL } from "@/lib/core/Table/OTable.types";
|
||||
import { useShortcuts } from "@/lib/vue-shortcut-manager";
|
||||
import { isInputFocused } from "@/utils/keyboardShortcuts";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
export default defineComponent({
|
||||
name: "RegexPatternList",
|
||||
@@ -277,7 +286,29 @@ export default defineComponent({
|
||||
OTimeCell,
|
||||
},
|
||||
setup() {
|
||||
const filterQuery = ref("");
|
||||
const viewState = useListViewState("regex-patterns", {
|
||||
filterQuery: "",
|
||||
pageSize: 20,
|
||||
currentPage: 1,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const filterQuery = ref(viewState.filterQuery);
|
||||
const pageSize = ref(viewState.pageSize);
|
||||
const currentPage = ref(viewState.currentPage);
|
||||
const sortBy = ref(viewState.sortBy);
|
||||
const sortOrder = ref(viewState.sortOrder);
|
||||
const oTableRef = ref<any>(null);
|
||||
|
||||
watch([filterQuery, pageSize, currentPage, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
filterQuery: filterQuery.value,
|
||||
pageSize: pageSize.value,
|
||||
currentPage: currentPage.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
const { t } = useI18nTyped();
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
@@ -358,6 +389,15 @@ export default defineComponent({
|
||||
const bulkDeleteLoading = ref(false);
|
||||
|
||||
const listLoading = regexPatternsList.isPending;
|
||||
// setTimeout(0) runs after TanStack's deferred auto-reset-on-data-change, so the restored page wins.
|
||||
watch(
|
||||
listLoading,
|
||||
(v) => {
|
||||
if (v) return;
|
||||
setTimeout(() => oTableRef.value?.restorePage?.(currentPage.value), 0);
|
||||
},
|
||||
{ once: true, immediate: true },
|
||||
);
|
||||
// A 403 lands in the query's error rather than a loader's catch, so derive the no-access state from it.
|
||||
const forbidden = computed(() => {
|
||||
const e: any = regexPatternsList.error.value;
|
||||
@@ -618,6 +658,11 @@ export default defineComponent({
|
||||
store,
|
||||
router,
|
||||
filterQuery,
|
||||
pageSize,
|
||||
currentPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
oTableRef,
|
||||
columns,
|
||||
regexPatterns,
|
||||
filterData,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
|
||||
import { createStore } from "vuex";
|
||||
@@ -316,6 +317,8 @@ function mountComponent(store?: ReturnType<typeof createMockStore>) {
|
||||
// Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
beforeEach(() => resetListViewState());
|
||||
|
||||
describe("SyntheticsLocationsList", () => {
|
||||
let wrapper: VueWrapper<any>;
|
||||
|
||||
@@ -1281,3 +1284,40 @@ describe("SyntheticsLocationsList", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("list view state", () => {
|
||||
it("restores search, page size, page and sort from the saved view", async () => {
|
||||
Object.assign(useListViewState("synthetics-locations", {}), {
|
||||
filterQuery: "abc",
|
||||
pageSize: 50,
|
||||
currentPage: 2,
|
||||
sortBy: "label",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
const w: any = mountComponent();
|
||||
await flushPromises();
|
||||
expect(w.vm.filterQuery).toBe("abc");
|
||||
expect(w.vm.pageSize).toBe(50);
|
||||
expect(w.vm.currentPage).toBe(2);
|
||||
expect(w.vm.sortBy).toBe("label");
|
||||
expect(w.vm.sortOrder).toBe("desc");
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it("saves list state so it survives unmount", async () => {
|
||||
const w: any = mountComponent();
|
||||
await flushPromises();
|
||||
w.vm.filterQuery = "xyz";
|
||||
w.vm.pageSize = 100;
|
||||
w.vm.sortBy = "label";
|
||||
w.vm.sortOrder = "desc";
|
||||
await flushPromises();
|
||||
w.unmount();
|
||||
expect(useListViewState("synthetics-locations", {})).toMatchObject({
|
||||
filterQuery: "xyz",
|
||||
pageSize: 100,
|
||||
sortBy: "label",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,6 +49,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<div class="bg-card-glass-bg min-h-0 flex-1 overflow-hidden">
|
||||
<OTable
|
||||
ref="oTableRef"
|
||||
:frame="false"
|
||||
data-test="synthetics-locations-list-table"
|
||||
:data="visibleRows"
|
||||
@@ -57,8 +58,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:selected-ids="selectedLocationIds"
|
||||
selection="multiple"
|
||||
pagination="client"
|
||||
:page-size="20"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[10, 20, 50, 100]"
|
||||
:current-page="currentPage"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:page-size="pageSize = $event"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
sorting="client"
|
||||
filter-mode="client"
|
||||
:default-columns="false"
|
||||
@@ -259,7 +267,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref, onMounted, defineComponent, computed } from "vue";
|
||||
import { ref, onMounted, defineComponent, computed, watch } from "vue";
|
||||
import type { Ref } from "vue";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import { useStore } from "vuex";
|
||||
@@ -280,6 +288,7 @@ import syntheticsService from "@/services/synthetics";
|
||||
import SyntheticsLocationForm from "./SyntheticsLocationForm.vue";
|
||||
import ImportSyntheticsLocations from "./ImportSyntheticsLocations.vue";
|
||||
import type { SyntheticsLocationRecord } from "@/types/synthetics";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
export default defineComponent({
|
||||
name: "SyntheticsLocationsList",
|
||||
@@ -297,7 +306,29 @@ export default defineComponent({
|
||||
ImportSyntheticsLocations,
|
||||
},
|
||||
setup() {
|
||||
const filterQuery = ref("");
|
||||
const viewState = useListViewState("synthetics-locations", {
|
||||
filterQuery: "",
|
||||
pageSize: 20,
|
||||
currentPage: 1,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const filterQuery = ref(viewState.filterQuery);
|
||||
const pageSize = ref(viewState.pageSize);
|
||||
const currentPage = ref(viewState.currentPage);
|
||||
const sortBy = ref(viewState.sortBy);
|
||||
const sortOrder = ref(viewState.sortOrder);
|
||||
const oTableRef = ref<any>(null);
|
||||
|
||||
watch([filterQuery, pageSize, currentPage, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
filterQuery: filterQuery.value,
|
||||
pageSize: pageSize.value,
|
||||
currentPage: currentPage.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
const { t } = useI18nTyped();
|
||||
const store = useStore();
|
||||
const { confirm } = useConfirmDialog();
|
||||
@@ -360,6 +391,14 @@ export default defineComponent({
|
||||
];
|
||||
|
||||
const locations = ref<SyntheticsLocationRecord[]>([]);
|
||||
// setTimeout(0) runs after TanStack's deferred auto-reset-on-data-change, so the restored page wins.
|
||||
watch(
|
||||
locations,
|
||||
() => {
|
||||
setTimeout(() => oTableRef.value?.restorePage?.(currentPage.value), 0);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
const selectedLocations: Ref<SyntheticsLocationRecord[]> = ref([]);
|
||||
const listLoading = ref(false);
|
||||
const bulkActionLoading = ref(false);
|
||||
@@ -644,6 +683,11 @@ export default defineComponent({
|
||||
t,
|
||||
store,
|
||||
filterQuery,
|
||||
pageSize,
|
||||
currentPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
oTableRef,
|
||||
columns,
|
||||
locations,
|
||||
listLoading,
|
||||
|
||||
@@ -16,14 +16,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<template>
|
||||
<OTable
|
||||
ref="oTableRef"
|
||||
v-model:selected-ids="localSelectedIds"
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:loading="loading"
|
||||
:forbidden="forbidden"
|
||||
pagination="client"
|
||||
:page-size="20"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[10, 20, 25, 50]"
|
||||
:current-page="currentPage"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
row-key="id"
|
||||
selection="multiple"
|
||||
:show-global-filter="false"
|
||||
@@ -37,6 +41,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:get-row-style="monitorRowStyle"
|
||||
show-index
|
||||
@row-click="(row: any) => emit('row-click', row)"
|
||||
@update:current-page="emit('update:currentPage', $event)"
|
||||
@update:page-size="emit('update:pageSize', $event)"
|
||||
@update:sort-by="emit('update:sortBy', $event)"
|
||||
@update:sort-order="emit('update:sortOrder', $event)"
|
||||
>
|
||||
<!-- Summary strip supplied by the parent (it owns the status facet). -->
|
||||
<template v-if="$slots.subheader" #subheader>
|
||||
@@ -613,8 +621,13 @@ const props = withDefaults(
|
||||
* human label. Falls back to the raw id when a location isn't in the map
|
||||
* (e.g. deleted since the check last ran). */
|
||||
locationNames?: Record<string, string>;
|
||||
pageSize?: number;
|
||||
currentPage?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
}>(),
|
||||
{
|
||||
pageSize: 20,
|
||||
loading: false,
|
||||
dataTest: "monitor-table",
|
||||
toggleLoadingMap: () => ({}),
|
||||
@@ -647,8 +660,16 @@ const emit = defineEmits<{
|
||||
"trigger-selected": [];
|
||||
"navigate-to-folder": [folderId: string];
|
||||
"empty-action": [actionId: string];
|
||||
"update:currentPage": [page: number];
|
||||
"update:pageSize": [size: number];
|
||||
"update:sortBy": [columnId: string];
|
||||
"update:sortOrder": [order: "asc" | "desc"];
|
||||
}>();
|
||||
|
||||
const oTableRef = ref<any>(null);
|
||||
|
||||
defineExpose({ restorePage: (page: number) => oTableRef.value?.restorePage?.(page) });
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
|
||||
// Not a `withDefaults` default: that is evaluated once at module scope and would freeze the copy in the first-loaded locale.
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
// hatch. Heavy children (OTable, drawers, the graph preview) are stubbed.
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
const { mockRouter, mockRoute, mockToast, mockHydrate, mockGetFolders } = vi.hoisted(() => ({
|
||||
mockRouter: {
|
||||
@@ -258,6 +259,7 @@ describe("WorkflowsList", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetListViewState();
|
||||
mockRouter.currentRoute.value = { name: "workflows", query: {} } as any;
|
||||
mockRoute.query = {};
|
||||
listWorkflows.mockResolvedValue({
|
||||
@@ -1131,4 +1133,31 @@ describe("WorkflowsList", () => {
|
||||
expect(restorePage).toHaveBeenCalledWith(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("view state restore", () => {
|
||||
it("restores search, tab, page size and sort", () => {
|
||||
useListViewState("workflows", {
|
||||
filterQuery: "nightly",
|
||||
tab: "all",
|
||||
pageSize: 50,
|
||||
sortBy: "name",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
wrapper = mountList();
|
||||
expect(wrapper.vm.filterQuery).toBe("nightly");
|
||||
expect(wrapper.vm.pageSize).toBe(50);
|
||||
expect(wrapper.vm.sortBy).toBe("name");
|
||||
expect(wrapper.vm.sortOrder).toBe("desc");
|
||||
});
|
||||
|
||||
it("saves changes to the view state", async () => {
|
||||
wrapper = mountList();
|
||||
wrapper.vm.filterQuery = "abc";
|
||||
wrapper.vm.pageSize = 100;
|
||||
await wrapper.vm.$nextTick();
|
||||
const saved: any = useListViewState("workflows", {});
|
||||
expect(saved.filterQuery).toBe("abc");
|
||||
expect(saved.pageSize).toBe(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,8 +77,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:forbidden="forbidden"
|
||||
:page-size="20"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[20, 50, 100, 250, 500]"
|
||||
@update:page-size="pageSize = $event"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
:enable-column-resize="true"
|
||||
:persist-columns="true"
|
||||
:default-columns="false"
|
||||
@@ -328,6 +333,7 @@ import { queryClient } from "@/composables/query/queryClient";
|
||||
import { ref, computed, defineAsyncComponent, onMounted, watch } from "vue";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import { useStore } from "vuex";
|
||||
|
||||
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
|
||||
@@ -368,7 +374,14 @@ const activeFolderId = computed(() => (route.query.folder as string) || "default
|
||||
|
||||
// Tabs come from the trigger registry, so enabling another trigger kind adds its
|
||||
// tab without touching this file.
|
||||
const activeTab = ref("all");
|
||||
const viewState = useListViewState("workflows", {
|
||||
filterQuery: "",
|
||||
tab: "all",
|
||||
pageSize: 20,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const activeTab = ref(viewState.tab);
|
||||
const workflowTabs = computed(() => [
|
||||
{ value: "all", label: t("workflow.tabAll") },
|
||||
...enabledTriggers().map((tr) => ({ value: tr.kind, label: t(tr.tabLabelKey ?? tr.labelKey) })),
|
||||
@@ -414,7 +427,19 @@ const loading = ref(true);
|
||||
const fetching = ref(false);
|
||||
const lastUpdatedAt = ref<number | null>(null);
|
||||
const forbidden = ref(false);
|
||||
const filterQuery = ref("");
|
||||
const filterQuery = ref(viewState.filterQuery);
|
||||
const pageSize = ref(viewState.pageSize);
|
||||
const sortBy = ref(viewState.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(viewState.sortOrder);
|
||||
watch([filterQuery, activeTab, pageSize, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
filterQuery: filterQuery.value,
|
||||
tab: activeTab.value,
|
||||
pageSize: pageSize.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
|
||||
// Cross-folder is a search mode, not a browse mode: with an empty box the list
|
||||
// stays in the selected folder, matching the Alerts and Dashboards lists. That
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import store from "@/stores";
|
||||
import { resetListViewState, useListViewState } from "./useListViewState";
|
||||
|
||||
describe("useListViewState", () => {
|
||||
beforeEach(() => {
|
||||
resetListViewState();
|
||||
store.state.selectedOrganization = { identifier: "org1" } as any;
|
||||
});
|
||||
|
||||
it("returns the defaults the first time", () => {
|
||||
const state = useListViewState("alerts", { searchQuery: "", pageSize: 20 });
|
||||
expect(state).toEqual({ searchQuery: "", pageSize: 20 });
|
||||
});
|
||||
|
||||
it("hands back the same state after the component is gone", () => {
|
||||
useListViewState("alerts", { searchQuery: "" }).searchQuery = "cpu";
|
||||
expect(useListViewState("alerts", { searchQuery: "" }).searchQuery).toBe("cpu");
|
||||
});
|
||||
|
||||
it("shares one object between callers of the same list", () => {
|
||||
const first = useListViewState("alerts", { searchQuery: "" });
|
||||
const second = useListViewState("alerts", { searchQuery: "", sortBy: "" });
|
||||
expect(second).toBe(first);
|
||||
expect(first.sortBy).toBe("");
|
||||
});
|
||||
|
||||
it("fills in fields added after the state was first stored", () => {
|
||||
useListViewState("alerts", { searchQuery: "cpu" });
|
||||
expect(useListViewState("alerts", { searchQuery: "", sortBy: "" })).toEqual({
|
||||
searchQuery: "cpu",
|
||||
sortBy: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps lists and organizations apart", () => {
|
||||
useListViewState("alerts", { searchQuery: "" }).searchQuery = "cpu";
|
||||
expect(useListViewState("reports", { searchQuery: "" }).searchQuery).toBe("");
|
||||
store.state.selectedOrganization = { identifier: "org2" } as any;
|
||||
expect(useListViewState("alerts", { searchQuery: "" }).searchQuery).toBe("");
|
||||
});
|
||||
|
||||
it("resets one list or all of them", () => {
|
||||
useListViewState("alerts", { q: "" }).q = "a";
|
||||
useListViewState("reports", { q: "" }).q = "b";
|
||||
resetListViewState("alerts");
|
||||
expect(useListViewState("alerts", { q: "" }).q).toBe("");
|
||||
expect(useListViewState("reports", { q: "" }).q).toBe("b");
|
||||
resetListViewState();
|
||||
expect(useListViewState("reports", { q: "" }).q).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { reactive } from "vue";
|
||||
import store from "@/stores";
|
||||
|
||||
export interface ListViewSort {
|
||||
sortBy: string;
|
||||
sortOrder: "asc" | "desc";
|
||||
}
|
||||
|
||||
const views = reactive<Record<string, Record<string, unknown>>>({});
|
||||
|
||||
const viewKey = (key: string) => `${store.state.selectedOrganization?.identifier ?? ""}:${key}`;
|
||||
|
||||
// Module-level on purpose: list pages are destroyed on navigation, so the state must outlive the component; every caller shares one object.
|
||||
export function useListViewState<T extends Record<string, unknown>>(key: string, defaults: T): T {
|
||||
const k = viewKey(key);
|
||||
if (!views[k]) views[k] = {};
|
||||
const existing = views[k];
|
||||
for (const field of Object.keys(defaults)) {
|
||||
if (!(field in existing)) existing[field] = defaults[field];
|
||||
}
|
||||
return existing as T;
|
||||
}
|
||||
|
||||
export function resetListViewState(key?: string): void {
|
||||
if (key) {
|
||||
delete views[viewKey(key)];
|
||||
return;
|
||||
}
|
||||
Object.keys(views).forEach((k) => delete views[k]);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import i18n from "@/locales";
|
||||
import SearchHistory from "./SearchHistory.vue";
|
||||
import searchService from "@/services/search";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
// Mock Toast
|
||||
vi.mock("@/lib/feedback/Toast/useToast", () => ({
|
||||
@@ -81,6 +82,7 @@ describe("SearchHistory Component", () => {
|
||||
let routerPushMock;
|
||||
|
||||
beforeEach(() => {
|
||||
resetListViewState();
|
||||
// Setup mock store
|
||||
mockStore = {
|
||||
state: {
|
||||
@@ -605,4 +607,65 @@ describe("SearchHistory Component", () => {
|
||||
expect(columns[0].sortable).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("View state restore", () => {
|
||||
const mountRestored = () =>
|
||||
mount(SearchHistory, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
provide: {
|
||||
store: {
|
||||
state: { ...mockStore.state, zoConfig: { usage_enabled: true } },
|
||||
},
|
||||
},
|
||||
stubs: {
|
||||
OTable: true,
|
||||
DateTime: { template: "<div />", methods: { setAbsoluteTime: vi.fn() } },
|
||||
AppTabs: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it("restores date range, page, size and sort", () => {
|
||||
resetListViewState();
|
||||
useListViewState("searchHistory", {
|
||||
valueType: "absolute",
|
||||
relativeTimePeriod: "",
|
||||
startTime: 1700000000000000,
|
||||
endTime: 1700000900000000,
|
||||
pageSize: 20,
|
||||
currentPage: 2,
|
||||
sortBy: "sql",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
const restored = mountRestored();
|
||||
expect(restored.vm.restoredDate.absolute).toEqual({
|
||||
startTime: 1700000000000000,
|
||||
endTime: 1700000900000000,
|
||||
});
|
||||
expect(restored.vm.pageSize).toBe(20);
|
||||
expect(restored.vm.currentPage).toBe(2);
|
||||
expect(restored.vm.sortBy).toBe("sql");
|
||||
expect(restored.vm.sortOrder).toBe("desc");
|
||||
restored.unmount();
|
||||
});
|
||||
|
||||
it("saves table and date changes", async () => {
|
||||
const live = mountRestored();
|
||||
live.vm.pageSize = 50;
|
||||
live.vm.sortBy = "sql";
|
||||
await live.vm.updateDateTime({
|
||||
valueType: "relative",
|
||||
relativeTimePeriod: "1h",
|
||||
startTime: 1,
|
||||
endTime: 2,
|
||||
});
|
||||
await live.vm.$nextTick();
|
||||
live.unmount();
|
||||
const saved = useListViewState("searchHistory", {});
|
||||
expect(saved.pageSize).toBe(50);
|
||||
expect(saved.sortBy).toBe("sql");
|
||||
expect(saved.relativeTimePeriod).toBe("1h");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,7 +46,9 @@
|
||||
auto-apply
|
||||
menu-align="end"
|
||||
:hide-range-shift="isMobile"
|
||||
:default-type="searchObj.data.datetime.type"
|
||||
:default-type="restoredDate.valueType || searchObj.data.datetime.type"
|
||||
:default-relative-time="restoredDate.relativeTimePeriod || '15m'"
|
||||
:default-absolute-time="restoredDate.absolute"
|
||||
@on:date-change="updateDateTime"
|
||||
/>
|
||||
</div>
|
||||
@@ -79,6 +81,7 @@
|
||||
</div>
|
||||
<div class="bg-card-glass-bg min-h-0 flex-1 overflow-hidden">
|
||||
<OTable
|
||||
ref="oTableRef"
|
||||
:frame="false"
|
||||
:data="dataToBeLoaded"
|
||||
:columns="columnsToBeRendered"
|
||||
@@ -88,7 +91,14 @@
|
||||
pagination="client"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:current-page="currentPage"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:page-size="pageSize = $event"
|
||||
sorting="client"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
expansion="single"
|
||||
:expand-on-row-click="true"
|
||||
v-model:expanded-ids="expandedIds"
|
||||
@@ -295,7 +305,16 @@
|
||||
</template>
|
||||
<script lang="ts">
|
||||
//@ts-nocheck
|
||||
import { ref, onMounted, computed, onUnmounted, defineAsyncComponent, defineComponent } from "vue";
|
||||
import {
|
||||
ref,
|
||||
onMounted,
|
||||
computed,
|
||||
onUnmounted,
|
||||
defineAsyncComponent,
|
||||
defineComponent,
|
||||
watch,
|
||||
} from "vue";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import { timestampToTimezoneDate, b64EncodeUnicode, getUUID } from "@/utils/zincutils";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { useStore } from "vuex";
|
||||
@@ -368,12 +387,35 @@ export default defineComponent({
|
||||
const wrapText = ref(true);
|
||||
const { searchObj } = searchState();
|
||||
const dataToBeLoaded: any = ref([]);
|
||||
const dateTimeToBeSent = ref({
|
||||
valueType: "relative",
|
||||
relativeTimePeriod: "15m",
|
||||
const viewState = useListViewState("searchHistory", {
|
||||
valueType: "",
|
||||
relativeTimePeriod: "",
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
pageSize: 100,
|
||||
currentPage: 1,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const restoredDate = {
|
||||
valueType: viewState.valueType,
|
||||
relativeTimePeriod: viewState.relativeTimePeriod,
|
||||
absolute:
|
||||
viewState.valueType === "absolute"
|
||||
? { startTime: viewState.startTime, endTime: viewState.endTime }
|
||||
: null,
|
||||
};
|
||||
const dateTimeToBeSent = ref({
|
||||
valueType: viewState.valueType || "relative",
|
||||
relativeTimePeriod: viewState.relativeTimePeriod || "15m",
|
||||
startTime: viewState.startTime,
|
||||
endTime: viewState.endTime,
|
||||
});
|
||||
const oTableRef: any = ref(null);
|
||||
const currentPage = ref(viewState.currentPage);
|
||||
const sortBy = ref(viewState.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(viewState.sortOrder);
|
||||
let pendingPageRestore = true;
|
||||
const columnsToBeRendered = ref<OTableColumnDef[]>([]);
|
||||
const expandedIds = ref<string[]>([]);
|
||||
const isLoading = ref(false);
|
||||
@@ -417,7 +459,7 @@ export default defineComponent({
|
||||
|
||||
onUnmounted(() => {});
|
||||
|
||||
const pageSize = ref(100);
|
||||
const pageSize = ref(viewState.pageSize);
|
||||
const pageSizeOptions = [5, 10, 20, 50, 100];
|
||||
|
||||
// Columns are a fixed schema (not derived from the response), so they can be
|
||||
@@ -531,6 +573,11 @@ export default defineComponent({
|
||||
});
|
||||
dataToBeLoaded.value = filteredHits;
|
||||
isLoading.value = false;
|
||||
if (pendingPageRestore) {
|
||||
pendingPageRestore = false;
|
||||
// Macrotask so the restored page wins over the table's own reset on data change.
|
||||
setTimeout(() => oTableRef.value?.restorePage?.(currentPage.value), 0);
|
||||
}
|
||||
} catch (error: any) {
|
||||
forbidden.value = error?.response?.status === 403;
|
||||
// The grouped access toast already reports a 403; a second red toast adds nothing.
|
||||
@@ -559,6 +606,10 @@ export default defineComponent({
|
||||
|
||||
const updateDateTime = async (value: any) => {
|
||||
dateTimeToBeSent.value = value;
|
||||
viewState.valueType = value.valueType;
|
||||
viewState.relativeTimePeriod = value.relativeTimePeriod ?? "";
|
||||
viewState.startTime = value.startTime;
|
||||
viewState.endTime = value.endTime;
|
||||
searchDateTimeRef.value.setAbsoluteTime(value.startTime, value.endTime);
|
||||
// Auto-run on a genuine user time change (consistent with the rest of the
|
||||
// app). Skip programmatic emits — the setAbsoluteTime() call above re-emits
|
||||
@@ -730,6 +781,14 @@ export default defineComponent({
|
||||
return filtered;
|
||||
}, {});
|
||||
}
|
||||
watch([pageSize, currentPage, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
pageSize: pageSize.value,
|
||||
currentPage: currentPage.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
useShortcuts([
|
||||
{
|
||||
id: "searchHistoryRefresh",
|
||||
@@ -775,6 +834,11 @@ export default defineComponent({
|
||||
delayMessage,
|
||||
pageSize,
|
||||
pageSizeOptions,
|
||||
currentPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
oTableRef,
|
||||
restoredDate,
|
||||
activeTab,
|
||||
tabs,
|
||||
moreDetailsToDisplay,
|
||||
|
||||
@@ -4,6 +4,7 @@ import SearchSchedulersList from "@/plugins/logs/SearchSchedulersList.vue";
|
||||
import i18n from "@/locales";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import { nextTick } from "vue";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
// Mock services
|
||||
vi.mock("@/services/search", async (importOriginal) => {
|
||||
@@ -75,7 +76,8 @@ vi.mock("@/utils/date", () => ({
|
||||
}));
|
||||
|
||||
// Mock utils
|
||||
vi.mock("@/utils/zincutils", () => ({
|
||||
vi.mock("@/utils/zincutils", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
timestampToTimezoneDate: vi.fn(),
|
||||
b64EncodeUnicode: vi.fn((str) => btoa(str)),
|
||||
b64DecodeUnicode: vi.fn((str) => atob(str)),
|
||||
@@ -97,6 +99,7 @@ describe("SearchSchedulersList Component", () => {
|
||||
beforeEach(async () => {
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
resetListViewState();
|
||||
|
||||
// Setup search service mock
|
||||
const { default: searchServiceMock } = await import("@/services/search");
|
||||
@@ -840,4 +843,41 @@ describe("SearchSchedulersList Component", () => {
|
||||
expect(typeof wrapper.vm.toBeCancelled).toBe("object");
|
||||
});
|
||||
});
|
||||
|
||||
describe("View state restore", () => {
|
||||
const mountList = () =>
|
||||
mount(SearchSchedulersList, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
provide: { store },
|
||||
stubs: { OTable: true, AppTabs: true, QueryEditor: true, ConfirmDialog: true },
|
||||
mocks: { $router: mockRouter },
|
||||
},
|
||||
});
|
||||
|
||||
it("restores page, size and sort", () => {
|
||||
resetListViewState();
|
||||
useListViewState("searchSchedulers", {
|
||||
pageSize: 10,
|
||||
currentPage: 3,
|
||||
sortBy: "created_at",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
const restored = mountList();
|
||||
expect(restored.vm.pageSize).toBe(10);
|
||||
expect(restored.vm.currentPage).toBe(3);
|
||||
expect(restored.vm.sortBy).toBe("created_at");
|
||||
expect(restored.vm.sortOrder).toBe("desc");
|
||||
restored.unmount();
|
||||
});
|
||||
|
||||
it("saves changes to the view state", async () => {
|
||||
wrapper.vm.pageSize = 50;
|
||||
wrapper.vm.sortBy = "user_id";
|
||||
await nextTick();
|
||||
const saved: any = useListViewState("searchSchedulers", {});
|
||||
expect(saved.pageSize).toBe(50);
|
||||
expect(saved.sortBy).toBe("user_id");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
</template>
|
||||
<div class="bg-card-glass-bg min-h-0 flex-1 overflow-hidden">
|
||||
<OTable
|
||||
ref="oTableRef"
|
||||
:forbidden="forbidden"
|
||||
:frame="false"
|
||||
class="search-scheduler-otable"
|
||||
@@ -43,6 +44,15 @@
|
||||
row-key="trace_id"
|
||||
:loading="isLoading"
|
||||
pagination="client"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:current-page="currentPage"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:page-size="pageSize = $event"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
expansion="single"
|
||||
:expand-on-row-click="true"
|
||||
v-model:expanded-ids="expandedIds"
|
||||
@@ -322,7 +332,9 @@ import {
|
||||
defineAsyncComponent,
|
||||
defineComponent,
|
||||
reactive,
|
||||
watch,
|
||||
} from "vue";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import { b64EncodeUnicode, b64DecodeUnicode } from "@/utils/zincutils";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { useStore } from "vuex";
|
||||
@@ -410,7 +422,26 @@ export default defineComponent({
|
||||
const activeTab = ref("query");
|
||||
const query = ref("");
|
||||
|
||||
const pageSize = ref(100);
|
||||
const viewState = useListViewState("searchSchedulers", {
|
||||
pageSize: 100,
|
||||
currentPage: 1,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const oTableRef: any = ref(null);
|
||||
const pageSize = ref(viewState.pageSize);
|
||||
const currentPage = ref(viewState.currentPage);
|
||||
const sortBy = ref(viewState.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(viewState.sortOrder);
|
||||
let pendingPageRestore = true;
|
||||
watch([pageSize, currentPage, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
pageSize: pageSize.value,
|
||||
currentPage: currentPage.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
const pageSizeOptions = [5, 10, 20, 50, 100];
|
||||
const tabs = reactive([
|
||||
{
|
||||
@@ -553,6 +584,11 @@ export default defineComponent({
|
||||
|
||||
dataToBeLoaded.value = responseToBeFetched;
|
||||
isLoading.value = false;
|
||||
if (pendingPageRestore) {
|
||||
pendingPageRestore = false;
|
||||
// Macrotask so the restored page wins over the table's own reset on data change.
|
||||
setTimeout(() => oTableRef.value?.restorePage?.(currentPage.value), 0);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
forbidden.value = e?.response?.status === 403;
|
||||
@@ -895,6 +931,10 @@ export default defineComponent({
|
||||
forbidden,
|
||||
pageSize,
|
||||
pageSizeOptions,
|
||||
currentPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
oTableRef,
|
||||
expandedIds,
|
||||
goToLogs,
|
||||
onExpandedIdsChange,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
|
||||
import { reactive } from "vue";
|
||||
import { createStore } from "vuex";
|
||||
import i18n from "@/locales";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
// web/.env sets VITE_OPENOBSERVE_ENTERPRISE=true locally but CI does not, so pin it.
|
||||
vi.mock("@/aws-exports", async (importOriginal) => {
|
||||
@@ -427,6 +428,7 @@ describe("ServicesCatalog", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetListViewState();
|
||||
localStorage.removeItem("servicesCatalog_streamFilter");
|
||||
|
||||
// Reset searchObj to baseline before each test
|
||||
@@ -3247,4 +3249,32 @@ describe("ServicesCatalog", () => {
|
||||
expect(panel.props("timeRange").endTime).toBe(secondOpen * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("View state restore", () => {
|
||||
it("restores filter, sort, page and size", () => {
|
||||
useListViewState("servicesCatalog", {
|
||||
filterText: "pay",
|
||||
currentPage: 2,
|
||||
rowsPerPage: 50,
|
||||
sortBy: "service_name",
|
||||
sortOrder: "asc",
|
||||
});
|
||||
wrapper = mountServicesCatalog();
|
||||
expect(wrapper.vm.filterText).toBe("pay");
|
||||
expect(wrapper.vm.currentPage).toBe(2);
|
||||
expect(wrapper.vm.rowsPerPage).toBe(50);
|
||||
expect(wrapper.vm.sortBy).toBe("service_name");
|
||||
expect(wrapper.vm.sortOrder).toBe("asc");
|
||||
});
|
||||
|
||||
it("saves changes to the view state", async () => {
|
||||
wrapper = mountServicesCatalog();
|
||||
wrapper.vm.filterText = "auth";
|
||||
wrapper.vm.sortBy = "service_name";
|
||||
await wrapper.vm.$nextTick();
|
||||
const saved: any = useListViewState("servicesCatalog", {});
|
||||
expect(saved.filterText).toBe("auth");
|
||||
expect(saved.sortBy).toBe("service_name");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -351,6 +351,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
pagination="client"
|
||||
:page-size="rowsPerPage"
|
||||
:page-size-options="rowsPerPageOptions"
|
||||
:current-page="currentPage"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:page-size="rowsPerPage = $event"
|
||||
table-id="services-catalog"
|
||||
data-test="services-catalog-table"
|
||||
@row-click="(row) => handleRowClick(row)"
|
||||
@@ -536,6 +539,7 @@ import searchService from "@/services/search";
|
||||
import config from "@/aws-exports";
|
||||
import { anomalyConfigsQuery } from "@/services/anomaly_detection.queries";
|
||||
import { timestampToTimezoneDate } from "@/utils/timezone";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
const { isMobile } = useBreakpoint();
|
||||
@@ -635,13 +639,40 @@ const isSearching = ref(false);
|
||||
// Epoch-ms of the last completed load, shown on the page header's refresh control.
|
||||
const lastRunAt = ref<number | null>(null);
|
||||
const services = ref<ServiceRow[]>([]);
|
||||
const filterText = ref("");
|
||||
const typeFilter = ref<TypeFilter>("service");
|
||||
const currentPage = ref(1);
|
||||
const rowsPerPage = ref(25);
|
||||
const viewState = useListViewState("servicesCatalog", {
|
||||
filterText: "",
|
||||
typeFilter: "service" as TypeFilter,
|
||||
currentPage: 1,
|
||||
rowsPerPage: 25,
|
||||
sortBy: "status",
|
||||
sortOrder: "desc" as "asc" | "desc",
|
||||
});
|
||||
const filterText = ref(viewState.filterText);
|
||||
const typeFilter = ref<TypeFilter>(viewState.typeFilter);
|
||||
const currentPage = ref(viewState.currentPage);
|
||||
const rowsPerPage = ref(viewState.rowsPerPage);
|
||||
const rowsPerPageOptions = [10, 25, 50, 100];
|
||||
const sortBy = ref<string>("status");
|
||||
const sortOrder = ref<"asc" | "desc">("desc");
|
||||
const sortBy = ref<string>(viewState.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(viewState.sortOrder);
|
||||
const oTableRef = ref<any>(null);
|
||||
watch(filterText, () => (currentPage.value = 1));
|
||||
watch([filterText, typeFilter, currentPage, rowsPerPage, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
filterText: filterText.value,
|
||||
typeFilter: typeFilter.value,
|
||||
currentPage: currentPage.value,
|
||||
rowsPerPage: rowsPerPage.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
let pageRestored = false;
|
||||
watch(isLoading, (loadingNow) => {
|
||||
if (loadingNow || pageRestored) return;
|
||||
pageRestored = true;
|
||||
// Macrotask so the restored page wins over the table's own reset on data change.
|
||||
setTimeout(() => oTableRef.value?.restorePage?.(currentPage.value), 0);
|
||||
});
|
||||
interface StreamSchemaFlags {
|
||||
org: string;
|
||||
stream: string;
|
||||
|
||||
@@ -163,13 +163,6 @@ export default createStore({
|
||||
// Share URL state for Safari-compatible clipboard copy
|
||||
// Polling mechanism checks this value and copies when available
|
||||
pendingShortURL: null,
|
||||
// Alert list filter state — persisted across navigation so returning
|
||||
// from add/edit alert screens restores the previous search & toggle state
|
||||
alertListFilters: {
|
||||
searchQuery: "",
|
||||
filterQuery: "",
|
||||
searchAcrossFolders: false,
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
login(state, payload) {
|
||||
@@ -382,9 +375,6 @@ export default createStore({
|
||||
clearPendingShortURL(state) {
|
||||
state.pendingShortURL = null;
|
||||
},
|
||||
setAlertListFilters(state, payload) {
|
||||
state.alertListFilters = { ...state.alertListFilters, ...payload };
|
||||
},
|
||||
/**
|
||||
* Cache the alert library manifest, stamping the time the TTL is measured
|
||||
* from. Leaving lastFetched unset would make a warm cache look permanently
|
||||
|
||||
@@ -190,11 +190,6 @@ const store = createStore({
|
||||
allAlertsListByFolderId: {},
|
||||
allAlertsListByNames: {},
|
||||
},
|
||||
alertListFilters: {
|
||||
searchQuery: "",
|
||||
filterQuery: "",
|
||||
searchAcrossFolders: false,
|
||||
},
|
||||
githubDashboardGallery: {
|
||||
dashboards: [] as any[],
|
||||
lastFetched: null as number | null,
|
||||
@@ -379,9 +374,6 @@ const store = createStore({
|
||||
clearPendingShortURL() {
|
||||
// Mock mutation for tests - clears pending short URL state
|
||||
},
|
||||
setAlertListFilters(state, payload) {
|
||||
state.alertListFilters = { ...state.alertListFilters, ...payload };
|
||||
},
|
||||
setAlertLibraryManifest(state, payload) {
|
||||
state.alertLibrary.manifest = payload;
|
||||
state.alertLibrary.lastFetched = Date.now();
|
||||
|
||||
@@ -41,6 +41,25 @@ describe("synthetics route builders", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the status-pages tab and omits section for checks", () => {
|
||||
expect(syntheticsListRoute(CTX, { section: "status-pages" })).toEqual({
|
||||
name: "synthetics",
|
||||
query: { org_identifier: "acme", folder: "f_ksuid_01", section: "status-pages" },
|
||||
});
|
||||
expect(syntheticsListRoute(CTX, { section: "checks" })).toEqual({
|
||||
name: "synthetics",
|
||||
query: { org_identifier: "acme", folder: "f_ksuid_01" },
|
||||
});
|
||||
});
|
||||
|
||||
it("merges extra query params into the results route", () => {
|
||||
expect(syntheticsResultsRoute(CTX, "m1", { extra: { period: "1h", run: "r1" } })).toEqual({
|
||||
name: "synthetic-monitor-results",
|
||||
params: { id: "m1" },
|
||||
query: { org_identifier: "acme", folder: "f_ksuid_01", period: "1h", run: "r1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("carries the folder and type into the create wizard", () => {
|
||||
expect(syntheticsCreateRoute(CTX, "browser")).toEqual({
|
||||
name: "synthetics-add",
|
||||
|
||||
@@ -59,13 +59,13 @@ function baseQuery(ctx: SyntheticsNavContext): Record<string, string> {
|
||||
return query;
|
||||
}
|
||||
|
||||
/** The checks list. `section: "private"` opens the Private Locations tab. */
|
||||
/** The checks list. Any `section` other than "checks" opens that tab. */
|
||||
export function syntheticsListRoute(
|
||||
ctx: SyntheticsNavContext,
|
||||
opts: { section?: "checks" | "private" } = {},
|
||||
opts: { section?: "checks" | "private" | "status-pages" | "variables" } = {},
|
||||
): RouteLocationRaw {
|
||||
const query = baseQuery(ctx);
|
||||
if (opts.section === "private") query.section = "private";
|
||||
if (opts.section && opts.section !== "checks") query.section = opts.section;
|
||||
return { name: "synthetics", query };
|
||||
}
|
||||
|
||||
@@ -96,9 +96,10 @@ export function statusPageEditRoute(ctx: SyntheticsNavContext, id: string): Rout
|
||||
export function syntheticsResultsRoute(
|
||||
ctx: SyntheticsNavContext,
|
||||
id: string,
|
||||
opts: { name?: string; lastTriggeredAt?: number } = {},
|
||||
opts: { name?: string; lastTriggeredAt?: number; extra?: Record<string, string> } = {},
|
||||
): RouteLocationRaw {
|
||||
const query = baseQuery(ctx);
|
||||
Object.assign(query, opts.extra);
|
||||
if (opts.name) query.name = opts.name;
|
||||
if (opts.lastTriggeredAt && opts.lastTriggeredAt > 0) {
|
||||
query.last_triggered_at = String(opts.lastTriggeredAt);
|
||||
|
||||
@@ -21,6 +21,7 @@ vi.mock("@/services/product_analytics", () => ({ default: { track: vi.fn() } }))
|
||||
import analytics from "@/services/product_analytics";
|
||||
import dashboardService from "@/services/dashboards";
|
||||
import { deleteDashboardById } from "@/utils/commons";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
vi.mock("@/utils/commons", () => ({
|
||||
deleteDashboardById: vi.fn().mockResolvedValue({}),
|
||||
@@ -946,6 +947,74 @@ describe("Dashboards.vue", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("List view state persistence", () => {
|
||||
const defaults = {
|
||||
searchQuery: "",
|
||||
filterQuery: "",
|
||||
searchAcrossFolders: false,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
pageSize: 20,
|
||||
};
|
||||
|
||||
beforeEach(() => resetListViewState());
|
||||
afterEach(() => resetListViewState());
|
||||
|
||||
const settle = async () => {
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
};
|
||||
const mountSettled = async () => {
|
||||
await router.push({ path: "/dashboards", query: { folder: "default" } });
|
||||
wrapper = shallowMount(Dashboards, {
|
||||
global: buildGlobalConfig(createMockStore(), router, i18n),
|
||||
});
|
||||
await settle();
|
||||
await flushPromises();
|
||||
await settle();
|
||||
};
|
||||
|
||||
it("seeds the search text, sort and page size from the saved view", async () => {
|
||||
Object.assign(useListViewState("dashboards", defaults), {
|
||||
filterQuery: "cpu",
|
||||
sortBy: "title",
|
||||
sortOrder: "desc",
|
||||
pageSize: 50,
|
||||
});
|
||||
await mountSettled();
|
||||
|
||||
expect(wrapper.vm.filterQuery).toBe("cpu");
|
||||
expect(wrapper.vm.sortBy).toBe("title");
|
||||
expect(wrapper.vm.sortOrder).toBe("desc");
|
||||
expect(wrapper.vm.pageSize).toBe(50);
|
||||
});
|
||||
|
||||
it("keeps a saved cross-folder search through the landing run", async () => {
|
||||
Object.assign(useListViewState("dashboards", defaults), {
|
||||
searchAcrossFolders: true,
|
||||
searchQuery: "cpu",
|
||||
});
|
||||
await mountSettled();
|
||||
|
||||
expect(wrapper.vm.searchAcrossFolders).toBe(true);
|
||||
expect(wrapper.vm.searchQuery).toBe("cpu");
|
||||
});
|
||||
|
||||
it("saves search and sort changes into the view state", async () => {
|
||||
await mountSettled();
|
||||
|
||||
wrapper.vm.filterQuery = "mem";
|
||||
wrapper.vm.sortBy = "created";
|
||||
await flushPromises();
|
||||
|
||||
expect(useListViewState("dashboards", defaults)).toMatchObject({
|
||||
filterQuery: "mem",
|
||||
sortBy: "created",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Methods", () => {
|
||||
it("should handle addDashboard method", async () => {
|
||||
wrapper = shallowMount(Dashboards, {
|
||||
|
||||
@@ -141,8 +141,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
show-index
|
||||
:global-filter="filterQuery"
|
||||
:show-global-filter="false"
|
||||
:page-size="20"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[20, 50, 100, 250, 500]"
|
||||
@update:page-size="pageSize = $event"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
:current-page="currentPage"
|
||||
@update:current-page="onPageChange"
|
||||
selection="multiple"
|
||||
@@ -581,6 +586,7 @@ import useNotifications from "@/composables/useNotifications";
|
||||
import { debounce } from "lodash-es";
|
||||
import { convertDashboardSchemaVersion } from "@/utils/dashboard/convertDashboardSchemaVersion";
|
||||
import { useLoading } from "@/composables/useLoading";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import { useReo } from "@/services/reodotdev_analytics";
|
||||
import { useAiDashboardEvents } from "@/composables/useAiDashboardEvents";
|
||||
import type { AiDashboardEvent } from "@/composables/useAiDashboardEvents";
|
||||
@@ -685,14 +691,35 @@ export default defineComponent({
|
||||
const isFolderEditMode = ref(false);
|
||||
const selectedFolderDelete = ref<string | null>(null);
|
||||
const selectedFolderToEdit = ref<string | null>(null);
|
||||
const searchQuery = ref("");
|
||||
const savedView = useListViewState("dashboards", {
|
||||
searchQuery: "",
|
||||
filterQuery: "",
|
||||
searchAcrossFolders: false,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
pageSize: 20,
|
||||
});
|
||||
const searchQuery = ref(savedView.searchQuery);
|
||||
const filteredResults = ref<DashboardSearchResult[]>([]);
|
||||
const confirmDeleteFolderDialog = ref<boolean>(false);
|
||||
const selectedDashboardToMove = ref<DashboardRow | null>(null);
|
||||
const selectedDashboardIdToMove = ref<string[] | null>(null);
|
||||
const showMoveDashboardDialog = ref(false);
|
||||
const searchAcrossFolders = ref(false);
|
||||
const filterQuery = ref("");
|
||||
const searchAcrossFolders = ref(savedView.searchAcrossFolders);
|
||||
const filterQuery = ref(savedView.filterQuery);
|
||||
const sortBy = ref<string>(savedView.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(savedView.sortOrder);
|
||||
const pageSize = ref<number>(savedView.pageSize);
|
||||
watch([searchQuery, filterQuery, searchAcrossFolders, sortBy, sortOrder, pageSize], () => {
|
||||
Object.assign(savedView, {
|
||||
searchQuery: searchQuery.value,
|
||||
filterQuery: filterQuery.value,
|
||||
searchAcrossFolders: searchAcrossFolders.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
pageSize: pageSize.value,
|
||||
});
|
||||
});
|
||||
const folderSearchQuery = ref("");
|
||||
const selectedIds = ref<string[]>([]);
|
||||
const { track } = useReo();
|
||||
@@ -967,7 +994,7 @@ export default defineComponent({
|
||||
// A folder switched away from mid-flight must not stamp over the one now active.
|
||||
if (activeFolderId.value === FAVORITES_FOLDER_ID) stampFolders(favFolders);
|
||||
});
|
||||
searchAcrossFolders.value = false;
|
||||
if (switching) searchAcrossFolders.value = false;
|
||||
router.push({
|
||||
path: "/dashboards",
|
||||
query: {
|
||||
@@ -1000,7 +1027,9 @@ export default defineComponent({
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
searchAcrossFolders.value = false;
|
||||
// The landing run is the return-from-detail restore, which keeps the saved cross-folder search.
|
||||
if (switching) searchAcrossFolders.value = false;
|
||||
else if (searchAcrossFolders.value && searchQuery.value) void runAcrossFoldersSearch();
|
||||
router.push({
|
||||
path: "/dashboards",
|
||||
query: {
|
||||
@@ -1028,6 +1057,25 @@ export default defineComponent({
|
||||
}
|
||||
});
|
||||
|
||||
const runAcrossFoldersSearch = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// Cancel any in-flight search
|
||||
if (currentSearchAbortController) {
|
||||
currentSearchAbortController.abort();
|
||||
}
|
||||
currentSearchAbortController = new AbortController();
|
||||
const searchResults = await fetchSearchResults.execute(searchQuery.value);
|
||||
filteredResults.value = toRaw(searchResults);
|
||||
} catch (error) {
|
||||
if (asCaughtError(error).name !== "AbortError") {
|
||||
filteredResults.value = [];
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(searchAcrossFolders, async (newVal) => {
|
||||
if (newVal) {
|
||||
// If searching across folders, use searchQuery
|
||||
@@ -1036,23 +1084,7 @@ export default defineComponent({
|
||||
filterQuery.value = "";
|
||||
}
|
||||
if (searchQuery.value) {
|
||||
loading.value = true;
|
||||
try {
|
||||
// Cancel any in-flight search
|
||||
if (currentSearchAbortController) {
|
||||
currentSearchAbortController.abort();
|
||||
}
|
||||
currentSearchAbortController = new AbortController();
|
||||
const searchResults = await fetchSearchResults.execute(searchQuery.value);
|
||||
filteredResults.value = toRaw(searchResults);
|
||||
} catch (error) {
|
||||
if (asCaughtError(error).name !== "AbortError") {
|
||||
filteredResults.value = [];
|
||||
// Handle error state
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
await runAcrossFoldersSearch();
|
||||
} else {
|
||||
// If no search query, clear filtered results
|
||||
filteredResults.value = [];
|
||||
@@ -1878,6 +1910,9 @@ export default defineComponent({
|
||||
selectedIds,
|
||||
currentPage,
|
||||
onPageChange,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
multipleExportDashboard,
|
||||
moveMultipleDashboards,
|
||||
openBulkDeleteDialog,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import ScheduledDashboards from "./ScheduledDashboards.vue";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
// Mock Vuex store
|
||||
const mockStore = {
|
||||
@@ -23,6 +24,10 @@ vi.mock("vuex", () => ({
|
||||
useStore: vi.fn(() => mockStore),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores", () => ({
|
||||
default: { state: { selectedOrganization: { identifier: "test-org" } } },
|
||||
}));
|
||||
|
||||
vi.mock("vue-router", () => ({
|
||||
useRouter: vi.fn(() => mockRouter),
|
||||
}));
|
||||
@@ -115,6 +120,8 @@ const i18n = createI18n({
|
||||
});
|
||||
|
||||
describe("ScheduledDashboards", () => {
|
||||
beforeEach(() => resetListViewState());
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
reports: [],
|
||||
@@ -627,4 +634,50 @@ describe("ScheduledDashboards", () => {
|
||||
expect(wrapper.findAll('[data-test="alert-list-add-alert-btn"]').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("view state persistence", () => {
|
||||
const defaults = {
|
||||
activeTab: "cached",
|
||||
filterQuery: "",
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
pageSize: 20,
|
||||
currentPage: 1,
|
||||
};
|
||||
|
||||
it("seeds tab, search, sort and paging from the saved view", () => {
|
||||
Object.assign(useListViewState("scheduled-dashboards", defaults), {
|
||||
activeTab: "shared",
|
||||
filterQuery: "daily",
|
||||
sortBy: "name",
|
||||
sortOrder: "desc",
|
||||
pageSize: 50,
|
||||
currentPage: 2,
|
||||
});
|
||||
const vm = createWrapper().vm as any;
|
||||
|
||||
expect(vm.scheduledActiveTab).toBe("shared");
|
||||
expect(vm.scheduledFilterQuery).toBe("daily");
|
||||
expect(vm.sortBy).toBe("name");
|
||||
expect(vm.sortOrder).toBe("desc");
|
||||
expect(vm.selectedPerPage).toBe(50);
|
||||
expect(vm.currentPage).toBe(2);
|
||||
});
|
||||
|
||||
it("saves changes into the view state and resets the page on a tab switch", async () => {
|
||||
Object.assign(useListViewState("scheduled-dashboards", defaults), { currentPage: 3 });
|
||||
const vm = createWrapper().vm as any;
|
||||
|
||||
vm.scheduledFilterQuery = "weekly";
|
||||
vm.scheduledActiveTab = "shared";
|
||||
await flushPromises();
|
||||
|
||||
expect(vm.currentPage).toBe(1);
|
||||
expect(useListViewState("scheduled-dashboards", defaults)).toMatchObject({
|
||||
filterQuery: "weekly",
|
||||
activeTab: "shared",
|
||||
currentPage: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<div data-test="scheduled-dashboards-container" class="scheduled-dashboards h-full min-h-0">
|
||||
<OTable
|
||||
ref="oTableRef"
|
||||
class="w-full"
|
||||
data-test="scheduled-dashboard-table"
|
||||
:data="displayReports"
|
||||
@@ -59,6 +60,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
pagination="client"
|
||||
:page-size="selectedPerPage"
|
||||
:page-size-options="perPageOptionsList"
|
||||
:current-page="currentPage"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:page-size="selectedPerPage = $event"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
:show-global-filter="false"
|
||||
:default-columns="false"
|
||||
show-index
|
||||
@@ -130,6 +138,7 @@ import OTable from "@/lib/core/Table/OTable.vue";
|
||||
import OTimeCell from "@/lib/core/Table/cells/OTimeCell.vue";
|
||||
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
|
||||
import { COL } from "@/lib/core/Table/OTable.types";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
@@ -170,8 +179,20 @@ const { t } = useI18nTyped();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const scheduledFilterQuery = ref("");
|
||||
const scheduledActiveTab = ref("cached");
|
||||
const savedView = useListViewState("scheduled-dashboards", {
|
||||
activeTab: "cached",
|
||||
filterQuery: "",
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
pageSize: 20,
|
||||
currentPage: 1,
|
||||
});
|
||||
const scheduledFilterQuery = ref(savedView.filterQuery);
|
||||
const scheduledActiveTab = ref(savedView.activeTab);
|
||||
const sortBy = ref<string>(savedView.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(savedView.sortOrder);
|
||||
const currentPage = ref<number>(savedView.currentPage);
|
||||
const oTableRef = ref<any>(null);
|
||||
const scheduledReportTypeTabs = reactive([
|
||||
{ label: t("reports.cached"), value: "cached", icon: "database" },
|
||||
{ label: t("reports.scheduled"), value: "shared", icon: "schedule" },
|
||||
@@ -213,8 +234,10 @@ watch(
|
||||
);
|
||||
|
||||
watch(scheduledActiveTab, () => {
|
||||
currentPage.value = 1;
|
||||
filterReports();
|
||||
});
|
||||
|
||||
//this makes sure that reports are formatted when the component is mounted
|
||||
//because sometimes watcher might not be triggered if the props are already set
|
||||
onMounted(() => {
|
||||
@@ -306,7 +329,31 @@ const columns: OTableColumnDef[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const selectedPerPage = ref(20);
|
||||
const selectedPerPage = ref<number>(savedView.pageSize);
|
||||
|
||||
watch(
|
||||
[scheduledActiveTab, scheduledFilterQuery, sortBy, sortOrder, selectedPerPage, currentPage],
|
||||
() => {
|
||||
Object.assign(savedView, {
|
||||
activeTab: scheduledActiveTab.value,
|
||||
filterQuery: scheduledFilterQuery.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
pageSize: selectedPerPage.value,
|
||||
currentPage: currentPage.value,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// setTimeout(0) runs after TanStack's deferred auto-reset on the first data load, so the restored page wins.
|
||||
let pageRestored = false;
|
||||
watch(formattedReports, (rows) => {
|
||||
if (pageRestored || !rows.length) return;
|
||||
pageRestored = true;
|
||||
setTimeout(() => {
|
||||
(oTableRef.value as any)?.restorePage?.(currentPage.value);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const perPageOptionsList = [5, 10, 20, 50, 100];
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import router from "@/test/unit/helpers/router";
|
||||
import streamService from "@/services/stream";
|
||||
import useStreams from "@/composables/useStreams";
|
||||
import analytics from "@/services/product_analytics";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
// Mock services
|
||||
vi.mock("@/services/stream", async (importOriginal) => {
|
||||
@@ -196,6 +197,7 @@ describe("LogStream Component", () => {
|
||||
// Reset mocks completely
|
||||
vi.clearAllMocks();
|
||||
mockNotify.mockClear();
|
||||
resetListViewState();
|
||||
|
||||
// Setup mock implementations with fresh instances
|
||||
mockUseStreams = {
|
||||
@@ -315,6 +317,39 @@ describe("LogStream Component", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("View state restore", () => {
|
||||
it("restores saved filter, type, page and sort on mount", async () => {
|
||||
resetListViewState();
|
||||
useListViewState("logStreams", {
|
||||
filterQuery: "abc",
|
||||
streamType: "metrics",
|
||||
pageSize: 50,
|
||||
currentPage: 3,
|
||||
sortBy: "doc_num",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
const restored = mountLogStream();
|
||||
await flushPromises();
|
||||
expect(restored.vm.filterQuery).toBe("abc");
|
||||
expect(restored.vm.selectedStreamType).toBe("metrics");
|
||||
expect(restored.vm.streamActiveTab).toBe("metrics");
|
||||
expect(restored.vm.pageSize).toBe(50);
|
||||
expect(restored.vm.currentPage).toBe(3);
|
||||
expect(restored.vm.sortBy).toBe("doc_num");
|
||||
expect(restored.vm.sortOrder).toBe("desc");
|
||||
restored.unmount();
|
||||
});
|
||||
|
||||
it("saves changes to the view state", async () => {
|
||||
wrapper.vm.pageSize = 100;
|
||||
wrapper.vm.sortOrder = "desc";
|
||||
await flushPromises();
|
||||
const saved: any = useListViewState("logStreams", {});
|
||||
expect(saved.pageSize).toBe(100);
|
||||
expect(saved.sortOrder).toBe("desc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Computed Properties", () => {
|
||||
it("should check if schema UDS is enabled", () => {
|
||||
store.state.zoConfig.user_defined_schemas_enabled = true;
|
||||
|
||||
@@ -429,6 +429,7 @@ import { orgSummaryQuery } from "@/services/organizations.queries";
|
||||
import { computed, defineComponent, ref, onActivated, onBeforeMount, type Ref, watch } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
|
||||
import OTable from "@/lib/core/Table/OTable.vue";
|
||||
@@ -498,9 +499,17 @@ export default defineComponent({
|
||||
const selectedIds = ref<string[]>([]);
|
||||
const orgData: any = ref(store.state.selectedOrganization);
|
||||
const previousOrgIdentifier = ref("");
|
||||
const filterQuery = ref("");
|
||||
const viewState = useListViewState("logStreams", {
|
||||
filterQuery: "",
|
||||
streamType: "logs",
|
||||
pageSize: 20,
|
||||
currentPage: 1,
|
||||
sortBy: "name",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const filterQuery = ref(viewState.filterQuery);
|
||||
const duplicateStreamList: Ref<any[]> = ref([]);
|
||||
const selectedStreamType = ref("logs");
|
||||
const selectedStreamType = ref(viewState.streamType);
|
||||
const loadingState = ref(true);
|
||||
// A refresh with rows already on screen: the button spins, the table does not
|
||||
// go back to a skeleton.
|
||||
@@ -509,14 +518,14 @@ export default defineComponent({
|
||||
const forbidden = ref(false);
|
||||
const searchKeyword = ref("");
|
||||
const deleteAssociatedAlertsPipelines = ref(true);
|
||||
const streamActiveTab = ref("logs");
|
||||
const streamActiveTab = ref(viewState.streamType);
|
||||
const { track } = useReo();
|
||||
|
||||
const pageSize = ref(20);
|
||||
const pageSize = ref(viewState.pageSize);
|
||||
const pageSizeOptions = [20, 50, 100, 250, 500];
|
||||
const currentPage = ref(1);
|
||||
const sortBy = ref("name");
|
||||
const sortOrder = ref<"asc" | "desc">("asc");
|
||||
const currentPage = ref(viewState.currentPage);
|
||||
const sortBy = ref(viewState.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(viewState.sortOrder);
|
||||
const totalCount = ref(0);
|
||||
|
||||
const selectedItems = computed(() =>
|
||||
@@ -1162,6 +1171,17 @@ export default defineComponent({
|
||||
getLogStream();
|
||||
});
|
||||
|
||||
watch([filterQuery, selectedStreamType, pageSize, currentPage, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
filterQuery: filterQuery.value,
|
||||
streamType: selectedStreamType.value,
|
||||
pageSize: pageSize.value,
|
||||
currentPage: currentPage.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
|
||||
watch(filterQuery, () => {
|
||||
currentPage.value = 1;
|
||||
getLogStream();
|
||||
|
||||
@@ -17,6 +17,7 @@ import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import i18n from "@/locales";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
import oncallService from "@/services/oncall";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import OnCallPolicies from "@/views/OnCall/OnCallPolicies.vue";
|
||||
@@ -34,11 +35,18 @@ const stubs = {
|
||||
OPageLayout: { name: "OPageLayout", template: "<div><slot /></div>" },
|
||||
OTable: {
|
||||
name: "OTable",
|
||||
props: ["data", "columns"],
|
||||
template: "<div><slot name='subheader' /><slot name='toolbar-trailing' /></div>",
|
||||
props: ["data", "columns", "currentPage", "pageSize", "sortBy", "sortOrder"],
|
||||
emits: ["update:currentPage", "update:pageSize", "update:sortBy", "update:sortOrder"],
|
||||
template:
|
||||
"<div><slot name='subheader' /><slot name='toolbar' /><slot name='toolbar-trailing' /></div>",
|
||||
},
|
||||
OStatStrip: { name: "OStatStrip", props: ["items"], template: "<div />" },
|
||||
OSearchInput: { name: "OSearchInput", template: "<div />" },
|
||||
OSearchInput: {
|
||||
name: "OSearchInput",
|
||||
props: ["modelValue"],
|
||||
emits: ["update:modelValue"],
|
||||
template: "<div />",
|
||||
},
|
||||
OButton: { name: "OButton", template: "<button />" },
|
||||
OTooltip: { name: "OTooltip", template: "<div />" },
|
||||
OEmptyState: { name: "OEmptyState", template: "<div />" },
|
||||
@@ -70,6 +78,7 @@ const statsOf = (w: any) =>
|
||||
describe("OnCallPolicies", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetListViewState();
|
||||
service.listTeams.mockResolvedValue({ data: TEAMS } as any);
|
||||
service.whoIsOnCall.mockResolvedValue({
|
||||
data: [{ rotation: "Weekdays", user_email: "ana@o2.ai", next_user_email: "bob@o2.ai" }],
|
||||
@@ -194,4 +203,41 @@ describe("OnCallPolicies", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("list view state", () => {
|
||||
it("restores search, page, page size and sort", async () => {
|
||||
Object.assign(
|
||||
useListViewState("oncall-policies", {
|
||||
search: "",
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
sortBy: "",
|
||||
sortOrder: "asc",
|
||||
}),
|
||||
{ search: "pay", currentPage: 2, pageSize: 50, sortBy: "teamName", sortOrder: "desc" },
|
||||
);
|
||||
const wrapper = render();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.findComponent({ name: "OSearchInput" }).props("modelValue")).toBe("pay");
|
||||
const table = wrapper.findComponent({ name: "OTable" });
|
||||
expect(table.props("currentPage")).toBe(2);
|
||||
expect(table.props("pageSize")).toBe(50);
|
||||
expect(table.props("sortBy")).toBe("teamName");
|
||||
expect(table.props("sortOrder")).toBe("desc");
|
||||
});
|
||||
|
||||
it("saves table changes", async () => {
|
||||
const wrapper = render();
|
||||
await flushPromises();
|
||||
const table = wrapper.findComponent({ name: "OTable" });
|
||||
table.vm.$emit("update:currentPage", 4);
|
||||
table.vm.$emit("update:sortOrder", "desc");
|
||||
await flushPromises();
|
||||
|
||||
const saved = useListViewState("oncall-policies", {}) as Record<string, unknown>;
|
||||
expect(saved.currentPage).toBe(4);
|
||||
expect(saved.sortOrder).toBe("desc");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
}"
|
||||
>
|
||||
<OTable
|
||||
ref="oTableRef"
|
||||
:frame="false"
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
@@ -51,7 +52,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:show-global-filter="false"
|
||||
:enable-column-resize="true"
|
||||
data-test="oncall-policies-table"
|
||||
:page-size="pageSize"
|
||||
:current-page="currentPage"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@row-click="openTeam"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:page-size="pageSize = $event"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
>
|
||||
<template #subheader>
|
||||
<div class="px-page-edge border-table-row-divider border-b py-1.5">
|
||||
@@ -134,7 +143,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useStore } from "vuex";
|
||||
|
||||
@@ -150,6 +159,7 @@ import type { StatItem } from "@/lib/data/StatStrip/OStatStrip.types";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import { oncallTeamsQuery, teamPolicyQuery, whoIsOnCallQuery } from "@/services/oncall.queries";
|
||||
import type { OnCallPolicy, OnCallPosition, OnCallTeam } from "@/ts/interfaces/oncall";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
@@ -175,7 +185,39 @@ const ABSENT = raw("—");
|
||||
const allRows = ref<PolicyRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const lastUpdatedAt = ref<number | null>(null);
|
||||
const search = ref("");
|
||||
const savedView = useListViewState("oncall-policies", {
|
||||
search: "",
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const search = ref(savedView.search);
|
||||
const currentPage = ref(savedView.currentPage);
|
||||
const pageSize = ref(savedView.pageSize);
|
||||
const sortBy = ref(savedView.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(savedView.sortOrder);
|
||||
const oTableRef = ref<any>(null);
|
||||
|
||||
watch(search, () => {
|
||||
currentPage.value = 1;
|
||||
});
|
||||
watch([search, currentPage, pageSize, sortBy, sortOrder], () => {
|
||||
Object.assign(savedView, {
|
||||
search: search.value,
|
||||
currentPage: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
// The first load lands after mount and TanStack resets to page 0 on its own deferred queue; setTimeout(0) runs after it so the restored page wins.
|
||||
let pageRestored = false;
|
||||
watch(loading, (isLoading) => {
|
||||
if (isLoading || pageRestored) return;
|
||||
pageRestored = true;
|
||||
setTimeout(() => oTableRef.value?.restorePage?.(currentPage.value), 0);
|
||||
});
|
||||
|
||||
const orgId = computed(() => store.state.selectedOrganization.identifier);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import destinationService from "@/services/alert_destination";
|
||||
import incidentsService from "@/services/incidents";
|
||||
import oncallService from "@/services/oncall";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
import OnCallResponses from "@/views/OnCall/OnCallResponses.vue";
|
||||
|
||||
vi.mock("@/services/oncall", () => ({
|
||||
@@ -122,8 +123,16 @@ const stubs = {
|
||||
"columnVisibility",
|
||||
"rowSection",
|
||||
"sectionOrder",
|
||||
"currentPage",
|
||||
"pageSize",
|
||||
],
|
||||
emits: [
|
||||
"update:selectedIds",
|
||||
"update:currentPage",
|
||||
"update:pageSize",
|
||||
"update:sortBy",
|
||||
"update:sortOrder",
|
||||
],
|
||||
emits: ["update:selectedIds"],
|
||||
// Sections are resolved the way the real component resolves them, so these
|
||||
// tests exercise the view's own `rowSection` rather than a stub's guess.
|
||||
computed: {
|
||||
@@ -247,6 +256,9 @@ const team = {
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
// File-level so every describe starts from an empty saved view.
|
||||
beforeEach(() => resetListViewState());
|
||||
|
||||
function render() {
|
||||
return mountTracked(OnCallResponses, { global: { plugins: [i18n, store], stubs } });
|
||||
}
|
||||
@@ -1002,6 +1014,71 @@ describe("OnCallResponses", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("list view state", () => {
|
||||
const seed = () =>
|
||||
useListViewState("oncall-responses", {
|
||||
search: "",
|
||||
teamFilter: "all",
|
||||
priorityFilter: "all",
|
||||
mineOnly: false,
|
||||
grouped: true,
|
||||
activeFilter: "all",
|
||||
causeFilter: "",
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
sortBy: "last_alert_at",
|
||||
sortOrder: "desc",
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const tabs = (w: VueWrapper) =>
|
||||
w
|
||||
.findAllComponents({ name: "OToggleGroup" })
|
||||
.find((c) => c.attributes("data-test") === "oncall-responses-filter-tabs")!;
|
||||
|
||||
it("restores the filter tab, page, page size and sort", async () => {
|
||||
Object.assign(seed(), {
|
||||
activeFilter: "ringing",
|
||||
teamFilter: "team_1",
|
||||
grouped: false,
|
||||
currentPage: 2,
|
||||
pageSize: 50,
|
||||
sortBy: "priority",
|
||||
sortOrder: "asc",
|
||||
});
|
||||
const wrapper = await withPages([page({ id: "a" })]);
|
||||
|
||||
expect(tabs(wrapper).props("modelValue")).toBe("ringing");
|
||||
const table = wrapper.findComponent({ name: "OTable" });
|
||||
expect(table.props("currentPage")).toBe(2);
|
||||
expect(table.props("pageSize")).toBe(50);
|
||||
expect(table.props("sortBy")).toBe("priority");
|
||||
expect(table.props("sortOrder")).toBe("asc");
|
||||
});
|
||||
|
||||
it("saves tab and table changes and resets the page on a filter change", async () => {
|
||||
const wrapper = await withPages([page({ id: "a" })]);
|
||||
const table = wrapper.findComponent({ name: "OTable" });
|
||||
table.vm.$emit("update:currentPage", 3);
|
||||
table.vm.$emit("update:sortBy", "priority");
|
||||
await flushPromises();
|
||||
|
||||
tabs(wrapper).vm.$emit("update:modelValue", "handled");
|
||||
await flushPromises();
|
||||
|
||||
const saved = seed();
|
||||
expect(saved.sortBy).toBe("priority");
|
||||
expect(saved.activeFilter).toBe("handled");
|
||||
expect(saved.currentPage).toBe(1);
|
||||
});
|
||||
|
||||
it("lets ?mine=1 override a saved mine=false", async () => {
|
||||
Object.assign(seed(), { mineOnly: false });
|
||||
routeQuery.mine = "1";
|
||||
await withPages([page({ id: "a" })]);
|
||||
expect(seed().mineOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/// "My on-call" narrows THIS list. It used to navigate, which threw away
|
||||
/// every other filter the reader had set and answered the question from a
|
||||
/// different dataset on a different screen.
|
||||
|
||||
@@ -101,6 +101,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
"On call now" column. -->
|
||||
<OTable
|
||||
v-if="!unavailable && !setupOnly"
|
||||
ref="oTableRef"
|
||||
:frame="false"
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
@@ -109,9 +110,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:streaming="backgroundLoading"
|
||||
:error="loadError"
|
||||
pagination="client"
|
||||
:page-size="20"
|
||||
sort-by="last_alert_at"
|
||||
sort-order="desc"
|
||||
:page-size="pageSize"
|
||||
:current-page="currentPage"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
:column-visibility="{
|
||||
firings: false,
|
||||
state: false,
|
||||
@@ -132,6 +134,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
expansion="single"
|
||||
v-model:expanded-ids="expandedIds"
|
||||
@row-click="openResponse"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:page-size="pageSize = $event"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
>
|
||||
<!-- During an incident the list IS the work surface. Opening 200 pages
|
||||
one at a time to claim them is not triage. -->
|
||||
@@ -884,6 +890,7 @@ import OText from "@/lib/core/Typography/OText.vue";
|
||||
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import useBreakpoint from "@/composables/useBreakpoint";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OInnerLoading from "@/lib/feedback/InnerLoading/OInnerLoading.vue";
|
||||
@@ -1037,35 +1044,55 @@ const loadError = ref<string | null>(null);
|
||||
/// §G.8.1: 404 or 403 "Not Supported" from the entry fetch — on-call is not
|
||||
/// available on this deployment. A fact about the build, never an error.
|
||||
const unavailable = ref(false);
|
||||
const search = ref("");
|
||||
const teamFilter = ref("all");
|
||||
const priorityFilter = ref("all");
|
||||
const savedView = useListViewState("oncall-responses", {
|
||||
search: "",
|
||||
teamFilter: "all",
|
||||
priorityFilter: "all",
|
||||
mineOnly: false,
|
||||
grouped: true,
|
||||
activeFilter: "all" as "all" | SectionKey,
|
||||
causeFilter: "" as ResolutionCause | "",
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
sortBy: "last_alert_at",
|
||||
sortOrder: "desc" as "asc" | "desc",
|
||||
});
|
||||
const search = ref(savedView.search);
|
||||
const teamFilter = ref(savedView.teamFilter);
|
||||
const priorityFilter = ref(savedView.priorityFilter);
|
||||
/// `?mine=1` opens the list already narrowed. It is what the retired
|
||||
/// `oncall/me` page now redirects to, so a bookmark from before still lands on
|
||||
/// the answer instead of a stub that told everybody they were on no team.
|
||||
const mineOnly = ref(false);
|
||||
const mineOnly = ref(savedView.mineOnly);
|
||||
const selectedIds = ref<string[]>([]);
|
||||
const grouped = ref(true);
|
||||
const grouped = ref(savedView.grouped);
|
||||
/// `"all"` is the default tab, and "all" means all — resolved included —
|
||||
/// so the very first fetch already asks for resolved pages too.
|
||||
const includeResolved = ref(true);
|
||||
const includeResolved = ref(
|
||||
savedView.activeFilter === "all" || savedView.activeFilter === "resolved",
|
||||
);
|
||||
/// All four sections start visible, matching the default `"all"` tab.
|
||||
const sectionVisibility = ref<Record<SectionKey, boolean>>({
|
||||
ringing: true,
|
||||
snoozed: true,
|
||||
handled: true,
|
||||
resolved: true,
|
||||
ringing: savedView.activeFilter === "all" || savedView.activeFilter === "ringing",
|
||||
snoozed: savedView.activeFilter === "all" || savedView.activeFilter === "snoozed",
|
||||
handled: savedView.activeFilter === "all" || savedView.activeFilter === "handled",
|
||||
resolved: includeResolved.value,
|
||||
});
|
||||
/// The tab the toolbar shows as pressed. `"all"` is every section at once
|
||||
/// (open and resolved alike) and has no `SectionKey` of its own; picking any
|
||||
/// other tab narrows `sectionVisibility` to just that one section — and, for
|
||||
/// every tab but `"all"` and `"resolved"`, drops resolved pages out of the
|
||||
/// fetch entirely.
|
||||
const activeFilter = ref<"all" | SectionKey>("all");
|
||||
const activeFilter = ref<"all" | SectionKey>(savedView.activeFilter);
|
||||
/// Empty means every cause. Cleared whenever resolved pages leave the list,
|
||||
/// because a cause filter over open records matches nothing and would read as
|
||||
/// "there is nothing here" rather than "this filter cannot apply".
|
||||
const causeFilter = ref<ResolutionCause | "">("");
|
||||
const causeFilter = ref<ResolutionCause | "">(includeResolved.value ? savedView.causeFilter : "");
|
||||
const currentPage = ref(savedView.currentPage);
|
||||
const pageSize = ref(savedView.pageSize);
|
||||
const sortBy = ref(savedView.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(savedView.sortOrder);
|
||||
const oTableRef = ref<any>(null);
|
||||
const busyId = ref("");
|
||||
const bulkBusy = ref(false);
|
||||
const confirmBulkResolve = ref(false);
|
||||
@@ -2060,6 +2087,49 @@ function onEmptyAction(id?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
watch([search, teamFilter, priorityFilter, mineOnly, grouped, activeFilter, causeFilter], () => {
|
||||
currentPage.value = 1;
|
||||
});
|
||||
watch(
|
||||
[
|
||||
search,
|
||||
teamFilter,
|
||||
priorityFilter,
|
||||
mineOnly,
|
||||
grouped,
|
||||
activeFilter,
|
||||
causeFilter,
|
||||
currentPage,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
],
|
||||
() => {
|
||||
Object.assign(savedView, {
|
||||
search: search.value,
|
||||
teamFilter: teamFilter.value,
|
||||
priorityFilter: priorityFilter.value,
|
||||
mineOnly: mineOnly.value,
|
||||
grouped: grouped.value,
|
||||
activeFilter: activeFilter.value,
|
||||
causeFilter: causeFilter.value,
|
||||
currentPage: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
},
|
||||
);
|
||||
// The first load lands after mount and TanStack resets to page 0 on its own deferred queue; setTimeout(0) runs after it so the restored page wins.
|
||||
watch(
|
||||
() => loaded.value && setupLoaded.value,
|
||||
(ready) => {
|
||||
if (!ready) return;
|
||||
setTimeout(() => oTableRef.value?.restorePage?.(currentPage.value), 0);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
useShortcuts([
|
||||
{
|
||||
id: "oncallRefresh",
|
||||
@@ -2072,7 +2142,7 @@ useShortcuts([
|
||||
]);
|
||||
|
||||
onMounted(() => {
|
||||
mineOnly.value = route.query.mine === "1";
|
||||
if (route.query.mine === "1") mineOnly.value = true;
|
||||
return loadAll();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { __resetOnCallRoutingConfig } from "@/composables/useOnCallRoutingConfig";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import i18n from "@/locales";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
import oncallService from "@/services/oncall";
|
||||
import { userKeys } from "@/services/users.querykeys";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
@@ -67,8 +68,10 @@ const stubs = {
|
||||
// draws rather than a column-def function OTable never calls.
|
||||
OTable: {
|
||||
name: "OTable",
|
||||
props: ["data", "columns", "error"],
|
||||
props: ["data", "columns", "error", "currentPage", "pageSize", "sortBy", "sortOrder"],
|
||||
emits: ["update:currentPage", "update:pageSize", "update:sortBy", "update:sortOrder"],
|
||||
template: `<div>
|
||||
<slot name='toolbar' />
|
||||
<slot v-if="error" name='error' />
|
||||
<template v-else>
|
||||
<div v-for="(row, i) in (data || [])" :key="i" data-test="row">
|
||||
@@ -86,7 +89,12 @@ const stubs = {
|
||||
emits: ["action"],
|
||||
template: `<div>{{ description }}<button @click="$emit('action')">{{ actionLabel }}</button></div>`,
|
||||
},
|
||||
OSearchInput: { name: "OSearchInput", template: "<input />" },
|
||||
OSearchInput: {
|
||||
name: "OSearchInput",
|
||||
props: ["modelValue"],
|
||||
emits: ["update:modelValue"],
|
||||
template: "<input />",
|
||||
},
|
||||
// `props` declared deliberately: a stub that does not declare what it is
|
||||
// passed drops it silently, and an assertion about a tooltip then passes
|
||||
// with no tooltip mounted.
|
||||
@@ -129,6 +137,7 @@ function onCallCell(wrapper: any, _row?: unknown) {
|
||||
describe("OnCallTeams", () => {
|
||||
beforeEach(() => {
|
||||
__resetOnCallRoutingConfig();
|
||||
resetListViewState();
|
||||
vi.clearAllMocks();
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
for (const key of Object.keys(routeQuery)) delete routeQuery[key];
|
||||
@@ -276,6 +285,55 @@ describe("OnCallTeams", () => {
|
||||
expect(service.whoIsOnCall).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe("list view state", () => {
|
||||
const seed = () =>
|
||||
useListViewState("oncall-teams", {
|
||||
search: "",
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
|
||||
it("restores search, page, page size and sort on mount", async () => {
|
||||
service.listTeams.mockResolvedValue({ data: [team("team_1", "Platform")] } as any);
|
||||
Object.assign(seed(), {
|
||||
search: "plat",
|
||||
currentPage: 2,
|
||||
pageSize: 50,
|
||||
sortBy: "name",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
const wrapper = render();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.findComponent({ name: "OSearchInput" }).props("modelValue")).toBe("plat");
|
||||
const table = wrapper.findComponent({ name: "OTable" });
|
||||
expect(table.props("currentPage")).toBe(2);
|
||||
expect(table.props("pageSize")).toBe(50);
|
||||
expect(table.props("sortBy")).toBe("name");
|
||||
expect(table.props("sortOrder")).toBe("desc");
|
||||
});
|
||||
|
||||
it("saves search, page and sort changes", async () => {
|
||||
service.listTeams.mockResolvedValue({ data: [team("team_1", "Platform")] } as any);
|
||||
const wrapper = render();
|
||||
await flushPromises();
|
||||
|
||||
wrapper.findComponent({ name: "OSearchInput" }).vm.$emit("update:modelValue", "pay");
|
||||
await flushPromises();
|
||||
const table = wrapper.findComponent({ name: "OTable" });
|
||||
table.vm.$emit("update:currentPage", 3);
|
||||
table.vm.$emit("update:sortBy", "name");
|
||||
await flushPromises();
|
||||
|
||||
const saved = seed();
|
||||
expect(saved.search).toBe("pay");
|
||||
expect(saved.currentPage).toBe(3);
|
||||
expect(saved.sortBy).toBe("name");
|
||||
});
|
||||
});
|
||||
|
||||
it("opens a team on row click", async () => {
|
||||
service.listTeams.mockResolvedValue({ data: [team("team_1", "Platform")] } as any);
|
||||
const wrapper = render();
|
||||
|
||||
@@ -35,6 +35,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<OTable
|
||||
ref="oTableRef"
|
||||
:error="loadError"
|
||||
:frame="false"
|
||||
:data="filteredTeams"
|
||||
@@ -48,7 +49,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:show-global-filter="false"
|
||||
:enable-column-resize="true"
|
||||
data-test="oncall-teams-table"
|
||||
:page-size="pageSize"
|
||||
:current-page="currentPage"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@row-click="openTeam"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:page-size="pageSize = $event"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
>
|
||||
<template #toolbar>
|
||||
<div class="flex w-full items-center gap-2 max-md:contents">
|
||||
@@ -298,6 +307,7 @@ import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
|
||||
import OUserCell from "@/lib/core/Table/cells/OUserCell.vue";
|
||||
import { COL, type OTableColumnDef } from "@/lib/core/Table/OTable.types";
|
||||
import { useConfirmDialog } from "@/composables/useConfirmDialog";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import { useOnCallPermissions } from "@/composables/useOnCallPermissions";
|
||||
import { useOnCallRoutingConfig } from "@/composables/useOnCallRoutingConfig";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
@@ -328,7 +338,19 @@ const loading = ref(false);
|
||||
const lastUpdatedAt = ref<number | null>(null);
|
||||
const loadError = ref<string | null>(null);
|
||||
const notAvailable = ref(false);
|
||||
const search = ref("");
|
||||
const savedView = useListViewState("oncall-teams", {
|
||||
search: "",
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const search = ref(savedView.search);
|
||||
const currentPage = ref(savedView.currentPage);
|
||||
const pageSize = ref(savedView.pageSize);
|
||||
const sortBy = ref(savedView.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(savedView.sortOrder);
|
||||
const oTableRef = ref<any>(null);
|
||||
const formOpen = ref(false);
|
||||
const editingTeam = ref<OnCallTeam | null>(null);
|
||||
const teamToDelete = ref<OnCallTeam | null>(null);
|
||||
@@ -458,6 +480,26 @@ const filteredTeams = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
watch(search, () => {
|
||||
currentPage.value = 1;
|
||||
});
|
||||
watch([search, currentPage, pageSize, sortBy, sortOrder], () => {
|
||||
Object.assign(savedView, {
|
||||
search: search.value,
|
||||
currentPage: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
// The first load lands after mount and TanStack resets to page 0 on its own deferred queue; setTimeout(0) runs after it so the restored page wins.
|
||||
let pageRestored = false;
|
||||
watch(loading, (isLoading) => {
|
||||
if (isLoading || pageRestored) return;
|
||||
pageRestored = true;
|
||||
setTimeout(() => oTableRef.value?.restorePage?.(currentPage.value), 0);
|
||||
});
|
||||
|
||||
async function fetchTeams(force = false) {
|
||||
loading.value = true;
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { nextTick } from "vue";
|
||||
import { createStore } from "vuex";
|
||||
@@ -155,6 +156,7 @@ describe("SourceMaps.vue", () => {
|
||||
let wrapper: any;
|
||||
|
||||
beforeEach(() => {
|
||||
resetListViewState();
|
||||
store = createMockStore();
|
||||
router = createMockRouter();
|
||||
i18n = createMockI18n();
|
||||
@@ -660,4 +662,34 @@ describe("SourceMaps.vue", () => {
|
||||
expect((wrapper.vm as any).formatTimestamp(null)).toBe("-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("View state restore", () => {
|
||||
it("restores filters, page, size and sort and fetches with the filters", async () => {
|
||||
useListViewState("rumSourceMaps", {
|
||||
filters: { service: "web" },
|
||||
pageSize: 50,
|
||||
currentPage: 2,
|
||||
sortBy: "version",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
wrapper = await mountComponent();
|
||||
const vm = wrapper.vm as any;
|
||||
expect(vm.filters).toEqual({ service: "web" });
|
||||
expect(vm.selectedPerPage).toBe(50);
|
||||
expect(vm.sortBy).toBe("version");
|
||||
expect(vm.sortOrder).toBe("desc");
|
||||
expect(listSourceMapsMock).toHaveBeenCalledWith(expect.anything(), { service: "web" });
|
||||
});
|
||||
|
||||
it("saves changes to the view state", async () => {
|
||||
wrapper = await mountComponent();
|
||||
const vm = wrapper.vm as any;
|
||||
vm.filters = { version: "1.0" };
|
||||
vm.selectedPerPage = 100;
|
||||
await nextTick();
|
||||
const saved: any = useListViewState("rumSourceMaps", {});
|
||||
expect(saved.filters).toEqual({ version: "1.0" });
|
||||
expect(saved.pageSize).toBe(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,6 +97,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<div class="source-maps-list min-h-0 flex-1">
|
||||
<!-- Source Maps Table (OTable handles loading skeleton) -->
|
||||
<OTable
|
||||
ref="oTableRef"
|
||||
:data="groupedSourceMaps"
|
||||
:columns="columns"
|
||||
:column-visibility="columnVisibility"
|
||||
@@ -106,6 +107,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
pagination="client"
|
||||
:page-size="selectedPerPage"
|
||||
:page-size-options="perPageOptionsList"
|
||||
:current-page="currentPage"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:page-size="selectedPerPage = $event"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
:show-global-filter="false"
|
||||
expansion="single"
|
||||
expand-on-row-click
|
||||
@@ -196,7 +204,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
// would silently drop it from the cache and bring back the refetch-on-return.
|
||||
defineOptions({ name: "SourceMaps" });
|
||||
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import { ref, onMounted, computed, watch } from "vue";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import { useStore } from "vuex";
|
||||
import { useRouter } from "vue-router";
|
||||
@@ -239,11 +248,15 @@ const filteredServiceOptions = ref<string[]>([]);
|
||||
const filteredEnvironmentOptions = ref<string[]>([]);
|
||||
|
||||
// Filters
|
||||
const filters = ref<{
|
||||
version?: string;
|
||||
service?: string;
|
||||
environment?: string;
|
||||
}>({});
|
||||
type SourceMapFilters = { version?: string; service?: string; environment?: string };
|
||||
const viewState = useListViewState("rumSourceMaps", {
|
||||
filters: {} as SourceMapFilters,
|
||||
pageSize: 20,
|
||||
currentPage: 1,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const filters = ref<SourceMapFilters>({ ...viewState.filters });
|
||||
|
||||
// Fetch filter values from API
|
||||
const fetchFilterValues = async () => {
|
||||
@@ -338,7 +351,22 @@ const columns = computed<OTableColumnDef[]>(() => [
|
||||
]);
|
||||
|
||||
// Pagination
|
||||
const selectedPerPage = ref<number>(20);
|
||||
const selectedPerPage = ref<number>(viewState.pageSize);
|
||||
const currentPage = ref(viewState.currentPage);
|
||||
const sortBy = ref(viewState.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(viewState.sortOrder);
|
||||
const oTableRef = ref<any>(null);
|
||||
watch([filters, selectedPerPage, currentPage, sortBy, sortOrder], () => {
|
||||
Object.assign(viewState, {
|
||||
filters: { ...filters.value },
|
||||
pageSize: selectedPerPage.value,
|
||||
currentPage: currentPage.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
watch(filters, () => (currentPage.value = 1), { deep: true });
|
||||
let pageRestored = false;
|
||||
|
||||
const perPageOptionsList = [20, 50, 100, 250];
|
||||
|
||||
@@ -370,6 +398,11 @@ const fetchSourceMaps = async () => {
|
||||
groupedSourceMaps.value = [];
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
if (!pageRestored) {
|
||||
pageRestored = true;
|
||||
// Macrotask so the restored page wins over the table's own reset on data change.
|
||||
setTimeout(() => oTableRef.value?.restorePage?.(currentPage.value), 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
|
||||
import { nextTick } from "vue";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
const $t = (key: string) => key;
|
||||
|
||||
@@ -154,7 +155,12 @@ const baseStubs = {
|
||||
"bulkActionLoading",
|
||||
"emptyMessage",
|
||||
"hasFilters",
|
||||
"pageSize",
|
||||
"currentPage",
|
||||
"sortBy",
|
||||
"sortOrder",
|
||||
],
|
||||
emits: ["update:currentPage", "update:pageSize", "update:sortBy", "update:sortOrder"],
|
||||
},
|
||||
CheckTypePicker: {
|
||||
template: '<div data-test="check-type-picker-stub"><slot /></div>',
|
||||
@@ -270,6 +276,7 @@ describe("SyntheticMonitoring", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetListViewState();
|
||||
mockServiceList.mockResolvedValue({ data: { monitors: [] } });
|
||||
// Both are process-global and mutated by individual tests, so they are
|
||||
// restored here rather than left for the next test to inherit.
|
||||
@@ -299,6 +306,75 @@ describe("SyntheticMonitoring", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("list view state", () => {
|
||||
const seed = () =>
|
||||
useListViewState("synthetics", {
|
||||
folder: "",
|
||||
activeTab: "all",
|
||||
statusFilter: "all",
|
||||
typeFilter: "all",
|
||||
locationFilter: "all",
|
||||
search: "",
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
|
||||
it("restores search, page, page size and sort from the saved view", () => {
|
||||
Object.assign(seed(), {
|
||||
search: "checkout",
|
||||
currentPage: 3,
|
||||
pageSize: 50,
|
||||
sortBy: "name",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
wrapper = mountPage();
|
||||
const mt = wrapper.findComponent('[data-test="synthetic-monitoring-monitors-table"]');
|
||||
expect(
|
||||
(
|
||||
wrapper.find('[data-test="synthetic-monitoring-search-input"]')
|
||||
.element as HTMLInputElement
|
||||
).value,
|
||||
).toBe("checkout");
|
||||
expect(mt.props("currentPage")).toBe(3);
|
||||
expect(mt.props("pageSize")).toBe(50);
|
||||
expect(mt.props("sortBy")).toBe("name");
|
||||
expect(mt.props("sortOrder")).toBe("desc");
|
||||
});
|
||||
|
||||
it("restores the type tab and drops the stored page when the folder differs", () => {
|
||||
Object.assign(seed(), {
|
||||
activeTab: "browser",
|
||||
typeFilter: "BROWSER",
|
||||
currentPage: 4,
|
||||
folder: "other",
|
||||
});
|
||||
mockRoute.query.folder = "default";
|
||||
wrapper = mountPage();
|
||||
const mt = wrapper.findComponent('[data-test="synthetic-monitoring-monitors-table"]');
|
||||
expect(mt.props("mode")).toBe("browser");
|
||||
expect(mt.props("currentPage")).toBe(1);
|
||||
});
|
||||
|
||||
it("saves search and table changes back to the view state", async () => {
|
||||
wrapper = mountPage();
|
||||
await wrapper.find('[data-test="synthetic-monitoring-search-input"]').setValue("api");
|
||||
const mt = wrapper.findComponent('[data-test="synthetic-monitoring-monitors-table"]');
|
||||
mt.vm.$emit("update:currentPage", 2);
|
||||
mt.vm.$emit("update:sortBy", "status");
|
||||
mt.vm.$emit("update:sortOrder", "desc");
|
||||
mt.vm.$emit("update:pageSize", 25);
|
||||
await nextTick();
|
||||
const saved = seed();
|
||||
expect(saved.search).toBe("api");
|
||||
expect(saved.currentPage).toBe(2);
|
||||
expect(saved.sortBy).toBe("status");
|
||||
expect(saved.sortOrder).toBe("desc");
|
||||
expect(saved.pageSize).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("service calls on mount", () => {
|
||||
it("should call listByFolderId when initialised", async () => {
|
||||
wrapper = mountPage();
|
||||
|
||||
@@ -120,7 +120,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
>
|
||||
<!-- ── CHECKS TABLE ── -->
|
||||
<MonitorTable
|
||||
ref="monitorTableRef"
|
||||
:mode="monitorTableMode"
|
||||
:page-size="pageSize"
|
||||
:current-page="currentPage"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:page-size="pageSize = $event"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
:data="filteredMonitors"
|
||||
:loading="loading"
|
||||
:forbidden="forbidden"
|
||||
@@ -535,6 +544,7 @@ import { getFoldersListByType } from "@/utils/commons";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import { useConfirmDialog } from "@/composables/useConfirmDialog";
|
||||
import useBreakpoint from "@/composables/useBreakpoint";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -773,12 +783,70 @@ watch(
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const activeTab = ref("all");
|
||||
const savedView = useListViewState("synthetics", {
|
||||
folder: "",
|
||||
activeTab: "all",
|
||||
statusFilter: "all",
|
||||
typeFilter: "all",
|
||||
locationFilter: "all",
|
||||
search: "",
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
// A stored page belongs to the folder it was viewed in; arriving on another folder starts at page 1.
|
||||
const savedFolder = (route.query.folder as string) ?? "default";
|
||||
if (savedView.folder && savedView.folder !== savedFolder) savedView.currentPage = 1;
|
||||
const activeTab = ref(savedView.activeTab);
|
||||
const monitorTableMode = computed(() => (activeTab.value === "browser" ? "browser" : "all"));
|
||||
const statusFilter = ref("all");
|
||||
const typeFilter = ref("all");
|
||||
const locationFilter = ref("all");
|
||||
const search = ref("");
|
||||
const statusFilter = ref(savedView.statusFilter);
|
||||
const typeFilter = ref(savedView.typeFilter);
|
||||
const locationFilter = ref(savedView.locationFilter);
|
||||
const search = ref(savedView.search);
|
||||
const currentPage = ref(savedView.currentPage);
|
||||
const pageSize = ref(savedView.pageSize);
|
||||
const sortBy = ref(savedView.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(savedView.sortOrder);
|
||||
const monitorTableRef = ref<InstanceType<typeof MonitorTable> | null>(null);
|
||||
|
||||
watch([activeTab, statusFilter, typeFilter, locationFilter, search], () => {
|
||||
currentPage.value = 1;
|
||||
});
|
||||
watch(
|
||||
[
|
||||
activeTab,
|
||||
statusFilter,
|
||||
typeFilter,
|
||||
locationFilter,
|
||||
search,
|
||||
currentPage,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
],
|
||||
() => {
|
||||
Object.assign(savedView, {
|
||||
folder: activeFolderId.value,
|
||||
activeTab: activeTab.value,
|
||||
statusFilter: statusFilter.value,
|
||||
typeFilter: typeFilter.value,
|
||||
locationFilter: locationFilter.value,
|
||||
search: search.value,
|
||||
currentPage: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
},
|
||||
);
|
||||
// The first data load makes TanStack reset to page 0 on its own deferred queue; setTimeout(0) runs after it so the restored page wins.
|
||||
let pageRestored = false;
|
||||
watch(loading, (isLoading) => {
|
||||
if (isLoading || pageRestored) return;
|
||||
pageRestored = true;
|
||||
setTimeout(() => monitorTableRef.value?.restorePage?.(currentPage.value), 0);
|
||||
});
|
||||
const showTypePicker = ref(false);
|
||||
|
||||
// ── Folder state ───────────────────────────────────────────────────────
|
||||
@@ -796,6 +864,8 @@ const activeFolderName = computed(() => {
|
||||
});
|
||||
|
||||
watch(activeFolderId, async (newFolderId) => {
|
||||
currentPage.value = 1;
|
||||
savedView.folder = newFolderId;
|
||||
selectedMonitorIds.value = [];
|
||||
duplicateFolder.value = newFolderId;
|
||||
if (searchAcrossFolders.value) {
|
||||
|
||||
@@ -454,7 +454,7 @@ const backTarget = computed(() => ({
|
||||
to: {
|
||||
name: "alertList",
|
||||
query: {
|
||||
// Spread first so page/tab/search survive — folder still needs its own explicit fallback below since an absent query.folder must resolve to "default", not itself disappear.
|
||||
// Spread first so the tab the list pushed survives — folder still needs its own explicit fallback below since an absent query.folder must resolve to "default", not itself disappear.
|
||||
...route.query,
|
||||
org_identifier: orgId.value,
|
||||
folder: route.query.folder || "default",
|
||||
@@ -674,6 +674,7 @@ const editAlert = () => {
|
||||
query: {
|
||||
org_identifier: orgId.value,
|
||||
folder: route.query.folder || "default",
|
||||
tab: route.query.tab,
|
||||
},
|
||||
});
|
||||
return;
|
||||
@@ -687,6 +688,7 @@ const editAlert = () => {
|
||||
// navigated with; "default" is the folder every org is created with. The
|
||||
// editor needs it to send the user back to the right folder on save.
|
||||
folder: route.query.folder || "default",
|
||||
tab: route.query.tab,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -57,8 +57,13 @@
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:page-size="25"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[25, 50, 100]"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
@update:page-size="pageSize = $event"
|
||||
:current-page="currentPage"
|
||||
:show-global-filter="false"
|
||||
table-id="slos-list"
|
||||
@@ -510,6 +515,7 @@ import type { StatItem } from "@/lib/data/StatStrip/OStatStrip.types";
|
||||
import type { SloListItem } from "@/ts/interfaces/slo";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import useBreakpoint from "@/composables/useBreakpoint";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import alertsService from "@/services/alerts";
|
||||
import { sloDetailRoute } from "@/utils/alerts/sloAlertRouting";
|
||||
import { slosToTerraform } from "@/utils/slos/sloTerraform";
|
||||
@@ -567,9 +573,30 @@ const error = computed<string | null>(() => {
|
||||
if (!e) return null;
|
||||
return e?.response?.data?.message || e?.message || t("slos.loadFailed");
|
||||
});
|
||||
const search = ref("");
|
||||
const typeFilter = ref("all");
|
||||
const healthFilter = ref<string | null>(null);
|
||||
const savedView = useListViewState("slos", {
|
||||
search: "",
|
||||
typeFilter: "all",
|
||||
healthFilter: null as string | null,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
pageSize: 25,
|
||||
});
|
||||
const search = ref(savedView.search);
|
||||
const typeFilter = ref(savedView.typeFilter);
|
||||
const healthFilter = ref<string | null>(savedView.healthFilter);
|
||||
const sortBy = ref<string>(savedView.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(savedView.sortOrder);
|
||||
const pageSize = ref<number>(savedView.pageSize);
|
||||
watch([search, typeFilter, healthFilter, sortBy, sortOrder, pageSize], () => {
|
||||
Object.assign(savedView, {
|
||||
search: search.value,
|
||||
typeFilter: typeFilter.value,
|
||||
healthFilter: healthFilter.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
pageSize: pageSize.value,
|
||||
});
|
||||
});
|
||||
const deleteDialog = ref(false);
|
||||
const pendingDelete = ref<SloListItem | null>(null);
|
||||
const selectedIds = ref<string[]>([]);
|
||||
|
||||
@@ -36,6 +36,7 @@ import i18n from "@/locales";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import router from "@/test/unit/helpers/router";
|
||||
import sloService from "@/services/slos";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
const node = document.createElement("div");
|
||||
node.setAttribute("id", "app");
|
||||
@@ -233,3 +234,66 @@ describe("SloList pagination persistence", () => {
|
||||
expect(pushed.query.folder).toBe("team-a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SloList view state persistence", () => {
|
||||
let wrapper: any;
|
||||
const defaults = {
|
||||
search: "",
|
||||
typeFilter: "all",
|
||||
healthFilter: null as string | null,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
pageSize: 25,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetListViewState();
|
||||
(router as any).currentRoute.value.query = {};
|
||||
vi.mocked(sloService.list).mockResolvedValue({ data: { list: [sloRow()] } } as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
resetListViewState();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("seeds search, type, health and sort from the saved view", async () => {
|
||||
Object.assign(useListViewState("slos", defaults), {
|
||||
search: "checkout",
|
||||
typeFilter: "metrics",
|
||||
healthFilter: "healthy",
|
||||
sortBy: "name",
|
||||
sortOrder: "desc",
|
||||
pageSize: 50,
|
||||
});
|
||||
wrapper = await mountList();
|
||||
|
||||
const vm = wrapper.vm as any;
|
||||
expect(vm.search).toBe("checkout");
|
||||
expect(vm.typeFilter).toBe("metrics");
|
||||
expect(vm.healthFilter).toBe("healthy");
|
||||
expect(vm.sortBy).toBe("name");
|
||||
expect(vm.sortOrder).toBe("desc");
|
||||
expect(vm.pageSize).toBe(50);
|
||||
});
|
||||
|
||||
it("saves filter changes into the view state", async () => {
|
||||
wrapper = await mountList();
|
||||
const vm = wrapper.vm as any;
|
||||
|
||||
vm.search = "api";
|
||||
vm.onStatSelect("breached");
|
||||
vm.sortBy = "target";
|
||||
vm.sortOrder = "desc";
|
||||
await flushPromises();
|
||||
|
||||
expect(useListViewState("slos", defaults)).toMatchObject({
|
||||
search: "api",
|
||||
healthFilter: "breached",
|
||||
sortBy: "target",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -220,6 +220,7 @@ vi.mock("vue-router", () => ({
|
||||
}));
|
||||
|
||||
import MonitorRuns from "./MonitorRuns.vue";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
// ── Stubs for every child component ─────────────────────────────────────
|
||||
const baseStubs = {
|
||||
@@ -292,6 +293,7 @@ const baseStubs = {
|
||||
"pagination",
|
||||
"pageSize",
|
||||
"pageSizeOptions",
|
||||
"currentPage",
|
||||
"sorting",
|
||||
"sortBy",
|
||||
"sortOrder",
|
||||
@@ -380,12 +382,52 @@ describe("MonitorRuns", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetListViewState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
});
|
||||
|
||||
describe("list view state", () => {
|
||||
const runsTable = (w: VueWrapper) =>
|
||||
w.findAllComponents({ name: "OTable" }).find((t) => t.props("pagination") === "client")!;
|
||||
|
||||
it("restores page, page size and sort saved for this monitor", () => {
|
||||
Object.assign(useListViewState("synthetic-runs:mon-1", {}), {
|
||||
currentPage: 3,
|
||||
pageSize: 20,
|
||||
sortBy: "duration",
|
||||
sortOrder: "asc",
|
||||
});
|
||||
wrapper = mountRuns();
|
||||
const table = runsTable(wrapper);
|
||||
expect(table.props("currentPage")).toBe(3);
|
||||
expect(table.props("pageSize")).toBe(20);
|
||||
expect(table.props("sortBy")).toBe("duration");
|
||||
expect(table.props("sortOrder")).toBe("asc");
|
||||
});
|
||||
|
||||
it("does not apply another monitor's saved view", () => {
|
||||
Object.assign(useListViewState("synthetic-runs:mon-2", {}), { currentPage: 5 });
|
||||
wrapper = mountRuns();
|
||||
expect(runsTable(wrapper).props("currentPage")).toBe(1);
|
||||
});
|
||||
|
||||
it("saves table changes back to the view state", async () => {
|
||||
wrapper = mountRuns();
|
||||
const table = runsTable(wrapper);
|
||||
table.vm.$emit("update:currentPage", 2);
|
||||
table.vm.$emit("update:sortBy", "status");
|
||||
table.vm.$emit("update:sortOrder", "asc");
|
||||
await flushPromises();
|
||||
const saved = useListViewState("synthetic-runs:mon-1", {}) as Record<string, unknown>;
|
||||
expect(saved.currentPage).toBe(2);
|
||||
expect(saved.sortBy).toBe("status");
|
||||
expect(saved.sortOrder).toBe("asc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("initial render", () => {
|
||||
it("should render the runs dashboard shell", () => {
|
||||
wrapper = mountRuns();
|
||||
|
||||
@@ -721,15 +721,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<!-- Runs table -->
|
||||
<OCard class="p-0">
|
||||
<OTable
|
||||
ref="runsTableRef"
|
||||
:columns="runColumns"
|
||||
:data="visibleRuns"
|
||||
:loading="runsLoading"
|
||||
pagination="client"
|
||||
:page-size="10"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[10, 20, 25, 50]"
|
||||
:current-page="currentPage"
|
||||
sorting="client"
|
||||
sort-by="last_run_at"
|
||||
sort-order="desc"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:page-size="pageSize = $event"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
row-key="id"
|
||||
:show-global-filter="false"
|
||||
:enable-column-resize="true"
|
||||
@@ -1128,6 +1134,7 @@ import type { BadgeVariant } from "@/lib/core/Badge/OBadge.types";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import EmptyStateActionCard from "@/lib/core/EmptyState/EmptyStateActionCard.vue";
|
||||
import OTable from "@/lib/core/Table/OTable.vue";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
|
||||
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
|
||||
import OSelect from "@/lib/forms/Select/OSelect.vue";
|
||||
@@ -1290,7 +1297,24 @@ function locationIcon(region: string): string {
|
||||
}
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────
|
||||
const activeTab = ref("overview");
|
||||
// Keyed by monitor so the filters outlive this component when the user leaves the results page and comes back.
|
||||
const savedView = useListViewState(`synthetic-runs:${props.monitorId}`, {
|
||||
activeTab: "overview",
|
||||
statusFilter: "all",
|
||||
browserFilter: "all",
|
||||
deviceFilter: "all",
|
||||
locationFilter: "all",
|
||||
durationFilter: "all",
|
||||
failedStepFilter: "all",
|
||||
locatorFilter: "",
|
||||
actionFilter: "all",
|
||||
errorFilter: null as string | null,
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
sortBy: "last_run_at",
|
||||
sortOrder: "desc" as "asc" | "desc",
|
||||
});
|
||||
const activeTab = ref(savedView.activeTab);
|
||||
|
||||
// ── Monitor type — derived from the prop set by parent's fetchCheck() call ──
|
||||
const isBrowser = computed(() => props.checkType === "browser");
|
||||
@@ -1322,15 +1346,70 @@ const windowLabel = computed(() =>
|
||||
? formatWindowLabel(timeRangeMicros.value.startTime, timeRangeMicros.value.endTime)
|
||||
: t("synthetics.runs.window24h"),
|
||||
);
|
||||
const statusFilter = ref("all");
|
||||
const browserFilter = ref("all");
|
||||
const deviceFilter = ref("all");
|
||||
const locationFilter = ref("all");
|
||||
const durationFilter = ref("all");
|
||||
const failedStepFilter = ref("all");
|
||||
const locatorFilter = ref("");
|
||||
const actionFilter = ref("all");
|
||||
const errorFilter = ref<string | null>(null);
|
||||
const statusFilter = ref(savedView.statusFilter);
|
||||
const browserFilter = ref(savedView.browserFilter);
|
||||
const deviceFilter = ref(savedView.deviceFilter);
|
||||
const locationFilter = ref(savedView.locationFilter);
|
||||
const durationFilter = ref(savedView.durationFilter);
|
||||
const failedStepFilter = ref(savedView.failedStepFilter);
|
||||
const locatorFilter = ref(savedView.locatorFilter);
|
||||
const actionFilter = ref(savedView.actionFilter);
|
||||
const errorFilter = ref<string | null>(savedView.errorFilter);
|
||||
const currentPage = ref(savedView.currentPage);
|
||||
const pageSize = ref(savedView.pageSize);
|
||||
const sortBy = ref(savedView.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(savedView.sortOrder);
|
||||
const runsTableRef = ref<any>(null);
|
||||
|
||||
watch([statusFilter, browserFilter, deviceFilter, locationFilter, errorFilter], () => {
|
||||
currentPage.value = 1;
|
||||
});
|
||||
watch(
|
||||
[
|
||||
activeTab,
|
||||
statusFilter,
|
||||
browserFilter,
|
||||
deviceFilter,
|
||||
locationFilter,
|
||||
durationFilter,
|
||||
failedStepFilter,
|
||||
locatorFilter,
|
||||
actionFilter,
|
||||
errorFilter,
|
||||
currentPage,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
],
|
||||
() => {
|
||||
Object.assign(savedView, {
|
||||
activeTab: activeTab.value,
|
||||
statusFilter: statusFilter.value,
|
||||
browserFilter: browserFilter.value,
|
||||
deviceFilter: deviceFilter.value,
|
||||
locationFilter: locationFilter.value,
|
||||
durationFilter: durationFilter.value,
|
||||
failedStepFilter: failedStepFilter.value,
|
||||
locatorFilter: locatorFilter.value,
|
||||
actionFilter: actionFilter.value,
|
||||
errorFilter: errorFilter.value,
|
||||
currentPage: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
},
|
||||
);
|
||||
// The first runs load lands after mount and TanStack resets to page 0 on its own deferred queue; setTimeout(0) runs after it so the restored page wins.
|
||||
let pageRestored = false;
|
||||
watch(
|
||||
() => synthetics.runsLoading.value,
|
||||
(isLoading) => {
|
||||
if (isLoading || pageRestored) return;
|
||||
pageRestored = true;
|
||||
setTimeout(() => runsTableRef.value?.restorePage?.(currentPage.value), 0);
|
||||
},
|
||||
);
|
||||
|
||||
// ── Smart empty state helpers ──────────────────────────────────────────
|
||||
const hasActiveFilters = computed(
|
||||
|
||||
@@ -42,6 +42,10 @@ vi.mock("vuex", () => ({
|
||||
useStore: () => ({ state: { selectedOrganization: { identifier: "org-1" } } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores", () => ({
|
||||
default: { state: { selectedOrganization: { identifier: "org-1" } } },
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/synthetics/format", () => ({
|
||||
formatTimeAgoUs: vi.fn((us: number) => `formatted-${us}`),
|
||||
}));
|
||||
@@ -49,6 +53,7 @@ vi.mock("@/utils/synthetics/format", () => ({
|
||||
// ── Component under test ───────────────────────────────────────────────────
|
||||
|
||||
import PrivateLocations from "./PrivateLocations.vue";
|
||||
import { resetListViewState, useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -107,12 +112,21 @@ const OTableStub = {
|
||||
"pagination",
|
||||
"pageSize",
|
||||
"pageSizeOptions",
|
||||
"currentPage",
|
||||
"sortBy",
|
||||
"sortOrder",
|
||||
"showGlobalFilter",
|
||||
"persistColumns",
|
||||
"tableId",
|
||||
"enableColumnResize",
|
||||
],
|
||||
emits: ["row-click"],
|
||||
emits: [
|
||||
"row-click",
|
||||
"update:currentPage",
|
||||
"update:pageSize",
|
||||
"update:sortBy",
|
||||
"update:sortOrder",
|
||||
],
|
||||
};
|
||||
|
||||
const OButtonStub = {
|
||||
@@ -176,12 +190,54 @@ describe("PrivateLocations", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRouterPush.mockReset();
|
||||
resetListViewState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
});
|
||||
|
||||
describe("list view state", () => {
|
||||
const seed = () =>
|
||||
useListViewState("synthetics-private-locations", {
|
||||
search: "",
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
|
||||
it("restores search, page, page size and sort", () => {
|
||||
Object.assign(seed(), {
|
||||
search: "eu",
|
||||
currentPage: 2,
|
||||
pageSize: 10,
|
||||
sortBy: "label",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
wrapper = makeWrapper({ locations: [makeLocation({ label: "eu-1", region: "eu" })] });
|
||||
const table = wrapper.findComponent(OTableStub);
|
||||
expect(table.props("currentPage")).toBe(2);
|
||||
expect(table.props("pageSize")).toBe(10);
|
||||
expect(table.props("sortBy")).toBe("label");
|
||||
expect(table.props("sortOrder")).toBe("desc");
|
||||
expect((wrapper.find("input").element as HTMLInputElement).value).toBe("eu");
|
||||
});
|
||||
|
||||
it("saves search and table changes", async () => {
|
||||
wrapper = makeWrapper({ locations: [makeLocation()] });
|
||||
await wrapper.find("input").setValue("abc");
|
||||
const table = wrapper.findComponent(OTableStub);
|
||||
table.vm.$emit("update:currentPage", 3);
|
||||
table.vm.$emit("update:sortBy", "label");
|
||||
await wrapper.vm.$nextTick();
|
||||
const saved = seed();
|
||||
expect(saved.search).toBe("abc");
|
||||
expect(saved.currentPage).toBe(3);
|
||||
expect(saved.sortBy).toBe("label");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rendering", () => {
|
||||
it("renders the table with the correct data-test attribute", () => {
|
||||
wrapper = makeWrapper({ locations: [makeLocation()] });
|
||||
|
||||
@@ -17,19 +17,27 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<OTable
|
||||
ref="oTableRef"
|
||||
:data="filteredLocations"
|
||||
:columns="columns"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
pagination="client"
|
||||
:page-size="20"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[10, 20, 50]"
|
||||
:current-page="currentPage"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
:show-global-filter="false"
|
||||
:persist-columns="true"
|
||||
table-id="synthetics-private-locations-table"
|
||||
:enable-column-resize="true"
|
||||
data-test="synthetics-private-locations-table"
|
||||
@row-click="openDetail"
|
||||
@update:current-page="currentPage = $event"
|
||||
@update:page-size="pageSize = $event"
|
||||
@update:sort-by="sortBy = $event"
|
||||
@update:sort-order="sortOrder = $event"
|
||||
>
|
||||
<template #toolbar>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
@@ -216,7 +224,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useStore } from "vuex";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
@@ -234,6 +242,7 @@ import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import type { SyntheticLocation } from "@/types/synthetics";
|
||||
import { formatTimeAgoUs } from "@/utils/synthetics/format";
|
||||
import { locationLiveStatus as liveStatus } from "@/utils/synthetics/locationLiveStatus";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
import { syntheticsPrivateLocationRoute } from "@/utils/synthetics/routes";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -249,7 +258,43 @@ const emit = defineEmits<{
|
||||
const { t } = useI18nTyped();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const search = ref("");
|
||||
const savedView = useListViewState("synthetics-private-locations", {
|
||||
search: "",
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
sortBy: "",
|
||||
sortOrder: "asc" as "asc" | "desc",
|
||||
});
|
||||
const search = ref(savedView.search);
|
||||
const currentPage = ref(savedView.currentPage);
|
||||
const pageSize = ref(savedView.pageSize);
|
||||
const sortBy = ref(savedView.sortBy);
|
||||
const sortOrder = ref<"asc" | "desc">(savedView.sortOrder);
|
||||
const oTableRef = ref<any>(null);
|
||||
|
||||
watch(search, () => {
|
||||
currentPage.value = 1;
|
||||
});
|
||||
watch([search, currentPage, pageSize, sortBy, sortOrder], () => {
|
||||
Object.assign(savedView, {
|
||||
search: search.value,
|
||||
currentPage: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value,
|
||||
});
|
||||
});
|
||||
// Locations arrive after mount and TanStack resets to page 0 on its own deferred queue; setTimeout(0) runs after it so the restored page wins.
|
||||
let pageRestored = false;
|
||||
watch(
|
||||
() => [props.loading, props.locations.length] as const,
|
||||
([isLoading, count]) => {
|
||||
if (pageRestored || isLoading || count === 0) return;
|
||||
pageRestored = true;
|
||||
setTimeout(() => oTableRef.value?.restorePage?.(currentPage.value), 0);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const filteredLocations = computed(() => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
|
||||
@@ -46,7 +46,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:subtitle="raw(currentRun.timestamp)"
|
||||
:back="{
|
||||
label: t('synthetics.results.monitors'),
|
||||
to: backTo,
|
||||
onClick: goBack,
|
||||
dataTest: 'synthetics-run-detail-back-btn',
|
||||
}"
|
||||
>
|
||||
@@ -598,6 +598,7 @@ import type { BadgeVariant } from "@/lib/core/Badge/OBadge.types";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { raw, useI18nTyped, type I18nKey, type I18nText } from "@/types/i18n";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useListBackNavigation } from "@/composables/useListBackNavigation";
|
||||
import { useStore } from "vuex";
|
||||
import syntheticsService from "@/services/synthetics";
|
||||
import { timestampToTimezoneDate } from "@/utils/timezone";
|
||||
@@ -726,20 +727,28 @@ const executionIdParam = computed(() =>
|
||||
// Passed to per-check API calls so RBAC can resolve folder-scoped grants.
|
||||
const folderId = computed(() => String(route.query.folder ?? ""));
|
||||
|
||||
/**
|
||||
* Back to this run's monitor, keeping the params the results page needs.
|
||||
*
|
||||
* Previously only `params.id` was carried, so the results page it returned to
|
||||
* had no org, no folder (breaking its own RBAC-scoped fetch and its Trigger Run)
|
||||
* and no `?name=`, leaving the header untitled.
|
||||
*/
|
||||
const backTo = computed(() =>
|
||||
syntheticsResultsRoute(
|
||||
// Time range, environment and the run to reopen travel with the fallback so the results page lands where the user left it.
|
||||
const backTo = computed(() => {
|
||||
const extra: Record<string, string> = {};
|
||||
for (const key of ["period", "from", "to", "env"]) {
|
||||
const v = route.query[key];
|
||||
if (typeof v === "string" && v) extra[key] = v;
|
||||
}
|
||||
if (runIdParam.value && executionIdParam.value) {
|
||||
extra.run = runIdParam.value;
|
||||
extra.exec = executionIdParam.value;
|
||||
}
|
||||
return syntheticsResultsRoute(
|
||||
{ orgIdentifier: store.state.selectedOrganization?.identifier, folderId: folderId.value },
|
||||
monitorId.value,
|
||||
{ name: displayMonitorName.value },
|
||||
),
|
||||
);
|
||||
{ name: displayMonitorName.value, extra },
|
||||
);
|
||||
});
|
||||
|
||||
const goBack = useListBackNavigation({
|
||||
isListPath: (path) => /\/synthetics\/[^/]+\/results$/.test(path),
|
||||
fallback: () => backTo.value,
|
||||
});
|
||||
|
||||
// ── Composable ─────────────────────────────────────────────────────────────
|
||||
const synthetics = useSyntheticResults(t);
|
||||
|
||||
@@ -738,8 +738,7 @@ async function rotateSlug() {
|
||||
function goBack() {
|
||||
const ctx = syntheticsNavContextFromRoute(route);
|
||||
if (!ctx.orgIdentifier && orgIdentifier.value) ctx.orgIdentifier = orgIdentifier.value;
|
||||
const base = syntheticsListRoute(ctx) as { name: string; query: Record<string, string> };
|
||||
router.push({ name: base.name, query: { ...base.query, section: "status-pages" } });
|
||||
router.push(syntheticsListRoute(ctx, { section: "status-pages" }));
|
||||
}
|
||||
|
||||
watch(orgIdentifier, (org) => {
|
||||
|
||||
Reference in New Issue
Block a user