From 11bb2ce4dae801095b4c8c2474cf42e7326e77db Mon Sep 17 00:00:00 2001 From: Bhargav Date: Mon, 12 Oct 2026 11:20:11 +0000 Subject: [PATCH] fix: restore list filters, sort and page size when returning from detail pages (#15269) --- web/src/components/alerts/AddAlert.vue | 1 + web/src/components/alerts/AlertHistory.vue | 6 +- web/src/components/alerts/AlertInsights.vue | 3 +- web/src/components/alerts/AlertList.spec.ts | 77 +++++++++---- web/src/components/alerts/AlertList.vue | 71 ++++++++++-- .../alerts/AlertsDestinationList.spec.ts | 34 ++++++ .../alerts/AlertsDestinationList.vue | 34 +++++- .../components/alerts/IncidentList.spec.ts | 28 +++++ web/src/components/alerts/IncidentList.vue | 34 +++++- .../alerts/PipelinesDestinationList.spec.ts | 40 +++++++ .../alerts/PipelinesDestinationList.vue | 48 +++++++- .../components/alerts/TemplateList.spec.ts | 44 ++++++++ web/src/components/alerts/TemplateList.vue | 31 +++++- .../functions/EnrichmentTableList.spec.js | 4 + .../functions/EnrichmentTableList.spec.ts | 40 +++++++ .../functions/EnrichmentTableList.vue | 39 ++++++- .../components/functions/FunctionList.spec.ts | 41 +++++++ web/src/components/functions/FunctionList.vue | 35 +++++- .../components/iam/groups/AppGroups.spec.ts | 40 +++++++ web/src/components/iam/groups/AppGroups.vue | 44 +++++++- web/src/components/iam/roles/AppRoles.spec.ts | 42 ++++++- web/src/components/iam/roles/AppRoles.vue | 39 ++++++- web/src/components/iam/roles/RoleTable.vue | 23 +++- .../oncall/OnCallMyDeliveries.spec.ts | 42 ++++++- .../components/oncall/OnCallMyDeliveries.vue | 36 +++++- .../components/pipeline/PipelinesList.spec.ts | 34 ++++++ web/src/components/pipeline/PipelinesList.vue | 35 +++++- .../components/reports/CreateReport.spec.ts | 55 +++++++++ web/src/components/reports/CreateReport.vue | 29 +++-- web/src/components/reports/ReportList.spec.ts | 82 ++++++++++++++ web/src/components/reports/ReportList.vue | 103 +++++++++++++++-- .../NamedEventsListPage.spec.ts | 13 +++ .../productAnalytics/NamedEventsListPage.vue | 46 +++++++- .../productAnalytics/SavedFunnelsPage.spec.ts | 14 +++ .../rum/productAnalytics/SavedFunnelsPage.vue | 46 +++++++- .../components/settings/AiToolsets.spec.ts | 72 ++++++++++++ web/src/components/settings/AiToolsets.vue | 49 +++++++- .../components/settings/CipherKeys.spec.ts | 43 ++++++- web/src/components/settings/CipherKeys.vue | 49 +++++++- .../settings/LlmProvidersSettings.spec.ts | 76 +++++++++++++ .../settings/LlmProvidersSettings.vue | 44 +++++++- .../settings/ModelPricingList.spec.ts | 45 +++++++- .../components/settings/ModelPricingList.vue | 46 +++++++- .../settings/RegexPatternList.spec.ts | 26 +++++ .../components/settings/RegexPatternList.vue | 49 +++++++- .../settings/SyntheticsLocationsList.spec.ts | 40 +++++++ .../settings/SyntheticsLocationsList.vue | 50 ++++++++- .../synthetic-monitoring/MonitorTable.vue | 23 +++- .../workflows/WorkflowsList.spec.ts | 29 +++++ .../components/workflows/WorkflowsList.vue | 31 +++++- web/src/composables/useListViewState.spec.ts | 52 +++++++++ web/src/composables/useListViewState.ts | 45 ++++++++ web/src/plugins/logs/SearchHistory.spec.js | 63 +++++++++++ web/src/plugins/logs/SearchHistory.vue | 76 ++++++++++++- .../plugins/logs/SearchSchedulersList.spec.ts | 42 ++++++- web/src/plugins/logs/SearchSchedulersList.vue | 42 ++++++- .../plugins/traces/ServicesCatalog.spec.ts | 30 +++++ web/src/plugins/traces/ServicesCatalog.vue | 43 ++++++- web/src/stores/index.ts | 10 -- web/src/test/unit/helpers/store.ts | 8 -- web/src/utils/synthetics/routes.spec.ts | 19 ++++ web/src/utils/synthetics/routes.ts | 9 +- web/src/views/Dashboards/Dashboards.spec.ts | 69 ++++++++++++ web/src/views/Dashboards/Dashboards.vue | 81 ++++++++++---- .../Dashboards/ScheduledDashboards.spec.ts | 53 +++++++++ .../views/Dashboards/ScheduledDashboards.vue | 53 ++++++++- web/src/views/LogStream.spec.ts | 35 ++++++ web/src/views/LogStream.vue | 34 ++++-- web/src/views/OnCall/OnCallPolicies.spec.ts | 52 ++++++++- web/src/views/OnCall/OnCallPolicies.vue | 46 +++++++- web/src/views/OnCall/OnCallResponses.spec.ts | 79 ++++++++++++- web/src/views/OnCall/OnCallResponses.vue | 102 ++++++++++++++--- web/src/views/OnCall/OnCallTeams.spec.ts | 62 ++++++++++- web/src/views/OnCall/OnCallTeams.vue | 44 +++++++- web/src/views/RUM/SourceMaps.spec.ts | 32 ++++++ web/src/views/RUM/SourceMaps.vue | 47 ++++++-- web/src/views/SyntheticMonitoring.spec.ts | 76 +++++++++++++ web/src/views/SyntheticMonitoring.vue | 80 ++++++++++++- web/src/views/alerts/AlertDetail.vue | 4 +- web/src/views/slos/SloList.vue | 35 +++++- web/src/views/slos/SloListPagination.spec.ts | 64 +++++++++++ web/src/views/synthetics/MonitorRuns.spec.ts | 42 +++++++ web/src/views/synthetics/MonitorRuns.vue | 105 +++++++++++++++--- .../views/synthetics/PrivateLocations.spec.ts | 58 +++++++++- web/src/views/synthetics/PrivateLocations.vue | 51 ++++++++- web/src/views/synthetics/RunDetail.vue | 35 +++--- .../status-pages/StatusPageEditor.vue | 3 +- 87 files changed, 3533 insertions(+), 253 deletions(-) create mode 100644 web/src/components/settings/AiToolsets.spec.ts create mode 100644 web/src/components/settings/LlmProvidersSettings.spec.ts create mode 100644 web/src/composables/useListViewState.spec.ts create mode 100644 web/src/composables/useListViewState.ts diff --git a/web/src/components/alerts/AddAlert.vue b/web/src/components/alerts/AddAlert.vue index 6982aa6c9e..f9e6fe5436 100644 --- a/web/src/components/alerts/AddAlert.vue +++ b/web/src/components/alerts/AddAlert.vue @@ -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, }, }); diff --git a/web/src/components/alerts/AlertHistory.vue b/web/src/components/alerts/AlertHistory.vue index 34a93de9d8..f31fd2dc50 100644 --- a/web/src/components/alerts/AlertHistory.vue +++ b/web/src/components/alerts/AlertHistory.vue @@ -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 }, }); }; diff --git a/web/src/components/alerts/AlertInsights.vue b/web/src/components/alerts/AlertInsights.vue index e6a7fa296f..34394eb42a 100644 --- a/web/src/components/alerts/AlertInsights.vue +++ b/web/src/components/alerts/AlertInsights.vue @@ -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 }, }); }; diff --git a/web/src/components/alerts/AlertList.spec.ts b/web/src/components/alerts/AlertList.spec.ts index a6c717e61c..ade319398d 100644 --- a/web/src/components/alerts/AlertList.spec.ts +++ b/web/src/components/alerts/AlertList.spec.ts @@ -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" }), + }), + ); }); }); diff --git a/web/src/components/alerts/AlertList.vue b/web/src/components/alerts/AlertList.vue index 23c85af749..c3db81bd5b 100644 --- a/web/src/components/alerts/AlertList.vue +++ b/web/src/components/alerts/AlertList.vue @@ -113,6 +113,11 @@ along with this program. If not, see . :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(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, diff --git a/web/src/components/alerts/AlertsDestinationList.spec.ts b/web/src/components/alerts/AlertsDestinationList.spec.ts index 8af86eaeb2..f14a2ddaad 100644 --- a/web/src/components/alerts/AlertsDestinationList.spec.ts +++ b/web/src/components/alerts/AlertsDestinationList.spec.ts @@ -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); + }); + }); }); diff --git a/web/src/components/alerts/AlertsDestinationList.vue b/web/src/components/alerts/AlertsDestinationList.vue index 737254677d..205b123a6b 100644 --- a/web/src/components/alerts/AlertsDestinationList.vue +++ b/web/src/components/alerts/AlertsDestinationList.vue @@ -62,10 +62,15 @@ along with this program. If not, see . :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()); 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, diff --git a/web/src/components/alerts/IncidentList.spec.ts b/web/src/components/alerts/IncidentList.spec.ts index 90cca5621e..95d530574c 100644 --- a/web/src/components/alerts/IncidentList.spec.ts +++ b/web/src/components/alerts/IncidentList.spec.ts @@ -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); + }); + }); }); diff --git a/web/src/components/alerts/IncidentList.vue b/web/src/components/alerts/IncidentList.vue index 584b8d8788..760a1235a5 100644 --- a/web/src/components/alerts/IncidentList.vue +++ b/web/src/components/alerts/IncidentList.vue @@ -35,7 +35,12 @@ along with this program. If not, see . :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, diff --git a/web/src/components/alerts/PipelinesDestinationList.spec.ts b/web/src/components/alerts/PipelinesDestinationList.spec.ts index 7fabb1efaf..deb519e324 100644 --- a/web/src/components/alerts/PipelinesDestinationList.spec.ts +++ b/web/src/components/alerts/PipelinesDestinationList.spec.ts @@ -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 | 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", + }); + }); +}); diff --git a/web/src/components/alerts/PipelinesDestinationList.vue b/web/src/components/alerts/PipelinesDestinationList.vue index 99c0fe1fde..b5f507daf5 100644 --- a/web/src/components/alerts/PipelinesDestinationList.vue +++ b/web/src/components/alerts/PipelinesDestinationList.vue @@ -34,6 +34,7 @@ along with this program. If not, see .
. :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([]); 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(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, diff --git a/web/src/components/alerts/TemplateList.spec.ts b/web/src/components/alerts/TemplateList.spec.ts index 7236c2f959..55f63783ce 100644 --- a/web/src/components/alerts/TemplateList.spec.ts +++ b/web/src/components/alerts/TemplateList.spec.ts @@ -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); + }); +}); diff --git a/web/src/components/alerts/TemplateList.vue b/web/src/components/alerts/TemplateList.vue index 6185524d0f..fe55fdd54e 100644 --- a/web/src/components/alerts/TemplateList.vue +++ b/web/src/components/alerts/TemplateList.vue @@ -62,10 +62,15 @@ along with this program. If not, see . 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 = ref([]); const confirmBulkDelete = ref(false); const bulkDeleteLoading = ref(false); const deletingTemplates = ref(new Set()); -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. diff --git a/web/src/components/functions/EnrichmentTableList.spec.js b/web/src/components/functions/EnrichmentTableList.spec.js index b13588be69..c5e7592d85 100644 --- a/web/src/components/functions/EnrichmentTableList.spec.js +++ b/web/src/components/functions/EnrichmentTableList.spec.js @@ -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`), diff --git a/web/src/components/functions/EnrichmentTableList.spec.ts b/web/src/components/functions/EnrichmentTableList.spec.ts index d98c3bd717..1a252786ea 100644 --- a/web/src/components/functions/EnrichmentTableList.spec.ts +++ b/web/src/components/functions/EnrichmentTableList.spec.ts @@ -14,6 +14,7 @@ // along with this program. If not, see . 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"); + }); + }); }); diff --git a/web/src/components/functions/EnrichmentTableList.vue b/web/src/components/functions/EnrichmentTableList.vue index e217374cce..7f557aa393 100644 --- a/web/src/components/functions/EnrichmentTableList.vue +++ b/web/src/components/functions/EnrichmentTableList.vue @@ -54,7 +54,12 @@ along with this program. If not, see . :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 . 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(false); const showUrlJobsDialogState = ref(false); const selectedTableForUrlJobs = ref(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(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(100); - const selectedPerPage = ref(20); + const selectedPerPage = ref(viewState.pageSize); const selectedEnrichmentTable = ref(null); - const selectedFilter = ref("all"); + const selectedFilter = ref(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, diff --git a/web/src/components/functions/FunctionList.spec.ts b/web/src/components/functions/FunctionList.spec.ts index b10d92848d..976f3f63c3 100644 --- a/web/src/components/functions/FunctionList.spec.ts +++ b/web/src/components/functions/FunctionList.spec.ts @@ -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(); + }); + }); }); diff --git a/web/src/components/functions/FunctionList.vue b/web/src/components/functions/FunctionList.vue index b22d9db55a..89d155c978 100644 --- a/web/src/components/functions/FunctionList.vue +++ b/web/src/components/functions/FunctionList.vue @@ -63,6 +63,11 @@ along with this program. If not, see . @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 . 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([]); @@ -939,6 +964,8 @@ export default defineComponent({ refreshList, pageSize, pageSizeOptions, + sortBy, + sortOrder, addTransform, deleteFn, isUpdated, diff --git a/web/src/components/iam/groups/AppGroups.spec.ts b/web/src/components/iam/groups/AppGroups.spec.ts index e5fe15cfec..5ea5eadd4e 100644 --- a/web/src/components/iam/groups/AppGroups.spec.ts +++ b/web/src/components/iam/groups/AppGroups.spec.ts @@ -13,6 +13,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +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", + }); + }); +}); diff --git a/web/src/components/iam/groups/AppGroups.vue b/web/src/components/iam/groups/AppGroups.vue index 4e36047b75..6a6ddfb9b6 100644 --- a/web/src/components/iam/groups/AppGroups.vue +++ b/web/src/components/iam/groups/AppGroups.vue @@ -51,6 +51,7 @@ along with this program. If not, see .
. 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" >