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)

This commit is contained in:
Omkar Kesarkhane
2026-10-16 10:44:12 +05:30
committed by GitHub
parent 0ac032b913
commit c2e07c11ba
33 changed files with 2615 additions and 770 deletions
+22 -18
View File
@@ -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}`);
@@ -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();
@@ -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 <http://www.gnu.org/licenses/>.
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: '<div v-if="open" v-bind="$attrs"><slot /></div>',
};
// The real dropdown portals its items; render them inline so the < md menu is reachable.
const ODropdownStub = {
name: "ODropdown",
template: '<div><slot name="trigger" /><slot /></div>',
};
const ODropdownItemStub = {
name: "ODropdownItem",
props: ["iconLeft", "variant"],
emits: ["select"],
template: '<div v-bind="$attrs" @click="$emit(\'select\')"><slot /></div>',
};
describe("SavedViewsListDialog", () => {
let wrapper: VueWrapper<any> | undefined;
const mountDialog = (props: Record<string, unknown> = {}) =>
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);
});
});
@@ -0,0 +1,293 @@
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<script setup lang="ts">
import { computed, watch } from "vue";
import { useI18nTyped, raw } from "@/types/i18n";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import OTable from "@/lib/core/Table/OTable.vue";
import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
export interface SavedViewRow {
view_id: string;
view_name: string;
[key: string]: unknown;
}
type Pane = "all" | "favorites";
interface DialogTestIds {
root: string;
list: string;
search: string;
loading: string;
empty: string;
table: (pane: Pane) => string;
apply: (pane: Pane, row: SavedViewRow) => string;
favorite: (pane: Pane, row: SavedViewRow) => string;
update: (pane: Pane, row: SavedViewRow) => string;
remove: (pane: Pane, row: SavedViewRow) => string;
more: (pane: Pane, row: SavedViewRow) => string;
}
const props = withDefaults(
defineProps<{
open: boolean;
views: SavedViewRow[];
favoriteIds: string[];
favoriteViews: SavedViewRow[];
loading?: boolean;
dataTestPrefix: string;
}>(),
{ loading: false },
);
const emit = defineEmits<{
(e: "update:open", value: boolean): void;
(e: "apply", row: SavedViewRow): void;
(e: "update", row: SavedViewRow): void;
(e: "delete", row: SavedViewRow): void;
(e: "toggle-favorite", row: SavedViewRow, isFavorite: boolean): void;
}>();
const search = defineModel<string>("search", { default: "" });
const { t } = useI18nTyped();
watch(
() => props.open,
(open) => {
if (open) search.value = "";
},
);
const paneTestId = (pane: Pane) =>
pane === "all" ? props.dataTestPrefix : `${props.dataTestPrefix}-favorites`;
const ids = computed<DialogTestIds>(() => ({
root: props.dataTestPrefix,
list: `${props.dataTestPrefix}-list`,
search: `${props.dataTestPrefix}-search`,
loading: `${props.dataTestPrefix}-loading`,
empty: `${props.dataTestPrefix}-empty`,
table: (pane) => `${paneTestId(pane)}-table`,
apply: (pane, row) => `${paneTestId(pane)}-apply-${row.view_id}`,
favorite: (pane, row) => `${paneTestId(pane)}-favorite-${row.view_id}`,
update: (pane, row) => `${paneTestId(pane)}-update-${row.view_id}`,
remove: (pane, row) => `${paneTestId(pane)}-delete-${row.view_id}`,
more: (pane, row) => `${paneTestId(pane)}-more-actions-${row.view_id}`,
}));
// `meta.isAction` gives the right-aligned action cell; the column-level `isAction` would also pin it with a sticky shadow.
const columns = [
{
id: "view_name",
header: raw(""),
accessorKey: "view_name",
sortable: false,
meta: { align: "left" as const },
},
{
id: "actions",
header: raw(""),
sortable: false,
size: 30,
meta: { align: "right" as const, actionCount: 3, isAction: true },
},
];
const HOVER_REVEAL =
"opacity-0 group-hover/row:opacity-100 focus-visible:opacity-100 max-md:opacity-100";
const filteredViews = computed(() => {
const term = search.value.trim().toLowerCase();
if (!term) return props.views;
return props.views.filter((view) => String(view.view_name).toLowerCase().includes(term));
});
const panes = computed(() => {
const hasFavorites = props.favoriteViews.length > 0;
const all = {
key: "all" as Pane,
rows: filteredViews.value,
class: hasFavorites ? "border-card-glass-border w-3/5 border-e" : "w-full",
};
if (!hasFavorites) return [all];
return [all, { key: "favorites" as Pane, rows: props.favoriteViews, class: "w-2/5 ps-3" }];
});
const isFavorite = (pane: Pane, row: SavedViewRow) =>
pane === "favorites" || props.favoriteIds.includes(row.view_id);
const applyView = (row: SavedViewRow) => {
emit("apply", row);
emit("update:open", false);
};
</script>
<template>
<ODialog
:open="props.open"
size="lg"
:title="t('search.savedViewsLabel')"
:data-test="ids.root"
@update:open="emit('update:open', $event)"
>
<div :data-test="ids.list" class="flex">
<div
v-for="pane in panes"
:key="pane.key"
class="flex max-h-120 min-h-70 flex-col"
:class="pane.class"
>
<OTable
:data-test="ids.table(pane.key)"
:data="pane.rows"
:columns="columns"
row-key="view_id"
compact
:show-header="false"
:show-global-filter="false"
:pagination-bordered="false"
:pagination="pane.key === 'all' ? 'client' : 'none'"
:page-size="10"
:page-size-options="[10, 20, 50]"
class="min-h-0 flex-1"
>
<template #top>
<template v-if="pane.key === 'all'">
<div class="box-border w-full min-w-0 p-2">
<OSearchInput
v-model="search"
:data-test="ids.search"
clearable
:debounce="300"
class="w-full"
:placeholder="t('search.searchSavedView')"
/>
</div>
<div
v-if="props.loading"
:data-test="ids.loading"
class="flex w-full items-center gap-2 p-2 text-sm font-medium"
>
<OSpinner size="xs" />
{{ t("confirmDialog.loading") }}
</div>
</template>
<template v-else>
<div class="text-text-secondary p-2 text-xs leading-6 font-bold tracking-wide">
{{ t("search.favoriteViews") }}
</div>
</template>
</template>
<template #cell-view_name="{ row, value }">
<div
class="w-full min-w-0 cursor-pointer truncate text-sm"
:title="value"
:data-test="ids.apply(pane.key, row)"
:data-test-view-name="row.view_name"
@click.stop="applyView(row)"
>
{{ value }}
</div>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-0.5">
<OButton
:title="t('common.favourite')"
variant="ghost-neutral"
size="icon-sm"
:class="HOVER_REVEAL"
:data-test="ids.favorite(pane.key, row)"
@click.stop="emit('toggle-favorite', row, isFavorite(pane.key, row))"
>
<OIcon
:name="isFavorite(pane.key, row) ? 'star' : 'star-outline'"
size="xs"
:class="isFavorite(pane.key, row) ? 'text-favorite' : ''"
/>
</OButton>
<OButton
:title="t('common.edit')"
variant="ghost-neutral"
size="icon-sm"
:class="[HOVER_REVEAL, 'max-md:hidden']"
:data-test="ids.update(pane.key, row)"
@click.stop="emit('update', row)"
>
<OIcon name="edit" size="xs" />
</OButton>
<OButton
:title="t('common.delete')"
variant="ghost-neutral"
size="icon-sm"
:class="[HOVER_REVEAL, 'max-md:hidden']"
:data-test="ids.remove(pane.key, row)"
@click.stop="emit('delete', row)"
>
<OIcon name="delete" size="xs" />
</OButton>
<ODropdown side="bottom" align="end">
<template #trigger>
<OButton
icon-left="more-vert"
variant="ghost"
size="icon-xs-sq"
class="md:hidden"
:data-test="ids.more(pane.key, row)"
@click.stop
/>
</template>
<ODropdownItem
icon-left="edit"
class="md:hidden"
:data-test="`${ids.update(pane.key, row)}-menu`"
@select="emit('update', row)"
>
<span>{{ t("common.edit") }}</span>
</ODropdownItem>
<ODropdownItem
icon-left="delete"
variant="destructive"
class="md:hidden"
:data-test="`${ids.remove(pane.key, row)}-menu`"
@select="emit('delete', row)"
>
<span>{{ t("common.delete") }}</span>
</ODropdownItem>
</ODropdown>
</div>
</template>
<template #empty>
<OEmptyState
v-if="!props.loading"
size="inline"
:title="t('search.savedViewsNotFound')"
:data-test="ids.empty"
/>
</template>
</OTable>
</div>
</div>
</ODialog>
</template>
@@ -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 <http://www.gnu.org/licenses/>.
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<string, unknown> = {};
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<string, unknown> = {};
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();
});
});
@@ -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 <http://www.gnu.org/licenses/>.
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<string, FavoriteSavedView> => {
try {
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
};
const writeStore = (value: Record<string, FavoriteSavedView>) => {
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<Record<string, FavoriteSavedView>>(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 };
}
@@ -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", () => {
+15 -10
View File
@@ -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<any[] | undefined> => {
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;
}
};
+101
View File
@@ -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();
});
});
+80 -44
View File
@@ -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 <http://www.gnu.org/licenses/>.
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<Record<ToolbarPinKey, string>>;
}
// 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<Record<ToolbarPinKey, string>> = {
histogram: "logs_toolbar_histogram_pin_decided",
const SCOPES: Record<ToolbarPinScope, ToolbarPinScopeConfig> = {
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<ToolbarPinScope, Ref<Set<ToolbarPinKey>>>();
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<Set<ToolbarPinKey>>(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<Set<ToolbarPinKey>> => {
let state = scopeStates.get(scope);
if (!state) {
state = ref(new Set(readInitial(SCOPES[scope]))) as Ref<Set<ToolbarPinKey>>;
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<ToolbarPinKey[]>(() =>
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 };
+13
View File
@@ -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 () => {
+1
View File
@@ -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";
+2 -1
View File
@@ -276,6 +276,7 @@ const sizeClasses: Record<NonNullable<ButtonProps["size"]>, 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<NonNullable<ButtonProps["size"]>, 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",
};
+31
View File
@@ -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 ───────────────────────────────────────
+4
View File
@@ -444,6 +444,8 @@ export interface OTableProps<TData = any> {
/** 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<TData = any> {
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;
+13 -5
View File
@@ -73,12 +73,14 @@ const props = withDefaults(defineProps<OTableProps<TData>>(), {
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;
}
@@ -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"
@@ -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<string, any>;
@@ -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'
@@ -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"
@@ -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<string, any> {
: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<string, any> {
: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 ?? '',
@@ -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(() => {
<template>
<div
:data-test="`o2-table-pagination-${position}`"
class="border-border-default flex flex-wrap items-center gap-x-3 gap-y-1 border-t px-3 py-1"
:class="[
'flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-1',
bordered ? 'border-border-default border-t' : '',
]"
>
<!-- Slot presence is read here, not in a computed: slots are not reactive, so a cached answer would miss a slot the page adds later. -->
<div
+1
View File
@@ -776,6 +776,7 @@
/* Row height tokens: change these to resize all OTable rows globally. */
--table-row-height-dense: 2.375rem; /* 38px */
--table-row-height-normal: 2.75rem; /* 44px */
--table-row-height-compact: 1.5625rem; /* 25px */
/* ── Chart panel bar ───────────────────────────────────────
The title strip at the top of every chart panel (dashboard panels via
@@ -0,0 +1,177 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mount, flushPromises, VueWrapper } from "@vue/test-utils";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import { createRouter, createMemoryHistory } from "vue-router";
import SearchBar from "@/plugins/logs/SearchBar.vue";
import SavedViewsListDialog from "@/components/savedViews/SavedViewsListDialog.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
const { mockSavedViewsGet, mockSavedViewsDelete } = vi.hoisted(() => ({
mockSavedViewsGet: vi.fn(),
mockSavedViewsDelete: vi.fn(),
}));
vi.mock("@/services/saved_views", async (importOriginal) => {
const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
return overlayServiceMock(await importOriginal(), {
default: { get: mockSavedViewsGet, delete: mockSavedViewsDelete },
});
});
// A light in-memory router: the app router lazy-loads real pages on navigation.
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: "/logs", name: "logs", component: { template: "<div />" } }],
});
const logsView = {
view_id: "l1",
view_name: "error logs",
org_id: "default",
view_type: "logs",
};
const logsView2 = {
view_id: "l2",
view_name: "slow requests",
org_id: "default",
view_type: "logs",
};
describe("SearchBar — saved views list dialog", () => {
let wrapper: VueWrapper<any> | undefined;
const mountSearchBar = () =>
mount(SearchBar, {
global: {
provide: { store },
plugins: [i18n, router],
stubs: { QueryEditor: true },
},
});
const dialog = () => wrapper!.findComponent(SavedViewsListDialog);
const favorites = () => JSON.parse(localStorage.getItem("savedViews") || "{}");
beforeEach(() => {
localStorage.removeItem("savedViews");
mockSavedViewsGet.mockResolvedValue({ data: { views: [logsView, logsView2] } });
mockSavedViewsDelete.mockResolvedValue({ status: 200, data: {} });
});
afterEach(() => {
wrapper?.unmount();
wrapper = undefined;
localStorage.removeItem("savedViews");
vi.clearAllMocks();
});
it("passes the logs data-test prefix, views and favourites to the dialog", async () => {
localStorage.setItem("savedViews", JSON.stringify({ l1: logsView }));
wrapper = mountSearchBar();
wrapper.vm.searchObj.data.savedViews = [logsView, logsView2];
await flushPromises();
expect(dialog().props("dataTestPrefix")).toBe("logs-saved-views-dialog");
expect(dialog().props("views")).toEqual([logsView, logsView2]);
expect(dialog().props("favoriteIds")).toEqual(["l1"]);
expect(dialog().props("favoriteViews")).toEqual([logsView]);
});
it("toggles a favourite from the dialog", async () => {
wrapper = mountSearchBar();
await flushPromises();
dialog().vm.$emit("toggle-favorite", logsView, false);
await flushPromises();
expect(dialog().props("favoriteIds")).toEqual(["l1"]);
expect(Object.keys(favorites())).toEqual(["l1"]);
dialog().vm.$emit("toggle-favorite", logsView, true);
await flushPromises();
expect(dialog().props("favoriteIds")).toEqual([]);
expect(favorites()).toEqual({});
});
it("a confirmed delete from the dialog drops the favourite", async () => {
localStorage.setItem("savedViews", JSON.stringify({ l1: logsView }));
wrapper = mountSearchBar();
await flushPromises();
wrapper.vm.savedViewsListDialog = true;
await flushPromises();
dialog().vm.$emit("delete", logsView);
await flushPromises();
expect(dialog().props("open")).toBe(false);
const confirm = wrapper
.findAllComponents(ConfirmDialog)
.find((c) => c.props("modelValue") === true)!;
expect(confirm.props("title")).toBe(i18n.global.t("search.deleteSavedView"));
expect(mockSavedViewsDelete).not.toHaveBeenCalled();
confirm.vm.$emit("update:ok");
await flushPromises();
expect(mockSavedViewsDelete).toHaveBeenCalledWith("default", "l1");
expect(dialog().props("favoriteIds")).toEqual([]);
expect(favorites()).toEqual({});
}, 20000);
it("prunes favourites deleted elsewhere once the list loads, freeing the cap", async () => {
const stored: Record<string, unknown> = {
t1: { ...logsView, view_id: "t1", view_type: "traces" },
};
for (let i = 0; i < 9; i++) stored[`gone${i}`] = { ...logsView, view_id: `gone${i}` };
stored.l1 = logsView;
localStorage.setItem("savedViews", JSON.stringify(stored));
wrapper = mountSearchBar();
wrapper.vm.searchObj.data.savedViews = [];
await flushPromises();
expect(dialog().props("favoriteIds")).toHaveLength(10);
wrapper.vm.openSavedViewsList();
await flushPromises();
expect(mockSavedViewsGet).toHaveBeenCalledTimes(1);
expect(dialog().props("favoriteIds")).toEqual(["l1"]);
expect(Object.keys(favorites())).toEqual(["t1", "l1"]);
dialog().vm.$emit("toggle-favorite", logsView2, false);
await flushPromises();
expect(dialog().props("favoriteIds")).toEqual(["l1", "l2"]);
});
it("keeps every favourite when the list fails to load", async () => {
mockSavedViewsGet.mockRejectedValue(new Error("boom"));
const gone = { ...logsView, view_id: "gone" };
localStorage.setItem("savedViews", JSON.stringify({ l1: logsView, gone }));
wrapper = mountSearchBar();
wrapper.vm.searchObj.data.savedViews = [];
await flushPromises();
wrapper.vm.openSavedViewsList();
await flushPromises();
expect(mockSavedViewsGet).toHaveBeenCalledTimes(1);
expect(dialog().props("loading")).toBe(false);
expect(dialog().props("views")).toEqual([]);
expect(dialog().props("favoriteIds")).toEqual(["l1", "gone"]);
expect(favorites()).toEqual({ l1: logsView, gone });
});
});
@@ -0,0 +1,114 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mount, flushPromises, VueWrapper } from "@vue/test-utils";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import { createRouter, createMemoryHistory } from "vue-router";
import SearchBar from "@/plugins/logs/SearchBar.vue";
import { useToolbarPins } from "@/composables/useToolbarPins";
// A light in-memory router: the app router lazy-loads real pages on navigation.
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: "/logs", name: "logs", component: { template: "<div />" } }],
});
describe("SearchBar — pinned saved views", () => {
let wrapper: VueWrapper<any> | undefined;
const { isPinned, togglePin } = useToolbarPins();
// jsdom reports zero widths, so every pinned item would fall back into the More menu.
const mountSearchBar = (toolbarWidth = 0) => {
if (toolbarWidth) {
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (
this: HTMLElement,
) {
const isToolbarBar = this.classList.contains("border-b-card-glass-border");
return { width: isToolbarBar ? toolbarWidth : 0 } as DOMRect;
});
}
return mount(SearchBar, {
global: {
provide: { store },
plugins: [i18n, router],
stubs: { QueryEditor: true },
},
});
};
const group = () => wrapper!.find('[data-test="logs-search-bar-saved-views-pinned"]');
beforeEach(() => {
if (!isPinned("savedViews")) togglePin("savedViews");
});
afterEach(() => {
wrapper?.unmount();
wrapper = undefined;
if (isPinned("savedViews")) togglePin("savedViews");
vi.restoreAllMocks();
});
it("gives the list trigger the grouped xs size with an icon and a caret", async () => {
wrapper = mountSearchBar(1600);
await flushPromises();
const trigger = group()
.findAllComponents({ name: "OButton" })
.find((b) => b.attributes("data-test") === "logs-search-bar-saved-views-pinned-list-btn")!;
expect(trigger.props("size")).toBe("xs-grouped");
const icons = trigger.findAllComponents({ name: "OIcon" }).map((c) => c.props("name"));
expect(icons).toEqual(["saved-search", "arrow-drop-down"]);
});
it("divides the list trigger from the save button with a vertical separator", async () => {
wrapper = mountSearchBar(1600);
await flushPromises();
expect(group().exists()).toBe(true);
const separator = group().findComponent({ name: "OSeparator" });
expect(separator.exists()).toBe(true);
expect(separator.props("vertical")).toBe(true);
expect(
group().find('[data-test="logs-search-bar-saved-views-pinned-create-btn"]').exists(),
).toBe(true);
});
it("is as tall as the xs toolbar buttons: a hairline border around 1.625rem children", async () => {
wrapper = mountSearchBar(1600);
await flushPromises();
expect(group().classes()).toContain("border");
expect(group().classes()).toContain("p-0");
expect(
group().find('[data-test="logs-search-bar-saved-views-pinned-list-btn"]').classes(),
).toContain("h-6.5");
expect(
group().find('[data-test="logs-search-bar-saved-views-pinned-create-btn"]').classes(),
).toContain("size-6.5");
expect(wrapper!.find('[data-test="logs-search-bar-reset-filters-btn"]').classes()).toContain(
"h-7",
);
});
it("moves the group into the More menu when the toolbar has no room", async () => {
wrapper = mountSearchBar();
await flushPromises();
expect(group().exists()).toBe(false);
});
});
-53
View File
@@ -1796,12 +1796,6 @@ describe("SearchBar.vue Actual Component Methods", () => {
}
}),
filterSavedViewFn: vi.fn((rows, terms) => {
if (terms === "") return [];
terms = terms.toLowerCase();
return rows.filter((row) => row.view_name.toLowerCase().includes(terms));
}),
regionFilterMethod: vi.fn((node, filter) => {
const filt = filter.toLowerCase();
return (node && node.label && node.label.toLowerCase().indexOf(filt) > -1) || false;
@@ -2185,21 +2179,6 @@ describe("SearchBar.vue Actual Component Methods", () => {
});
});
// Test 100: filterSavedViewFn method
it("should filter saved views by search terms", () => {
const rows = [
{ view_name: "My Test View" },
{ view_name: "Production Logs" },
{ view_name: "Test Results" },
];
const filtered = componentInstance.filterSavedViewFn(rows, "test");
expect(filtered).toHaveLength(2);
expect(filtered[0].view_name).toBe("My Test View");
expect(filtered[1].view_name).toBe("Test Results");
});
// Test 101: regionFilterMethod
it("should filter regions by label", () => {
const node = { label: "US East" };
@@ -2678,24 +2657,6 @@ describe("SearchBar.vue Actual Component Methods", () => {
});
});
// Test 142: filterSavedViewFn with empty search terms
it("should return empty array for empty search terms", () => {
const rows = [{ view_name: "Test View" }];
const result = componentInstance.filterSavedViewFn(rows, "");
expect(result).toEqual([]);
});
// Test 143: filterSavedViewFn with no matches
it("should return empty array when no views match", () => {
const rows = [{ view_name: "Production Logs" }, { view_name: "Error Tracking" }];
const result = componentInstance.filterSavedViewFn(rows, "development");
expect(result).toEqual([]);
});
// Test 144: resetRegionFilter method
it("should reset region filter to empty string", () => {
componentInstance.regionFilter = "us-east";
@@ -3419,20 +3380,6 @@ describe("SearchBar.vue Actual Component Methods", () => {
expect(componentInstance.searchObj.data.customDownloadQueryObj.query.size).toBe(100);
});
// Test 208: filterSavedViewFn with special characters
it("should filter views with special characters", () => {
const rows = [
{ view_name: "Test-View_123" },
{ view_name: "Production@Logs" },
{ view_name: "Dev Environment" },
];
const result = componentInstance.filterSavedViewFn(rows, "-");
expect(result).toHaveLength(1);
expect(result[0].view_name).toBe("Test-View_123");
});
// Test 209: regionFilterMethod with empty filter
it("should handle empty filter in region method", () => {
const node = { label: "US East" };
+36 -442
View File
@@ -239,7 +239,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButton
data-test="logs-search-bar-saved-views-pinned-list-btn"
variant="ghost"
size="sm-toolbar"
size="xs-grouped"
>
<OIcon name="saved-search" size="sm" />
<OIcon name="arrow-drop-down" size="sm" />
@@ -310,7 +310,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButton
data-test="logs-search-bar-saved-views-pinned-create-btn"
variant="ghost"
size="icon-toolbar"
size="icon-panel"
@click="fnSavedView"
>
<OIcon name="save" size="sm" />
@@ -1719,240 +1719,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Query Plan Dialog -->
<QueryPlanDialog v-model="showExplainDialog" :searchObj="searchObj" />
<!-- Saved Views List Dialog -->
<ODialog
<SavedViewsListDialog
v-model:open="savedViewsListDialog"
size="lg"
:title="t('search.savedViewsLabel')"
data-test="saved-views-list-dialog"
>
<div>
<div data-test="logs-search-saved-view-list" class="flex">
<div
class="flex flex-col"
:class="localSavedViews.length > 0 ? 'border-card-glass-border border-e' : ''"
:style="localSavedViews.length > 0 ? 'width: 60%' : 'width: 100%'"
>
<div class="flex flex-col" style="max-height: 30.375rem; min-height: 17.5rem">
<OTable
data-test="log-search-saved-view-list-fields-table"
:data="searchObj.data.savedViews"
:columns="savedViewColumns"
row-key="view_id"
:global-filter="searchObj.data.savedViewFilterFields"
:page-size="rowsPerPage"
:page-size-options="[10, 20, 50]"
class="saved-view-table o2-table-hide-header h-full! max-h-full"
>
<template #top>
<div class="box-border w-full min-w-0 px-2 py-2">
<OSearchInput
data-test="log-search-saved-view-field-search-input"
v-model="searchObj.data.savedViewFilterFields"
clearable
:debounce="300"
class="w-full"
:placeholder="t('search.searchSavedView')"
/>
</div>
<div v-if="searchObj.loadingSavedView == true" class="w-full p-2">
<div class="text-sm font-bold font-medium">
<OSpinner size="xs" />
{{ t("confirmDialog.loading") }}
</div>
</div>
</template>
<template #cell-view_name="{ row, value }">
<div
class="w-full min-w-0 cursor-pointer truncate text-sm"
:title="value"
:data-test="`logs-search-bar-apply-${value}-saved-view-btn`"
@click.stop="
applySavedView(row);
savedViewsListDialog = false;
"
>
{{ value }}
</div>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-0.5">
<OButton
:title="t('common.favourite')"
class="hover:text-text-body! hover:bg-interactive-hover-bg! action-btn-hover"
variant="ghost-neutral"
size="icon-sm"
:data-test="`logs-search-bar-favorite-${row.view_id}-saved-view-btn`"
@click.stop="
handleFavoriteSavedView(row, favoriteViews.includes(row.view_id))
"
>
<OIcon
:name="favoriteViews.includes(row.view_id) ? 'star' : 'star-outline'"
size="xs"
:class="favoriteViews.includes(row.view_id) ? 'text-favorite' : ''"
/>
</OButton>
<OButton
:title="t('common.edit')"
class="hover:text-text-body! hover:bg-interactive-hover-bg! action-btn-hover max-md:hidden"
variant="ghost-neutral"
size="icon-sm"
:data-test="`logs-search-bar-update-${row.view_id}-saved-view-btn`"
@click.stop="handleUpdateSavedView(row)"
>
<OIcon name="edit" size="xs" />
</OButton>
<OButton
:title="t('common.delete')"
class="hover:text-text-body! hover:bg-interactive-hover-bg! action-btn-hover max-md:hidden"
variant="ghost-neutral"
size="icon-sm"
:data-test="`logs-search-bar-delete-${row.view_id}-saved-view-btn`"
@click.stop="handleDeleteSavedView(row)"
>
<OIcon name="delete" size="xs" />
</OButton>
<ODropdown side="bottom" align="end">
<template #trigger>
<OButton
icon-left="more-vert"
variant="ghost"
size="icon-xs-sq"
class="md:hidden"
data-test="logs-search-bar-saved-view-row-more-actions"
@click.stop
/>
</template>
<ODropdownItem
icon-left="edit"
class="md:hidden"
:data-test="`logs-search-bar-update-${row.view_id}-saved-view-btn-menu`"
@select="handleUpdateSavedView(row)"
>
<span>{{ t("common.edit") }}</span>
</ODropdownItem>
<ODropdownItem
icon-left="delete"
variant="destructive"
class="md:hidden"
:data-test="`logs-search-bar-delete-${row.view_id}-saved-view-btn-menu`"
@select="handleDeleteSavedView(row)"
>
<span>{{ t("common.delete") }}</span>
</ODropdownItem>
</ODropdown>
</div>
</template>
<template #empty>
<div v-if="searchObj.loadingSavedView == false" class="w-full p-2 text-center">
<span>{{ t("search.savedViewsNotFound") }}</span>
</div>
</template>
</OTable>
</div>
</div>
<div class="ms-0 flex w-[40%] flex-col ps-3" v-if="localSavedViews.length > 0">
<div class="flex flex-col" style="max-height: 30rem; min-height: 17.5rem">
<OTable
data-test="log-search-saved-view-favorite-list-fields-table"
:data="localSavedViews"
:columns="savedViewColumns"
row-key="view_id"
pagination="none"
class="saved-view-table o2-table-hide-header h-full! max-h-full"
>
<template #top>
<div
class="text-muted-foreground p-2 text-xs leading-6 font-bold tracking-wide uppercase"
>
{{ t("search.favoriteViews") }}
</div>
<div class="border-border my-1 border-t" />
</template>
<template #cell-view_name="{ row, value }">
<div
class="w-full min-w-0 cursor-pointer truncate text-sm"
:title="value"
:data-test="`logs-search-bar-dialog-favorite-saved-view-row-${value}`"
@click.stop="
applySavedView(row);
savedViewsListDialog = false;
"
>
{{ value }}
</div>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-0.5">
<OButton
:title="t('common.favourite')"
class="hover:text-text-body! hover:bg-interactive-hover-bg! action-btn-hover"
variant="ghost-neutral"
size="icon-sm"
:data-test="`logs-search-bar-favorite-${row.view_id}-saved-view-btn`"
@click.stop="handleFavoriteSavedView(row, true)"
>
<OIcon name="star" size="xs" class="text-favorite" />
</OButton>
<OButton
:title="t('common.edit')"
class="hover:text-text-body! hover:bg-interactive-hover-bg! action-btn-hover max-md:hidden"
variant="ghost-neutral"
size="icon-sm"
:data-test="`logs-search-bar-update-${row.view_id}-favorite-saved-view-btn`"
@click.stop="handleUpdateSavedView(row)"
>
<OIcon name="edit" size="xs" />
</OButton>
<OButton
:title="t('common.delete')"
class="hover:text-text-body! hover:bg-interactive-hover-bg! action-btn-hover max-md:hidden"
variant="ghost-neutral"
size="icon-sm"
:data-test="`logs-search-bar-delete-${row.view_id}-favorite-saved-view-btn`"
@click.stop="handleDeleteSavedView(row)"
>
<OIcon name="delete" size="xs" />
</OButton>
<ODropdown side="bottom" align="end">
<template #trigger>
<OButton
icon-left="more-vert"
variant="ghost"
size="icon-xs-sq"
class="md:hidden"
data-test="logs-search-bar-favorite-saved-view-row-more-actions"
@click.stop
/>
</template>
<ODropdownItem
icon-left="edit"
class="md:hidden"
:data-test="`logs-search-bar-update-${row.view_id}-favorite-saved-view-btn-menu`"
@select="handleUpdateSavedView(row)"
>
<span>{{ t("common.edit") }}</span>
</ODropdownItem>
<ODropdownItem
icon-left="delete"
variant="destructive"
class="md:hidden"
:data-test="`logs-search-bar-delete-${row.view_id}-favorite-saved-view-btn-menu`"
@select="handleDeleteSavedView(row)"
>
<span>{{ t("common.delete") }}</span>
</ODropdownItem>
</ODropdown>
</div>
</template>
</OTable>
</div>
</div>
</div>
</div>
</ODialog>
v-model:search="searchObj.data.savedViewFilterFields"
data-test-prefix="logs-saved-views-dialog"
:views="searchObj.data.savedViews"
:favorite-ids="favoriteViews"
:favorite-views="localSavedViews"
:loading="searchObj.loadingSavedView == true"
@apply="applySavedView"
@update="handleUpdateSavedView"
@delete="handleDeleteSavedView"
@toggle-favorite="handleFavoriteSavedView"
/>
</div>
</template>
@@ -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({
</script>
<style scoped>
/* keep(complex-state): every selector below reaches into OTable's internal DOM
(its <td>, its border/pagination wrappers, its footer chip) from the
.saved-view-table modifier this file puts on the OTable root, so they need
:deep() rather than template utilities — the markup is not ours to annotate. */
.saved-view-table :deep(.action-btn-hover) {
opacity: 0;
transition: opacity 0.15s;
}
.saved-view-table :deep(tr:hover .action-btn-hover) {
opacity: 1;
}
/* Touch has no row hover — keep the row actions visible. */
@media (max-width: 47.99rem) {
.saved-view-table :deep(.action-btn-hover) {
opacity: 1;
}
}
/* Remove outer box border so both panels blend into the dialog background
Exclude elements that also have rounded-default (OInput wrapper) so the
search input keeps its visible border. */
.saved-view-table :deep(.border:not(.rounded-default)) {
border: none;
}
/* Normalize cell background and strip the auto-pin shadow
(isAction columns are auto-pinned right by OTable, which adds an inline box-shadow) */
.saved-view-table :deep(td) {
background: transparent;
box-shadow: none !important;
padding: 0;
height: 1.5625rem !important;
min-height: 1.5625rem !important;
}
/* ── .logs-search-bar-component: the root modifier this file puts on its own
wrapper. The rest of the former global block (18 nested rules — .reset-filters,
.toggle-container, .ddlWrapper/.listWrapper, .savedview-dropdown, #logsQueryEditor,
@@ -5583,11 +5182,6 @@ export default defineComponent({
padding: 0.125rem 0.25rem !important;
}
/* Remove pagination top separator */
.saved-view-table :deep(.border-t) {
border-top: none;
}
/* Query editor placeholder text styling is global (styles/tailwind.css) —
shared with traces, RUM sessions, RUM error tracking, and alerts. */
</style>
+609 -12
View File
@@ -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 = {
'<div class="o-dropdown-item-stub" v-bind="$attrs" @click="$emit(\'select\')"><slot name="icon-left" /><slot /><slot name="icon-right" /></div>',
emits: ["select"],
},
ODropdownGroup: { template: "<div><slot /></div>" },
ODropdownGroup: {
template: '<div v-bind="$attrs"><slot name="label-action" /><slot /></div>',
},
ODropdownSeparator: { template: "<hr />" },
};
@@ -392,6 +409,15 @@ function mountSearchBar(props: Record<string, unknown> = {}): 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<typeof mockToolbarWidth>;
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<typeof mockToolbarWidth>;
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<string, unknown> = {
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);
});
});
});
});
+345 -161
View File
@@ -21,86 +21,165 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<div
ref="toolbarLeftRef"
class="flex min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden max-lg:w-full max-lg:flex-none max-lg:flex-wrap max-lg:gap-y-1 max-lg:overflow-visible"
class="flex min-w-0 flex-1 flex-row items-center gap-1.5 max-lg:w-full max-lg:flex-none max-lg:flex-wrap max-lg:gap-y-1"
>
<!-- Unified View Toggle: Service Graph / Traces / Spans -->
<OToggleGroup
:model-value="searchObj.meta.searchMode"
@update:model-value="$emit('update:searchMode', $event)"
<!-- Clips its overflow so More, a shrink-0 sibling, is never clipped. -->
<div
class="flex min-w-0 flex-initial flex-row items-center gap-1.5 overflow-hidden max-lg:flex-wrap max-lg:gap-y-1 max-lg:overflow-visible"
>
<OToggleGroupItem
data-test="traces-search-mode-spans-btn"
value="spans"
size="sm"
:tooltip="shouldHideToggleText ? t('traces.spansTab') : undefined"
<!-- Unified View Toggle: Service Graph / Traces / Spans -->
<OToggleGroup
:model-value="searchObj.meta.searchMode"
@update:model-value="$emit('update:searchMode', $event)"
>
<template #icon-left><OIcon name="layers" size="sm" class="shrink-0" /></template>
<span v-if="!shouldHideToggleText">{{ t("traces.spansTab") }}</span>
</OToggleGroupItem>
<OToggleGroupItem
data-test="traces-search-mode-traces-btn"
value="traces"
size="sm"
:tooltip="shouldHideToggleText ? t('traces.tracesTab') : undefined"
>
<template #icon-left><OIcon name="account-tree" size="sm" class="shrink-0" /></template>
<span v-if="!shouldHideToggleText">{{ t("traces.tracesTab") }}</span>
</OToggleGroupItem>
<OToggleGroupItem
v-if="config.isEnterprise == 'true'"
data-test="traces-service-graph-toggle"
value="service-graph"
size="sm"
:tooltip="shouldHideToggleText ? t('traces.serviceGraphTab') : undefined"
>
<template #icon-left><OIcon name="share" size="sm" class="shrink-0" /></template>
<span v-if="!shouldHideToggleText">{{ t("traces.serviceGraphTab") }}</span>
</OToggleGroupItem>
<OToggleGroupItem
data-test="traces-search-mode-services-catalog-btn"
value="services-catalog"
size="sm"
:tooltip="shouldHideToggleText ? t('traces.servicesCatalog.tabLabel') : undefined"
>
<template #icon-left><OIcon name="menu-book" size="sm" class="shrink-0" /></template>
<span v-if="!shouldHideToggleText">{{ t("traces.servicesCatalog.tabLabel") }}</span>
</OToggleGroupItem>
</OToggleGroup>
<OToggleGroupItem
data-test="traces-search-mode-spans-btn"
value="spans"
size="sm"
:tooltip="shouldHideToggleText ? t('traces.spansTab') : undefined"
>
<template #icon-left><OIcon name="layers" size="sm" class="shrink-0" /></template>
<span v-if="!shouldHideToggleText">{{ t("traces.spansTab") }}</span>
</OToggleGroupItem>
<OToggleGroupItem
data-test="traces-search-mode-traces-btn"
value="traces"
size="sm"
:tooltip="shouldHideToggleText ? t('traces.tracesTab') : undefined"
>
<template #icon-left
><OIcon name="account-tree" size="sm" class="shrink-0"
/></template>
<span v-if="!shouldHideToggleText">{{ t("traces.tracesTab") }}</span>
</OToggleGroupItem>
<OToggleGroupItem
v-if="config.isEnterprise == 'true'"
data-test="traces-service-graph-toggle"
value="service-graph"
size="sm"
:tooltip="shouldHideToggleText ? t('traces.serviceGraphTab') : undefined"
>
<template #icon-left><OIcon name="share" size="sm" class="shrink-0" /></template>
<span v-if="!shouldHideToggleText">{{ t("traces.serviceGraphTab") }}</span>
</OToggleGroupItem>
<OToggleGroupItem
data-test="traces-search-mode-services-catalog-btn"
value="services-catalog"
size="sm"
:tooltip="shouldHideToggleText ? t('traces.servicesCatalog.tabLabel') : undefined"
>
<template #icon-left><OIcon name="menu-book" size="sm" class="shrink-0" /></template>
<span v-if="!shouldHideToggleText">{{ t("traces.servicesCatalog.tabLabel") }}</span>
</OToggleGroupItem>
</OToggleGroup>
<OButton
v-if="showDrillDown"
data-test="insights-button"
variant="outline"
size="xs"
@click="$emit('drill-down')"
>
<template #icon-left>
<OIcon name="timeline" size="sm" class="shrink-0" />
</template>
<span v-if="!shouldHideToggleText">{{ t("traces.drillDown") }}</span>
<OTooltip :content="t('volumeInsights.analyzeTooltipTraces')" />
</OButton>
<!-- Show search controls only when not on Service Graph or Services Catalog -->
<template
v-if="
searchObj.meta.searchMode !== 'service-graph' &&
searchObj.meta.searchMode !== 'services-catalog'
"
>
<!-- Reset: icon+text at wide widths, icon-only when narrow -->
<OButton
data-test="traces-search-bar-reset-filters-btn"
v-if="showDrillDown"
data-test="insights-button"
variant="outline"
size="xs"
@click="resetFilters"
@click="$emit('drill-down')"
>
<template #icon-left>
<OIcon name="restart-alt" size="sm" class="shrink-0" />
<OIcon name="timeline" size="sm" class="shrink-0" />
</template>
<span v-if="!shouldHideResetText">{{ t("common.reset") }}</span>
<span v-if="!shouldHideToggleText">{{ t("traces.drillDown") }}</span>
<OTooltip :content="t('volumeInsights.analyzeTooltipTraces')" />
</OButton>
</template>
<!-- Show search controls only when not on Service Graph or Services Catalog -->
<template
v-if="
searchObj.meta.searchMode !== 'service-graph' &&
searchObj.meta.searchMode !== 'services-catalog'
"
>
<!-- Reset: icon+text at wide widths, icon-only when narrow -->
<OButton
data-test="traces-search-bar-reset-filters-btn"
variant="outline"
size="xs"
@click="resetFilters"
>
<template #icon-left>
<OIcon name="restart-alt" size="sm" class="shrink-0" />
</template>
<span v-if="!shouldHideResetText">{{ t("common.reset") }}</span>
</OButton>
</template>
<OButton
v-if="showPinnedMetrics"
data-test="traces-search-bar-metrics-pinned-btn"
size="xs"
variant="outline"
class="gap-1.5"
@click="searchObj.meta.showHistogram = !searchObj.meta.showHistogram"
>
<OSwitch v-model="searchObj.meta.showHistogram" size="md" @click.stop />
<OIcon name="bar-chart" size="sm" class="shrink-0" />
<OTooltip :content="t('traces.redMetrics')" />
</OButton>
<OButtonGroup
v-if="showPinnedSavedViews"
data-test="traces-search-bar-saved-views"
class="element-box-shadow border-button-outline-border shrink-0 border p-0"
>
<ODropdown
:open="savedViewsDropdownOpen"
side="bottom"
align="start"
@update:open="savedViewsDropdownOpen = $event"
>
<template #trigger>
<OButton
data-test="traces-search-bar-saved-views-btn"
variant="ghost"
size="xs-grouped"
>
<OIcon name="saved-search" size="sm" />
<OIcon name="arrow-drop-down" size="sm" />
<OTooltip :content="t('search.listSavedViews')" :side-offset="2" />
</OButton>
</template>
<ODropdownGroup :label="t('search.savedViewsLabel')">
<TracesSavedViewsMenuItems
:views="sortedTracesSavedViews"
:favorite-ids="favoriteIds"
:loading="savedViewsLoading"
@apply="applySavedView"
@update="quickUpdateSavedView"
/>
</ODropdownGroup>
<ODropdownSeparator />
<ODropdownItem
icon-left="save"
data-test="traces-saved-view-create"
@select="openSaveViewDialog"
>
{{ t("search.createSavedView") }}
</ODropdownItem>
<ODropdownItem
icon-left="settings"
data-test="traces-saved-view-manage"
@select="openSavedViewsDialog"
>
{{ t("search.manageSavedViews") }}
</ODropdownItem>
</ODropdown>
<OSeparator vertical />
<OButton
data-test="traces-search-bar-saved-views-create-btn"
variant="ghost"
size="icon-panel"
@click="openSaveViewDialog"
>
<OIcon name="save" size="sm" />
<OTooltip :content="t('search.createSavedView')" :side-offset="6" />
</OButton>
</OButtonGroup>
</div>
<ODropdown side="bottom" align="start">
<template #trigger>
@@ -109,17 +188,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
variant="outline"
size="xs"
icon-left="more-horiz"
class="max-lg:ms-auto"
class="shrink-0 max-lg:ms-auto"
>
{{ t("search.menuMore") }}
</OButton>
</template>
<ODropdownItem
v-if="
searchObj.meta.searchMode !== 'service-graph' &&
searchObj.meta.searchMode !== 'services-catalog'
"
v-if="isSearchMode"
data-test="traces-search-bar-menu-metrics-btn"
@select.prevent="searchObj.meta.showHistogram = !searchObj.meta.showHistogram"
>
@@ -132,16 +208,77 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
{{ t("traces.redMetrics") }}
<template #icon-right>
<OSwitch
v-model="searchObj.meta.showHistogram"
size="md"
class="ms-auto"
data-test="traces-search-bar-show-metrics-toggle-btn"
@click.stop
/>
<span class="ms-auto flex items-center gap-1">
<OSwitch
v-model="searchObj.meta.showHistogram"
size="md"
data-test="traces-search-bar-show-metrics-toggle-btn"
@click.stop
/>
<OButton
data-test="traces-search-bar-menu-pin-metrics-btn"
variant="ghost-neutral"
size="icon-sm"
:title="
isPinned('histogram') ? t('search.unpinFromToolbar') : t('search.pinToToolbar')
"
@click.stop="togglePin('histogram')"
>
<OIcon :name="isPinned('histogram') ? 'keep' : 'keep-outline'" size="sm" />
</OButton>
</span>
</template>
</ODropdownItem>
<template v-if="isSearchMode">
<ODropdownSeparator />
<ODropdownGroup
:label="t('search.menuGroupSavedViews')"
data-test="traces-search-bar-menu-saved-views-group"
>
<template #label-action>
<OButton
data-test="traces-search-bar-menu-pin-saved-views-btn"
variant="ghost-neutral"
size="icon-sm"
:title="
isPinned('savedViews') ? t('search.unpinFromToolbar') : t('search.pinToToolbar')
"
@click.stop="togglePin('savedViews')"
>
<OIcon :name="isPinned('savedViews') ? 'keep' : 'keep-outline'" size="sm" />
</OButton>
</template>
<ODropdownItem
data-test="traces-search-bar-menu-list-saved-views-btn"
@select="openSavedViewsDialog"
>
<template #icon-left>
<span
class="rounded-default bg-section-header-bg text-text-secondary inline-flex h-7 w-7 shrink-0 items-center justify-center"
>
<OIcon name="format-list-bulleted" size="sm" />
</span>
</template>
{{ t("search.listSavedViews") }}
</ODropdownItem>
<ODropdownItem
data-test="traces-search-bar-menu-create-saved-view-btn"
@select="openSaveViewDialog"
>
<template #icon-left>
<span
class="rounded-default bg-section-header-bg text-text-secondary inline-flex h-7 w-7 shrink-0 items-center justify-center"
>
<OIcon name="add" size="sm" />
</span>
</template>
{{ t("search.createSavedView") }}
</ODropdownItem>
</ODropdownGroup>
<ODropdownSeparator />
</template>
<SyntaxGuide
:sqlmode="searchObj.meta.sqlMode"
:menuItem="true"
@@ -269,79 +406,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<OIcon name="download" size="sm" />
</OButton>
<OButtonGroup
data-test="traces-search-bar-saved-views"
class="element-box-shadow border-button-outline-border border p-0"
>
<ODropdown side="bottom" align="end">
<template #trigger>
<OButton
data-test="traces-search-bar-saved-views-btn"
variant="ghost"
size="sm-toolbar"
>
<OIcon name="saved-search" size="sm" />
<OIcon name="arrow-drop-down" size="sm" />
<OTooltip :content="t('search.listSavedViews')" :side-offset="2" />
</OButton>
</template>
<ODropdownGroup :label="t('search.savedViewsLabel')">
<div
v-if="tracesSavedViews.length"
class="max-h-72 overflow-y-auto overscroll-contain"
>
<ODropdownItem
v-for="view in tracesSavedViews"
:key="view.view_id"
:data-test="`traces-saved-view-apply-${view.view_id}`"
@select="applySavedView(view)"
>
<span class="max-w-56 truncate">{{ view.view_name }}</span>
<template #icon-right>
<OButton
variant="ghost"
size="icon-xs-sq"
icon-left="edit"
class="ms-auto"
:title="t('search.updateSavedViewWithCurrent')"
:data-test="`traces-saved-view-update-${view.view_id}`"
@click.stop.prevent="updateSavedViewFromCurrent(view)"
/>
<OButton
variant="ghost"
size="icon-xs-sq"
icon-left="delete"
:title="t('search.deleteSavedView')"
:data-test="`traces-saved-view-delete-${view.view_id}`"
@click.stop.prevent="deleteTracesSavedView(view)"
/>
</template>
</ODropdownItem>
</div>
<ODropdownItem v-else disabled>
{{ t("search.savedViewsNotFound") }}
</ODropdownItem>
</ODropdownGroup>
<ODropdownSeparator />
<ODropdownItem
icon-left="save"
data-test="traces-saved-view-create"
@select="openSaveViewDialog"
>
{{ t("search.createSavedView") }}
</ODropdownItem>
</ODropdown>
<OSeparator vertical />
<OButton
data-test="traces-search-bar-saved-views-create-btn"
variant="ghost"
size="icon-toolbar"
@click="openSaveViewDialog"
>
<OIcon name="save" size="sm" />
<OTooltip :content="t('search.createSavedView')" :side-offset="6" />
</OButton>
</OButtonGroup>
<ShareButton
data-test="logs-search-bar-share-link-btn"
:url="tracesShareURL"
@@ -472,6 +536,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="traces-saved-view-name-input"
/>
</ODialog>
<SavedViewsListDialog
v-model:open="savedViewsDialogOpen"
data-test-prefix="traces-saved-views-dialog"
:views="tracesSavedViews"
:favorite-ids="favoriteIds"
:favorite-views="favoriteViews"
:loading="savedViewsLoading"
@apply="applySavedView"
@update="confirmUpdateSavedView"
@delete="deleteTracesSavedView"
@toggle-favorite="toggleFavorite"
/>
</div>
</template>
@@ -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: {
@@ -0,0 +1,94 @@
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<script setup lang="ts">
import { useI18nTyped } from "@/types/i18n";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
interface TracesSavedView {
view_id: string;
view_name: string;
}
const props = withDefaults(
defineProps<{
views: TracesSavedView[];
favoriteIds: string[];
loading?: boolean;
}>(),
{ loading: false },
);
const emit = defineEmits<{
(e: "apply", view: TracesSavedView): void;
(e: "update", view: TracesSavedView): void;
}>();
const { t } = useI18nTyped();
const isFavorite = (view: TracesSavedView) => props.favoriteIds.includes(view.view_id);
</script>
<template>
<div
v-if="props.loading"
class="text-text-secondary flex items-center gap-2 px-3 py-1.5 text-sm"
data-test="traces-saved-views-menu-loading"
>
<OSpinner size="xs" />
{{ t("confirmDialog.loading") }}
</div>
<div
v-else-if="props.views.length"
class="max-h-72 overflow-y-auto overscroll-contain"
data-test="traces-saved-views-menu-list"
>
<ODropdownItem
v-for="view in props.views"
:key="view.view_id"
:text-value="view.view_name"
:data-test="`traces-saved-view-apply-${view.view_id}`"
@select="emit('apply', view)"
>
<template #icon-left>
<OIcon
:name="isFavorite(view) ? 'star' : 'saved-search'"
size="sm"
:class="isFavorite(view) ? 'text-favorite' : ''"
:data-test="`traces-saved-view-icon-${view.view_id}`"
/>
</template>
<span class="max-w-56 min-w-0 truncate" :title="view.view_name">{{ view.view_name }}</span>
<template #icon-right>
<OButton
variant="ghost"
size="icon-xs-sq"
icon-left="edit"
class="ms-auto"
:title="t('search.updateSavedViewWithCurrent')"
:data-test="`traces-saved-view-update-${view.view_id}`"
@click.stop.prevent="emit('update', view)"
/>
</template>
</ODropdownItem>
</div>
<ODropdownItem v-else disabled>
{{ t("search.savedViewsNotFound") }}
</ODropdownItem>
</template>
-15
View File
@@ -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
// ---------------------------------------------------------------------------
-4
View File
@@ -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;