fix: restore list filters, sort and page size when returning from detail pages (#15269)

This commit is contained in:
Bhargav
2026-10-12 11:20:11 +00:00
committed by GitHub
parent 2df0932314
commit 11bb2ce4da
87 changed files with 3533 additions and 253 deletions
+1
View File
@@ -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,
},
});
+4 -2
View File
@@ -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 },
});
};
+2 -1
View File
@@ -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 },
});
};
+57 -20
View File
@@ -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" }),
}),
);
});
});
+61 -10
View File
@@ -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);
});
});
});
+29 -5
View File
@@ -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);
});
});
+28 -3
View File
@@ -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();
});
});
});
+31 -4
View File
@@ -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",
});
});
});
+42 -2
View File
@@ -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;
+41 -1
View File
@@ -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",
});
});
});
+38 -1
View File
@@ -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;
+21 -2
View File
@@ -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);
});
});
});
+31 -4
View File
@@ -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", () => {
+22 -7
View File
@@ -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);
});
});
+93 -10
View File
@@ -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,
});
});
});
+47 -2
View File
@@ -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,
+42 -1
View File
@@ -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",
});
});
});
+47 -2
View File
@@ -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);
});
});
});
+28 -3
View File
@@ -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("");
});
});
+45
View File
@@ -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");
});
});
});
+70 -6
View File
@@ -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");
});
});
});
+41 -1
View File
@@ -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");
});
});
});
+37 -6
View File
@@ -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;
-10
View File
@@ -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
-8
View File
@@ -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();
+19
View File
@@ -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",
+5 -4
View File
@@ -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, {
+58 -23
View File
@@ -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];
+35
View File
@@ -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;
+27 -7
View File
@@ -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();
+49 -3
View File
@@ -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");
});
});
});
+44 -2
View File
@@ -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);
+78 -1
View File
@@ -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.
+86 -16
View File
@@ -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>
+60 -2
View File
@@ -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();
+43 -1
View File
@@ -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 {
+32
View File
@@ -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);
});
});
});
+40 -7
View File
@@ -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);
}
}
};
+76
View File
@@ -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();
+75 -5
View File
@@ -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) {
+3 -1
View File
@@ -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,
},
});
};
+31 -4
View File
@@ -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();
+92 -13
View File
@@ -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()] });
+48 -3
View File
@@ -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();
+22 -13
View File
@@ -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) => {