From c2e07c11ba48cd26259ff07efcf118e610d95352 Mon Sep 17 00:00:00 2001 From: Omkar Kesarkhane <127078473+omkarK06@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:44:12 +0530 Subject: [PATCH] fix(saved-views): Traces saved views in the More menu with a pin, and one shared list dialog with Logs (v1.1.0) (#15189) (#15438) --- tests/ui-testing/pages/logsPages/logsPage.js | 40 +- .../playwright-tests/Logs/logsqueries.spec.js | 3 +- .../savedViews/SavedViewsListDialog.spec.ts | 307 +++++++++ .../savedViews/SavedViewsListDialog.vue | 293 +++++++++ .../savedViews}/savedViewsSort.spec.ts | 0 .../savedViews}/savedViewsSort.ts | 0 .../composables/useFavoriteSavedViews.spec.ts | 221 +++++++ web/src/composables/useFavoriteSavedViews.ts | 104 +++ .../composables/useLogs/useSearchBar.spec.ts | 13 + web/src/composables/useLogs/useSearchBar.ts | 25 +- web/src/composables/useToolbarPins.spec.ts | 101 +++ web/src/composables/useToolbarPins.ts | 124 ++-- web/src/lib/core/Button/OButton.spec.ts | 13 + web/src/lib/core/Button/OButton.types.ts | 1 + web/src/lib/core/Button/OButton.vue | 3 +- web/src/lib/core/Table/OTable.spec.ts | 31 + web/src/lib/core/Table/OTable.types.ts | 4 + web/src/lib/core/Table/OTable.vue | 18 +- .../core/Table/sub-components/OTableBody.vue | 4 + .../Table/sub-components/OTableBodyCell.vue | 3 +- .../Table/sub-components/OTableBodyRow.vue | 2 + .../Table/sub-components/OTableHeader.vue | 5 +- .../Table/sub-components/OTablePagination.vue | 7 +- web/src/lib/styles/tokens/component.css | 1 + .../logs/SearchBar.savedViewsDialog.spec.ts | 177 +++++ .../logs/SearchBar.savedViewsPinned.spec.ts | 114 ++++ web/src/plugins/logs/SearchBar.spec.ts | 53 -- web/src/plugins/logs/SearchBar.vue | 478 +------------- web/src/plugins/traces/SearchBar.spec.ts | 621 +++++++++++++++++- web/src/plugins/traces/SearchBar.vue | 506 +++++++++----- .../traces/TracesSavedViewsMenuItems.vue | 94 +++ web/src/utils/storage.spec.ts | 15 - web/src/utils/storage.ts | 4 - 33 files changed, 2615 insertions(+), 770 deletions(-) create mode 100644 web/src/components/savedViews/SavedViewsListDialog.spec.ts create mode 100644 web/src/components/savedViews/SavedViewsListDialog.vue rename web/src/{plugins/logs => components/savedViews}/savedViewsSort.spec.ts (100%) rename web/src/{plugins/logs => components/savedViews}/savedViewsSort.ts (100%) create mode 100644 web/src/composables/useFavoriteSavedViews.spec.ts create mode 100644 web/src/composables/useFavoriteSavedViews.ts create mode 100644 web/src/plugins/logs/SearchBar.savedViewsDialog.spec.ts create mode 100644 web/src/plugins/logs/SearchBar.savedViewsPinned.spec.ts create mode 100644 web/src/plugins/traces/TracesSavedViewsMenuItems.vue diff --git a/tests/ui-testing/pages/logsPages/logsPage.js b/tests/ui-testing/pages/logsPages/logsPage.js index 3b0869bcac..df9518bf91 100644 --- a/tests/ui-testing/pages/logsPages/logsPage.js +++ b/tests/ui-testing/pages/logsPages/logsPage.js @@ -122,7 +122,7 @@ export class LogsPage { this.savedViewDialog = '[data-test="search-bar-store-state-saved-view-dialog"]'; this.savedViewArrow = '[data-test="logs-search-bar-utilities-menu-btn"]'; // OInput convention §4: drive the auto-derived `-field` inner native input for fill(). - this.savedViewSearchInput = '[data-test="log-search-saved-view-field-search-input-field"]'; + this.savedViewSearchInput = '[data-test="logs-saved-views-dialog-search-field"]'; // Matches both ConfirmDialog.vue (data-test="confirm-dialog") and SearchBar's inline // ODialog (data-test="search-bar-confirm-dialog") via substring selector. this.confirmButton = '[data-test*="confirm-dialog"] [data-test="o-dialog-primary-btn"]'; @@ -133,7 +133,7 @@ export class LogsPage { this.menuTransformEditorToggleBtn = '[data-test="logs-search-bar-menu-transform-editor-toggle-btn"]'; this.menuListSavedViewsBtn = '[data-test="logs-search-bar-menu-list-saved-views-btn"]'; this.menuCreateSavedViewBtn = '[data-test="logs-search-bar-menu-create-saved-view-btn"]'; - this.savedViewsListDialogEl = '[data-test="saved-views-list-dialog"]'; + this.savedViewsListDialogEl = '[data-test="logs-saved-views-dialog"]'; this.menuHistogramBtn = '[data-test="logs-search-bar-menu-histogram-btn"]'; this.menuSqlModeBtn = '[data-test="logs-search-bar-menu-sql-mode-btn"]'; this.menuSqlModeBtnState = '[data-test="logs-search-bar-menu-sql-mode-btn"] [data-state]'; @@ -3349,7 +3349,7 @@ export class LogsPage { async clickSaveViewButton() { // Post-menu-migration: "Create saved view" moved into utilities ("More") menu. // Close any open menus/dialogs first, then open the menu and click the item. - const listDialog = this.page.locator('[data-test="saved-views-list-dialog"]'); + const listDialog = this.page.locator(this.savedViewsListDialogEl); const isListOpen = await listDialog.isVisible({ timeout: 500 }).catch(() => false); if (isListOpen) { await listDialog.locator('[data-test="o-dialog-close-btn"]').click(); @@ -3460,17 +3460,23 @@ export class LogsPage { return await searchInput.fill(text); } + // The `-apply-` prefix keeps the favourites pane (`-favorites-apply-`) out of the match. + savedViewApplyLocator(name) { + if (typeof name !== 'string') { + throw new TypeError(`savedViewApplyLocator expects the exact view name, got ${name}`); + } + return this.page.locator(`[data-test^="logs-saved-views-dialog-apply-"][data-test-view-name="${LogsPage.escapeCssAttrValue(name)}"]`); + } + async clickSavedViewByTitle(title) { - const element = this.page.locator(`[data-test="logs-search-bar-apply-${title}-saved-view-btn"]`).first(); + const element = this.savedViewApplyLocator(title).first(); await element.waitFor({ state: 'visible', timeout: 10000 }); // force: true — ODropdown portal transiently detaches items during initial render return await element.click({ force: true }); } async clickDeleteButton() { - // Delete buttons in saved views carry data-test="logs-search-bar-delete-{view_id}-saved-view-btn" - // Click the first visible delete button in the saved views area - const deleteBtn = this.page.locator('[data-test*="logs-search-bar-delete-"][data-test*="-saved-view-btn"]').first(); + const deleteBtn = this.page.locator('[data-test="logs-saved-views-dialog-table"] [data-test^="logs-saved-views-dialog-delete-"]:not([data-test$="-menu"])').first(); await deleteBtn.waitFor({ state: 'visible', timeout: 5000 }); return await deleteBtn.click(); } @@ -3523,13 +3529,13 @@ export class LogsPage { } async clickSavedViewByText(text) { - const element = this.page.locator(`[data-test="logs-search-bar-apply-${text}-saved-view-btn"]`).first(); + const element = this.savedViewApplyLocator(text).first(); await element.waitFor({ state: 'visible', timeout: 10000 }); return await element.click(); } async waitForSavedViewText(text) { - return await this.page.locator(`[data-test="logs-search-bar-apply-${text}-saved-view-btn"]`).first().waitFor({ state: 'visible', timeout: 10000 }); + return await this.savedViewApplyLocator(text).first().waitFor({ state: 'visible', timeout: 10000 }); } /** @@ -3552,7 +3558,7 @@ export class LogsPage { async clickDeleteSavedViewButton(savedViewName) { // The caller (clickSavedViewByTitle) closes the dialog. Wait passively for it to // reach hidden state so the re-open sequence doesn't race the close animation. - await this.page.locator('[data-test="saved-views-list-dialog"]') + await this.page.locator(this.savedViewsListDialogEl) .waitFor({ state: 'hidden', timeout: 5000 }) .catch(() => {}); @@ -3574,8 +3580,8 @@ export class LogsPage { // The delete button data-test uses view_id (a UUID), not the view name, so we // can't build the exact selector. Instead scope to the main saved-views table // (not favorites) — after filtering by name there is exactly one visible row. - const mainTable = this.page.locator('[data-test="log-search-saved-view-list-fields-table"]'); - const deleteBtn = mainTable.locator('[data-test*="logs-search-bar-delete-"]').first(); + const mainTable = this.page.locator('[data-test="logs-saved-views-dialog-table"]'); + const deleteBtn = mainTable.locator('[data-test^="logs-saved-views-dialog-delete-"]:not([data-test$="-menu"])').first(); await deleteBtn.waitFor({ state: 'visible', timeout: 10000 }); await deleteBtn.scrollIntoViewIfNeeded(); await deleteBtn.click(); @@ -4082,7 +4088,7 @@ export class LogsPage { } async clickSavedViewByLabel(label) { - const element = this.page.locator(`[data-test="logs-search-bar-apply-${label}-saved-view-btn"]`).first(); + const element = this.savedViewApplyLocator(label).first(); await element.waitFor({ state: 'visible', timeout: 10000 }); return await element.click({ force: true }); } @@ -8878,9 +8884,7 @@ export class LogsPage { * @param {string} name - Saved view name */ async clickSavedViewByName(name) { - // SearchBar.vue uses data-test="logs-search-bar-apply-${value}-saved-view-btn" for the apply button. - // The previously used `logs-search-saved-view-item-${name}` does not exist in the DOM. - const savedView = this.page.locator(`[data-test="logs-search-bar-apply-${name}-saved-view-btn"]`).first(); + const savedView = this.savedViewApplyLocator(name).first(); await savedView.waitFor({ state: 'visible', timeout: 10000 }); // force: true — ODropdown portal transiently detaches items during initial render await savedView.click({ force: true }); @@ -8954,8 +8958,8 @@ export class LogsPage { */ async clickDeleteSavedViewByName(name) { // data-test uses view_id (UUID), not name — scope to main table after filtering. - const mainTable = this.page.locator('[data-test="log-search-saved-view-list-fields-table"]'); - const deleteBtn = mainTable.locator('[data-test*="logs-search-bar-delete-"]').first(); + const mainTable = this.page.locator('[data-test="logs-saved-views-dialog-table"]'); + const deleteBtn = mainTable.locator('[data-test^="logs-saved-views-dialog-delete-"]:not([data-test$="-menu"])').first(); await deleteBtn.waitFor({ state: 'visible', timeout: 10000 }); await deleteBtn.click(); testLogger.info(`Clicked delete for saved view: ${name}`); diff --git a/tests/ui-testing/playwright-tests/Logs/logsqueries.spec.js b/tests/ui-testing/playwright-tests/Logs/logsqueries.spec.js index fb99dc3dc7..6bee1b10a5 100644 --- a/tests/ui-testing/playwright-tests/Logs/logsqueries.spec.js +++ b/tests/ui-testing/playwright-tests/Logs/logsqueries.spec.js @@ -77,13 +77,14 @@ test.describe("Logs Queries testcases", () => { await pm.logsPage.fillSavedViewName("e2etimestamp"); await pm.logsPage.clickSavedViewDialogSaveContent(); await pm.logsPage.clickSavedViewArrow(); - await pm.logsPage.clickSavedViewByLabel(/timestamp/); + await pm.logsPage.clickSavedViewByLabel('e2etimestamp'); await pm.logsPage.clickSavedViewsExpand(); await pm.logsPage.clickSavedViewSearchInput(); await pm.logsPage.fillSavedViewSearchInput('e2e'); await pm.logsPage.clickSavedViewByTitle('e2etimestamp'); await pm.logsPage.clickSavedViewsExpand(); await pm.logsPage.clickSavedViewSearchInput(); + await pm.logsPage.fillSavedViewSearchInput('e2e'); await pm.logsPage.clickSavedViewByTitle('e2etimestamp'); await pm.logsPage.clickDeleteButton(); await pm.logsPage.clickConfirmButton(); diff --git a/web/src/components/savedViews/SavedViewsListDialog.spec.ts b/web/src/components/savedViews/SavedViewsListDialog.spec.ts new file mode 100644 index 0000000000..6c3d4fe8ab --- /dev/null +++ b/web/src/components/savedViews/SavedViewsListDialog.spec.ts @@ -0,0 +1,307 @@ +// 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 . + +import { describe, it, expect, afterEach, vi } from "vitest"; +import { mount, flushPromises, VueWrapper } from "@vue/test-utils"; +import SavedViewsListDialog from "@/components/savedViews/SavedViewsListDialog.vue"; + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ t: (k: string) => k, te: () => true }), +})); + +const views = [ + { view_id: "t1", view_name: "checkout errors" }, + { view_id: "t2", view_name: "slow payments" }, +]; + +// The real ODialog portals out of the wrapper; render its body inline instead. +const ODialogStub = { + name: "ODialog", + props: ["open", "size", "title"], + emits: ["update:open"], + template: '
', +}; + +// The real dropdown portals its items; render them inline so the < md menu is reachable. +const ODropdownStub = { + name: "ODropdown", + template: '
', +}; + +const ODropdownItemStub = { + name: "ODropdownItem", + props: ["iconLeft", "variant"], + emits: ["select"], + template: '
', +}; + +describe("SavedViewsListDialog", () => { + let wrapper: VueWrapper | undefined; + + const mountDialog = (props: Record = {}) => + mount(SavedViewsListDialog, { + props: { + open: true, + views, + favoriteIds: [], + favoriteViews: [], + dataTestPrefix: "traces-saved-views-dialog", + ...props, + }, + global: { + stubs: { + ODialog: ODialogStub, + ODropdown: ODropdownStub, + ODropdownItem: ODropdownItemStub, + }, + }, + }); + + const find = (dataTest: string) => wrapper!.find(`[data-test="${dataTest}"]`); + + afterEach(() => { + wrapper?.unmount(); + wrapper = undefined; + }); + + it("lists only the views passed in", async () => { + wrapper = mountDialog({ views: [views[0]] }); + await flushPromises(); + + expect(find("traces-saved-views-dialog-apply-t1").text()).toBe("checkout errors"); + expect(find("traces-saved-views-dialog-apply-t2").exists()).toBe(false); + }); + + it("filters the list by name, case-insensitively", async () => { + wrapper = mountDialog(); + await flushPromises(); + + await wrapper.findComponent({ name: "OSearchInput" }).vm.$emit("update:modelValue", "SLOW"); + await flushPromises(); + + expect(find("traces-saved-views-dialog-apply-t1").exists()).toBe(false); + expect(find("traces-saved-views-dialog-apply-t2").exists()).toBe(true); + expect(wrapper.emitted("update:search")?.[0]).toEqual(["SLOW"]); + }); + + it("clears the search each time the dialog opens", async () => { + wrapper = mountDialog(); + await flushPromises(); + await wrapper.findComponent({ name: "OSearchInput" }).vm.$emit("update:modelValue", "SLOW"); + await flushPromises(); + expect(find("traces-saved-views-dialog-apply-t1").exists()).toBe(false); + + await wrapper.setProps({ open: false }); + await wrapper.setProps({ open: true }); + await flushPromises(); + + expect(wrapper.findComponent({ name: "OSearchInput" }).props("modelValue")).toBe(""); + expect(find("traces-saved-views-dialog-apply-t1").exists()).toBe(true); + expect(wrapper.emitted("update:search")?.at(-1)).toEqual([""]); + }); + + it("clears a bound search model when the dialog opens", async () => { + wrapper = mountDialog({ open: false, search: "SLOW" }); + await wrapper.setProps({ open: true }); + await flushPromises(); + + expect(wrapper.emitted("update:search")).toEqual([[""]]); + }); + + it("emits apply and closes when a view is chosen", async () => { + wrapper = mountDialog(); + await flushPromises(); + + await find("traces-saved-views-dialog-apply-t2").trigger("click"); + + expect(wrapper.emitted("apply")?.[0]).toEqual([views[1]]); + expect(wrapper.emitted("update:open")?.[0]).toEqual([false]); + }); + + it("emits update and delete without closing itself", async () => { + wrapper = mountDialog(); + await flushPromises(); + + await find("traces-saved-views-dialog-update-t1").trigger("click"); + await find("traces-saved-views-dialog-delete-t2").trigger("click"); + + expect(wrapper.emitted("update")?.[0]).toEqual([views[0]]); + expect(wrapper.emitted("delete")?.[0]).toEqual([views[1]]); + expect(wrapper.emitted("apply")).toBeUndefined(); + expect(wrapper.emitted("update:open")).toBeUndefined(); + }); + + it("emits edit and delete from the < md row menu", async () => { + wrapper = mountDialog(); + await flushPromises(); + + expect(find("traces-saved-views-dialog-more-actions-t1").classes()).toContain("md:hidden"); + await find("traces-saved-views-dialog-update-t1-menu").trigger("click"); + await find("traces-saved-views-dialog-delete-t1-menu").trigger("click"); + + expect(wrapper.emitted("update")?.[0]).toEqual([views[0]]); + expect(wrapper.emitted("delete")?.[0]).toEqual([views[0]]); + }); + + it("hides edit and delete buttons below md and reveals row actions on hover", async () => { + wrapper = mountDialog(); + await flushPromises(); + + const edit = find("traces-saved-views-dialog-update-t1"); + expect(edit.classes()).toEqual( + expect.arrayContaining(["max-md:hidden", "opacity-0", "group-hover/row:opacity-100"]), + ); + expect(find("traces-saved-views-dialog-favorite-t1").classes()).not.toContain("max-md:hidden"); + }); + + it("shows the empty state only when not loading", async () => { + wrapper = mountDialog({ views: [] }); + await flushPromises(); + expect(find("traces-saved-views-dialog-empty").text()).toContain("search.savedViewsNotFound"); + expect(find("traces-saved-views-dialog-loading").exists()).toBe(false); + + await wrapper.setProps({ loading: true }); + await flushPromises(); + expect(find("traces-saved-views-dialog-empty").exists()).toBe(false); + expect(find("traces-saved-views-dialog-loading").text()).toContain("confirmDialog.loading"); + }); + + it("renders the favourites pane only when there are favourites", async () => { + wrapper = mountDialog(); + await flushPromises(); + expect(find("traces-saved-views-dialog-favorites-table").exists()).toBe(false); + expect(find("traces-saved-views-dialog-table").element.parentElement!.className).toContain( + "w-full", + ); + + await wrapper.setProps({ favoriteIds: ["t2"], favoriteViews: [views[1]] }); + await flushPromises(); + + const favorites = find("traces-saved-views-dialog-favorites-table"); + expect(favorites.exists()).toBe(true); + expect(favorites.text()).toContain("search.favoriteViews"); + const header = favorites.element.firstElementChild as HTMLElement; + expect(header.textContent!.trim()).toBe("search.favoriteViews"); + const below = header.nextElementSibling as HTMLElement; + for (const el of [header, below]) { + expect(el.tagName).not.toBe("HR"); + expect(el.className).not.toMatch(/(^|\s)border(-[tb])?(\s|$)/); + } + expect(favorites.find("hr").exists()).toBe(false); + expect( + favorites.find('[data-test="traces-saved-views-dialog-favorites-apply-t2"]').text(), + ).toBe("slow payments"); + expect(find("traces-saved-views-dialog-table").element.parentElement!.className).toContain( + "w-3/5", + ); + }); + + it("toggles favourites with the current state, and the favourites pane always removes", async () => { + wrapper = mountDialog({ favoriteIds: ["t2"], favoriteViews: [views[1]] }); + await flushPromises(); + + const starIcon = (dataTest: string) => + find(dataTest).findComponent({ name: "OIcon" }).props("name"); + expect(starIcon("traces-saved-views-dialog-favorite-t1")).toBe("star-outline"); + expect(starIcon("traces-saved-views-dialog-favorite-t2")).toBe("star"); + + await find("traces-saved-views-dialog-favorite-t1").trigger("click"); + await find("traces-saved-views-dialog-favorite-t2").trigger("click"); + await find("traces-saved-views-dialog-favorites-favorite-t2").trigger("click"); + + expect(wrapper.emitted("toggle-favorite")).toEqual([ + [views[0], false], + [views[1], true], + [views[1], true], + ]); + }); + + it("applies from the favourites pane and closes", async () => { + wrapper = mountDialog({ favoriteIds: ["t2"], favoriteViews: [views[1]] }); + await flushPromises(); + + await find("traces-saved-views-dialog-favorites-apply-t2").trigger("click"); + + expect(wrapper.emitted("apply")?.[0]).toEqual([views[1]]); + expect(wrapper.emitted("update:open")?.[0]).toEqual([false]); + }); + + it("renders compact rows with no header and no pager border", async () => { + wrapper = mountDialog(); + await flushPromises(); + + const table = wrapper.findComponent({ name: "OTable" }); + expect(table.props("compact")).toBe(true); + expect(table.props("showHeader")).toBe(false); + expect(table.props("paginationBordered")).toBe(false); + expect(table.find("thead").exists()).toBe(false); + }); + + it("renders the actions as an unpinned, right-aligned action cell", async () => { + wrapper = mountDialog(); + await flushPromises(); + + const cell = wrapper.find('[data-test="o2-table-cell-actions"]'); + expect(cell.attributes("style") ?? "").not.toContain("sticky"); + expect(cell.classes()).toContain("text-right"); + expect(cell.element.firstElementChild!.className).toContain("inline-flex"); + expect(cell.find(".truncate").exists()).toBe(false); + }); + + it("derives every data-test from dataTestPrefix", async () => { + const p = "logs-saved-views-dialog"; + wrapper = mountDialog({ dataTestPrefix: p, favoriteIds: ["t2"], favoriteViews: [views[1]] }); + await flushPromises(); + + const expected = [ + p, + `${p}-list`, + `${p}-search`, + `${p}-search-field`, + `${p}-table`, + `${p}-favorites-table`, + `${p}-apply-t1`, + `${p}-favorite-t1`, + `${p}-update-t1`, + `${p}-delete-t1`, + `${p}-more-actions-t1`, + `${p}-update-t1-menu`, + `${p}-delete-t1-menu`, + `${p}-favorites-apply-t2`, + `${p}-favorites-favorite-t2`, + `${p}-favorites-update-t2`, + `${p}-favorites-delete-t2`, + `${p}-favorites-more-actions-t2`, + `${p}-favorites-update-t2-menu`, + `${p}-favorites-delete-t2-menu`, + ]; + for (const id of expected) { + expect(wrapper.find(`[data-test="${id}"]`).exists(), id).toBe(true); + } + expect(find(`${p}-apply-t1`).attributes("data-test-view-name")).toBe("checkout errors"); + expect(find(`${p}-favorites-apply-t2`).attributes("data-test-view-name")).toBe("slow payments"); + expect(wrapper.find('[data-test^="traces-"]').exists()).toBe(false); + }); + + it("re-renders data-test values when dataTestPrefix changes", async () => { + wrapper = mountDialog(); + await wrapper.setProps({ dataTestPrefix: "other-dialog" }); + + expect(find("other-dialog").exists()).toBe(true); + expect(find("other-dialog-table").exists()).toBe(true); + expect(find("other-dialog-apply-t1").exists()).toBe(true); + expect(find("traces-saved-views-dialog-table").exists()).toBe(false); + }); +}); diff --git a/web/src/components/savedViews/SavedViewsListDialog.vue b/web/src/components/savedViews/SavedViewsListDialog.vue new file mode 100644 index 0000000000..aa039988cd --- /dev/null +++ b/web/src/components/savedViews/SavedViewsListDialog.vue @@ -0,0 +1,293 @@ + + + + + diff --git a/web/src/plugins/logs/savedViewsSort.spec.ts b/web/src/components/savedViews/savedViewsSort.spec.ts similarity index 100% rename from web/src/plugins/logs/savedViewsSort.spec.ts rename to web/src/components/savedViews/savedViewsSort.spec.ts diff --git a/web/src/plugins/logs/savedViewsSort.ts b/web/src/components/savedViews/savedViewsSort.ts similarity index 100% rename from web/src/plugins/logs/savedViewsSort.ts rename to web/src/components/savedViews/savedViewsSort.ts diff --git a/web/src/composables/useFavoriteSavedViews.spec.ts b/web/src/composables/useFavoriteSavedViews.spec.ts new file mode 100644 index 0000000000..b587e99333 --- /dev/null +++ b/web/src/composables/useFavoriteSavedViews.spec.ts @@ -0,0 +1,221 @@ +// 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 . + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { reactive } from "vue"; + +const { toastMock, storeState } = vi.hoisted(() => ({ + toastMock: vi.fn(), + storeState: { selectedOrganization: { identifier: "org-a" } }, +})); + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ t: (k: string) => k }), +})); + +vi.mock("vuex", () => ({ + useStore: () => ({ state: storeState }), +})); + +vi.mock("@/lib/feedback/Toast/useToast", () => ({ + toast: toastMock, +})); + +import { useFavoriteSavedViews } from "@/composables/useFavoriteSavedViews"; + +const view = (id: string, org = "org-a", type?: string) => ({ + view_id: id, + view_name: `view ${id}`, + org_id: org, + ...(type ? { view_type: type } : {}), +}); + +const stored = () => JSON.parse(localStorage.getItem("savedViews") || "{}"); + +describe("useFavoriteSavedViews", () => { + beforeEach(() => { + localStorage.clear(); + toastMock.mockClear(); + storeState.selectedOrganization = reactive({ identifier: "org-a" }); + }); + + it("reads the existing { [view_id]: row } storage shape", () => { + localStorage.setItem("savedViews", JSON.stringify({ l1: view("l1"), l2: view("l2") })); + + const { favoriteIds, favoriteViews } = useFavoriteSavedViews("logs"); + + expect(favoriteIds.value).toEqual(["l1", "l2"]); + expect(favoriteViews.value).toEqual([view("l1"), view("l2")]); + }); + + it("filters by the current org and by view type, rows without a type being logs", () => { + localStorage.setItem( + "savedViews", + JSON.stringify({ + l1: view("l1"), + l2: view("l2", "org-b"), + t1: view("t1", "org-a", "traces"), + l3: view("l3", "org-a", "logs"), + }), + ); + + expect(useFavoriteSavedViews("logs").favoriteIds.value).toEqual(["l1", "l3"]); + expect(useFavoriteSavedViews("traces").favoriteIds.value).toEqual(["t1"]); + }); + + it("ignores unreadable storage", () => { + localStorage.setItem("savedViews", "not json"); + expect(useFavoriteSavedViews("logs").favoriteIds.value).toEqual([]); + }); + + it("adds a favourite, keeps other entries and toasts", () => { + localStorage.setItem("savedViews", JSON.stringify({ x1: view("x1", "org-b") })); + const { favoriteIds, toggleFavorite } = useFavoriteSavedViews("traces"); + + toggleFavorite(view("t1", "org-a", "traces"), false); + + expect(favoriteIds.value).toEqual(["t1"]); + expect(Object.keys(stored())).toEqual(["x1", "t1"]); + expect(toastMock).toHaveBeenCalledWith({ + message: "logs.searchBar.viewAddedFavorites", + variant: "success", + }); + }); + + it("removes a favourite when it is already one", () => { + localStorage.setItem("savedViews", JSON.stringify({ l1: view("l1"), l2: view("l2") })); + const { favoriteIds, toggleFavorite } = useFavoriteSavedViews("logs"); + + toggleFavorite(view("l1"), true); + + expect(favoriteIds.value).toEqual(["l2"]); + expect(Object.keys(stored())).toEqual(["l2"]); + expect(toastMock).toHaveBeenCalledWith({ + message: "logs.searchBar.viewRemovedFavorites", + variant: "success", + }); + }); + + it("caps favourites at 10 per org and view type", () => { + const entries: Record = {}; + for (let i = 0; i < 10; i++) entries[`l${i}`] = view(`l${i}`); + entries.b1 = view("b1", "org-b"); + localStorage.setItem("savedViews", JSON.stringify(entries)); + + const logs = useFavoriteSavedViews("logs"); + logs.toggleFavorite(view("l10"), false); + expect(logs.favoriteIds.value).toHaveLength(10); + expect(stored().l10).toBeUndefined(); + expect(toastMock).toHaveBeenCalledWith({ + message: "logs.searchBar.maxViewsLimit", + variant: "warning", + }); + + const traces = useFavoriteSavedViews("traces"); + traces.toggleFavorite(view("t1", "org-a", "traces"), false); + expect(traces.favoriteIds.value).toEqual(["t1"]); + }); + + it("counts favourites written by another instance before applying the cap", () => { + const first = useFavoriteSavedViews("logs"); + const second = useFavoriteSavedViews("logs"); + for (let i = 0; i < 10; i++) first.toggleFavorite(view(`l${i}`), false); + + second.toggleFavorite(view("l10"), false); + + expect(stored().l10).toBeUndefined(); + }); + + it("removeFavorite drops the entry silently", () => { + localStorage.setItem("savedViews", JSON.stringify({ t1: view("t1", "org-a", "traces") })); + const { favoriteIds, removeFavorite } = useFavoriteSavedViews("traces"); + + removeFavorite("t1"); + + expect(favoriteIds.value).toEqual([]); + expect(stored()).toEqual({}); + expect(toastMock).not.toHaveBeenCalled(); + }); + + it("follows an org switch", () => { + localStorage.setItem("savedViews", JSON.stringify({ l1: view("l1"), l2: view("l2", "org-b") })); + const { favoriteIds } = useFavoriteSavedViews("logs"); + expect(favoriteIds.value).toEqual(["l1"]); + + storeState.selectedOrganization.identifier = "org-b"; + + expect(favoriteIds.value).toEqual(["l2"]); + }); + + it("prunes only this org's and view type's favourites missing from the live list", () => { + localStorage.setItem( + "savedViews", + JSON.stringify({ + t1: view("t1", "org-a", "traces"), + t2: view("t2", "org-a", "traces"), + tb: view("tb", "org-b", "traces"), + l1: view("l1"), + }), + ); + const { favoriteIds, pruneFavorites } = useFavoriteSavedViews("traces"); + + pruneFavorites(["t1"]); + + expect(favoriteIds.value).toEqual(["t1"]); + expect(Object.keys(stored())).toEqual(["t1", "tb", "l1"]); + }); + + it("re-reads storage before pruning and skips the write when nothing is stale", () => { + const { favoriteIds, pruneFavorites } = useFavoriteSavedViews("logs"); + localStorage.setItem("savedViews", JSON.stringify({ l1: view("l1"), l2: view("l2") })); + const setItem = vi.spyOn(Storage.prototype, "setItem"); + + pruneFavorites(["l1", "l2"]); + expect(setItem).not.toHaveBeenCalled(); + + pruneFavorites(["l2"]); + expect(setItem).toHaveBeenCalledTimes(1); + expect(favoriteIds.value).toEqual(["l2"]); + setItem.mockRestore(); + }); + + it("frees the cap once deleted favourites are pruned", () => { + const entries: Record = {}; + for (let i = 0; i < 10; i++) entries[`l${i}`] = view(`l${i}`); + localStorage.setItem("savedViews", JSON.stringify(entries)); + const logs = useFavoriteSavedViews("logs"); + + logs.pruneFavorites(["l0", "l1", "l2", "l3", "l4", "l5", "l6", "l7", "l8"]); + logs.toggleFavorite(view("l10"), false); + + expect(stored().l9).toBeUndefined(); + expect(stored().l10).toEqual(view("l10")); + expect(logs.favoriteIds.value).toHaveLength(10); + }); + + it("keeps working in memory when localStorage rejects the write", () => { + const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("quota", "QuotaExceededError"); + }); + const { favoriteIds, toggleFavorite, removeFavorite, pruneFavorites } = + useFavoriteSavedViews("logs"); + + expect(() => toggleFavorite(view("l1"), false)).not.toThrow(); + expect(favoriteIds.value).toEqual(["l1"]); + expect(() => removeFavorite("l1")).not.toThrow(); + expect(() => pruneFavorites([])).not.toThrow(); + setItem.mockRestore(); + }); +}); diff --git a/web/src/composables/useFavoriteSavedViews.ts b/web/src/composables/useFavoriteSavedViews.ts new file mode 100644 index 0000000000..c8dda54376 --- /dev/null +++ b/web/src/composables/useFavoriteSavedViews.ts @@ -0,0 +1,104 @@ +// 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 . + +import { computed, ref } from "vue"; +import { useStore } from "vuex"; +import { useI18nTyped } from "@/types/i18n"; +import { toast } from "@/lib/feedback/Toast/useToast"; +import { viewTypeOf } from "@/services/saved_views"; + +// One key for every org and view type, so favourites saved before view types existed still load. +const STORAGE_KEY = "savedViews"; +const MAX_FAVORITES = 10; + +export type FavoriteViewType = "logs" | "traces"; + +export interface FavoriteSavedView { + view_id: string; + view_name: string; + org_id?: string; + view_type?: string | null; + [key: string]: unknown; +} + +const readStore = (): Record => { + try { + const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +}; + +const writeStore = (value: Record) => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(value)); + } catch { + // A full or blocked localStorage must not fail the caller; the in-memory favourites still update. + } +}; + +/** Favourite saved views of one view type in the current org, persisted in localStorage. */ +export function useFavoriteSavedViews(viewType: FavoriteViewType) { + const store = useStore(); + const { t } = useI18nTyped(); + const stored = ref>(readStore()); + + const isOwnFavorite = (view: FavoriteSavedView) => + view?.org_id === store.state.selectedOrganization?.identifier && viewTypeOf(view) === viewType; + + const favoriteViews = computed(() => Object.values(stored.value).filter(isOwnFavorite)); + const favoriteIds = computed(() => favoriteViews.value.map((view) => view.view_id)); + + const removeFavorite = (viewId: string) => { + const next = readStore(); + delete next[viewId]; + writeStore(next); + stored.value = next; + }; + + const toggleFavorite = (row: FavoriteSavedView, isFavorite: boolean) => { + if (isFavorite) { + removeFavorite(row.view_id); + toast({ message: t("logs.searchBar.viewRemovedFavorites"), variant: "success" }); + return; + } + const next = readStore(); + stored.value = next; + if (favoriteIds.value.length >= MAX_FAVORITES) { + toast({ message: t("logs.searchBar.maxViewsLimit"), variant: "warning" }); + return; + } + next[row.view_id] = JSON.parse(JSON.stringify(row)); + writeStore(next); + stored.value = { ...next }; + toast({ message: t("logs.searchBar.viewAddedFavorites"), variant: "success" }); + }; + + /** Drops this org's and view type's favourites missing from a complete, successfully fetched list. */ + const pruneFavorites = (liveIds: string[]) => { + const live = new Set(liveIds); + const next = readStore(); + const stale = Object.keys(next).filter( + (key) => isOwnFavorite(next[key]) && !live.has(next[key].view_id), + ); + if (!stale.length) return; + stale.forEach((key) => delete next[key]); + writeStore(next); + stored.value = next; + }; + + return { favoriteIds, favoriteViews, toggleFavorite, removeFavorite, pruneFavorites }; +} diff --git a/web/src/composables/useLogs/useSearchBar.spec.ts b/web/src/composables/useLogs/useSearchBar.spec.ts index 8af947c090..999946ce61 100644 --- a/web/src/composables/useLogs/useSearchBar.spec.ts +++ b/web/src/composables/useLogs/useSearchBar.spec.ts @@ -339,6 +339,19 @@ describe("useSearchBar Composable", () => { const ids = searchState().searchObj.data.savedViews.map((v: any) => v.view_id); expect(ids).toEqual(["1", "2"]); }); + + it("resolves with the complete list on success and undefined on failure", async () => { + const views = [ + { view_id: "1", view_name: "legacy" }, + { view_id: "3", view_name: "traces", view_type: "traces" }, + ]; + mockSavedViewsGet.mockResolvedValue({ data: { views } }); + await expect(wrapper.vm.getSavedViews(true)).resolves.toEqual(views); + + mockSavedViewsGet.mockRejectedValue(new Error("boom")); + await expect(wrapper.vm.getSavedViews(true)).resolves.toBeUndefined(); + expect(searchState().searchObj.loadingSavedView).toBe(false); + }); }); describe("getFunctions", () => { diff --git a/web/src/composables/useLogs/useSearchBar.ts b/web/src/composables/useLogs/useSearchBar.ts index 8fd6d7c157..69c8b7087f 100644 --- a/web/src/composables/useLogs/useSearchBar.ts +++ b/web/src/composables/useLogs/useSearchBar.ts @@ -86,31 +86,36 @@ export const useSearchBar = (t: TranslateFn) => { // `force` for the reloads that follow a create/update/delete; a plain call on // Logs entry is a cache hit. - const getSavedViews = async (force = false) => { + /** Resolves with the org's complete saved-views list, or undefined when the fetch failed. */ + const getSavedViews = async (force = false): Promise => { try { searchObj.loadingSavedView = true; const org = store.state.selectedOrganization.identifier; - (force - ? queryClient - .invalidateQueries({ - queryKey: savedViewsQuery(org).queryKey, - exact: true, - refetchType: "none", - }) - .then(() => queryClient.fetchQuery(savedViewsQuery(org))) - : queryClient.fetchQuery(savedViewsQuery(org)) + return await ( + force + ? queryClient + .invalidateQueries({ + queryKey: savedViewsQuery(org).queryKey, + exact: true, + refetchType: "none", + }) + .then(() => queryClient.fetchQuery(savedViewsQuery(org))) + : queryClient.fetchQuery(savedViewsQuery(org)) ) .then((views: any[]) => { searchObj.loadingSavedView = false; searchObj.data.savedViews = views.filter((v) => viewTypeOf(v) === "logs"); + return views; }) .catch((err) => { searchObj.loadingSavedView = false; console.log(err); + return undefined; }); } catch (e: any) { searchObj.loadingSavedView = false; console.log("Error while getting saved views", e); + return undefined; } }; diff --git a/web/src/composables/useToolbarPins.spec.ts b/web/src/composables/useToolbarPins.spec.ts index 8d035bbc67..457eab5484 100644 --- a/web/src/composables/useToolbarPins.spec.ts +++ b/web/src/composables/useToolbarPins.spec.ts @@ -60,3 +60,104 @@ describe("useToolbarPins defaults", () => { expect(pinnedItems.value).toEqual(["histogram", "sqlMode", "syntaxGuide"]); }); }); + +describe("useToolbarPins scopes", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("keeps the logs storage key and defaults unchanged", async () => { + const { useToolbarPins } = await importFresh(); + const { togglePin, pinnedItems } = useToolbarPins(); + expect(pinnedItems.value).toEqual(["histogram"]); + togglePin("sqlMode"); + expect(JSON.parse(window.localStorage.getItem("logs_toolbar_pinned_items")!)).toEqual([ + "histogram", + "sqlMode", + ]); + expect(window.localStorage.getItem("traces_toolbar_pinned_items")).toBeNull(); + }); + + it("pins traces saved views by default", async () => { + const { useToolbarPins } = await importFresh(); + const { isPinned, pinnedItems } = useToolbarPins("traces"); + expect(isPinned("savedViews")).toBe(true); + expect(isPinned("histogram")).toBe(false); + expect(pinnedItems.value).toEqual(["savedViews"]); + }); + + it("pins traces RED Metrics through the histogram key in canonical order", async () => { + const { useToolbarPins } = await importFresh(); + const { togglePin, pinnedItems } = useToolbarPins("traces"); + togglePin("histogram"); + expect(pinnedItems.value).toEqual(["histogram", "savedViews"]); + expect(JSON.parse(window.localStorage.getItem("traces_toolbar_pinned_items")!)).toEqual([ + "savedViews", + "histogram", + ]); + const fresh = await importFresh(); + expect(fresh.useToolbarPins("traces").isPinned("histogram")).toBe(true); + }); + + it("keeps the traces and logs histogram pins independent", async () => { + const { useToolbarPins } = await importFresh(); + useToolbarPins("traces").togglePin("histogram"); + expect(useToolbarPins("traces").isPinned("histogram")).toBe(true); + useToolbarPins().togglePin("histogram"); + expect(useToolbarPins().isPinned("histogram")).toBe(false); + expect(useToolbarPins("traces").isPinned("histogram")).toBe(true); + useToolbarPins("traces").togglePin("histogram"); + expect(useToolbarPins("traces").isPinned("histogram")).toBe(false); + expect(useToolbarPins().isPinned("histogram")).toBe(false); + }); + + it("isolates scopes from each other", async () => { + const { useToolbarPins } = await importFresh(); + useToolbarPins("traces").togglePin("savedViews"); + expect(useToolbarPins("traces").isPinned("savedViews")).toBe(false); + expect(useToolbarPins().isPinned("savedViews")).toBe(false); + useToolbarPins().togglePin("savedViews"); + expect(useToolbarPins().isPinned("savedViews")).toBe(true); + expect(useToolbarPins("traces").isPinned("savedViews")).toBe(false); + expect(window.localStorage.getItem("logs_toolbar_histogram_pin_decided")).toBeNull(); + }); + + it("keeps instances of one scope in sync", async () => { + const { useToolbarPins } = await importFresh(); + const a = useToolbarPins("traces"); + const b = useToolbarPins("traces"); + a.togglePin("savedViews"); + expect(b.isPinned("savedViews")).toBe(false); + }); + + it("keeps traces saved views unpinned across a module reload", async () => { + const { useToolbarPins } = await importFresh(); + useToolbarPins("traces").togglePin("savedViews"); + expect(window.localStorage.getItem("traces_toolbar_saved_views_pin_decided")).toBe("true"); + const fresh = await importFresh(); + expect(fresh.useToolbarPins("traces").isPinned("savedViews")).toBe(false); + }); + + it("keeps each default's decided flag when stored pins are corrupt", async () => { + window.localStorage.setItem("logs_toolbar_pinned_items", "{not json"); + window.localStorage.setItem("traces_toolbar_pinned_items", "{not json"); + window.localStorage.setItem("traces_toolbar_saved_views_pin_decided", "true"); + const { useToolbarPins } = await importFresh(); + expect(useToolbarPins("traces").isPinned("savedViews")).toBe(false); + expect(useToolbarPins().isPinned("histogram")).toBe(true); + }); +}); + +describe("useToolbarPins scope keys", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("ignores a key outside the scope's key list", async () => { + const { useToolbarPins } = await importFresh(); + const traces = useToolbarPins("traces"); + traces.togglePin("sqlMode"); + expect(traces.isPinned("sqlMode")).toBe(false); + expect(window.localStorage.getItem("traces_toolbar_pinned_items")).toBeNull(); + }); +}); diff --git a/web/src/composables/useToolbarPins.ts b/web/src/composables/useToolbarPins.ts index ffe13aadb9..b8a99b1dee 100644 --- a/web/src/composables/useToolbarPins.ts +++ b/web/src/composables/useToolbarPins.ts @@ -1,23 +1,27 @@ // Copyright 2026 OpenObserve Inc. // -// Toolbar "pin" preferences composable. +// 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. // -// Lets the user pin items out of the logs "More" (utilities) menu so they render -// as fixed-position toolbar controls. The set of pinned item keys is persisted to -// localStorage and shared reactively across every consumer via a module-level ref. +// 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. // -// Usage: -// const { isPinned, togglePin, pinnedItems } = useToolbarPins(); -// isPinned("sqlMode") // -> boolean -// togglePin("sqlMode") // pin / unpin and persist -// pinnedItems.value // -> ordered list of currently pinned keys +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . -import { computed, ref } from "vue"; +// Pin state is module-level per scope, so every useToolbarPins(scope) caller shares one reactive set. + +import { computed, ref, type Ref } from "vue"; -// Canonical keys for every pinnable item in the logs More menu. export type ToolbarPinKey = "histogram" | "sqlMode" | "quickMode" | "functionEditor" | "savedViews" | "syntaxGuide"; +export type ToolbarPinScope = "logs" | "traces"; + // Fixed left-to-right render order for pinned controls. Pinning never changes an // item's position — it only toggles whether the item is shown outside the menu. export const TOOLBAR_PIN_ORDER: ToolbarPinKey[] = [ @@ -29,58 +33,90 @@ export const TOOLBAR_PIN_ORDER: ToolbarPinKey[] = [ "syntaxGuide", ]; -const STORAGE_KEY = "logs_toolbar_pinned_items"; +interface ToolbarPinScopeConfig { + storageKey: string; + keys: ToolbarPinKey[]; + // A default pin's absence from storageKey means "never decided" until its decided flag is set. + defaultPins: Partial>; +} -// Items pinned by default. An item's absence from STORAGE_KEY (including -// arrays persisted before the item existed as a pin key) means "never -// decided", not "unpinned" — the per-item flag below records the user's first -// explicit pin/unpin, after which STORAGE_KEY alone is authoritative. -const DEFAULT_PINNED: ToolbarPinKey[] = ["histogram"]; -const PIN_DECIDED_KEYS: Partial> = { - histogram: "logs_toolbar_histogram_pin_decided", +const SCOPES: Record = { + logs: { + storageKey: "logs_toolbar_pinned_items", + keys: TOOLBAR_PIN_ORDER, + defaultPins: { histogram: "logs_toolbar_histogram_pin_decided" }, + }, + traces: { + storageKey: "traces_toolbar_pinned_items", + keys: ["histogram", "savedViews"], + defaultPins: { savedViews: "traces_toolbar_saved_views_pin_decided" }, + }, }; -const isValidKey = (key: string): key is ToolbarPinKey => - (TOOLBAR_PIN_ORDER as string[]).includes(key); +const scopeStates = new Map>>(); -const readInitial = (): ToolbarPinKey[] => { +const isDecided = (decidedKey: string): boolean => { try { - const raw = window.localStorage.getItem(STORAGE_KEY); - const parsed = raw ? JSON.parse(raw) : []; - const pins: ToolbarPinKey[] = Array.isArray(parsed) - ? parsed.filter((k): k is ToolbarPinKey => typeof k === "string" && isValidKey(k)) - : []; - for (const key of DEFAULT_PINNED) { - const decided = window.localStorage.getItem(PIN_DECIDED_KEYS[key]!) === "true"; - if (!decided && !pins.includes(key)) pins.push(key); - } - return pins; + return window.localStorage.getItem(decidedKey) === "true"; } catch { - return [...DEFAULT_PINNED]; + return false; } }; -// Module-level state so every component instance stays in sync. -const pinnedSet = ref>(new Set(readInitial())); - -const persist = () => { +const readStoredPins = (config: ToolbarPinScopeConfig): ToolbarPinKey[] => { try { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(pinnedSet.value))); - } catch (e) { - // localStorage may be unavailable (private mode / quota) — pins stay in-memory. - console.log(`Error persisting toolbar pins: ${e}`); + const raw = window.localStorage.getItem(config.storageKey); + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) + ? parsed.filter( + (k): k is ToolbarPinKey => typeof k === "string" && (config.keys as string[]).includes(k), + ) + : []; + } catch { + return []; } }; -export function useToolbarPins() { +const readInitial = (config: ToolbarPinScopeConfig): ToolbarPinKey[] => { + const pins = readStoredPins(config); + for (const [key, decidedKey] of Object.entries(config.defaultPins) as [ToolbarPinKey, string][]) { + if (!isDecided(decidedKey) && !pins.includes(key)) pins.push(key); + } + return pins; +}; + +const getScopeState = (scope: ToolbarPinScope): Ref> => { + let state = scopeStates.get(scope); + if (!state) { + state = ref(new Set(readInitial(SCOPES[scope]))) as Ref>; + scopeStates.set(scope, state); + } + return state; +}; + +export function useToolbarPins(scope: ToolbarPinScope = "logs") { + const config = SCOPES[scope]; + const pinnedSet = getScopeState(scope); + + const persist = () => { + try { + window.localStorage.setItem(config.storageKey, JSON.stringify(Array.from(pinnedSet.value))); + } catch (e) { + // localStorage may be unavailable (private mode / quota) — pins stay in-memory. + console.log(`Error persisting toolbar pins: ${e}`); + } + }; + const isPinned = (key: ToolbarPinKey): boolean => pinnedSet.value.has(key); const togglePin = (key: ToolbarPinKey): void => { + // A key outside the scope would be dropped by readStoredPins on reload, so never store it. + if (!config.keys.includes(key)) return; const next = new Set(pinnedSet.value); if (next.has(key)) next.delete(key); else next.add(key); pinnedSet.value = next; - const decidedKey = PIN_DECIDED_KEYS[key]; + const decidedKey = config.defaultPins[key]; if (decidedKey) { try { window.localStorage.setItem(decidedKey, "true"); @@ -93,7 +129,7 @@ export function useToolbarPins() { // Pinned keys in canonical order (not insertion order). const pinnedItems = computed(() => - TOOLBAR_PIN_ORDER.filter((key) => pinnedSet.value.has(key)), + TOOLBAR_PIN_ORDER.filter((key) => config.keys.includes(key) && pinnedSet.value.has(key)), ); return { isPinned, togglePin, pinnedItems }; diff --git a/web/src/lib/core/Button/OButton.spec.ts b/web/src/lib/core/Button/OButton.spec.ts index 56836200b3..aa71df259c 100644 --- a/web/src/lib/core/Button/OButton.spec.ts +++ b/web/src/lib/core/Button/OButton.spec.ts @@ -196,6 +196,19 @@ describe("OButton", () => { expect(classes).toContain("w-12"); }); + it("sizes xs-grouped one border-pair (0.125rem) shorter than xs", () => { + expect(mount(OButton, { props: { size: "xs" } }).classes()).toContain("h-7"); + const classes = mount(OButton, { props: { size: "xs-grouped" } }).classes(); + expect(classes).toContain("h-6.5"); + expect(classes).not.toContain("h-7"); + }); + + it("sizes icon-panel as a 1.625rem square, the icon partner of xs-grouped", () => { + const classes = mount(OButton, { props: { size: "icon-panel" } }).classes(); + expect(classes).toContain("size-6.5"); + expect(classes).toContain("p-0"); + }); + // --- Keyboard --- it("emits click on Enter key (native button behaviour)", async () => { diff --git a/web/src/lib/core/Button/OButton.types.ts b/web/src/lib/core/Button/OButton.types.ts index f2e99b7745..a5503e86d3 100644 --- a/web/src/lib/core/Button/OButton.types.ts +++ b/web/src/lib/core/Button/OButton.types.ts @@ -85,6 +85,7 @@ export type ButtonSize = | "sidebar-button" // 30px labeled button — matches toolbar icon height for labeled outline toolbar buttons | "sm-toolbar" + | "xs-grouped" // Chip with fixed 12px font — for dashboard query builder axis field chips | "chip-12"; diff --git a/web/src/lib/core/Button/OButton.vue b/web/src/lib/core/Button/OButton.vue index 022a9cae8e..1db168c0ba 100644 --- a/web/src/lib/core/Button/OButton.vue +++ b/web/src/lib/core/Button/OButton.vue @@ -276,6 +276,7 @@ const sizeClasses: Record, string> = { sm: "h-[2.125rem] ps-3 pe-3 text-sm gap-2 rounded-default", // 30px labeled — matches icon-toolbar height for labeled outline buttons in toolbars "sm-toolbar": "h-[1.875rem] ps-2 pe-2 text-xs gap-1.5 rounded-default", + "xs-grouped": "h-6.5 ps-2 pe-2 text-xs gap-1.5 rounded-default", // Compact labeled size for inline field chips (axis items) — ~28px, matches the dense button size // Extra-compact chip size — 24px height for axis field chips in query builder chip: "h-6 ps-2 pe-1.5 text-xs gap-1 rounded-default leading-none", @@ -302,7 +303,7 @@ const sizeClasses: Record, string> = { // 30×30px square — for toolbar icon buttons (auto-refresh, share, hamburger) "icon-toolbar": "size-[1.875rem] shrink-0 p-0 rounded-default gap-x-0", // 26px rounded-default — compact modern icon button for panel header collapse/expand - "icon-panel": "size-[1.625rem] shrink-0 p-0 rounded-default gap-x-0", + "icon-panel": "size-6.5 shrink-0 p-0 rounded-default gap-x-0", // Tall narrow vertical rectangle — 32px × 20px for splitter collapse/expand buttons "sidebar-button": "h-8 w-3 shrink-0 p-0 rounded-default overflow-hidden gap-x-0", }; diff --git a/web/src/lib/core/Table/OTable.spec.ts b/web/src/lib/core/Table/OTable.spec.ts index 8501e1c389..51b287eb3c 100644 --- a/web/src/lib/core/Table/OTable.spec.ts +++ b/web/src/lib/core/Table/OTable.spec.ts @@ -1340,6 +1340,37 @@ describe("OTable", () => { const tableEl = wrapper.find('[data-test="o2-table"]'); expect(tableEl.attributes("style")).toContain("2.25rem"); }); + + it("uses the compact row token and unpadded cells when compact is true", () => { + wrapper = mount(OTable, { + props: { data: makeRows(3), columns: makeColumns(), compact: true }, + }); + const tableEl = wrapper.find('[data-test="o2-table"]'); + expect(tableEl.attributes("style")).toContain("var(--table-row-height-compact, 1.5625rem)"); + const cell = wrapper.find('td[data-test^="o2-table-cell-"]'); + expect(cell.classes()).toContain("px-0"); + expect(cell.classes()).not.toContain("px-2"); + expect(wrapper.find('th[data-test^="o2-table-th-"]').classes()).toContain("h-6"); + }); + + it("keeps padded cells and the default header height without compact", () => { + wrapper = mount(OTable, { + props: { data: makeRows(3), columns: makeColumns() }, + }); + expect(wrapper.find('td[data-test^="o2-table-cell-"]').classes()).toContain("px-2"); + expect(wrapper.find('th[data-test^="o2-table-th-"]').classes()).toContain("h-8"); + }); + + it("draws the pager hairline by default and drops it with paginationBordered false", async () => { + wrapper = mount(OTable, { + props: { data: makeRows(5), columns: makeColumns(), pageSize: 2 }, + }); + const pager = () => wrapper.find('[data-test="o2-table-pagination-bottom"]'); + expect(pager().classes()).toContain("border-t"); + + await wrapper.setProps({ paginationBordered: false }); + expect(pager().classes()).not.toContain("border-t"); + }); }); // ── Pagination: none ─────────────────────────────────────── diff --git a/web/src/lib/core/Table/OTable.types.ts b/web/src/lib/core/Table/OTable.types.ts index fa047010e9..beadb7f987 100644 --- a/web/src/lib/core/Table/OTable.types.ts +++ b/web/src/lib/core/Table/OTable.types.ts @@ -444,6 +444,8 @@ export interface OTableProps { /** Text shown when data is empty and not loading */ emptyMessage?: I18nText; dense?: boolean; + /** Short rows (`--table-row-height-compact`) with no cell padding, for small list dialogs; overrides `dense`. */ + compact?: boolean; bordered?: boolean; /** * Draws the outer frame (border) around the whole table region. Default true. @@ -453,6 +455,8 @@ export interface OTableProps { frame?: boolean; /** Draws the hairline divider under the toolbar row (default true); set false when a subheader below would read as a double line. */ toolbarBordered?: boolean; + /** Draws the hairline above the built-in pager (default true); set false when the table sits flush in a dialog. */ + paginationBordered?: boolean; striped?: boolean; stickyHeader?: boolean; showHeader?: boolean; diff --git a/web/src/lib/core/Table/OTable.vue b/web/src/lib/core/Table/OTable.vue index 9d81bb9a86..e26ccab046 100644 --- a/web/src/lib/core/Table/OTable.vue +++ b/web/src/lib/core/Table/OTable.vue @@ -73,12 +73,14 @@ const props = withDefaults(defineProps>(), { streaming: false, error: null, dense: true, + compact: false, bordered: true, // No outer box border on tables (design system): the app chrome already frames // content, and CRUD listing tables sit flush to the edges. Row dividers (the // `bordered` row-bottom hairlines) stay; only the surrounding border is gone. frame: false, toolbarBordered: true, + paginationBordered: true, striped: false, stickyHeader: true, wrap: false, @@ -718,7 +720,7 @@ const { scrollMargin: props.scrollMargin ?? 0, // Keep this in sync with the --table-row-height-* tokens (dense = 38px) so the // virtualizer's measured height matches the actual rendered row height. - rowHeight: props.rowHeight ?? (props.dense ? 38 : 54), + rowHeight: props.rowHeight ?? (props.compact ? 25 : props.dense ? 38 : 54), overscan: props.overscan ?? 100, dynamicRowHeight: () => useDynamicRowHeight.value, // A delegated scroller can contain a histogram or other content before this @@ -938,6 +940,7 @@ watch( effectiveColumns, () => props.loading, () => props.dense, + () => props.compact, () => frozen.value, () => measuredColumnSizeVars.value, () => containerWidth.value, @@ -1411,9 +1414,11 @@ defineExpose({ '--table-row-height': props.rowHeight != null ? `${props.rowHeight}px` - : props.dense - ? 'var(--table-row-height-dense, 2.25rem)' - : 'var(--table-row-height-normal, 2.75rem)', + : props.compact + ? 'var(--table-row-height-compact, 1.5625rem)' + : props.dense + ? 'var(--table-row-height-dense, 2.25rem)' + : 'var(--table-row-height-normal, 2.75rem)', }" data-test="o2-table" :data-test-loading="props.loading ? 'true' : 'false'" @@ -1445,6 +1450,7 @@ defineExpose({ :sticky-header="props.stickyHeader" :bordered="props.bordered" :dense="props.dense" + :compact="props.compact" :pivot-header-levels="props.pivotHeaderLevels" :pivot-row-columns="props.pivotRowColumns" :sticky-col-totals="props.stickyColTotals" @@ -1494,6 +1500,7 @@ defineExpose({ :get-highlighted-html="highlighting.getHighlightedHtml" :wrap="props.wrap" :dense="props.dense" + :compact="props.compact" :bordered="props.bordered" :striped="props.striped" :row-class="resolvedRowClass as any" @@ -1751,6 +1758,7 @@ defineExpose({ :is-last-page="pagination.isLastPage.value" :loading="heldLoading" :selected-count="selection.selectedCount.value" + :bordered="props.paginationBordered" @update:page-size="pagination.setPageSize" @first-page="pagination.firstPage" @prev-page="pagination.prevPage" @@ -1822,7 +1830,7 @@ defineExpose({ /* keep(lib-override:o2-table-hide-header): public modifier, same shape as the block above — `thead` is this component's own render, and the class is passed - in by components/queries/QueryList.vue and plugins/logs/SearchBar.vue (x2). */ + in by components/queries/QueryList.vue. */ .o2-table-hide-header :deep(thead) { display: none; } diff --git a/web/src/lib/core/Table/sub-components/OTableBody.vue b/web/src/lib/core/Table/sub-components/OTableBody.vue index 61bf1f2ad3..c40bc6ba4b 100644 --- a/web/src/lib/core/Table/sub-components/OTableBody.vue +++ b/web/src/lib/core/Table/sub-components/OTableBody.vue @@ -27,6 +27,7 @@ const props = defineProps<{ getHighlightedHtml?: (columnId: string, cellValue: any) => string | null; wrap?: boolean; dense?: boolean; + compact?: boolean; bordered?: boolean; striped?: boolean; rowClass?: string | ((row: any) => string); @@ -241,6 +242,7 @@ const headingColspan = computed( :get-highlighted-html="getHighlightedHtml" :wrap="wrap" :dense="dense" + :compact="compact" :bordered="bordered" :striped="striped" :row-class-fn="rowClass" @@ -304,6 +306,7 @@ const headingColspan = computed( :get-highlighted-html="getHighlightedHtml" :wrap="wrap" :dense="dense" + :compact="compact" :bordered="bordered" :striped="striped" :row-class-fn="rowClass" @@ -371,6 +374,7 @@ const headingColspan = computed( :get-highlighted-html="getHighlightedHtml" :wrap="wrap" :dense="dense" + :compact="compact" :bordered="bordered" :striped="striped" :row-class-fn="rowClass" diff --git a/web/src/lib/core/Table/sub-components/OTableBodyCell.vue b/web/src/lib/core/Table/sub-components/OTableBodyCell.vue index 6fa8a0cdfb..ccc2ca5a9f 100644 --- a/web/src/lib/core/Table/sub-components/OTableBodyCell.vue +++ b/web/src/lib/core/Table/sub-components/OTableBodyCell.vue @@ -56,6 +56,7 @@ const props = defineProps<{ getHighlightedHtml?: (columnId: string, cellValue: any) => string | null; wrap?: boolean; dense?: boolean; + compact?: boolean; bordered?: boolean; enableCellCopy?: boolean; getCellStyle?: (params: { columnId: string; row: any; value: any }) => Record; @@ -427,7 +428,7 @@ onBeforeUnmount(() => window.removeEventListener("scroll", onScrollDismiss, true // instead of falling back to a grey inherited value in dark mode. Inner // links/badges override this with their own color. 'text-text-body', - meta?.spacer + meta?.spacer || compact ? 'px-0 align-middle' : meta?.compactPadding ? 'px-1 align-middle' diff --git a/web/src/lib/core/Table/sub-components/OTableBodyRow.vue b/web/src/lib/core/Table/sub-components/OTableBodyRow.vue index d4237878b7..dbb93ba7cc 100644 --- a/web/src/lib/core/Table/sub-components/OTableBodyRow.vue +++ b/web/src/lib/core/Table/sub-components/OTableBodyRow.vue @@ -29,6 +29,7 @@ const props = defineProps<{ getHighlightedHtml?: (columnId: string, cellValue: any) => string | null; wrap?: boolean; dense?: boolean; + compact?: boolean; bordered?: boolean; striped?: boolean; rowClassFn?: string | ((row: any) => string); @@ -273,6 +274,7 @@ function onRowMouseleave() { :get-highlighted-html="getHighlightedHtml" :wrap="wrap" :dense="dense" + :compact="compact" :bordered="bordered" :enable-cell-copy="enableCellCopy" :get-cell-style="getCellStyle" diff --git a/web/src/lib/core/Table/sub-components/OTableHeader.vue b/web/src/lib/core/Table/sub-components/OTableHeader.vue index e94de49256..5fc1893164 100644 --- a/web/src/lib/core/Table/sub-components/OTableHeader.vue +++ b/web/src/lib/core/Table/sub-components/OTableHeader.vue @@ -49,6 +49,7 @@ const props = defineProps<{ pivotRowColumns?: any[]; stickyColTotals?: boolean; dense?: boolean; + compact?: boolean; /** Show the per-column value-filter dropdown on filterable columns. */ enableColumnFilter?: boolean; /** Show the per-column "format this column" icon on formattable columns. */ @@ -532,7 +533,7 @@ function getStandardStickyTotalStyle(header: any): Record { :class="[ `${headerPaddingClass(header)} text-table-header-text relative text-left text-xs font-medium select-none`, 'table-head', - dense ? 'h-8' : 'h-9', + compact ? 'h-6' : dense ? 'h-8' : 'h-9', 'border-table-header-border border-b', 'group', // Sortable's `filter` selector — this header can't start a drag, so it @@ -872,7 +873,7 @@ function getStandardStickyTotalStyle(header: any): Record { :data-test="`o2-table-th-${header.id}`" :class="[ `${headerPaddingClass(header)} text-table-header-text relative text-left text-xs font-medium select-none`, - dense ? 'group h-8' : 'group h-9', + compact ? 'group h-6' : dense ? 'group h-8' : 'group h-9', 'border-table-header-border border-b', header.column.getIsPinned?.() ? 'bg-table-header-bg' : '', (header.column.columnDef.meta as any)?.headerClass ?? '', diff --git a/web/src/lib/core/Table/sub-components/OTablePagination.vue b/web/src/lib/core/Table/sub-components/OTablePagination.vue index ac3ab1052b..d357b11826 100644 --- a/web/src/lib/core/Table/sub-components/OTablePagination.vue +++ b/web/src/lib/core/Table/sub-components/OTablePagination.vue @@ -28,11 +28,13 @@ const props = withDefaults( loading?: boolean; /** Rows currently selected; read only when `#selection-actions` is provided. */ selectedCount?: number; + bordered?: boolean; }>(), { position: "bottom", totalCountExact: true, selectedCount: 0, + bordered: true, }, ); @@ -75,7 +77,10 @@ const selectionLabel = computed(() => { @@ -1982,7 +1761,7 @@ import { toRef, computed, } from "vue"; -import { useI18nTyped, raw } from "@/types/i18n"; +import { useI18nTyped } from "@/types/i18n"; import { useRouter } from "vue-router"; import { useStore } from "vuex"; import { useTheme } from "@/composables/useTheme"; @@ -1990,7 +1769,6 @@ import useBreakpoint from "@/composables/useBreakpoint"; import DateTime from "@/components/DateTime.vue"; import ShareButton from "@/components/common/ShareButton.vue"; import OButton from "@/lib/core/Button/OButton.vue"; -import OTable from "@/lib/core/Table/OTable.vue"; import ODialog from "@/lib/overlay/Dialog/ODialog.vue"; import OSplitter from "@/lib/core/Splitter/OSplitter.vue"; import useLogs from "@/composables/useLogs"; @@ -2014,7 +1792,6 @@ import { b64DecodeUnicode, getImageURL, useLocalInterestingFields, - useLocalSavedView, queryIndexSplit, timestampToTimezoneDate, b64EncodeUnicode, @@ -2066,7 +1843,6 @@ import { hasFieldCondition, removeFieldCondition } from "@/plugins/logs/filterUt import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue"; import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue"; import OInput from "@/lib/forms/Input/OInput.vue"; -import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue"; import OSelect from "@/lib/forms/Select/OSelect.vue"; import OSwitch from "@/lib/forms/Switch/OSwitch.vue"; import OForm from "@/lib/forms/Form/OForm.vue"; @@ -2081,7 +1857,9 @@ import type { AlertBuildOptions } from "@/ts/interfaces/alertPrefill"; import OSeparator from "@/lib/core/Separator/OSeparator.vue"; import OTree from "@/lib/data/Tree/OTree.vue"; import { makeSavedViewSchema, type SavedViewForm } from "./SearchBar.SavedView.schema"; -import { sortSavedViews } from "./savedViewsSort"; +import { sortSavedViews } from "@/components/savedViews/savedViewsSort"; +import SavedViewsListDialog from "@/components/savedViews/SavedViewsListDialog.vue"; +import { useFavoriteSavedViews } from "@/composables/useFavoriteSavedViews"; import { makeSavedFunctionSchema, type SavedFunctionForm } from "./SearchBar.SavedFunction.schema"; const defaultValue: any = () => { @@ -2165,14 +1943,13 @@ export default defineComponent({ OSpinner, OTooltip, OInput, - OSearchInput, OSelect, OForm, OFormInput, OFormSelect, OSwitch, OTree, - OTable, + SavedViewsListDialog, }, emits: [ "searchdata", @@ -2305,24 +2082,6 @@ export default defineComponent({ ); const { isDark } = useTheme(); const { showErrorNotification } = useNotifications(); - const rowsPerPage = ref(10); - const savedViewColumns = [ - { - id: "view_name", - header: raw(""), - accessorKey: "view_name", - sortable: false, - meta: { align: "left" }, - }, - { - id: "actions", - header: raw(""), - isAction: true, - sortable: false, - size: 30, - meta: { align: "right" }, - }, - ]; const regionFilter = ref(); const regionFilterRef = ref(null); const { resetStreamData, searchObj } = searchState(); @@ -2476,21 +2235,13 @@ export default defineComponent({ let streamName = ""; const dateTimeRef = ref(null); - const favoriteViews = ref([]); - - const localSavedViews = ref([]); - let savedViews = useLocalSavedView(); - if (savedViews.value != null) { - const favoriteValues = []; - Object.values(savedViews.value).forEach((view) => { - if (view.org_id === store.state.selectedOrganization.identifier) { - favoriteViews.value.push(view.view_id); - favoriteValues.push(view); - } - }); - - localSavedViews.value.push(...favoriteValues); - } + const { + favoriteIds: favoriteViews, + favoriteViews: localSavedViews, + toggleFavorite: handleFavoriteSavedView, + removeFavorite, + pruneFavorites, + } = useFavoriteSavedViews("logs"); const { autoCompleteData, @@ -4084,27 +3835,13 @@ export default defineComponent({ deleteSavedView .mutateAsync(deleteViewID.value) .then((res: any) => { - //remove it from localstorage as well - const localStoredSavedViews = JSON.parse(localStorage.getItem("savedViews") || "[]"); - delete localStoredSavedViews[deleteViewID.value]; - favoriteViews.value.forEach((item: any) => { - //remove it from favorite views list because we dont need to show it in the favorite views list - if (item == deleteViewID.value) { - favoriteViews.value.splice(favoriteViews.value.indexOf(item), 1); - } - }); - //remove it from local saved views list because we dont need to show it in the local saved views list - localSavedViews.value = localSavedViews.value.filter( - (item: any) => item.view_id !== deleteViewID.value, - ); - localStorage.setItem("savedViews", JSON.stringify(localStoredSavedViews)); - //we are deleting the local storage item and also we are removing the item from the favoriteViews array + removeFavorite(deleteViewID.value); if (res.status == 200) { toast({ message: t("search.viewDeletedSuccessfully"), variant: "success", }); - getSavedViews(); + refreshSavedViews(); } else { toast({ message: t("search.errorDeletingSavedViewDetail", { @@ -4219,7 +3956,7 @@ export default defineComponent({ message: t("search.viewCreatedSuccessfully"), variant: "success", }); - getSavedViews(); + refreshSavedViews(); isSavedViewAction.value = "create"; } else { toast({ @@ -4442,111 +4179,18 @@ export default defineComponent({ { label: "JSON", value: "json" }, ]); + // Only a successful fetch is the complete list; pruning on error would drop every favourite. + const refreshSavedViews = async () => { + const views = await getSavedViews(); + if (views) pruneFavorites(views.map((view) => view.view_id)); + }; + const loadSavedView = () => { if (searchObj.data.savedViews.length == 0) { - getSavedViews(); + refreshSavedViews(); } }; - const filteredSavedViews = computed(() => { - const filter = (searchObj.data.savedViewFilterFields ?? "").toLowerCase(); - if (!filter) return searchObj.data.savedViews; - return searchObj.data.savedViews.filter((v: any) => - (v.view_name ?? "").toLowerCase().includes(filter), - ); - }); - - const savedViewPage = ref(1); - const savedViewPageSize = ref(10); - - const savedViewTotalPages = computed(() => - Math.ceil(filteredSavedViews.value.length / savedViewPageSize.value), - ); - - const paginatedSavedViews = computed(() => { - const start = (savedViewPage.value - 1) * savedViewPageSize.value; - return filteredSavedViews.value.slice(start, start + savedViewPageSize.value); - }); - - watch(filteredSavedViews, () => { - savedViewPage.value = 1; - }); - - const handleFavoriteSavedView = (row: any, flag: boolean) => { - let localSavedView: any = {}; - let savedViews = useLocalSavedView(); - - if (savedViews.value != null) { - localSavedView = savedViews.value; - } - - Object.keys(localSavedView).forEach((item) => { - if (item == row.view_id) { - if (flag) { - delete localSavedView[item]; - useLocalSavedView(localSavedView); - const index = favoriteViews.value.indexOf(row.view_id); - if (index > -1) { - favoriteViews.value.splice(index, 1); - } - - let favoriteViewsList = localSavedViews.value; - if (favoriteViewsList.length > 0) { - favoriteViewsList = favoriteViewsList.filter((item) => item.view_id != row.view_id); - // for (const [key, item] of favoriteViewsList.entries()) { - // console.log(item, key); - // if (item.view_id == row.view_id) { - // delete favoriteViewsList[key]; - // } - // } - localSavedViews.value = favoriteViewsList; - } - } - } - }); - - if (!flag) { - if (favoriteViews.value.length >= 10) { - toast({ - message: t("logs.searchBar.maxViewsLimit"), - variant: "warning", - }); - return; - } - localSavedView[row.view_id] = JSON.parse(JSON.stringify(row)); - favoriteViews.value = [...favoriteViews.value, row.view_id]; - localSavedViews.value = [...localSavedViews.value, row]; - - // moveItemsToTop(localSavedView, favoriteViews.value); - - useLocalSavedView(localSavedView); - toast({ - message: t("logs.searchBar.viewAddedFavorites"), - variant: "success", - }); - } else { - // alert(favoriteViews.value.length) - // moveItemsToTop(localSavedView, favoriteViews.value); - toast({ - message: t("logs.searchBar.viewRemovedFavorites"), - variant: "success", - }); - } - }; - - const filterSavedViewFn = (rows: any, terms: any) => { - var filtered = []; - if (terms != "") { - terms = terms.toLowerCase(); - for (var i = 0; i < rows.length; i++) { - if (rows[i]["view_name"].toLowerCase().includes(terms)) { - filtered.push(rows[i]); - } - } - } - return filtered; - }; - const regionFilterMethod = (node, filter) => { const filt = filter.toLowerCase(); return node.label && node.label.toLowerCase().indexOf(filt) > -1; @@ -5163,18 +4807,10 @@ export default defineComponent({ downloadCustomRangeOptions, buildSearch, confirmSavedViewDialogVisible, - rowsPerPage, handleFavoriteSavedView, favoriteViews, localSavedViews, loadSavedView, - filterSavedViewFn, - filteredSavedViews, - savedViewPage, - savedViewPageSize, - savedViewTotalPages, - paginatedSavedViews, - savedViewColumns, config, handleRegionsSelection, handleQuickMode, @@ -5501,43 +5137,6 @@ export default defineComponent({ diff --git a/web/src/plugins/traces/SearchBar.spec.ts b/web/src/plugins/traces/SearchBar.spec.ts index bec1bf1bb7..c3cb08de3c 100644 --- a/web/src/plugins/traces/SearchBar.spec.ts +++ b/web/src/plugins/traces/SearchBar.spec.ts @@ -131,6 +131,19 @@ vi.mock("@/composables/useConfirmDialog", () => ({ useConfirmDialog: () => ({ confirm: mockConfirm }), })); +// Read at setup time, so set it before mounting. +const breakpointState = vi.hoisted(() => ({ lgUp: true })); + +vi.mock("@/composables/useBreakpoint", () => ({ + default: () => ({ + isMobile: { value: false }, + isTablet: { value: !breakpointState.lgUp }, + isDesktop: { value: breakpointState.lgUp }, + mdUp: { value: true }, + lgUp: { value: breakpointState.lgUp }, + }), +})); + // --------------------------------------------------------------------------- // Shared mutable searchObj — reset to a fresh copy in every beforeEach so // that mutations in one test cannot affect the next. @@ -280,6 +293,8 @@ vi.mock("@/composables/useTraces", () => ({ // Import the component AFTER all vi.mock declarations. // --------------------------------------------------------------------------- import SearchBar from "@/plugins/traces/SearchBar.vue"; +import SavedViewsListDialog from "@/components/savedViews/SavedViewsListDialog.vue"; +import { useToolbarPins } from "@/composables/useToolbarPins"; // --------------------------------------------------------------------------- // DOM anchor node required by attachTo @@ -370,7 +385,9 @@ const sharedStubs = { '
', emits: ["select"], }, - ODropdownGroup: { template: "
" }, + ODropdownGroup: { + template: '
', + }, ODropdownSeparator: { template: "
" }, }; @@ -392,6 +409,15 @@ function mountSearchBar(props: Record = {}): VueWrapper { }); } +// jsdom reports zero widths, so the pinned saved-views group would always fall back into More. +function mockToolbarWidth(width: number) { + return vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function ( + this: HTMLElement, + ) { + return { width: this.classList.contains("justify-between") ? width : 0 } as DOMRect; + }); +} + // Minimal RFC 4180 reader so the test checks column alignment independently of the writer. function csvToRows(csv: string): string[][] { const rows: string[][] = []; @@ -733,6 +759,182 @@ describe("SearchBar", () => { ); }); + // ------------------------------------------------------------------------- + describe("RED Metrics pin", () => { + const { isPinned, togglePin } = useToolbarPins("traces"); + let widthSpy: ReturnType; + + const pinBtn = () => wrapper.find('[data-test="traces-search-bar-menu-pin-metrics-btn"]'); + const pinnedBtn = () => wrapper.find('[data-test="traces-search-bar-metrics-pinned-btn"]'); + const savedViewsGroup = () => wrapper.find('[data-test="traces-search-bar-saved-views"]'); + const menuItem = () => wrapper.find('[data-test="traces-search-bar-menu-metrics-btn"]'); + const resetPins = () => { + if (isPinned("histogram")) togglePin("histogram"); + if (!isPinned("savedViews")) togglePin("savedViews"); + }; + const useWidth = (width: number) => { + widthSpy.mockRestore(); + widthSpy = mockToolbarWidth(width); + }; + + beforeEach(() => { + mockSavedViewsGet.mockResolvedValue({ data: { views: [] } }); + searchObjInstance.meta.searchMode = "spans"; + breakpointState.lgUp = true; + resetPins(); + widthSpy = mockToolbarWidth(1600); + }); + + afterEach(() => { + widthSpy.mockRestore(); + breakpointState.lgUp = true; + resetPins(); + }); + + it("offers a pin in the More item and no toolbar copy by default", async () => { + wrapper = mountSearchBar(); + await flushPromises(); + + expect(pinBtn().exists()).toBe(true); + expect(pinBtn().attributes("title")).toBe("search.pinToToolbar"); + expect(pinBtn().findComponent({ name: "OIcon" }).props("name")).toBe("keep-outline"); + expect(pinnedBtn().exists()).toBe(false); + }); + + it("pins without selecting the menu item, and unpins", async () => { + searchObjInstance.meta.showHistogram = true; + wrapper = mountSearchBar(); + await flushPromises(); + + const item = wrapper + .findAllComponents({ name: "ODropdownItem" }) + .find((c) => c.attributes("data-test") === "traces-search-bar-menu-metrics-btn")!; + await pinBtn().trigger("click"); + await flushPromises(); + + expect(item.emitted("select")).toBeUndefined(); + expect(searchObjInstance.meta.showHistogram).toBe(true); + expect(pinnedBtn().exists()).toBe(true); + expect(pinBtn().attributes("title")).toBe("search.unpinFromToolbar"); + expect(pinBtn().findComponent({ name: "OIcon" }).props("name")).toBe("keep"); + expect(menuItem().exists()).toBe(true); + + await pinBtn().trigger("click"); + await flushPromises(); + expect(pinnedBtn().exists()).toBe(false); + }); + + it("flips showHistogram from the pinned button and its switch exactly once", async () => { + searchObjInstance.meta.showHistogram = true; + togglePin("histogram"); + wrapper = mountSearchBar(); + await flushPromises(); + + await pinnedBtn().trigger("click"); + await flushPromises(); + expect(searchObjInstance.meta.showHistogram).toBe(false); + + await pinnedBtn().find('[role="switch"]').trigger("click"); + await flushPromises(); + expect(searchObjInstance.meta.showHistogram).toBe(true); + }); + + it("keeps a single show-metrics toggle data-test while pinned", async () => { + togglePin("histogram"); + wrapper = mountSearchBar(); + await flushPromises(); + + expect(pinnedBtn().exists()).toBe(true); + expect( + wrapper.findAll('[data-test="traces-search-bar-show-metrics-toggle-btn"]'), + ).toHaveLength(1); + }); + + it.each(["service-graph", "services-catalog"] as const)( + "hides the pinned copy on the %s tab", + async (mode) => { + searchObjInstance.meta.searchMode = mode; + togglePin("histogram"); + wrapper = mountSearchBar(); + await flushPromises(); + + expect(pinnedBtn().exists()).toBe(false); + }, + ); + + it("shows the pinned copy below lg without a width budget", async () => { + useWidth(0); + breakpointState.lgUp = false; + togglePin("histogram"); + wrapper = mountSearchBar(); + await flushPromises(); + + expect(pinnedBtn().exists()).toBe(true); + expect(savedViewsGroup().exists()).toBe(true); + }); + + it("falls back to More at a narrow lg width", async () => { + useWidth(300); + togglePin("histogram"); + wrapper = mountSearchBar(); + await flushPromises(); + + expect(pinnedBtn().exists()).toBe(false); + expect(menuItem().exists()).toBe(true); + }); + + it.each([ + [359, false], + [360, true], + ])( + "admits the pinned copy at width %i only when its full width fits: %s", + async (width, shown) => { + togglePin("savedViews"); + togglePin("histogram"); + useWidth(width); + wrapper = mountSearchBar(); + await flushPromises(); + + expect(pinnedBtn().exists()).toBe(shown); + expect(menuItem().exists()).toBe(true); + }, + ); + + it("keeps RED Metrics before Saved Views when only one fits", async () => { + useWidth(400); + wrapper = mountSearchBar(); + await flushPromises(); + expect(savedViewsGroup().exists()).toBe(true); + + togglePin("histogram"); + await flushPromises(); + expect(pinnedBtn().exists()).toBe(true); + expect(savedViewsGroup().exists()).toBe(false); + expect(wrapper.find('[data-test="traces-search-bar-menu-saved-views-group"]').exists()).toBe( + true, + ); + }); + + it("shrinks the toggle labels for the pinned RED Metrics width", async () => { + togglePin("savedViews"); + togglePin("histogram"); + useWidth(800); + wrapper = mountSearchBar(); + await flushPromises(); + + expect(pinnedBtn().exists()).toBe(true); + expect(wrapper.find('[data-test="traces-search-mode-spans-btn"]').text()).not.toContain( + "traces.spansTab", + ); + + togglePin("histogram"); + await flushPromises(); + expect(wrapper.find('[data-test="traces-search-mode-spans-btn"]').text()).toContain( + "traces.spansTab", + ); + }); + }); + // ------------------------------------------------------------------------- describe("Drill down button", () => { const applySearch = () => { @@ -1512,9 +1714,23 @@ describe("SearchBar", () => { }); describe("saved views", () => { - const tracesView = { view_id: "t1", view_name: "checkout errors", view_type: "traces" }; + const tracesView = { + view_id: "t1", + view_name: "checkout errors", + view_type: "traces", + org_id: "default", + }; + const tracesView2 = { + view_id: "t2", + view_name: "Alpha latency", + view_type: "traces", + org_id: "default", + }; const logsView = { view_id: "l1", view_name: "logs view" }; + const { isPinned, togglePin } = useToolbarPins("traces"); + let widthSpy: ReturnType; + const expectedData = () => ({ version: 1, stream: { label: "default", value: "default" }, @@ -1546,6 +1762,17 @@ describe("SearchBar", () => { searchObjInstance.meta.resultGrid.sortBy = "duration"; searchObjInstance.meta.resultGrid.sortOrder = "asc"; searchObjInstance.data.stream.selectedFields = ["service_name", "duration"]; + + breakpointState.lgUp = true; + if (!isPinned("savedViews")) togglePin("savedViews"); + widthSpy = mockToolbarWidth(1600); + localStorage.removeItem("savedViews"); + }); + + afterEach(() => { + widthSpy.mockRestore(); + breakpointState.lgUp = true; + if (!isPinned("savedViews")) togglePin("savedViews"); }); it("lists only traces views", async () => { @@ -1588,6 +1815,27 @@ describe("SearchBar", () => { expect(separator.props("vertical")).toBe(true); }); + it("is as tall as Reset and More: a hairline border around 1.625rem children", async () => { + wrapper = mountSearchBar(); + await flushPromises(); + + const group = wrapper.find('[data-test="traces-search-bar-saved-views"]'); + expect(group.classes()).toContain("border"); + expect(group.classes()).toContain("p-0"); + expect(group.find('[data-test="traces-search-bar-saved-views-btn"]').classes()).toContain( + "h-6.5", + ); + expect( + group.find('[data-test="traces-search-bar-saved-views-create-btn"]').classes(), + ).toContain("size-6.5"); + for (const neighbour of [ + "traces-search-bar-reset-filters-btn", + "traces-search-bar-more-menu-btn", + ]) { + expect(wrapper.find(`[data-test="${neighbour}"]`).classes()).toContain("h-7"); + } + }); + it.each(["service-graph", "services-catalog"])("is hidden on the %s tab", async (mode) => { searchObjInstance.meta.searchMode = mode as any; wrapper = mountSearchBar(); @@ -1660,26 +1908,375 @@ describe("SearchBar", () => { expect(wrapper.emitted("apply-saved-view")).toBeUndefined(); }); - it("delete calls the delete mutation once confirmed", async () => { + it("pinned dropdown has no delete action", async () => { wrapper = mountSearchBar(); await flushPromises(); - await wrapper.find('[data-test="traces-saved-view-delete-t1"]').trigger("click"); - await flushPromises(); - - expect(mockConfirm).toHaveBeenCalledTimes(1); - expect(mockSavedViewsDelete).toHaveBeenCalledWith(expect.any(String), "t1"); + expect(wrapper.find('[data-test="traces-saved-view-update-t1"]').exists()).toBe(true); + expect(wrapper.find('[data-test="traces-saved-view-delete-t1"]').exists()).toBe(false); }); - it("delete does nothing when not confirmed", async () => { - mockConfirm.mockResolvedValue(false); + it("pinned dropdown lists favourites first, then by name, with a star on favourites", async () => { + mockSavedViewsGet.mockResolvedValue({ data: { views: [tracesView, tracesView2, logsView] } }); + localStorage.setItem("savedViews", JSON.stringify({ t1: tracesView })); wrapper = mountSearchBar(); await flushPromises(); - await wrapper.find('[data-test="traces-saved-view-delete-t1"]').trigger("click"); + const items = wrapper + .findAll('[data-test^="traces-saved-view-apply-"]') + .map((item) => item.attributes("data-test")); + expect(items).toEqual(["traces-saved-view-apply-t1", "traces-saved-view-apply-t2"]); + const iconName = (id: string) => + wrapper + .findAllComponents({ name: "OIcon" }) + .find((c) => c.attributes("data-test") === `traces-saved-view-icon-${id}`) + ?.props("name"); + expect(iconName("t1")).toBe("star"); + expect(iconName("t2")).toBe("saved-search"); + }); + + it("pinned dropdown shows a loading row while the list loads", async () => { + mockSavedViewsGet.mockReturnValue(new Promise(() => {})); + wrapper = mountSearchBar(); await flushPromises(); - expect(mockSavedViewsDelete).not.toHaveBeenCalled(); + expect(wrapper.find('[data-test="traces-saved-views-menu-loading"]').exists()).toBe(true); + expect(wrapper.findComponent(SavedViewsListDialog).props("loading")).toBe(true); + }); + + describe("in the More menu", () => { + const moreGroup = () => + wrapper.find('[data-test="traces-search-bar-menu-saved-views-group"]'); + const toolbarGroup = () => wrapper.find('[data-test="traces-search-bar-saved-views"]'); + const dialog = () => wrapper.findComponent(SavedViewsListDialog); + + it("shows the saved-views group with List, Create and the pin button", async () => { + wrapper = mountSearchBar(); + await flushPromises(); + + expect(moreGroup().exists()).toBe(true); + expect( + moreGroup().find('[data-test="traces-search-bar-menu-list-saved-views-btn"]').exists(), + ).toBe(true); + expect( + moreGroup().find('[data-test="traces-search-bar-menu-create-saved-view-btn"]').exists(), + ).toBe(true); + expect( + moreGroup().find('[data-test="traces-search-bar-menu-pin-saved-views-btn"]').exists(), + ).toBe(true); + expect(moreGroup().find('[data-test="traces-saved-view-apply-t1"]').exists()).toBe(false); + }); + + it("opens the saved-views dialog from List saved views", async () => { + wrapper = mountSearchBar(); + await flushPromises(); + expect(dialog().props("open")).toBe(false); + + await moreGroup() + .find('[data-test="traces-search-bar-menu-list-saved-views-btn"]') + .trigger("click"); + + expect(dialog().props("open")).toBe(true); + expect(dialog().props("views")).toEqual([tracesView]); + }); + + it("opens the saved-views dialog from the pinned dropdown's Manage item", async () => { + wrapper = mountSearchBar(); + await flushPromises(); + + await toolbarGroup().find('[data-test="traces-saved-view-manage"]').trigger("click"); + + expect(dialog().props("open")).toBe(true); + }); + + it("deletes from the dialog through the confirmation flow while unpinned", async () => { + togglePin("savedViews"); + wrapper = mountSearchBar(); + await flushPromises(); + expect(toolbarGroup().exists()).toBe(false); + + dialog().vm.$emit("delete", tracesView); + await flushPromises(); + + expect(mockConfirm).toHaveBeenCalledTimes(1); + expect(mockSavedViewsDelete).toHaveBeenCalledWith(expect.any(String), "t1"); + }); + + it("applies and updates views from the dialog", async () => { + mockGetViewDetail.mockResolvedValue({ data: { ...tracesView, data: expectedData() } }); + wrapper = mountSearchBar(); + await flushPromises(); + + dialog().vm.$emit("apply", tracesView); + await flushPromises(); + expect(wrapper.emitted("apply-saved-view")?.[0]).toEqual([expectedData()]); + + dialog().vm.$emit("update", tracesView); + await flushPromises(); + expect(mockSavedViewsPut).toHaveBeenCalledWith(expect.any(String), "t1", { + view_name: "checkout errors", + data: expectedData(), + }); + }); + + it("refetches the saved views each time the dialog opens", async () => { + wrapper = mountSearchBar(); + await flushPromises(); + const callsBefore = mockSavedViewsGet.mock.calls.length; + mockSavedViewsGet.mockResolvedValue({ + data: { views: [tracesView, tracesView2, logsView] }, + }); + + await wrapper + .find('[data-test="traces-search-bar-menu-list-saved-views-btn"]') + .trigger("click"); + await flushPromises(); + + expect(mockSavedViewsGet.mock.calls.length).toBe(callsBefore + 1); + expect(dialog().props("open")).toBe(true); + expect(dialog().props("views")).toEqual([tracesView, tracesView2]); + + dialog().vm.$emit("update:open", false); + await flushPromises(); + expect(dialog().props("open")).toBe(false); + await wrapper + .find('[data-test="traces-search-bar-menu-list-saved-views-btn"]') + .trigger("click"); + await flushPromises(); + + expect(mockSavedViewsGet.mock.calls.length).toBe(callsBefore + 2); + expect(dialog().props("open")).toBe(true); + }); + + it("prunes favourites deleted elsewhere once the list loads, freeing the cap", async () => { + const favourites: Record = { + l1: { ...logsView, org_id: "default" }, + }; + for (let i = 0; i < 9; i++) { + favourites[`gone${i}`] = { ...tracesView, view_id: `gone${i}` }; + } + favourites.t1 = tracesView; + localStorage.setItem("savedViews", JSON.stringify(favourites)); + wrapper = mountSearchBar(); + await flushPromises(); + + expect(dialog().props("favoriteIds")).toEqual(["t1"]); + expect(Object.keys(JSON.parse(localStorage.getItem("savedViews") || "{}"))).toEqual([ + "l1", + "t1", + ]); + + dialog().vm.$emit("toggle-favorite", tracesView2, false); + await flushPromises(); + expect(dialog().props("favoriteIds")).toEqual(["t1", "t2"]); + }); + + it("keeps every favourite when the list fails to load", async () => { + mockSavedViewsGet.mockRejectedValue(new Error("boom")); + const gone = { ...tracesView, view_id: "gone" }; + localStorage.setItem("savedViews", JSON.stringify({ t1: tracesView, gone })); + wrapper = mountSearchBar(); + await flushPromises(); + + expect(dialog().props("loading")).toBe(false); + expect(dialog().props("views")).toEqual([]); + expect(dialog().props("favoriteIds")).toEqual(["t1", "gone"]); + expect(JSON.parse(localStorage.getItem("savedViews") || "{}")).toEqual({ + t1: tracesView, + gone, + }); + }); + + it("reports a successful delete even when localStorage rejects the write", async () => { + localStorage.setItem("savedViews", JSON.stringify({ t1: tracesView })); + wrapper = mountSearchBar(); + await flushPromises(); + const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("quota", "QuotaExceededError"); + }); + + dialog().vm.$emit("delete", tracesView); + await flushPromises(); + setItem.mockRestore(); + + expect(mockSavedViewsDelete).toHaveBeenCalledWith(expect.any(String), "t1"); + expect(dialog().props("favoriteIds")).toEqual([]); + expect(toastMock).toHaveBeenCalledWith({ + message: "search.viewDeletedSuccessfully", + variant: "success", + }); + }); + + it("passes the traces data-test prefix, views and favourites to the dialog", async () => { + localStorage.setItem( + "savedViews", + JSON.stringify({ t1: tracesView, l1: { ...logsView, org_id: "default" } }), + ); + wrapper = mountSearchBar(); + await flushPromises(); + + expect(dialog().props("dataTestPrefix")).toBe("traces-saved-views-dialog"); + expect(dialog().props("loading")).toBe(false); + expect(dialog().props("favoriteIds")).toEqual(["t1"]); + expect(dialog().props("favoriteViews")).toEqual([tracesView]); + }); + + it("update from the dialog closes it and asks for confirmation first", async () => { + mockConfirm.mockResolvedValue(false); + wrapper = mountSearchBar(); + await flushPromises(); + await wrapper + .find('[data-test="traces-search-bar-menu-list-saved-views-btn"]') + .trigger("click"); + expect(dialog().props("open")).toBe(true); + + dialog().vm.$emit("update", tracesView); + await flushPromises(); + + expect(dialog().props("open")).toBe(false); + expect(mockConfirm).toHaveBeenCalledWith({ + title: "search.updateSavedView", + message: "search.updateSavedViewConfirm", + }); + expect(mockSavedViewsPut).not.toHaveBeenCalled(); + }); + + it("delete from the dialog closes it, confirms, and drops the favourite", async () => { + localStorage.setItem("savedViews", JSON.stringify({ t1: tracesView })); + wrapper = mountSearchBar(); + await flushPromises(); + await wrapper + .find('[data-test="traces-search-bar-menu-list-saved-views-btn"]') + .trigger("click"); + + dialog().vm.$emit("delete", tracesView); + await flushPromises(); + + expect(dialog().props("open")).toBe(false); + expect(mockConfirm).toHaveBeenCalledWith({ + title: "search.deleteSavedView", + message: "search.deleteSavedViewConfirm", + }); + expect(mockSavedViewsDelete).toHaveBeenCalledWith(expect.any(String), "t1"); + expect(dialog().props("favoriteIds")).toEqual([]); + expect(JSON.parse(localStorage.getItem("savedViews") || "{}")).toEqual({}); + }); + + it("delete from the dialog does nothing when not confirmed", async () => { + mockConfirm.mockResolvedValue(false); + localStorage.setItem("savedViews", JSON.stringify({ t1: tracesView })); + wrapper = mountSearchBar(); + await flushPromises(); + + dialog().vm.$emit("delete", tracesView); + await flushPromises(); + + expect(mockSavedViewsDelete).not.toHaveBeenCalled(); + expect(dialog().props("favoriteIds")).toEqual(["t1"]); + }); + + it("toggles a favourite from the dialog", async () => { + wrapper = mountSearchBar(); + await flushPromises(); + + dialog().vm.$emit("toggle-favorite", tracesView, false); + await flushPromises(); + expect(dialog().props("favoriteIds")).toEqual(["t1"]); + + dialog().vm.$emit("toggle-favorite", tracesView, true); + await flushPromises(); + expect(dialog().props("favoriteIds")).toEqual([]); + }); + + it("pin click toggles the toolbar group without closing the menu", async () => { + wrapper = mountSearchBar(); + await flushPromises(); + expect(toolbarGroup().exists()).toBe(true); + + const bubbled = vi.fn(); + moreGroup().element.addEventListener("click", bubbled); + await moreGroup() + .find('[data-test="traces-search-bar-menu-pin-saved-views-btn"]') + .trigger("click"); + + expect(isPinned("savedViews")).toBe(false); + expect(toolbarGroup().exists()).toBe(false); + expect(bubbled).not.toHaveBeenCalled(); + + await moreGroup() + .find('[data-test="traces-search-bar-menu-pin-saved-views-btn"]') + .trigger("click"); + expect(toolbarGroup().exists()).toBe(true); + }); + + it("opens the save dialog from Create saved view", async () => { + wrapper = mountSearchBar(); + await flushPromises(); + + await wrapper + .find('[data-test="traces-search-bar-menu-create-saved-view-btn"]') + .trigger("click"); + expect((wrapper.vm as any).saveViewDialogOpen).toBe(true); + }); + + it.each(["service-graph", "services-catalog"])( + "hides the group but keeps More on the %s tab", + async (mode) => { + searchObjInstance.meta.searchMode = mode as any; + wrapper = mountSearchBar(); + await flushPromises(); + + expect(wrapper.find('[data-test="traces-search-bar-more-menu-btn"]').exists()).toBe(true); + expect(moreGroup().exists()).toBe(false); + }, + ); + + it("keeps the toolbar group off when unpinned", async () => { + togglePin("savedViews"); + wrapper = mountSearchBar(); + await flushPromises(); + + expect(toolbarGroup().exists()).toBe(false); + expect(moreGroup().exists()).toBe(true); + }); + + it("shows the pinned group below lg without a width budget", async () => { + widthSpy.mockRestore(); + widthSpy = mockToolbarWidth(0); + breakpointState.lgUp = false; + wrapper = mountSearchBar(); + await flushPromises(); + + expect(toolbarGroup().exists()).toBe(true); + }); + + it("shrinks the toggle labels before the pinned group falls back", async () => { + widthSpy.mockRestore(); + widthSpy = mockToolbarWidth(800); + wrapper = mountSearchBar(); + await flushPromises(); + + expect(toolbarGroup().exists()).toBe(true); + expect(wrapper.find('[data-test="traces-search-mode-spans-btn"]').text()).not.toContain( + "traces.spansTab", + ); + + togglePin("savedViews"); + await flushPromises(); + expect(wrapper.find('[data-test="traces-search-mode-spans-btn"]').text()).toContain( + "traces.spansTab", + ); + }); + + it("falls back to More at desktop width when the toolbar has no room", async () => { + widthSpy.mockRestore(); + widthSpy = mockToolbarWidth(360); + wrapper = mountSearchBar(); + await flushPromises(); + + expect(toolbarGroup().exists()).toBe(false); + expect(moreGroup().exists()).toBe(true); + }); }); }); }); diff --git a/web/src/plugins/traces/SearchBar.vue b/web/src/plugins/traces/SearchBar.vue index 46fa66b745..8a110b2edc 100644 --- a/web/src/plugins/traces/SearchBar.vue +++ b/web/src/plugins/traces/SearchBar.vue @@ -21,86 +21,165 @@ along with this program. If not, see . >
- - +
- + - - {{ t("traces.spansTab") }} - - - - {{ t("traces.tracesTab") }} - - - - {{ t("traces.serviceGraphTab") }} - - - - {{ t("traces.servicesCatalog.tabLabel") }} - - + + + {{ t("traces.spansTab") }} + + + + {{ t("traces.tracesTab") }} + + + + {{ t("traces.serviceGraphTab") }} + + + + {{ t("traces.servicesCatalog.tabLabel") }} + + - - - {{ t("traces.drillDown") }} - - - - - + + + + + + + + + + + + + + + + + + + {{ t("search.createSavedView") }} + + + {{ t("search.manageSavedViews") }} + + + + + + + + +
@@ -132,16 +208,77 @@ along with this program. If not, see . {{ t("traces.redMetrics") }} + + . > - - - - -
- - {{ view.view_name }} - - -
- - {{ t("search.savedViewsNotFound") }} - -
- - - {{ t("search.createSavedView") }} - -
- - - - - -
. data-test="traces-saved-view-name-input" /> +
@@ -512,11 +588,17 @@ import OSeparator from "@/lib/core/Separator/OSeparator.vue"; import useTraces from "@/composables/useTraces"; import { useSqlEditorDiagnostics } from "@/composables/useSqlEditorDiagnostics"; import SyntaxGuide from "./SyntaxGuide.vue"; +import TracesSavedViewsMenuItems from "./TracesSavedViewsMenuItems.vue"; +import SavedViewsListDialog from "@/components/savedViews/SavedViewsListDialog.vue"; +import { sortSavedViews } from "@/components/savedViews/savedViewsSort"; +import { useFavoriteSavedViews } from "@/composables/useFavoriteSavedViews"; import { debounce } from "lodash-es"; import analytics from "@/services/product_analytics"; import config from "@/aws-exports"; import { useToolbarResponsive } from "@/composables/useToolbarResponsive"; +import { useToolbarPins } from "@/composables/useToolbarPins"; +import useBreakpoint from "@/composables/useBreakpoint"; import useSqlSuggestions from "@/composables/useSuggestions"; import useStreams from "@/composables/useStreams"; import { @@ -561,6 +643,8 @@ export default defineComponent({ OTooltip, CodeQueryEditor: defineAsyncComponent(() => import("@/components/CodeQueryEditor.vue")), SyntaxGuide, + TracesSavedViewsMenuItems, + SavedViewsListDialog, }, emits: [ "searchdata", @@ -996,6 +1080,22 @@ export default defineComponent({ const tracesSavedViews = computed(() => (savedViewsList.data.value ?? []).filter((view) => view.view_type === "traces"), ); + const savedViewsLoading = computed(() => savedViewsList.isLoading.value); + const { favoriteIds, favoriteViews, toggleFavorite, removeFavorite, pruneFavorites } = + useFavoriteSavedViews("traces"); + // Only a successful fetch is the complete list; pruning on error would drop every favourite. + watch( + () => savedViewsList.data.value, + (views) => { + if (savedViewsList.isSuccess.value && views) { + pruneFavorites(views.map((view) => view.view_id)); + } + }, + { immediate: true }, + ); + const sortedTracesSavedViews = computed(() => + sortSavedViews(tracesSavedViews.value, favoriteIds.value), + ); const createSavedView = useMutation(() => createSavedViewMutation(orgId.value)); const updateSavedView = useMutation(() => updateSavedViewMutation(orgId.value)); const deleteSavedView = useMutation(() => deleteSavedViewMutation(orgId.value)); @@ -1030,6 +1130,12 @@ export default defineComponent({ saveViewDialogOpen.value = true; }; + const savedViewsDialogOpen = ref(false); + const openSavedViewsDialog = () => { + savedViewsList.refetch(); + savedViewsDialogOpen.value = true; + }; + const saveAsNewView = async () => { const viewName = newViewName.value.trim(); if (!viewName) { @@ -1067,7 +1173,23 @@ export default defineComponent({ } }; + const savedViewsDropdownOpen = ref(false); + const quickUpdateSavedView = (view) => { + savedViewsDropdownOpen.value = false; + return updateSavedViewFromCurrent(view); + }; + + const confirmUpdateSavedView = async (view) => { + savedViewsDialogOpen.value = false; + const confirmed = await confirm({ + title: t("search.updateSavedView"), + message: t("search.updateSavedViewConfirm"), + }); + if (confirmed) await updateSavedViewFromCurrent(view); + }; + const deleteTracesSavedView = async (view) => { + savedViewsDialogOpen.value = false; const confirmed = await confirm({ title: t("search.deleteSavedView"), message: t("search.deleteSavedViewConfirm"), @@ -1075,6 +1197,7 @@ export default defineComponent({ if (!confirmed) return; try { await deleteSavedView.mutateAsync(view.view_id); + removeFavorite(view.view_id); toast({ message: t("search.viewDeletedSuccessfully"), variant: "success" }); } catch (err) { toast({ @@ -1100,18 +1223,44 @@ export default defineComponent({ // Responsive toolbar — shared composable tracks available left-section width const { toolbarLeftRef, toolbarRightRef, availableLeftWidth } = useToolbarResponsive(); + const { lgUp } = useBreakpoint(); + const { isPinned, togglePin } = useToolbarPins("traces"); - // Traces-specific breakpoints (actual content widths + 60px buffer to fire before clipping): - // Toggle items with text: ~682px total → hide at 750 (682+68 buffer) - // After toggle icon-only (~459px) + reset text: hide reset text at 540 - const shouldHideToggleText = computed(() => availableLeftWidth.value < 750); - const shouldHideResetText = computed(() => availableLeftWidth.value < 540); + const isSearchMode = computed( + () => + searchObj.meta.searchMode !== "service-graph" && + searchObj.meta.searchMode !== "services-catalog", + ); + + // Approximate rendered widths of the Traces left-section items, used only to decide whether the pinned group fits. + const TOOLBAR_ITEM_GAP = 6; + const METRICS_PINNED_WIDTH = 72 + TOOLBAR_ITEM_GAP; + const SAVED_VIEWS_GROUP_WIDTH = 87 + TOOLBAR_ITEM_GAP; + const TOGGLE_WIDTH = { enterprise: { text: 422, icon: 150 }, oss: { text: 298, icon: 114 } }; + const DRILL_DOWN_WIDTH = { text: 97, icon: 36 }; + const RESET_WIDTH = { text: 72, icon: 36 }; + const MORE_WIDTH = 70; + + const metricsPinned = computed(() => isSearchMode.value && isPinned("histogram")); + const savedViewsPinned = computed(() => isSearchMode.value && isPinned("savedViews")); + const pinnedGroupWidth = computed( + () => + (metricsPinned.value ? METRICS_PINNED_WIDTH : 0) + + (savedViewsPinned.value ? SAVED_VIEWS_GROUP_WIDTH : 0), + ); + + // Traces-specific breakpoints, raised by the pinned group so labels shrink before it falls back into More. + const shouldHideToggleText = computed( + () => availableLeftWidth.value < 750 + pinnedGroupWidth.value, + ); + const shouldHideResetText = computed( + () => availableLeftWidth.value < 540 + pinnedGroupWidth.value, + ); // Only while Index renders SearchResult with its header, since the click is routed to it. const showDrillDown = computed( () => - searchObj.meta.searchMode !== "service-graph" && - searchObj.meta.searchMode !== "services-catalog" && + isSearchMode.value && !searchObj.loadingStream && !searchObj.loading && searchObj.data.stream.streamLists.length > 0 && @@ -1120,6 +1269,26 @@ export default defineComponent({ !!searchObj.searchApplied, ); + const leftBaseWidth = computed(() => { + const textKey = shouldHideToggleText.value ? "icon" : "text"; + const toggle = config.isEnterprise == "true" ? TOGGLE_WIDTH.enterprise : TOGGLE_WIDTH.oss; + const widths = [toggle[textKey], MORE_WIDTH]; + if (showDrillDown.value) widths.push(DRILL_DOWN_WIDTH[textKey]); + if (isSearchMode.value) widths.push(RESET_WIDTH[shouldHideResetText.value ? "icon" : "text"]); + return widths.reduce((sum, w) => sum + w, 0) + (widths.length - 1) * TOOLBAR_ITEM_GAP; + }); + + // Below lg the bar wraps and availableLeftWidth reads ~0, so pinned items need no budget there. + const pinnedFit = computed(() => { + if (!lgUp.value) return { metrics: metricsPinned.value, savedViews: savedViewsPinned.value }; + let budget = availableLeftWidth.value - leftBaseWidth.value; + const metrics = metricsPinned.value && budget >= METRICS_PINNED_WIDTH; + if (metrics) budget -= METRICS_PINNED_WIDTH; + return { metrics, savedViews: savedViewsPinned.value && budget >= SAVED_VIEWS_GROUP_WIDTH }; + }); + const showPinnedMetrics = computed(() => pinnedFit.value.metrics); + const showPinnedSavedViews = computed(() => pinnedFit.value.savedViews); + return { t, router, @@ -1156,15 +1325,30 @@ export default defineComponent({ shouldHideToggleText, shouldHideResetText, showDrillDown, + isSearchMode, + isPinned, + togglePin, + showPinnedMetrics, + showPinnedSavedViews, tracesSavedViews, isSavingView, saveViewDialogOpen, newViewName, openSaveViewDialog, + savedViewsDialogOpen, + openSavedViewsDialog, saveAsNewView, updateSavedViewFromCurrent, deleteTracesSavedView, applySavedView, + savedViewsLoading, + favoriteIds, + favoriteViews, + toggleFavorite, + sortedTracesSavedViews, + savedViewsDropdownOpen, + quickUpdateSavedView, + confirmUpdateSavedView, }; }, computed: { diff --git a/web/src/plugins/traces/TracesSavedViewsMenuItems.vue b/web/src/plugins/traces/TracesSavedViewsMenuItems.vue new file mode 100644 index 0000000000..0a63527d1c --- /dev/null +++ b/web/src/plugins/traces/TracesSavedViewsMenuItems.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/web/src/utils/storage.spec.ts b/web/src/utils/storage.spec.ts index 978206b584..cfc47e8faa 100644 --- a/web/src/utils/storage.spec.ts +++ b/web/src/utils/storage.spec.ts @@ -35,7 +35,6 @@ import { useLocalLogFilterField, useLocalTraceFilterField, useLocalInterestingFields, - useLocalSavedView, useLocalUserInfo, useLocalTimezone, useLocalWrapContent, @@ -243,20 +242,6 @@ describe("useLocalInterestingFields", () => { }); }); -// --------------------------------------------------------------------------- -// useLocalSavedView -// --------------------------------------------------------------------------- - -describe("useLocalSavedView", () => { - it("reads from localStorage with key 'savedViews'", () => { - localStorageMock.getItem.mockReturnValue(null); - - useLocalSavedView("val"); - - expect(localStorageMock.getItem).toHaveBeenCalledWith("savedViews"); - }); -}); - // --------------------------------------------------------------------------- // useLocalUserInfo — returns .value (primitive), not the ref // --------------------------------------------------------------------------- diff --git a/web/src/utils/storage.ts b/web/src/utils/storage.ts index b5f06953fc..68578b4962 100644 --- a/web/src/utils/storage.ts +++ b/web/src/utils/storage.ts @@ -119,10 +119,6 @@ export const useLocalInterestingFields = (val = "", isDelete = false) => { return useLocalStorage("interestingFields", val, isDelete, true); }; -export const useLocalSavedView = (val = "", isDelete = false) => { - return useLocalStorage("savedViews", val, isDelete, true); -}; - export const useLocalUserInfo = (val = "", isDelete = false) => { const userInfo: any = useLocalStorage("userInfo", val, isDelete); return userInfo.value;