mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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 ───────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user