feat(saved-views): saved views submenu with favorites, edit and delete on Logs and Traces (#16077)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Ashish Kolhe <35160958+oasisk@users.noreply.github.com>
This commit is contained in:
Bhargav
2026-10-31 14:55:13 +00:00
committed by GitHub
co-authored by github-actions[bot] Ashish Kolhe
parent 6a1ff919b5
commit 46043dfa9b
44 changed files with 1950 additions and 1487 deletions
+17 -8
View File
@@ -22599,6 +22599,7 @@
"customRange": "06ea254e73bbab2e",
"deleteSavedView": "64d7b3bab0b50cc0",
"deleteSavedViewConfirm": "e1555bb4863e8cb2",
"deleteViewNamed": "3cf8146e529f1e42",
"deltaScanSize": "81d40c84284d2ec0",
"downloadCSV": "eb6b42f54c42d283",
"downloadJSON": "db1a21a0bc2ef8fb",
@@ -22642,7 +22643,7 @@
"explainResults": "b64075059084248e",
"exportLogs": "437c5e8f71a7a1f8",
"failedToFetchFieldValues": "65eb66697a2df8b5",
"favoriteViews": "d2e4e43c5b91e51a",
"favoriteViewNamed": "ea8a16e59889a0d2",
"fetchingResults": "4d15ef1c91fc8092",
"fetchingValues": "c6837b65bbc83780",
"fieldMissingInStreams": "c349f2b1b98bca04",
@@ -22730,7 +22731,6 @@
"liveModeTooltip": "28bf0972f3888162",
"loadingPlan": "687ba06353583216",
"logicalPlan": "f9c628db537a449b",
"manageSavedViews": "b70a3b0c5b410752",
"maxEventsScheduleJob": "1e33f66246307f84",
"maxRecords": "209f9c68099d7e6b",
"menuGroupAlerts": "f62e9267d196c761",
@@ -22738,7 +22738,6 @@
"menuGroupExplain": "3b61e4e936523550",
"menuGroupHistory": "0e76960093379060",
"menuGroupInspect": "e0723a86a5b9408a",
"menuGroupSavedViews": "8b4b9a23dfb5e991",
"menuGroupSchedule": "f4830a1dae298044",
"menuGroupSetOnce": "7722b43b4b6931cf",
"menuMore": "d47d7cb0e4f8fd2b",
@@ -22797,6 +22796,10 @@
"occurrenceColumnHeader": "37bac4952becf21a",
"of": "28391d3bc64ec15c",
"original": "88d759ea02cef4b8",
"overwriteSavedView": "484c5ddb5d0abaf3",
"overwriteSavedViewConfirm": "011e1d4e19f77577",
"overwriteSavedViewNamed": "764b55e98873c80e",
"overwriteSavedViewOk": "b24963ea2cbc74b9",
"patternAnomalyDetected": "d2bd334c661bf20d",
"patternAnomalyLowFreq": "fb473531d3e61eaf",
"patternAnomalyRare": "e75077de2370d11b",
@@ -22867,9 +22870,19 @@
"runningAnalyze": "1e8f6a027cd4e5cf",
"saveFunctionName": "dae596892b2dd17c",
"savedFunctionNotFound": "a330ceefc8dc19c1",
"savedViewApplied": "0d3b7a579736eb6d",
"savedViewName": "da7a321c73c90d88",
"savedViewsAllGroup": "20b0b5a1811f1f60",
"savedViewsEmpty": "861d00de83e6681c",
"savedViewsFavoritesGroup": "e39e59fb49c815d2",
"savedViewsLabel": "edc47cfbced35fc7",
"savedViewsNotFound": "d271ae2e71620984",
"savedViewsNoMatch": "3e4ed5a972a2a1e9",
"savedViewsRowLabel": "8b4b9a23dfb5e991",
"savedViewsSearchCount": "3c0e27d0f74136b8",
"savedViewsSearchPlaceholder": "db71b188cd05760f",
"savedViewsSummary": "a831814a345ed1b1",
"savedViewsSummaryFavorites": "d53b1a67a1dfc3cc",
"savedViewsSummaryViews": "31b4c95468c67eb0",
"scanSize": "b41c8c7779b8cc15",
"scannedSoFar": "ff88237fc82a362e",
"scheduleSearchJob": "21daedde3d118f2b",
@@ -22882,7 +22895,6 @@
"searchOperationCancelled": "2429bc389adabe7f",
"searchQueryEmptyOrInvalid": "7682ef6f589ed1ea",
"searchSavedFunction": "49c266baaaa70981",
"searchSavedView": "db71b188cd05760f",
"selectFunctionNamePlaceholder": "185f94c6eda7de48",
"selectIndex": "bae161900447ff32",
"selectLogStream": "a8e6de79496e8d62",
@@ -22952,9 +22964,6 @@
"unknownError": "27c2ccd962c2b8dc",
"unknownErrorOccurred": "43c4deec555b4c7e",
"unpinFromToolbar": "c9e9cb30a9457d6e",
"updateSavedView": "f0e9e5ac3af3b640",
"updateSavedViewConfirm": "165c5f6398bd57f9",
"updateSavedViewWithCurrent": "9ec1d2751c4654c4",
"usageCostHistory": "b97c1a6d83f75800",
"userDefinedSchemaLabel": "ad895e214ecd7872",
"viewCreatedSuccessfully": "6a620ddf5d4013a9",
+21 -52
View File
@@ -124,7 +124,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="logs-saved-views-dialog-search-field"]';
this.savedViewSearchInput = '[data-test="logs-search-bar-menu-saved-views-search"] input';
// 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"]';
@@ -135,7 +135,6 @@ 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="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]';
@@ -3430,22 +3429,7 @@ export class LogsPage {
}
async clickSavedViewsExpand() {
// CRITICAL: This method is often called after applying a saved view
//
// Problem: When a saved view is applied via applySavedView():
// 1. The saved views dialog closes immediately (savedViewsListDialog = false)
// 2. applySavedView() fetches the saved view details from API (async)
// 3. It updates searchObj with the saved view's query/filters
// 4. This triggers a NEW SEARCH which updates the logs table
// 5. The search also updates searchObj.data.savedViews (the saved views list)
//
// If we try to reopen the saved views dialog while the search is still running:
// - The dialog's table will be re-rendering as data updates
// - The search input element becomes "unstable" (detached/recreated)
// - Test clicks fail with "element was detached from DOM"
//
// Solution: Wait for the search triggered by applySavedView to complete
// before opening the dialog again. This ensures the dialog opens with stable data.
// Reopening while the search triggered by applying a view is still rendering detaches the menu's elements.
try {
// Wait for any ongoing search to complete by checking for the logs table
@@ -3472,22 +3456,15 @@ 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(this.savedViewsListDialogEl);
const isListOpen = await listDialog.isVisible({ timeout: 500 }).catch(() => false);
if (isListOpen) {
await listDialog.locator('[data-test="o-dialog-close-btn"]').click();
await listDialog.waitFor({ state: 'hidden', timeout: 5000 });
// The overlay has a 200ms fade-out — wait for it to fully detach so
// it doesn't intercept the utilities-menu click. Scoped by data-test.
await this.page.locator('[data-test="o-dialog-overlay"]')
.waitFor({ state: 'detached', timeout: 5000 });
}
const createSavedViewBtn = this.page.locator('[data-test="logs-search-bar-menu-create-saved-view-btn"]');
const isVisible = await createSavedViewBtn.isVisible({ timeout: 500 }).catch(() => false);
if (!isVisible) {
await this.page.locator(this.utilitiesMenuButton).click({ force: true });
// Create lives in the Saved views submenu, which only renders while open.
const createSavedViewBtn = this.page.locator(this.menuCreateSavedViewBtn);
if (!(await createSavedViewBtn.isVisible({ timeout: 500 }).catch(() => false))) {
const submenuTrigger = this.page.locator(this.menuListSavedViewsBtn);
if (!(await submenuTrigger.isVisible().catch(() => false))) {
await this.page.locator(this.utilitiesMenuButton).click({ force: true });
await submenuTrigger.waitFor({ state: 'visible', timeout: 5000 });
}
await submenuTrigger.click({ force: true });
await createSavedViewBtn.waitFor({ state: 'visible', timeout: 5000 });
}
await createSavedViewBtn.click();
@@ -3585,12 +3562,14 @@ export class LogsPage {
return await searchInput.fill(text);
}
// The `-apply-` prefix keeps the favourites pane (`-favorites-apply-`) out of the match.
// The `-apply-` prefix keeps the favorite toggle (`-favorite-`) 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)}"]`);
return this.page
.locator('[data-test^="logs-search-bar-menu-saved-views-apply-"]')
.filter({ has: this.page.getByText(name, { exact: true }) });
}
async clickSavedViewByTitle(title) {
@@ -3601,7 +3580,7 @@ export class LogsPage {
}
async clickDeleteButton() {
const deleteBtn = this.page.locator('[data-test="logs-saved-views-dialog-table"] [data-test^="logs-saved-views-dialog-delete-"]:not([data-test$="-menu"])').first();
const deleteBtn = this.page.locator('[data-test^="logs-search-bar-menu-saved-views-delete-"]').first();
await deleteBtn.waitFor({ state: 'visible', timeout: 5000 });
return await deleteBtn.click();
}
@@ -3681,12 +3660,6 @@ 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(this.savedViewsListDialogEl)
.waitFor({ state: 'hidden', timeout: 5000 })
.catch(() => {});
// Re-open the saved views panel (waits for any ongoing search to settle first).
await this.clickSavedViewsExpand();
@@ -3702,11 +3675,8 @@ export class LogsPage {
// Allow OInput debounce (300 ms) to apply the filter before looking for the row.
await this.page.waitForTimeout(1000);
// 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="logs-saved-views-dialog-table"]');
const deleteBtn = mainTable.locator('[data-test^="logs-saved-views-dialog-delete-"]:not([data-test$="-menu"])').first();
// The delete data-test uses view_id, not the name, so match by prefix; the name filter leaves one row.
const deleteBtn = this.page.locator('[data-test^="logs-search-bar-menu-saved-views-delete-"]').first();
await deleteBtn.waitFor({ state: 'visible', timeout: 10000 });
await deleteBtn.scrollIntoViewIfNeeded();
await deleteBtn.click();
@@ -9079,7 +9049,7 @@ export class LogsPage {
if (!opened) throw new Error('Could not open saved views menu after 3 attempts');
// force: true — ODropdown portal transiently detaches items during initial render
await listMenuItem.click({ force: true });
await this.page.locator(this.savedViewsListDialogEl).waitFor({ state: 'visible', timeout: 10000 });
await this.page.locator(this.savedViewSearchInput).waitFor({ state: 'visible', timeout: 10000 });
testLogger.info('Clicked saved views dropdown arrow');
}
@@ -9099,9 +9069,8 @@ export class LogsPage {
* @param {string} name - Saved view name
*/
async clickDeleteSavedViewByName(name) {
// data-test uses view_id (UUID), not name — scope to main table after filtering.
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();
// data-test uses view_id (UUID), not name; the caller's name filter leaves one row.
const deleteBtn = this.page.locator('[data-test^="logs-search-bar-menu-saved-views-delete-"]').first();
await deleteBtn.waitFor({ state: 'visible', timeout: 10000 });
await deleteBtn.click();
testLogger.info(`Clicked delete for saved view: ${name}`);
@@ -14,7 +14,6 @@ export class LogsToolbarPinsPage {
// ===== STORAGE KEYS (useToolbarPins.ts) =====
this.pinsKey = 'logs_toolbar_pinned_items';
this.histogramDecidedKey = 'logs_toolbar_histogram_pin_decided';
this.savedViewsDecidedKey = 'logs_toolbar_saved_views_pin_decided';
this.tracesPinsKey = 'traces_toolbar_pinned_items';
// ===== LOCATORS =====
@@ -70,10 +69,10 @@ export class LogsToolbarPinsPage {
// ===== SETUP =====
/** Seeds pin storage before boot; a string `pins` is written verbatim, `decided` sets both default-pin flags. */
/** Seeds pin storage before boot; a string `pins` is written verbatim, `decided` sets the histogram default-pin flag. */
async seedPinStorage({ pins, decided } = {}) {
await this.page.addInitScript(
([key, value, decidedKeys, decidedValue]) => {
([key, value, decidedKey, decidedValue]) => {
try {
// addInitScript re-runs on every navigation, so seed once or a reload
// would wipe what the test just pinned.
@@ -81,10 +80,8 @@ export class LogsToolbarPinsPage {
sessionStorage.setItem('__pinSeedApplied', '1');
if (value === null) localStorage.removeItem(key);
else localStorage.setItem(key, value);
for (const decidedKey of decidedKeys) {
if (decidedValue === null) localStorage.removeItem(decidedKey);
else localStorage.setItem(decidedKey, decidedValue);
}
if (decidedValue === null) localStorage.removeItem(decidedKey);
else localStorage.setItem(decidedKey, decidedValue);
} catch (_) {
// Private mode / quota — the app falls back to in-memory pins.
}
@@ -92,7 +89,7 @@ export class LogsToolbarPinsPage {
[
this.pinsKey,
pins === undefined ? null : typeof pins === 'string' ? pins : JSON.stringify(pins),
[this.histogramDecidedKey, this.savedViewsDecidedKey],
this.histogramDecidedKey,
decided === undefined ? null : String(decided),
],
);
@@ -228,10 +225,6 @@ export class LogsToolbarPinsPage {
return this.page.evaluate((key) => localStorage.getItem(key), this.histogramDecidedKey);
}
async readSavedViewsDecidedFlag() {
return this.page.evaluate((key) => localStorage.getItem(key), this.savedViewsDecidedKey);
}
async readTracesPins() {
return this.page.evaluate((key) => localStorage.getItem(key), this.tracesPinsKey);
}
@@ -192,7 +192,6 @@ export class TracesPage {
this.moreMenuButton = '[data-test="traces-search-bar-more-menu-btn"]';
this.openDropdownMenu = page.getByRole('menu');
this.menuSavedViewsGroup = '[data-test="traces-search-bar-menu-saved-views-group"]';
this.menuPinSavedViewsButton = '[data-test="traces-search-bar-menu-pin-saved-views-btn"]';
this.menuListSavedViewsButton = '[data-test="traces-search-bar-menu-list-saved-views-btn"]';
this.menuCreateSavedViewButton = '[data-test="traces-search-bar-menu-create-saved-view-btn"]';
@@ -200,8 +199,6 @@ export class TracesPage {
this.pinnedSavedViewsTrigger = '[data-test="traces-search-bar-saved-views-btn"]';
this.pinnedSavedViewItemPrefix = '[data-test^="traces-saved-view-apply-"]';
this.savedViewNameInput = '[data-test="traces-saved-view-name-input"]';
this.savedViewsListDialog = '[data-test="traces-saved-views-dialog"]';
this.savedViewsDialogApplyPrefix = '[data-test^="traces-saved-views-dialog-apply-"]';
this.waterfallAxisLastLabels = '[data-test="trace-header-tic-label-3"]';
this.spanBar = '[data-test="span-marker"]';
@@ -3088,10 +3085,7 @@ export class TracesPage {
}
async applySavedView(viewName) {
await this.page.locator('[data-test="traces-search-bar-saved-views-btn"]').click();
const view = this.page.locator('[data-test^="traces-saved-view-apply-"]').filter({ hasText: viewName }).first();
await view.waitFor({ state: 'visible', timeout: 10000 });
await view.click();
await this.applySavedViewFromMoreMenu(viewName);
}
// enterTraceQuery appends to existing text, so clear first and confirm the editor holds only `query`.
@@ -3156,22 +3150,25 @@ export class TracesPage {
}
async closeMoreMenu() {
await this.page.keyboard.press('Escape');
await expect(this.openDropdownMenu).toBeHidden({ timeout: 10000 });
// A nested submenu consumes the first Escape, so repeat until no menu is left.
await expect(async () => {
if (await this.openDropdownMenu.count()) await this.page.keyboard.press('Escape');
await expect(this.openDropdownMenu).toHaveCount(0, { timeout: 1000 });
}).toPass({ timeout: 10000 });
}
async expectMoreMenuSavedViewsEntriesVisible() {
const group = this.page.locator(this.menuSavedViewsGroup);
await expect(group).toBeVisible({ timeout: 10000 });
await expect(group.locator(this.menuPinSavedViewsButton)).toBeVisible();
await expect(group.locator(this.menuListSavedViewsButton)).toContainText(/list saved views/i);
await expect(group.locator(this.menuCreateSavedViewButton)).toContainText(/create saved view/i);
const trigger = this.page.locator(this.menuListSavedViewsButton);
await expect(trigger).toBeVisible({ timeout: 10000 });
await expect(this.page.locator(this.menuPinSavedViewsButton)).toBeVisible();
await expect(trigger).toContainText(/saved views/i);
await trigger.click();
await expect(this.page.locator(this.menuCreateSavedViewButton)).toContainText(/create saved view/i);
}
/** Asserts on an open menu, so an unopened menu cannot pass for a missing group. */
async expectMoreMenuSavedViewsEntriesHidden() {
await expect(this.openDropdownMenu).toBeVisible({ timeout: 10000 });
await expect(this.page.locator(this.menuSavedViewsGroup)).toHaveCount(0);
await expect(this.page.locator(this.menuListSavedViewsButton)).toHaveCount(0);
await expect(this.page.locator(this.menuCreateSavedViewButton)).toHaveCount(0);
}
@@ -3198,6 +3195,7 @@ export class TracesPage {
async openCreateSavedViewFromMoreMenu() {
await this.openMoreMenu();
await this.page.locator(this.menuListSavedViewsButton).click();
await this.page.locator(this.menuCreateSavedViewButton).click();
await expect(this.page.locator(this.savedViewNameInput)).toBeVisible({ timeout: 10000 });
}
@@ -3209,21 +3207,14 @@ export class TracesPage {
await view.click();
}
async openSavedViewsListFromMoreMenu() {
async applySavedViewFromMoreMenu(viewName) {
await this.openMoreMenu();
await this.page.locator(this.menuListSavedViewsButton).click();
await expect(this.page.locator(this.savedViewsListDialog)).toBeVisible({ timeout: 10000 });
}
savedViewsDialogApplyCell(viewName) {
return this.page.locator(`${this.savedViewsListDialog} ${this.savedViewsDialogApplyPrefix}[data-test-view-name="${viewName}"]`);
}
async applySavedViewFromListDialog(viewName) {
const cell = this.savedViewsDialogApplyCell(viewName);
await expect(cell).toBeVisible({ timeout: 15000 });
await cell.click();
await expect(this.page.locator(this.savedViewsListDialog)).toBeHidden({ timeout: 10000 });
const view = this.page
.locator('[data-test^="traces-search-bar-menu-saved-views-apply-"]')
.filter({ has: this.page.getByText(viewName, { exact: true }) });
await expect(view).toBeVisible({ timeout: 15000 });
await view.click();
}
async expectQueryEditorEventuallyContains(text) {
@@ -24,7 +24,10 @@ const notes = {
};
const baseAxeNodes = [
{ id: 'aria-allowed-attr', target: '[role="menu"] div[aria-haspopup="menu"]:has(> [data-cy="syntax-guide-button"])' },
{ id: 'aria-required-children', target: '[role="menu"]:has([data-test="logs-search-bar-menu-create-saved-view-btn"])' },
// The utilities menu (pin buttons), the Saved views submenu and the pinned dropdown (both hold a search input) are known non-menuitem children.
{ id: 'aria-required-children', target: '[role="menu"]:has([data-test="logs-search-bar-menu-list-saved-views-btn"])' },
{ id: 'aria-required-children', target: '[role="menu"]:has([data-test="logs-search-bar-menu-saved-views-content"])' },
{ id: 'aria-required-children', target: '[role="menu"]:has([data-test="logs-search-bar-saved-views-menu-content"])' },
{ id: 'button-name', target: '[data-test="logs-search-bar-sql-mode-toggle-btn"]' },
{ id: 'button-name', target: '[data-test="logs-search-bar-show-histogram-toggle-btn-btn"]' },
{ id: 'button-name', target: '[data-test="logs-search-bar-quick-mode-switch-btn"]' },
@@ -74,6 +77,7 @@ test.describe('G1 visible action reasons C2', () => {
const trigger = page.locator(`[data-test="${menu === 'saved' ? 'logs-search-bar-saved-views-pinned-list-btn' : `logs-search-bar-${menu === 'more' ? 'more-options' : 'utilities-menu'}-btn`}"]`);
await trigger.click();
await settleMenu(page);
if (menu === 'utilities') await page.locator('[data-test="logs-search-bar-menu-list-saved-views-btn"]').click();
const scheduled = page.locator('[data-test="search-scheduler-create-new-btn"]');
const scheduleLocked = menu === 'more' && await page.locator('[data-test="search-scheduler-locked-icon"]').count() > 0;
// A locked item opens the upgrade dialog, not the query, so it carries no edited-query note.
@@ -49,18 +49,6 @@ test.describe('Logs toolbar pins', () => {
testLogger.info('Histogram renders pinned on a profile that never chose');
});
test('saved views is pinned by default until the user decides', {
tag: ['@toolbarPins', '@logs', '@P0', '@all'],
}, async () => {
await pm.logsToolbarPinsPage.seedPinStorage();
await pm.logsToolbarPinsPage.navigateToLogs(getOrgIdentifier());
await pm.logsToolbarPinsPage.expectPinnedVisible('savedViews');
expect(await pm.logsToolbarPinsPage.readSavedViewsDecidedFlag()).toBeNull();
testLogger.info('Saved views renders pinned on a profile that never chose');
});
test('unpinning the histogram survives a reload', {
tag: ['@toolbarPins', '@logs', '@P0', '@all'],
}, async () => {
@@ -138,7 +126,7 @@ test.describe('Logs toolbar pins', () => {
// this moves by nav-rail click, never page.goto. The nav helpers assert a
// document stamp survives, which enforces that.
const pins = pm.logsToolbarPinsPage;
await pins.seedPinStorage({ pins: [], decided: true });
await pins.seedPinStorage();
await pins.navigateToLogs(getOrgIdentifier());
await pins.stampDocument();
@@ -147,15 +135,14 @@ test.describe('Logs toolbar pins', () => {
expect(await pins.readPins()).toContain('quickMode');
await pins.gotoTracesViaNav();
// Its own default being pinned proves the traces scope really instantiated.
await pins.expectTracesPinState('savedViews', true);
// Traces has no default pins, and logs' quickMode pin must not leak into it.
await pins.expectTracesPinState('savedViews', false);
await pins.expectTracesPinState('histogram', false);
await pins.expectTracesMetricsPinnedHidden();
await pins.toggleTracesPin('histogram');
const tracesPins = JSON.parse((await pins.readTracesPins()) ?? 'null');
expect(tracesPins).toContain('savedViews');
expect(tracesPins).toContain('histogram');
expect(tracesPins).toEqual(['histogram']);
expect(tracesPins).not.toContain('quickMode');
// Same document, so this reads the live shared map, not storage.
@@ -51,7 +51,7 @@ test.describe('Traces saved views in the More menu with a pin (#15189)', () => {
testLogger.testEnd(testInfo.title, testInfo.status);
});
test('the More menu on Traces search offers "List saved views" and "Create saved view" with a pin', {
test('the More menu on Traces search offers a Saved views submenu with "Create saved view" and a pin', {
tag: ['@bug-15189', '@P1', '@regression', '@tracesRegression'],
}, async () => {
await pm.tracesPage.openMoreMenu();
@@ -62,26 +62,15 @@ test.describe('Traces saved views in the More menu with a pin (#15189)', () => {
testLogger.info('Create saved view opened from the More menu');
});
test('unpinning moves saved views out of the toolbar, pinning brings them back, and both stick after reload', {
test('saved views are not pinned by default, pinning shows them in the toolbar, unpinning removes them, and both stick after reload', {
tag: ['@bug-15189', '@P1', '@regression', '@tracesRegression'],
}, async () => {
await pm.tracesPage.expectPinnedSavedViewsInToolbar();
await pm.tracesPage.toggleSavedViewsPinFromMoreMenu();
await pm.tracesPage.expectSavedViewsPinTitle(PIN_TITLE);
await pm.tracesPage.closeMoreMenu();
await pm.tracesPage.expectPinnedSavedViewsNotInToolbar();
await pm.tracesPage.openMoreMenu();
await pm.tracesPage.expectSavedViewsPinTitle(PIN_TITLE);
await pm.tracesPage.expectMoreMenuSavedViewsEntriesVisible();
await pm.tracesPage.closeMoreMenu();
await pm.tracesPage.reloadPage();
await pm.tracesPage.openMoreMenu();
await pm.tracesPage.expectSavedViewsPinTitle(PIN_TITLE);
await pm.tracesPage.closeMoreMenu();
await pm.tracesPage.expectPinnedSavedViewsNotInToolbar();
await pm.tracesPage.toggleSavedViewsPinFromMoreMenu();
await pm.tracesPage.expectSavedViewsPinTitle(UNPIN_TITLE);
await pm.tracesPage.closeMoreMenu();
@@ -89,27 +78,38 @@ test.describe('Traces saved views in the More menu with a pin (#15189)', () => {
await pm.tracesPage.reloadPage();
await pm.tracesPage.expectPinnedSavedViewsInToolbar();
await pm.tracesPage.toggleSavedViewsPinFromMoreMenu();
await pm.tracesPage.expectSavedViewsPinTitle(PIN_TITLE);
await pm.tracesPage.closeMoreMenu();
await pm.tracesPage.expectPinnedSavedViewsNotInToolbar();
await pm.tracesPage.reloadPage();
await pm.tracesPage.expectPinnedSavedViewsNotInToolbar();
});
test('a saved view applies from the pinned toolbar dropdown', {
tag: ['@bug-15189', '@P1', '@regression', '@tracesRegression'],
}, async () => {
await pm.tracesPage.toggleSavedViewsPinFromMoreMenu();
await pm.tracesPage.closeMoreMenu();
await pm.tracesPage.expectPinnedSavedViewsInToolbar();
await pm.tracesPage.applySavedViewFromPinnedGroup(viewName);
await pm.tracesPage.expectQueryEditorEventuallyContains(viewFilter);
});
test('a saved view applies from the List saved views dialog opened from the More menu', {
test('a saved view applies from the Saved views submenu in the More menu', {
tag: ['@bug-15189', '@P1', '@regression', '@tracesRegression'],
}, async () => {
await pm.tracesPage.openSavedViewsListFromMoreMenu();
await pm.tracesPage.applySavedViewFromListDialog(viewName);
await pm.tracesPage.applySavedViewFromMoreMenu(viewName);
await pm.tracesPage.expectQueryEditorEventuallyContains(viewFilter);
});
test('the saved views group is hidden on Services Catalog', {
tag: ['@bug-15189', '@P2', '@regression', '@tracesRegression'],
}, async () => {
await pm.tracesPage.toggleSavedViewsPinFromMoreMenu();
await pm.tracesPage.closeMoreMenu();
await pm.tracesPage.expectPinnedSavedViewsInToolbar();
await pm.tracesPage.navigateToServicesViaTab();
@@ -1,307 +0,0 @@
// 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);
});
});
@@ -1,295 +0,0 @@
<!-- 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";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.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 }">
<OTruncatedText
as="div"
class="w-full cursor-pointer text-sm"
:data-test="ids.apply(pane.key, row)"
:data-test-view-name="row.view_name"
@click.stop="applyView(row)"
>
{{ value }}
</OTruncatedText>
</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
:aria-label="t('common.moreActions')"
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,451 @@
// 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 { afterEach, describe, expect, it, vi } from "vitest";
import { h, ref } from "vue";
import { flushPromises, mount } from "@vue/test-utils";
import i18n from "@/locales";
import { raw } from "@/types/i18n";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import SavedViewsMenu from "./SavedViewsMenu.vue";
import SavedViewsSubmenu from "./SavedViewsSubmenu.vue";
const views = [
{ view_id: "a", view_name: "Alpha" },
{ view_id: "b", view_name: "Beta" },
{ view_id: "c", view_name: "Gamma" },
];
let wrapper: ReturnType<typeof mount> | undefined;
afterEach(() => {
document.documentElement.dir = "ltr";
wrapper?.unmount();
wrapper = undefined;
});
const el = (id: string) => document.querySelector<HTMLElement>(`[data-test="${id}"]`);
const mountMenu = async (
props: Record<string, unknown> = {},
listeners: Record<string, unknown> = {},
) => {
wrapper = mount(ODropdown, {
attachTo: document.body,
props: { open: true },
global: { plugins: [i18n] },
slots: {
trigger: () => h("button", "Menu"),
default: () =>
h(SavedViewsMenu, {
views,
favoriteIds: ["b"],
dataTestPrefix: "sv",
...props,
...listeners,
}),
},
});
await flushPromises();
};
describe("SavedViewsMenu", () => {
it("groups favorites first and the rest under All Saved Views", async () => {
await mountMenu();
expect(el("sv-favorites-group")?.textContent).toContain("Favorites (1)");
expect(el("sv-favorites-group")?.querySelector('[data-test="sv-apply-b"]')).not.toBeNull();
expect(el("sv-all-group")?.textContent).toContain("All Saved Views");
expect(el("sv-all-group")?.textContent).not.toContain("(2)");
expect(el("sv-all-group")?.querySelector('[data-test="sv-apply-a"]')).not.toBeNull();
expect(el("sv-all-group")?.querySelector('[data-test="sv-apply-b"]')).toBeNull();
expect(el("sv-search")).not.toBeNull();
});
it("hides the favorites group when there are none", async () => {
await mountMenu({ favoriteIds: [] });
expect(el("sv-favorites-group")).toBeNull();
expect(el("sv-all-group")).not.toBeNull();
});
it("filters by name and shows the no-match state", async () => {
await mountMenu();
const input = el("sv-search")!.querySelector("input")!;
input.value = "gam";
input.dispatchEvent(new Event("input", { bubbles: true }));
await flushPromises();
expect(el("sv-apply-c")).not.toBeNull();
expect(el("sv-apply-a")).toBeNull();
input.value = "zzz";
input.dispatchEvent(new Event("input", { bubbles: true }));
await flushPromises();
expect(el("sv-empty")?.textContent).toContain(i18n.global.t("search.savedViewsNoMatch"));
});
it("shows the empty state when there are no views and the loading state while loading", async () => {
await mountMenu({ views: [] });
expect(el("sv-empty")?.textContent).toContain(i18n.global.t("search.savedViewsEmpty"));
wrapper!.unmount();
await mountMenu({ loading: true });
expect(el("sv-loading")).not.toBeNull();
});
it("uses a generic search placeholder while loading and a counted one after", async () => {
await mountMenu({ views: [], loading: true });
expect(el("sv-search")?.querySelector("input")?.getAttribute("placeholder")).toBe(
"Search saved views",
);
wrapper!.unmount();
await mountMenu();
expect(el("sv-search")?.querySelector("input")?.getAttribute("placeholder")).toBe(
"Search 3 saved views",
);
});
it("renders no button inside another button", async () => {
await mountMenu();
expect(document.querySelectorAll("button button")).toHaveLength(0);
});
it("marks the applied view and keeps the star label constant with aria-pressed", async () => {
await mountMenu({ appliedViewId: "a" });
expect(el("sv-applied-a")?.getAttribute("aria-label")).toBe(
i18n.global.t("search.savedViewApplied"),
);
expect(el("sv-applied-b")).toBeNull();
expect(el("sv-favorite-b")?.getAttribute("aria-pressed")).toBe("true");
expect(el("sv-favorite-b")?.getAttribute("aria-label")).toBe("Favorite Beta");
expect(el("sv-favorite-a")?.getAttribute("aria-label")).toBe("Favorite Alpha");
expect(el("sv-favorite-a")?.getAttribute("aria-pressed")).toBe("false");
});
it("emits apply, favorite, update, delete and create", async () => {
const onApply = vi.fn();
const onFav = vi.fn();
const onUpdate = vi.fn();
const onCreate = vi.fn();
const onDelete = vi.fn();
await mountMenu(
{},
{
onApply,
onToggleFavorite: onFav,
onUpdate,
onCreate,
onDelete,
},
);
el("sv-favorite-a")!.click();
expect(onFav).toHaveBeenCalledWith(views[0], false);
el("sv-favorite-b")!.click();
expect(onFav).toHaveBeenCalledWith(views[1], true);
expect(el("sv-update-c")?.getAttribute("aria-label")).toBe(
"Overwrite Gamma with current query",
);
el("sv-update-c")!.click();
expect(onUpdate).toHaveBeenCalledWith(views[2]);
el("sv-delete-c")!.click();
expect(onDelete).toHaveBeenCalledWith(views[2]);
expect(el("sv-delete-c")?.getAttribute("aria-label")).toBe("Delete Gamma");
expect(onApply).not.toHaveBeenCalled();
el("sv-apply-a")!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await flushPromises();
expect(onApply).toHaveBeenCalledWith(views[0]);
});
it("applies the first match on Enter in the search box", async () => {
const onApply = vi.fn();
await mountMenu({}, { onApply });
const input = el("sv-search")!.querySelector("input")!;
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await flushPromises();
expect(onApply).toHaveBeenCalledWith(views[1]);
expect(wrapper!.emitted("update:open")?.at(-1)).toEqual([false]);
});
it("keeps focus on the same star after a favorite toggle moves the row", async () => {
const favoriteIds = ref<string[]>(["b"]);
wrapper = mount(ODropdown, {
attachTo: document.body,
props: { open: true },
global: { plugins: [i18n] },
slots: {
trigger: () => h("button", "Menu"),
default: () =>
h(SavedViewsMenu, {
views,
favoriteIds: favoriteIds.value,
dataTestPrefix: "sv",
onToggleFavorite: (view: { view_id: string }) => {
favoriteIds.value = [...favoriteIds.value, view.view_id];
},
}),
},
});
await flushPromises();
el("sv-favorite-a")!.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
);
await flushPromises();
expect(el("sv-favorites-group")?.querySelector('[data-test="sv-apply-a"]')).not.toBeNull();
expect(document.activeElement).toBe(el("sv-favorite-a"));
});
it("highlights the applied row with the selected background", async () => {
await mountMenu({ appliedViewId: "a" });
expect(el("sv-apply-a")?.className).toContain("bg-select-item-selected-bg");
expect(el("sv-apply-c")?.className).not.toContain("bg-select-item-selected-bg");
});
it("disables create and edit with the reason when saving is blocked", async () => {
await mountMenu({ saveReason: raw("Run the query first") });
expect(el("sv-create")?.getAttribute("aria-disabled")).toBe("true");
expect(el("sv-update-a")?.getAttribute("aria-disabled")).toBe("true");
expect(el("sv-create")?.textContent).toContain("Run the query first");
});
it("uses the supplied create test id", async () => {
await mountMenu({ createTestId: "my-create" });
expect(el("my-create")).not.toBeNull();
expect(el("sv-manage")).toBeNull();
});
});
describe("SavedViewsSubmenu", () => {
it("summarises views and favorites on the trigger", async () => {
wrapper = mount(ODropdown, {
attachTo: document.body,
props: { open: true },
global: { plugins: [i18n] },
slots: {
trigger: () => h("button", "Menu"),
default: () =>
h(SavedViewsSubmenu, {
views,
favoriteIds: ["a", "b"],
dataTestPrefix: "sv",
triggerTestId: "sv-trigger",
}),
},
});
await flushPromises();
const text = el("sv-trigger")?.textContent ?? "";
expect(text).toContain("Saved Views");
expect(text).toContain("3 views, 2 favorites");
});
it("omits the favorites part when there are none and handles no views", async () => {
wrapper = mount(ODropdown, {
attachTo: document.body,
props: { open: true },
global: { plugins: [i18n] },
slots: {
trigger: () => h("button", "Menu"),
default: () => [
h(SavedViewsSubmenu, {
views: [views[0]],
favoriteIds: [],
dataTestPrefix: "one",
triggerTestId: "one-trigger",
}),
h(SavedViewsSubmenu, {
views: [],
favoriteIds: [],
dataTestPrefix: "none",
triggerTestId: "none-trigger",
}),
],
},
});
await flushPromises();
expect(el("one-trigger")?.textContent).toContain("1 view");
expect(el("one-trigger")?.textContent).not.toContain("favorite");
expect(el("none-trigger")?.textContent).toContain("No saved views yet");
});
it("shows no empty text on the trigger while loading", async () => {
wrapper = mount(ODropdown, {
attachTo: document.body,
props: { open: true },
global: { plugins: [i18n] },
slots: {
trigger: () => h("button", "Menu"),
default: () =>
h(SavedViewsSubmenu, {
views: [],
favoriteIds: [],
loading: true,
dataTestPrefix: "ld",
triggerTestId: "ld-trigger",
}),
},
});
await flushPromises();
expect(el("ld-trigger")?.textContent).not.toContain("No saved views yet");
});
const mountSubmenu = async (
props: Record<string, unknown> = {},
slots: Record<string, () => unknown> = {},
) => {
wrapper = mount(ODropdown, {
attachTo: document.body,
props: { open: true },
global: { plugins: [i18n] },
slots: {
trigger: () => h("button", "Menu"),
default: () =>
h(
SavedViewsSubmenu,
{
views,
favoriteIds: ["b"],
dataTestPrefix: "sv",
triggerTestId: "sv-trigger",
...props,
},
slots,
),
},
});
await flushPromises();
};
it("opens the submenu and passes attrs through to the menu", async () => {
const onApply = vi.fn();
const onUpdate = vi.fn();
const onDelete = vi.fn();
const onFav = vi.fn();
await mountSubmenu({
appliedViewId: "a",
createTestId: "sv-create-custom",
onApply,
onUpdate,
onDelete,
onToggleFavorite: onFav,
});
expect(el("sv-content")).toBeNull();
el("sv-trigger")!.click();
await flushPromises();
expect(el("sv-content")).not.toBeNull();
expect(el("sv-create-custom")).not.toBeNull();
expect(el("sv-applied-a")).not.toBeNull();
expect(el("sv-applied-b")).toBeNull();
el("sv-update-c")!.click();
expect(onUpdate).toHaveBeenCalledWith(views[2]);
el("sv-delete-c")!.click();
expect(onDelete).toHaveBeenCalledWith(views[2]);
el("sv-favorite-a")!.click();
expect(onFav).toHaveBeenCalledWith(views[0], false);
el("sv-apply-a")!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await flushPromises();
expect(onApply).toHaveBeenCalledWith(views[0]);
});
it("emits open when the submenu opens", async () => {
const onOpen = vi.fn();
await mountSubmenu({ onOpen });
expect(onOpen).not.toHaveBeenCalled();
el("sv-trigger")!.click();
await flushPromises();
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("keeps the actions slot outside the trigger button", async () => {
await mountSubmenu({}, { actions: () => h("button", { "data-test": "sv-pin" }, "pin") });
el("sv-trigger")!.click();
await flushPromises();
expect(el("sv-pin")).not.toBeNull();
expect(el("sv-trigger")?.contains(el("sv-pin"))).toBe(false);
expect(document.querySelectorAll("button button")).toHaveLength(0);
});
it("moves focus between the trigger and the pin with the arrow keys", async () => {
await mountSubmenu({}, { actions: () => h("button", { "data-test": "sv-pin" }, "pin") });
el("sv-trigger")!.focus();
el("sv-trigger")!.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true, cancelable: true }),
);
expect(document.activeElement).toBe(el("sv-pin"));
el("sv-pin")!.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }),
);
expect(document.activeElement).toBe(el("sv-trigger"));
});
it("lets Escape from the pin reach the menu while other keys stay contained", async () => {
await mountSubmenu({}, { actions: () => h("button", { "data-test": "sv-pin" }, "pin") });
const seen: string[] = [];
const listener = (e: KeyboardEvent) => seen.push(e.key);
document.addEventListener("keydown", listener);
for (const key of ["Escape", "a"]) {
el("sv-pin")!.dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
}
document.removeEventListener("keydown", listener);
expect(seen).toEqual(["Escape"]);
});
it("swaps the arrow keys between the trigger and the pin under rtl", async () => {
document.documentElement.dir = "rtl";
await mountSubmenu({}, { actions: () => h("button", { "data-test": "sv-pin" }, "pin") });
el("sv-trigger")!.focus();
el("sv-trigger")!.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true, cancelable: true }),
);
expect(document.activeElement).toBe(el("sv-trigger"));
el("sv-trigger")!.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }),
);
expect(document.activeElement).toBe(el("sv-pin"));
el("sv-pin")!.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true, cancelable: true }),
);
expect(document.activeElement).toBe(el("sv-trigger"));
});
});
describe("SavedViewsMenuRow arrow keys", () => {
const press = (key: string) =>
document.activeElement!.dispatchEvent(
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
);
it("walks star, edit and delete with ArrowRight and back with ArrowLeft", async () => {
await mountMenu();
el("sv-favorite-a")!.focus();
press("ArrowRight");
expect(document.activeElement).toBe(el("sv-update-a"));
press("ArrowRight");
expect(document.activeElement).toBe(el("sv-delete-a"));
press("ArrowLeft");
expect(document.activeElement).toBe(el("sv-update-a"));
});
it("walks the actions with the swapped keys under rtl", async () => {
document.documentElement.dir = "rtl";
await mountMenu();
el("sv-favorite-a")!.focus();
press("ArrowLeft");
expect(document.activeElement).toBe(el("sv-update-a"));
press("ArrowLeft");
expect(document.activeElement).toBe(el("sv-delete-a"));
press("ArrowRight");
expect(document.activeElement).toBe(el("sv-update-a"));
press("ArrowRight");
expect(document.activeElement).toBe(el("sv-favorite-a"));
press("ArrowRight");
expect(document.activeElement).toBe(el("sv-apply-a"));
});
});
@@ -0,0 +1,199 @@
<!-- 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, nextTick, onMounted, ref } from "vue";
import { useI18nTyped, type I18nText } from "@/types/i18n";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
import ODropdownGroup from "@/lib/overlay/Dropdown/ODropdownGroup.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import ODropdownSeparator from "@/lib/overlay/Dropdown/ODropdownSeparator.vue";
import SavedViewsMenuRow from "./SavedViewsMenuRow.vue";
import { sortSavedViews } from "./savedViewsSort";
import type { SavedViewRow } from "./savedViews.types";
const props = withDefaults(
defineProps<{
views: SavedViewRow[];
favoriteIds: string[];
appliedViewId?: string | null;
loading?: boolean;
saveReason?: I18nText | null;
saveNote?: I18nText | null;
createShortcutId?: string;
dataTestPrefix: string;
createTestId?: string;
}>(),
{
appliedViewId: null,
loading: false,
saveReason: null,
saveNote: null,
createShortcutId: undefined,
createTestId: undefined,
},
);
const emit = defineEmits<{
(e: "apply", view: SavedViewRow): void;
(e: "update", view: SavedViewRow): void;
(e: "delete", view: SavedViewRow): void;
(e: "toggle-favorite", view: SavedViewRow, isFavorite: boolean): void;
(e: "create"): void;
}>();
const { t } = useI18nTyped();
const root = ref<HTMLElement | null>(null);
const search = ref("");
const isFavorite = (view: SavedViewRow) => props.favoriteIds.includes(view.view_id);
const matching = computed(() => {
const term = search.value.trim().toLowerCase();
const sorted = sortSavedViews(props.views, props.favoriteIds);
if (!term) return sorted;
return sorted.filter((view) => String(view.view_name).toLowerCase().includes(term));
});
const favorites = computed(() => matching.value.filter(isFavorite));
const others = computed(() => matching.value.filter((view) => !isFavorite(view)));
const focusSearch = () => root.value?.querySelector<HTMLInputElement>("input")?.focus();
const focusFirstRow = () =>
root.value?.querySelector<HTMLElement>('[role="menuitem"]:not([data-disabled])')?.focus();
// Keys typed in the search box must not reach the menu, whose typeahead would pull focus onto a row.
const onSearchKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") return;
event.stopPropagation();
if (event.key === "ArrowDown") {
event.preventDefault();
focusFirstRow();
} else if (event.key === "Enter") {
event.preventDefault();
root.value?.querySelector<HTMLElement>('[role="menuitem"][data-test*="-apply-"]')?.click();
}
};
const onToggleFavorite = async (view: SavedViewRow, favorite: boolean) => {
emit("toggle-favorite", view, favorite);
await nextTick();
await nextTick();
root.value
?.querySelector<HTMLElement>(`[data-test="${props.dataTestPrefix}-favorite-${view.view_id}"]`)
?.focus();
};
onMounted(() => nextTick(focusSearch));
</script>
<template>
<div
ref="root"
class="flex w-[min(20rem,calc(100vw-2rem))] flex-col"
:data-test="`${props.dataTestPrefix}-content`"
>
<div class="px-1 pt-0.5 pb-0.5" @keydown="onSearchKeydown">
<OSearchInput
v-model="search"
size="sm"
class="w-full"
:placeholder="
props.loading
? t('search.savedViewsSearchPlaceholder')
: t('search.savedViewsSearchCount', { count: props.views.length }, props.views.length)
"
:data-test="`${props.dataTestPrefix}-search`"
/>
</div>
<div
v-if="props.loading"
class="text-text-secondary flex items-center gap-2 px-3 py-1.5 text-sm"
:data-test="`${props.dataTestPrefix}-loading`"
>
<OSpinner size="xs" />
{{ t("confirmDialog.loading") }}
</div>
<div
v-else-if="matching.length"
class="max-h-72 overflow-y-auto overscroll-contain"
:data-test="`${props.dataTestPrefix}-list`"
>
<ODropdownGroup
v-if="favorites.length"
:label="t('search.savedViewsFavoritesGroup', { count: favorites.length })"
:data-test="`${props.dataTestPrefix}-favorites-group`"
>
<SavedViewsMenuRow
v-for="view in favorites"
:key="view.view_id"
:view="view"
:favorite="true"
:applied="view.view_id === props.appliedViewId"
:update-reason="props.saveReason"
:data-test-prefix="props.dataTestPrefix"
@apply="emit('apply', view)"
@update="emit('update', view)"
@delete="emit('delete', view)"
@toggle-favorite="onToggleFavorite(view, true)"
/>
</ODropdownGroup>
<ODropdownSeparator v-if="favorites.length && others.length" class="my-0.5!" />
<ODropdownGroup
v-if="others.length"
:label="t('search.savedViewsAllGroup')"
:data-test="`${props.dataTestPrefix}-all-group`"
>
<SavedViewsMenuRow
v-for="view in others"
:key="view.view_id"
:view="view"
:favorite="false"
:applied="view.view_id === props.appliedViewId"
:update-reason="props.saveReason"
:data-test-prefix="props.dataTestPrefix"
@apply="emit('apply', view)"
@update="emit('update', view)"
@delete="emit('delete', view)"
@toggle-favorite="onToggleFavorite(view, false)"
/>
</ODropdownGroup>
</div>
<div
v-else
class="text-text-secondary px-3 py-1.5 text-sm"
:data-test="`${props.dataTestPrefix}-empty`"
>
{{ props.views.length ? t("search.savedViewsNoMatch") : t("search.savedViewsEmpty") }}
</div>
<ODropdownSeparator class="my-0.5!" />
<ODropdownItem
class="py-1!"
:data-test="props.createTestId ?? `${props.dataTestPrefix}-create`"
:disabled="!!props.saveReason"
focusable-unavailable
:description="props.saveReason || props.saveNote"
:shortcut-id="props.createShortcutId"
@select="emit('create')"
>
<template #icon-left><OIcon name="add" size="sm" /></template>
{{ t("search.createSavedView") }}
</ODropdownItem>
</div>
</template>
@@ -0,0 +1,134 @@
<!-- 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 { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { horizontalStep } from "./savedViewsKeys";
import type { SavedViewRow } from "./savedViews.types";
const props = defineProps<{
view: SavedViewRow;
favorite: boolean;
applied: boolean;
dataTestPrefix: string;
updateReason?: I18nText | null;
}>();
const emit = defineEmits<{
(e: "apply"): void;
(e: "update"): void;
(e: "delete"): void;
(e: "toggle-favorite"): void;
}>();
const { t } = useI18nTyped();
const viewId = props.view.view_id;
// Arrow keys walk the row's inline actions, so star, edit and delete stay reachable without a mouse.
const onRowKeydown = (event: KeyboardEvent) => {
const step = horizontalStep(event.key);
if (!step) return;
const row = event.currentTarget as HTMLElement;
const buttons = Array.from(row.querySelectorAll<HTMLElement>("button:not([disabled])"));
const index = buttons.indexOf(document.activeElement as HTMLElement);
if (step === "back" && index < 0) return;
event.preventDefault();
event.stopPropagation();
if (step === "back" && index === 0) row.focus();
else buttons[index + (step === "forward" ? 1 : -1)]?.focus();
};
</script>
<template>
<ODropdownItem
:text-value="props.view.view_name"
:aria-current="props.applied ? 'true' : undefined"
:class="['py-0.5!', props.applied ? 'bg-select-item-selected-bg' : '']"
:data-test="`${props.dataTestPrefix}-apply-${viewId}`"
@select="emit('apply')"
@keydown="onRowKeydown"
>
<template #icon-left>
<OIcon name="saved-search" size="sm" class="text-text-secondary" />
</template>
<OTruncatedText class="min-w-0 flex-1">{{ props.view.view_name }}</OTruncatedText>
<template #icon-right>
<span class="ms-auto flex shrink-0 items-center gap-0.5">
<OIcon
v-if="props.applied"
name="check"
size="md"
class="text-accent"
role="img"
:aria-label="t('search.savedViewApplied')"
:data-test="`${props.dataTestPrefix}-applied-${viewId}`"
/>
<OButton
variant="ghost-neutral"
size="icon-xs-sq"
:aria-pressed="props.favorite"
:aria-label="t('search.favoriteViewNamed', { name: raw(props.view.view_name) })"
:data-test="`${props.dataTestPrefix}-favorite-${viewId}`"
@click.stop.prevent="emit('toggle-favorite')"
@keydown.enter.stop.prevent="emit('toggle-favorite')"
@keydown.space.stop
>
<OIcon
:name="props.favorite ? 'star' : 'star-outline'"
size="sm"
:class="props.favorite ? 'text-favorite' : ''"
/>
<OTooltip :content="t('search.favoriteViewNamed', { name: raw(props.view.view_name) })" />
</OButton>
<OButton
variant="ghost-neutral"
size="icon-xs-sq"
icon-left="edit"
:aria-label="t('search.overwriteSavedViewNamed', { name: raw(props.view.view_name) })"
:disabled="!!props.updateReason"
focusable-unavailable
:data-test="`${props.dataTestPrefix}-update-${viewId}`"
@click.stop.prevent="emit('update')"
@keydown.enter.stop.prevent="emit('update')"
@keydown.space.stop
>
<OTooltip
:content="t('search.overwriteSavedViewNamed', { name: raw(props.view.view_name) })"
/>
<template #unavailable-reason>{{ props.updateReason }}</template>
</OButton>
<OButton
variant="ghost-neutral"
size="icon-xs-sq"
icon-left="delete"
:aria-label="t('search.deleteViewNamed', { name: raw(props.view.view_name) })"
:data-test="`${props.dataTestPrefix}-delete-${viewId}`"
@click.stop.prevent="emit('delete')"
@keydown.enter.stop.prevent="emit('delete')"
@keydown.space.stop
>
<OTooltip :content="t('search.deleteViewNamed', { name: raw(props.view.view_name) })" />
</OButton>
</span>
</template>
</ODropdownItem>
</template>
@@ -0,0 +1,121 @@
<!-- 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, ref, useSlots } from "vue";
import { raw, useI18nTyped } from "@/types/i18n";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import ODropdownSub from "@/lib/overlay/Dropdown/ODropdownSub.vue";
import SavedViewsMenu from "./SavedViewsMenu.vue";
import { horizontalStep } from "./savedViewsKeys";
import type { SavedViewRow } from "./savedViews.types";
defineOptions({ inheritAttrs: false });
const props = defineProps<{
views: SavedViewRow[];
favoriteIds: string[];
loading?: boolean;
dataTestPrefix: string;
triggerTestId: string;
}>();
const emit = defineEmits<{ (e: "open"): void }>();
const { t } = useI18nTyped();
const slots = useSlots();
const root = ref<HTMLElement | null>(null);
const actions = ref<HTMLElement | null>(null);
// The pin sits beside the trigger (a <button>) so no button nests in another; arrows hop between them.
const onRootKeydown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
const step = horizontalStep(event.key);
if (actions.value?.contains(target)) {
// Escape must still reach the menu, or focus on the pin traps the keyboard.
if (event.key !== "Escape") event.stopPropagation();
if (step !== "forward") return;
event.preventDefault();
root.value?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
} else if (step === "back" && slots.actions && target.getAttribute("role") === "menuitem") {
event.preventDefault();
event.stopPropagation();
actions.value?.querySelector<HTMLElement>("button")?.focus();
}
};
const favoriteCount = computed(
() => props.views.filter((view) => props.favoriteIds.includes(view.view_id)).length,
);
const summary = computed(() => {
if (props.loading && !props.views.length) return raw("");
if (!props.views.length) return t("search.savedViewsEmpty");
const views = t(
"search.savedViewsSummaryViews",
{ count: props.views.length },
props.views.length,
);
if (!favoriteCount.value) return views;
const favorites = t(
"search.savedViewsSummaryFavorites",
{ count: favoriteCount.value },
favoriteCount.value,
);
return t("search.savedViewsSummary", { views, favorites });
});
</script>
<template>
<div ref="root" class="relative" @keydown="onRootKeydown">
<ODropdownSub
:data-test="props.triggerTestId"
:text-value="t('search.savedViewsRowLabel')"
:description="summary"
@update:open="(open: boolean) => open && emit('open')"
>
<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="saved-search" size="sm" />
</span>
</template>
<template #trigger>{{ t("search.savedViewsRowLabel") }}</template>
<template #icon-right>
<span class="ms-auto flex items-center gap-1">
<span v-if="slots.actions" class="w-7 shrink-0" aria-hidden="true" />
<OIcon name="chevron-right" size="sm" />
</span>
</template>
<SavedViewsMenu
v-bind="$attrs"
:views="props.views"
:favorite-ids="props.favoriteIds"
:loading="props.loading"
:data-test-prefix="props.dataTestPrefix"
/>
</ODropdownSub>
<span
v-if="slots.actions"
ref="actions"
class="absolute end-9 top-1/2 flex -translate-y-1/2 items-center"
>
<slot name="actions" />
</span>
</div>
</template>
@@ -0,0 +1,7 @@
// Copyright 2026 OpenObserve Inc.
export interface SavedViewRow {
view_id: string;
view_name: string;
[key: string]: unknown;
}
@@ -0,0 +1,12 @@
// Copyright 2026 OpenObserve Inc.
export type HorizontalStep = "forward" | "back";
// Same direction source as the nav keyboard handling (Header, ONavGroup).
const isRtl = () => document.documentElement.dir === "rtl";
/** Maps a physical arrow key to reading-order movement, which flips under rtl. */
export const horizontalStep = (key: string): HorizontalStep | null => {
if (key !== "ArrowLeft" && key !== "ArrowRight") return null;
return (key === "ArrowRight") !== isRtl() ? "forward" : "back";
};
@@ -18,11 +18,7 @@ export interface SavedViewLike {
view_name: string;
}
/**
* Ordering for the saved-views quick dropdown: favorited views first, then
* case-insensitive alphabetical. The dropdown has no search box, so ordering
* is what makes a long list scannable. Returns a new array.
*/
/** Favorites first, then case-insensitive alphabetical; returns a new array. */
export function sortSavedViews<T extends SavedViewLike>(
views: T[] | null | undefined,
favoriteIds: string[],
+17 -46
View File
@@ -27,11 +27,11 @@ describe("useToolbarPins defaults", () => {
window.localStorage.clear();
});
it("pins ONLY histogram and savedViews by default on a pristine profile", async () => {
it("pins ONLY histogram by default on a pristine profile", async () => {
const { useToolbarPins } = await importFresh();
const { isPinned } = useToolbarPins();
expect(isPinned("histogram")).toBe(true);
expect(isPinned("savedViews")).toBe(true);
expect(isPinned("savedViews")).toBe(false);
expect(isPinned("sqlMode")).toBe(false);
});
@@ -44,20 +44,6 @@ describe("useToolbarPins defaults", () => {
expect(useToolbarPins().isPinned("savedViews")).toBe(true);
});
it("pins savedViews for a profile that stored pins before it was a default", async () => {
window.localStorage.setItem("logs_toolbar_pinned_items", JSON.stringify(["histogram"]));
const { useToolbarPins } = await importFresh();
expect(useToolbarPins().isPinned("savedViews")).toBe(true);
});
it("stays unpinned once the user unpins savedViews", async () => {
const { useToolbarPins } = await importFresh();
useToolbarPins().togglePin("savedViews");
expect(window.localStorage.getItem("logs_toolbar_saved_views_pin_decided")).toBe("true");
const fresh = await importFresh();
expect(fresh.useToolbarPins().isPinned("savedViews")).toBe(false);
});
it("keeps the histogram decided-flag behavior intact", async () => {
const { useToolbarPins } = await importFresh();
useToolbarPins().togglePin("histogram");
@@ -71,7 +57,7 @@ describe("useToolbarPins defaults", () => {
const { togglePin, pinnedItems } = useToolbarPins();
togglePin("syntaxGuide");
togglePin("sqlMode");
expect(pinnedItems.value).toEqual(["histogram", "sqlMode", "savedViews", "syntaxGuide"]);
expect(pinnedItems.value).toEqual(["histogram", "sqlMode", "syntaxGuide"]);
});
});
@@ -80,30 +66,30 @@ describe("useToolbarPins scopes", () => {
window.localStorage.clear();
});
it("keeps the logs storage key and pins saved views by default", async () => {
it("keeps the logs storage key and defaults unchanged", async () => {
const { useToolbarPins } = await importFresh();
const { togglePin, pinnedItems } = useToolbarPins();
expect(pinnedItems.value).toEqual(["histogram", "savedViews"]);
expect(pinnedItems.value).toEqual(["histogram"]);
togglePin("sqlMode");
expect(JSON.parse(window.localStorage.getItem("logs_toolbar_pinned_items")!)).toEqual([
"histogram",
"savedViews",
"sqlMode",
]);
expect(window.localStorage.getItem("traces_toolbar_pinned_items")).toBeNull();
});
it("pins traces saved views by default", async () => {
it("pins nothing by default on traces", async () => {
const { useToolbarPins } = await importFresh();
const { isPinned, pinnedItems } = useToolbarPins("traces");
expect(isPinned("savedViews")).toBe(true);
expect(isPinned("savedViews")).toBe(false);
expect(isPinned("histogram")).toBe(false);
expect(pinnedItems.value).toEqual(["savedViews"]);
expect(pinnedItems.value).toEqual([]);
});
it("pins traces RED Metrics through the histogram key in canonical order", async () => {
const { useToolbarPins } = await importFresh();
const { togglePin, pinnedItems } = useToolbarPins("traces");
togglePin("savedViews");
togglePin("histogram");
expect(pinnedItems.value).toEqual(["histogram", "savedViews"]);
expect(JSON.parse(window.localStorage.getItem("traces_toolbar_pinned_items")!)).toEqual([
@@ -129,10 +115,11 @@ describe("useToolbarPins scopes", () => {
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(true);
useToolbarPins().togglePin("savedViews");
expect(useToolbarPins("traces").isPinned("savedViews")).toBe(true);
expect(useToolbarPins().isPinned("savedViews")).toBe(false);
useToolbarPins().togglePin("savedViews");
useToolbarPins("traces").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();
});
@@ -142,37 +129,21 @@ describe("useToolbarPins scopes", () => {
const a = useToolbarPins("traces");
const b = useToolbarPins("traces");
a.togglePin("savedViews");
expect(b.isPinned("savedViews")).toBe(false);
expect(b.isPinned("savedViews")).toBe(true);
});
it("keeps traces saved views unpinned across a module reload", async () => {
it("keeps traces saved views pinned across a module reload once pinned", 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);
expect(fresh.useToolbarPins("traces").isPinned("savedViews")).toBe(true);
});
it("keeps each default's decided flag when stored pins are corrupt", async () => {
it("keeps the logs histogram 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();
});
});
+2 -5
View File
@@ -44,15 +44,12 @@ const SCOPES: Record<ToolbarPinScope, ToolbarPinScopeConfig> = {
logs: {
storageKey: "logs_toolbar_pinned_items",
keys: TOOLBAR_PIN_ORDER,
defaultPins: {
histogram: "logs_toolbar_histogram_pin_decided",
savedViews: "logs_toolbar_saved_views_pin_decided",
},
defaultPins: { histogram: "logs_toolbar_histogram_pin_decided" },
},
traces: {
storageKey: "traces_toolbar_pinned_items",
keys: ["histogram", "savedViews"],
defaultPins: { savedViews: "traces_toolbar_saved_views_pin_decided" },
defaultPins: {},
},
};
+17 -8
View File
@@ -1188,7 +1188,6 @@
"quickModeLabel": "وضع السرعة",
"menuMore": "المزيد",
"menuGroupSetOnce": "اضبط مرة واحدة",
"menuGroupSavedViews": "المشاهدات المحفوظة",
"menuGroupHistory": "التاريخ",
"menuGroupDownloads": "التنزيلات",
"menuGroupSchedule": "الجدول الزمني",
@@ -1216,12 +1215,12 @@
"noOfRecordsTooltip": "يمكن تحديد عدد السجلات مثلا: إذا كان عدد السجلات هو 1000، يمكن للمستخدم الحصول على حد أقصى 1000 سجل",
"maxEventsScheduleJob": "يمكن إرجاع 100000 حدث كحد أقصى في المهمة المجدولة",
"histogramDisabledScheduleJob": "سيتم تعطيل المدرج التكراري للمهمة المجدولة",
"favoriteViews": "المشاهد المفضلة",
"region": "المنطقة",
"deleteSavedView": "حذف العرض المحفوظ",
"deleteSavedViewConfirm": "هل أنت متأكد من أنك تريد حذف العرض المحفوظ {name}؟",
"updateSavedView": "تحديث العرض المحفوظ",
"updateSavedViewConfirm": "هل أنت متأكد من رغبتك في تحديث العرض المحفوظ؟ هذا الإجراء سيكتب فوق العرض الحالي.",
"overwriteSavedView": "الكتابة فوق العرض المحفوظ",
"overwriteSavedViewConfirm": "هل تريد استبدال الاستعلام المحفوظ للعرض {name} بالاستعلام الحالي؟",
"overwriteSavedViewOk": "الكتابة فوق",
"collapse": "طي",
"expand": "التوسع",
"syntaxGuideLabel": "دليل النحوية",
@@ -1250,12 +1249,9 @@
"legendLabel": "وسيلة الإيضاح",
"switchToLogs": "العودة إلى السجلات",
"savedViewsLabel": "عرض محفوظ",
"savedViewsNotFound": "لم يتم العثور على عرض محفوظ!",
"savedViewName": "عرض الاسم",
"createSavedView": "إنشاء عرض محفوظ",
"listSavedViews": "قائمة المشاهدات المحفوظة",
"manageSavedViews": "إدارة العروض المحفوظة",
"updateSavedViewWithCurrent": "حدث هذا العرض مع البحث الحالي",
"pinToToolbar": "تثبيت في شريط الأدوات",
"unpinFromToolbar": "فك التثبيت من شريط الأدوات",
"shareLink": "مشاركة رابط",
@@ -1270,7 +1266,20 @@
"functionErrorDataRange": "{message} (البيانات التي أعيد إلى: {start} إلى {end})",
"histogramErrorBtnLabel": "انقر لتفاصيل الخطأ",
"applySearch": "قم بضبط معلمات التصفية واضغط على 'تشغيل الاستعلام'.",
"searchSavedView": "البحث عن العروض المحفوظة",
"savedViewsRowLabel": "العروض المحفوظة",
"savedViewsSummaryViews": "{count} عرض | {count} عروض",
"savedViewsSummaryFavorites": "{count} مفضل | {count} مفضلة",
"savedViewsSummary": "{views}، {favorites}",
"savedViewsSearchCount": "البحث في {count} عرض محفوظ | البحث في {count} عروض محفوظة",
"savedViewsSearchPlaceholder": "البحث عن العروض المحفوظة",
"savedViewsFavoritesGroup": "المفضلة ({count})",
"savedViewsAllGroup": "كل العروض المحفوظة",
"savedViewsEmpty": "لا توجد عروض محفوظة بعد",
"savedViewsNoMatch": "لا توجد عروض محفوظة تطابق بحثك",
"savedViewApplied": "مطبّق حاليًا",
"favoriteViewNamed": "إضافة {name} إلى المفضلة",
"deleteViewNamed": "حذف {name}",
"overwriteSavedViewNamed": "استبدال {name} بالاستعلام الحالي",
"searchSavedFunction": "البحث",
"streamNotExist": "لم يتم العثور على التدفقات التالية [STREAM_NAME]. يرجى التحقق مما إذا كانت هذه التدفقات موجودة.",
"noFieldFound": "لم يتم العثور على حقول للتدفق المختار",
+17 -8
View File
@@ -1188,7 +1188,6 @@
"quickModeLabel": "Schneller Modus",
"menuMore": "Mehr",
"menuGroupSetOnce": "Einmal einstellen",
"menuGroupSavedViews": "Gespeicherte Ansichten",
"menuGroupHistory": "Geschichte",
"menuGroupDownloads": "herunterladbare",
"menuGroupSchedule": "Zeitplan",
@@ -1216,12 +1215,12 @@
"noOfRecordsTooltip": "Die Anzahl der Datensätze kann angegeben werden, z. B.: Wenn die Anzahl der Datensätze 1000 ist, kann der Benutzer maximal 1000 Datensätze abrufen",
"maxEventsScheduleJob": "Maximal 100.000 Ereignisse können in einem Zeitplanauftrag zurückgegeben werden",
"histogramDisabledScheduleJob": "Das Histogramm wird für den Zeitplan-Job deaktiviert",
"favoriteViews": "Bevorzugte Ansichten",
"region": "Region",
"deleteSavedView": "Gespeicherte Ansicht löschen",
"deleteSavedViewConfirm": "Möchten Sie die gespeicherte Ansicht {name} wirklich löschen?",
"updateSavedView": "Gespeicherte Ansicht aktualisieren",
"updateSavedViewConfirm": "Möchten Sie die gespeicherte Ansicht wirklich aktualisieren? Diese Aktion überschreibt die bestehende.",
"overwriteSavedView": "Gespeicherte Ansicht überschreiben",
"overwriteSavedViewConfirm": "Die gespeicherte Abfrage von {name} durch die aktuelle Abfrage ersetzen?",
"overwriteSavedViewOk": "Überschreiben",
"collapse": "Einklappen",
"expand": "Erweitern",
"syntaxGuideLabel": "Leitfaden zur Syntax",
@@ -1250,12 +1249,9 @@
"legendLabel": "Legende",
"switchToLogs": "Zurück zu Logs",
"savedViewsLabel": "Gespeicherte Ansichten",
"savedViewsNotFound": "Gespeicherte Ansicht wurde nicht gefunden!",
"savedViewName": "Namen anzeigen",
"createSavedView": "Gespeicherte Ansicht erstellen",
"listSavedViews": "Gespeicherte Ansichten auflisten",
"manageSavedViews": "Gespeicherte Ansichten verwalten",
"updateSavedViewWithCurrent": "Diese Ansicht mit der aktuellen Suche aktualisieren",
"pinToToolbar": "An Werkzeugleiste anheften",
"unpinFromToolbar": "Von der Werkzeugleiste lösen",
"shareLink": "Link teilen",
@@ -1270,7 +1266,20 @@
"functionErrorDataRange": "{message} (Daten zurückgegeben für: {start} bis {end})",
"histogramErrorBtnLabel": "Klicken Sie hier für Fehlerdetails",
"applySearch": "Passen Sie die Filterparameter an und klicken Sie auf „Abfrage ausführen“.",
"searchSavedView": "Gespeicherte Ansichten durchsuchen",
"savedViewsRowLabel": "Gespeicherte Ansichten",
"savedViewsSummaryViews": "{count} Ansicht | {count} Ansichten",
"savedViewsSummaryFavorites": "{count} Favorit | {count} Favoriten",
"savedViewsSummary": "{views}, {favorites}",
"savedViewsSearchCount": "{count} gespeicherte Ansicht durchsuchen | {count} gespeicherte Ansichten durchsuchen",
"savedViewsSearchPlaceholder": "Gespeicherte Ansichten durchsuchen",
"savedViewsFavoritesGroup": "Favoriten ({count})",
"savedViewsAllGroup": "Alle gespeicherten Ansichten",
"savedViewsEmpty": "Noch keine gespeicherten Ansichten",
"savedViewsNoMatch": "Keine gespeicherten Ansichten entsprechen Ihrer Suche",
"savedViewApplied": "Aktuell angewendet",
"favoriteViewNamed": "{name} als Favorit markieren",
"deleteViewNamed": "{name} löschen",
"overwriteSavedViewNamed": "{name} mit aktueller Abfrage überschreiben",
"searchSavedFunction": "Suchen",
"streamNotExist": "Die folgenden [STREAM_NAME]-Streams wurden nicht gefunden. Bitte überprüfen Sie, ob die Streams existieren.",
"noFieldFound": "Für den ausgewählten Stream wurden keine Felder gefunden",
+17 -8
View File
@@ -1188,7 +1188,6 @@
"quickModeLabel": "Quick Mode",
"menuMore": "More",
"menuGroupSetOnce": "Set Once",
"menuGroupSavedViews": "Saved Views",
"menuGroupHistory": "History",
"menuGroupDownloads": "Downloads",
"menuGroupSchedule": "Schedule",
@@ -1216,12 +1215,12 @@
"noOfRecordsTooltip": "Number of records can be specified eg: if the no. of records is 1000 then user can get maximum of 1000 records",
"maxEventsScheduleJob": "Maximum 100000 events can be returned in schedule job",
"histogramDisabledScheduleJob": "Histogram will be disabled for the schedule job",
"favoriteViews": "Favorite Views",
"region": "Region",
"deleteSavedView": "Delete Saved View",
"deleteSavedViewConfirm": "Are you sure you want to delete the {name} saved view?",
"updateSavedView": "Update Saved View",
"updateSavedViewConfirm": "Are you sure you want to update the saved view? This action will overwrite the existing one.",
"overwriteSavedView": "Overwrite Saved View",
"overwriteSavedViewConfirm": "Replace the saved query of {name} with the current query?",
"overwriteSavedViewOk": "Overwrite",
"collapse": "Collapse",
"expand": "Expand",
"syntaxGuideLabel": "Syntax Guide",
@@ -1250,12 +1249,9 @@
"legendLabel": "Legend",
"switchToLogs": "Switch back to Logs",
"savedViewsLabel": "Saved View",
"savedViewsNotFound": "Saved view not found!",
"savedViewName": "View Name",
"createSavedView": "Create Saved View",
"listSavedViews": "List Saved Views",
"manageSavedViews": "Manage Saved Views",
"updateSavedViewWithCurrent": "Update this view with the current search",
"pinToToolbar": "Pin to toolbar",
"unpinFromToolbar": "Unpin from toolbar",
"shareLink": "Share Link",
@@ -1270,7 +1266,20 @@
"functionErrorDataRange": "{message} (Data returned for: {start} to {end})",
"histogramErrorBtnLabel": "Click for error details",
"applySearch": "Adjust filter parameters and click 'Run query'.",
"searchSavedView": "Search saved views",
"savedViewsRowLabel": "Saved Views",
"savedViewsSummaryViews": "{count} view | {count} views",
"savedViewsSummaryFavorites": "{count} favorite | {count} favorites",
"savedViewsSummary": "{views}, {favorites}",
"savedViewsSearchCount": "Search {count} saved view | Search {count} saved views",
"savedViewsSearchPlaceholder": "Search saved views",
"savedViewsFavoritesGroup": "Favorites ({count})",
"savedViewsAllGroup": "All Saved Views",
"savedViewsEmpty": "No saved views yet",
"savedViewsNoMatch": "No saved views match your search",
"savedViewApplied": "Currently applied",
"favoriteViewNamed": "Favorite {name}",
"deleteViewNamed": "Delete {name}",
"overwriteSavedViewNamed": "Overwrite {name} with current query",
"searchSavedFunction": "Search",
"streamNotExist": "The following [STREAM_NAME] streams were not found. Please verify if the streams exist.",
"noFieldFound": "No fields found for the selected stream",
+17 -8
View File
@@ -1188,7 +1188,6 @@
"quickModeLabel": "Modo rápido",
"menuMore": "Más",
"menuGroupSetOnce": "Establecer una vez",
"menuGroupSavedViews": "Vistas guardadas",
"menuGroupHistory": "Historia",
"menuGroupDownloads": "Descargas",
"menuGroupSchedule": "Programar",
@@ -1216,12 +1215,12 @@
"noOfRecordsTooltip": "Se puede especificar el número de registros, por ejemplo: si el número de registros es 1000, el usuario puede obtener un máximo de 1000 registros",
"maxEventsScheduleJob": "Se pueden devolver un máximo de 100 000 eventos en un trabajo programado",
"histogramDisabledScheduleJob": "El histograma se deshabilitará para el trabajo programado",
"favoriteViews": "Vistas favoritas",
"region": "Región",
"deleteSavedView": "Eliminar vista guardada",
"deleteSavedViewConfirm": "¿Seguro que quieres eliminar la vista guardada {name}?",
"updateSavedView": "Actualizar vista guardada",
"updateSavedViewConfirm": "¿Está seguro de que desea actualizar la vista guardada? Esta acción sobrescribirá la existente.",
"overwriteSavedView": "Sobrescribir vista guardada",
"overwriteSavedViewConfirm": "¿Reemplazar la consulta guardada de {name} por la consulta actual?",
"overwriteSavedViewOk": "Sobrescribir",
"collapse": "Contraer",
"expand": "Expandir",
"syntaxGuideLabel": "Guía de sintaxis",
@@ -1250,12 +1249,9 @@
"legendLabel": "Leyenda",
"switchToLogs": "Volver a Logs",
"savedViewsLabel": "Vistas guardadas",
"savedViewsNotFound": "¡No se encontró la vista guardada!",
"savedViewName": "Nombre de la vista",
"createSavedView": "Crear vista guardada",
"listSavedViews": "Listar vistas guardadas",
"manageSavedViews": "Gestionar vistas guardadas",
"updateSavedViewWithCurrent": "Actualizar esta vista con la búsqueda actual",
"pinToToolbar": "Fijar a la barra de herramientas",
"unpinFromToolbar": "Desanclar de la barra de herramientas",
"shareLink": "Compartir enlace",
@@ -1270,7 +1266,20 @@
"functionErrorDataRange": "{message} (Datos devueltos para: {start} a {end})",
"histogramErrorBtnLabel": "Haga clic para ver los detalles del error",
"applySearch": "Ajusta los parámetros del filtro y haz clic en «Ejecutar consulta».",
"searchSavedView": "Buscar vista guardada",
"savedViewsRowLabel": "Vistas guardadas",
"savedViewsSummaryViews": "{count} vista | {count} vistas",
"savedViewsSummaryFavorites": "{count} favorita | {count} favoritas",
"savedViewsSummary": "{views}, {favorites}",
"savedViewsSearchCount": "Buscar {count} vista guardada | Buscar {count} vistas guardadas",
"savedViewsSearchPlaceholder": "Buscar vistas guardadas",
"savedViewsFavoritesGroup": "Favoritas ({count})",
"savedViewsAllGroup": "Todas las vistas guardadas",
"savedViewsEmpty": "Aún no hay vistas guardadas",
"savedViewsNoMatch": "Ninguna vista guardada coincide con la búsqueda",
"savedViewApplied": "Aplicada actualmente",
"favoriteViewNamed": "Marcar {name} como favorita",
"deleteViewNamed": "Eliminar {name}",
"overwriteSavedViewNamed": "Sobrescribir {name} con la consulta actual",
"searchSavedFunction": "Buscar",
"streamNotExist": "Los siguientes streams [STREAM_NAME] no se encontraron. Por favor, verifique si los streams existen.",
"noFieldFound": "No se han encontrado campos para el stream seleccionado",
+17 -8
View File
@@ -1188,7 +1188,6 @@
"quickModeLabel": "Mode rapide",
"menuMore": "Plus",
"menuGroupSetOnce": "Réglez une fois",
"menuGroupSavedViews": "Vues enregistrées",
"menuGroupHistory": "L'histoire",
"menuGroupDownloads": "Téléchargements",
"menuGroupSchedule": "Calendrier",
@@ -1216,12 +1215,12 @@
"noOfRecordsTooltip": "Le nombre d'enregistrements peut être spécifié, par exemple : si le nombre d'enregistrements est de 1000, l'utilisateur peut obtenir un maximum de 1000 enregistrements",
"maxEventsScheduleJob": "Un maximum de 100 000 événements peuvent être renvoyés dans la tâche de planification",
"histogramDisabledScheduleJob": "L'histogramme sera désactivé pour la tâche de planification",
"favoriteViews": "Vues préférées",
"region": "Région",
"deleteSavedView": "Supprimer la vue enregistrée",
"deleteSavedViewConfirm": "Voulez-vous vraiment supprimer la vue enregistrée {name} ?",
"updateSavedView": "Mettre à jour la vue enregistrée",
"updateSavedViewConfirm": "Êtes-vous sûr de vouloir mettre à jour la vue enregistrée ? Cette action remplacera l'action existante.",
"overwriteSavedView": "Écraser la vue enregistrée",
"overwriteSavedViewConfirm": "Remplacer la requête enregistrée de {name} par la requête actuelle ?",
"overwriteSavedViewOk": "Écraser",
"collapse": "Effondrement",
"expand": "Élargir",
"syntaxGuideLabel": "Guide de syntaxe",
@@ -1250,12 +1249,9 @@
"legendLabel": "Légende",
"switchToLogs": "Revenir aux Logs",
"savedViewsLabel": "Vues enregistrées",
"savedViewsNotFound": "La vue enregistrée n'a pas été trouvée !",
"savedViewName": "Afficher le nom",
"createSavedView": "Créer une vue enregistrée",
"listSavedViews": "Répertorier les vues enregistrées",
"manageSavedViews": "Gérer les vues enregistrées",
"updateSavedViewWithCurrent": "Mettre à jour cette vue avec la recherche actuelle",
"pinToToolbar": "Ajouter à la barre d'outils",
"unpinFromToolbar": "Détacher de la barre d'outils",
"shareLink": "Partager le lien",
@@ -1270,7 +1266,20 @@
"functionErrorDataRange": "{message} (Données renvoyées pour : {start} à {end})",
"histogramErrorBtnLabel": "Cliquez pour obtenir les détails de l'erreur",
"applySearch": "Ajustez les paramètres du filtre et cliquez sur « Exécuter la requête ».",
"searchSavedView": "Rechercher dans la vue enregistrée",
"savedViewsRowLabel": "Vues enregistrées",
"savedViewsSummaryViews": "{count} vue | {count} vues",
"savedViewsSummaryFavorites": "{count} favorite | {count} favorites",
"savedViewsSummary": "{views}, {favorites}",
"savedViewsSearchCount": "Rechercher {count} vue enregistrée | Rechercher {count} vues enregistrées",
"savedViewsSearchPlaceholder": "Rechercher des vues enregistrées",
"savedViewsFavoritesGroup": "Favorites ({count})",
"savedViewsAllGroup": "Toutes les vues enregistrées",
"savedViewsEmpty": "Aucune vue enregistrée pour le moment",
"savedViewsNoMatch": "Aucune vue enregistrée ne correspond à votre recherche",
"savedViewApplied": "Actuellement appliquée",
"favoriteViewNamed": "Mettre {name} en favori",
"deleteViewNamed": "Supprimer {name}",
"overwriteSavedViewNamed": "Remplacer {name} par la requête actuelle",
"searchSavedFunction": "Rechercher",
"streamNotExist": "Les flux [STREAM_NAME] suivants n'ont pas été trouvés. Veuillez vérifier si les flux existent.",
"noFieldFound": "Aucun champ n'a été trouvé pour le flux sélectionné",
+17 -8
View File
@@ -1188,7 +1188,6 @@
"quickModeLabel": "Modalità rapida",
"menuMore": "Altro",
"menuGroupSetOnce": "Imposta una volta",
"menuGroupSavedViews": "Visualizzazioni salvate",
"menuGroupHistory": "Cronologia",
"menuGroupDownloads": "Download",
"menuGroupSchedule": "Pianificazione",
@@ -1216,12 +1215,12 @@
"noOfRecordsTooltip": "È possibile specificare il numero di record, ad esempio: se il numero di record è 1000, l'utente può ottenere un massimo di 1000 record",
"maxEventsScheduleJob": "Un job pianificato può restituire al massimo 100000 eventi",
"histogramDisabledScheduleJob": "L'istogramma verrà disabilitato per il job pianificato",
"favoriteViews": "Visualizzazioni preferite",
"region": "Regione",
"deleteSavedView": "Elimina visualizzazione salvata",
"deleteSavedViewConfirm": "Sei sicuro di voler eliminare la vista salvata {name}?",
"updateSavedView": "Aggiorna visualizzazione salvata",
"updateSavedViewConfirm": "Sei sicuro di voler aggiornare la visualizzazione salvata? Questa azione sovrascriverà quella esistente.",
"overwriteSavedView": "Sovrascrivi vista salvata",
"overwriteSavedViewConfirm": "Sostituire la query salvata di {name} con la query corrente?",
"overwriteSavedViewOk": "Sovrascrivi",
"collapse": "Comprimi",
"expand": "Espandi",
"syntaxGuideLabel": "Guida alla sintassi",
@@ -1250,12 +1249,9 @@
"legendLabel": "Legenda",
"switchToLogs": "Torna ai Log",
"savedViewsLabel": "Visualizzazioni salvate",
"savedViewsNotFound": "Visualizzazione salvata non trovata!",
"savedViewName": "Visualizza nome",
"createSavedView": "Crea visualizzazione salvata",
"listSavedViews": "Elenca visualizzazioni salvate",
"manageSavedViews": "Gestisci viste salvate",
"updateSavedViewWithCurrent": "Aggiorna questa vista con la ricerca corrente",
"pinToToolbar": "Aggiungi alla barra degli strumenti",
"unpinFromToolbar": "Sblocca dalla barra degli strumenti",
"shareLink": "Condividi link",
@@ -1270,7 +1266,20 @@
"functionErrorDataRange": "{message} (Dati restituiti per: da {start} a {end})",
"histogramErrorBtnLabel": "Fai clic per visualizzare i dettagli dell'errore",
"applySearch": "Regola i parametri del filtro e fai clic su «Esegui query».",
"searchSavedView": "Cerca nella visualizzazione salvata",
"savedViewsRowLabel": "Viste salvate",
"savedViewsSummaryViews": "{count} vista | {count} viste",
"savedViewsSummaryFavorites": "{count} preferita | {count} preferite",
"savedViewsSummary": "{views}, {favorites}",
"savedViewsSearchCount": "Cerca {count} vista salvata | Cerca {count} viste salvate",
"savedViewsSearchPlaceholder": "Cerca viste salvate",
"savedViewsFavoritesGroup": "Preferite ({count})",
"savedViewsAllGroup": "Tutte le viste salvate",
"savedViewsEmpty": "Nessuna vista salvata",
"savedViewsNoMatch": "Nessuna vista salvata corrisponde alla ricerca",
"savedViewApplied": "Attualmente applicata",
"favoriteViewNamed": "Aggiungi {name} ai preferiti",
"deleteViewNamed": "Elimina {name}",
"overwriteSavedViewNamed": "Sovrascrivi {name} con la query corrente",
"searchSavedFunction": "Cerca",
"streamNotExist": "I seguenti stream [STREAM_NAME] non sono stati trovati. Verifica che gli stream esistano.",
"noFieldFound": "Nessun campo trovato per lo stream selezionato",
+17 -8
View File
@@ -1188,7 +1188,6 @@
"quickModeLabel": "クイックモード",
"menuMore": "もっと",
"menuGroupSetOnce": "1 回設定",
"menuGroupSavedViews": "保存済みビュー",
"menuGroupHistory": "歴史",
"menuGroupDownloads": "ダウンロード",
"menuGroupSchedule": "スケジュール",
@@ -1216,12 +1215,12 @@
"noOfRecordsTooltip": "レコード数を指定できます。たとえば、レコード数が1000の場合、ユーザーは最大1000レコードを取得できます。",
"maxEventsScheduleJob": "スケジュールジョブでは最大10万件のイベントを返すことができます",
"histogramDisabledScheduleJob": "スケジュールジョブのヒストグラムは無効になります",
"favoriteViews": "お気に入りビュー",
"region": "リージョン",
"deleteSavedView": "保存したビューを削除",
"deleteSavedViewConfirm": "{name} の保存ビューを削除してもよろしいですか?",
"updateSavedView": "保存済みビューを更新",
"updateSavedViewConfirm": "保存したビューを更新してよろしいですか?この操作は既存のものを上書きします。",
"overwriteSavedView": "保存したビューを上書き",
"overwriteSavedViewConfirm": "{name} の保存済みクエリを現在のクエリで置き換えますか?",
"overwriteSavedViewOk": "上書き",
"collapse": "折りたたむ",
"expand": "拡大",
"syntaxGuideLabel": "シンタックスガイド",
@@ -1250,12 +1249,9 @@
"legendLabel": "凡例",
"switchToLogs": "ログに戻る",
"savedViewsLabel": "保存済みビュー",
"savedViewsNotFound": "保存されたビューが見つかりません",
"savedViewName": "ビュー名",
"createSavedView": "保存済みビューを作成",
"listSavedViews": "保存済みビューを一覧表示",
"manageSavedViews": "保存済みビューの管理",
"updateSavedViewWithCurrent": "現在の検索でこのビューを更新",
"pinToToolbar": "ツールバーに固定",
"unpinFromToolbar": "ツールバーからピン留め解除",
"shareLink": "リンクを共有",
@@ -1270,7 +1266,20 @@
"functionErrorDataRange": "{message}(データの返却範囲:{start} から {end})",
"histogramErrorBtnLabel": "クリックするとエラーの詳細が表示されます",
"applySearch": "フィルターパラメーターを調整し、[クエリを実行] をクリックします。",
"searchSavedView": "保存済みビューを検索",
"savedViewsRowLabel": "保存済みビュー",
"savedViewsSummaryViews": "{count} 件のビュー | {count} 件のビュー",
"savedViewsSummaryFavorites": "お気に入り {count} 件 | お気に入り {count} 件",
"savedViewsSummary": "{views}、{favorites}",
"savedViewsSearchCount": "{count} 件の保存済みビューを検索 | {count} 件の保存済みビューを検索",
"savedViewsSearchPlaceholder": "保存済みビューを検索",
"savedViewsFavoritesGroup": "お気に入り ({count})",
"savedViewsAllGroup": "すべての保存済みビュー",
"savedViewsEmpty": "保存済みビューはまだありません",
"savedViewsNoMatch": "検索に一致する保存済みビューはありません",
"savedViewApplied": "現在適用中",
"favoriteViewNamed": "{name} をお気に入りに追加",
"deleteViewNamed": "{name} を削除",
"overwriteSavedViewNamed": "{name} を現在のクエリで上書き",
"searchSavedFunction": "保存済み関数を検索",
"streamNotExist": "以下の [STREAM_NAME] ストリームが見つかりませんでした。存在するか確認してください。",
"noFieldFound": "選択したストリームのフィールドが見つかりません",
+17 -8
View File
@@ -1188,7 +1188,6 @@
"quickModeLabel": "퀵 모드",
"menuMore": "더 보기",
"menuGroupSetOnce": "한 번 설정",
"menuGroupSavedViews": "저장된 뷰",
"menuGroupHistory": "기록",
"menuGroupDownloads": "다운로드",
"menuGroupSchedule": "스케줄",
@@ -1216,12 +1215,12 @@
"noOfRecordsTooltip": "레코드 수를 지정할 수 있습니다. 예를 들어, 레코드 수가 1000개인 경우 사용자는 최대 1,000개의 레코드를 가져올 수 있습니다.",
"maxEventsScheduleJob": "스케줄 작업에서 최대 100,000개의 이벤트를 반환할 수 있습니다.",
"histogramDisabledScheduleJob": "스케줄 작업에 대한 히스토그램이 비활성화됩니다.",
"favoriteViews": "즐겨찾기 보기",
"region": "지역",
"deleteSavedView": "저장된 뷰 삭제",
"deleteSavedViewConfirm": "{name} 저장된 보기를 삭제하시겠습니까?",
"updateSavedView": "저장된 뷰 업데이트",
"updateSavedViewConfirm": "저장된 뷰를 정말로 업데이트하시겠습니까?이 작업을 수행하면 기존 항목을 덮어씁니다.",
"overwriteSavedView": "저장된 뷰 덮어쓰기",
"overwriteSavedViewConfirm": "{name}의 저장된 쿼리를 현재 쿼리로 바꾸시겠습니까?",
"overwriteSavedViewOk": "덮어쓰기",
"collapse": "축소",
"expand": "확장하기",
"syntaxGuideLabel": "구문 가이드",
@@ -1250,12 +1249,9 @@
"legendLabel": "범례",
"switchToLogs": "로그로 다시 전환",
"savedViewsLabel": "저장된 뷰",
"savedViewsNotFound": "저장된 뷰를 찾을 수 없습니다!",
"savedViewName": "뷰 이름",
"createSavedView": "저장된 뷰 생성",
"listSavedViews": "저장된 뷰 목록",
"manageSavedViews": "저장된 보기 관리",
"updateSavedViewWithCurrent": "현재 검색으로 이 보기 업데이트",
"pinToToolbar": "도구 모음에 고정",
"unpinFromToolbar": "도구 모음에서 고정 해제",
"shareLink": "링크 공유",
@@ -1270,7 +1266,20 @@
"functionErrorDataRange": "{message} (반환된 데이터: {start}부터 {end}까지)",
"histogramErrorBtnLabel": "오류 세부 정보를 보려면 클릭하십시오.",
"applySearch": "필터 매개변수를 조정하고 '쿼리 실행'을 클릭합니다.",
"searchSavedView": "저장된 보기 검색",
"savedViewsRowLabel": "저장된 보기",
"savedViewsSummaryViews": "보기 {count}개 | 보기 {count}개",
"savedViewsSummaryFavorites": "즐겨찾기 {count}개 | 즐겨찾기 {count}개",
"savedViewsSummary": "{views}, {favorites}",
"savedViewsSearchCount": "저장된 보기 {count}개 검색 | 저장된 보기 {count}개 검색",
"savedViewsSearchPlaceholder": "저장된 보기 검색",
"savedViewsFavoritesGroup": "즐겨찾기 ({count})",
"savedViewsAllGroup": "모든 저장된 보기",
"savedViewsEmpty": "저장된 보기가 아직 없습니다",
"savedViewsNoMatch": "검색과 일치하는 저장된 보기가 없습니다",
"savedViewApplied": "현재 적용됨",
"favoriteViewNamed": "{name} 즐겨찾기에 추가",
"deleteViewNamed": "{name} 삭제",
"overwriteSavedViewNamed": "{name}을(를) 현재 쿼리로 덮어쓰기",
"searchSavedFunction": "검색",
"streamNotExist": "[STREAM_NAME] 스트림을 찾을 수 없습니다. 스트림이 존재하는지 확인하세요.",
"noFieldFound": "선택한 스트림에 대한 필드를 찾을 수 없습니다.",
+17 -8
View File
@@ -1188,7 +1188,6 @@
"quickModeLabel": "Snelle modus",
"menuMore": "Meer",
"menuGroupSetOnce": "Eenmaal instellen",
"menuGroupSavedViews": "Opgeslagen weergaven",
"menuGroupHistory": "Historie",
"menuGroupDownloads": "Downloads",
"menuGroupSchedule": "Schema",
@@ -1216,12 +1215,12 @@
"noOfRecordsTooltip": "Het aantal records kan worden gespecificeerd, bijvoorbeeld: als het aantal records 1000 is, kan de gebruiker maximaal 1000 records krijgen",
"maxEventsScheduleJob": "Er kunnen maximaal 100.000 gebeurtenissen worden geretourneerd in de geplande taak",
"histogramDisabledScheduleJob": "Het histogram wordt uitgeschakeld voor de planningstaak",
"favoriteViews": "Favoriete weergaven",
"region": "regio",
"deleteSavedView": "Opgeslagen weergave verwijderen",
"deleteSavedViewConfirm": "Weet je zeker dat je de opgeslagen weergave {name} wilt verwijderen?",
"updateSavedView": "Opgeslagen weergave bijwerken",
"updateSavedViewConfirm": "Weet je zeker dat je de opgeslagen weergave wilt bijwerken? Met deze actie wordt de bestaande actie overschreven.",
"overwriteSavedView": "Opgeslagen weergave overschrijven",
"overwriteSavedViewConfirm": "De opgeslagen query van {name} vervangen door de huidige query?",
"overwriteSavedViewOk": "Overschrijven",
"collapse": "Inklappen",
"expand": "Uitbreiden",
"syntaxGuideLabel": "Handleiding voor syntaxis",
@@ -1250,12 +1249,9 @@
"legendLabel": "Legenda",
"switchToLogs": "Schakel terug naar Logs",
"savedViewsLabel": "Opgeslagen weergaven",
"savedViewsNotFound": "Opgeslagen weergave niet gevonden!",
"savedViewName": "Naam bekijken",
"createSavedView": "Opgeslagen weergave maken",
"listSavedViews": "Opgeslagen weergaven weergeven",
"manageSavedViews": "Opgeslagen weergaven beheren",
"updateSavedViewWithCurrent": "Deze weergave bijwerken met de huidige zoekopdracht",
"pinToToolbar": "Aan de werkbalk vastmaken",
"unpinFromToolbar": "Van de werkbalk loskoppelen",
"shareLink": "Link delen",
@@ -1270,7 +1266,20 @@
"functionErrorDataRange": "{message} (Gegevens geretourneerd voor: {start} tot {end})",
"histogramErrorBtnLabel": "Klik voor foutdetails",
"applySearch": "Pas de filterparameters aan en klik op 'Query uitvoeren'.",
"searchSavedView": "Opgeslagen weergaven zoeken",
"savedViewsRowLabel": "Opgeslagen weergaven",
"savedViewsSummaryViews": "{count} weergave | {count} weergaven",
"savedViewsSummaryFavorites": "{count} favoriet | {count} favorieten",
"savedViewsSummary": "{views}, {favorites}",
"savedViewsSearchCount": "{count} opgeslagen weergave zoeken | {count} opgeslagen weergaven zoeken",
"savedViewsSearchPlaceholder": "Opgeslagen weergaven zoeken",
"savedViewsFavoritesGroup": "Favorieten ({count})",
"savedViewsAllGroup": "Alle opgeslagen weergaven",
"savedViewsEmpty": "Nog geen opgeslagen weergaven",
"savedViewsNoMatch": "Geen opgeslagen weergaven komen overeen met uw zoekopdracht",
"savedViewApplied": "Momenteel toegepast",
"favoriteViewNamed": "{name} als favoriet markeren",
"deleteViewNamed": "{name} verwijderen",
"overwriteSavedViewNamed": "{name} overschrijven met de huidige query",
"searchSavedFunction": "Zoek",
"streamNotExist": "De volgende [STREAM_NAME]-streams zijn niet gevonden. Controleer of de streams bestaan.",
"noFieldFound": "Geen velden gevonden voor de geselecteerde stream",
+17 -8
View File
@@ -1188,7 +1188,6 @@
"quickModeLabel": "Tryb szybki",
"menuMore": "Więcej",
"menuGroupSetOnce": "Ustaw raz",
"menuGroupSavedViews": "Zapisane widoki",
"menuGroupHistory": "Historia",
"menuGroupDownloads": "Pobieranie",
"menuGroupSchedule": "Harmonogram",
@@ -1216,12 +1215,12 @@
"noOfRecordsTooltip": "Można określić liczbę rekordów, np. jeśli liczba rekordów wynosi 1000, użytkownik może otrzymać maksymalnie 1000 rekordów",
"maxEventsScheduleJob": "Maksymalnie 100 000 zdarzeń może zostać zwróconych w zaplanowanym zadaniu",
"histogramDisabledScheduleJob": "Histogram będzie wyłączony dla zaplanowanego zadania",
"favoriteViews": "Ulubione widoki",
"region": "Region",
"deleteSavedView": "Usuń zapisany widok",
"deleteSavedViewConfirm": "Czy na pewno chcesz usunąć zapisany widok {name}?",
"updateSavedView": "Aktualizuj zapisany widok",
"updateSavedViewConfirm": "Czy na pewno chcesz zaktualizować zapisany widok? Ta czynność zastąpi istniejący.",
"overwriteSavedView": "Zastąp zapisany widok",
"overwriteSavedViewConfirm": "Zastąpić zapisane zapytanie widoku {name} bieżącym zapytaniem?",
"overwriteSavedViewOk": "Zastąp",
"collapse": "Zwiń",
"expand": "Rozwiń",
"syntaxGuideLabel": "Przewodnik po składni",
@@ -1250,12 +1249,9 @@
"legendLabel": "Legenda",
"switchToLogs": "Przełącz z powrotem na Logi",
"savedViewsLabel": "Zapisany widok",
"savedViewsNotFound": "Nie znaleziono zapisanego widoku!",
"savedViewName": "Nazwa widoku",
"createSavedView": "Utwórz zapisany widok",
"listSavedViews": "Lista zapisanych widoków",
"manageSavedViews": "Zarządzaj zapisanymi widokami",
"updateSavedViewWithCurrent": "Aktualizuj ten widok bieżącym wyszukiwaniem",
"pinToToolbar": "Przypnij do paska narzędzi",
"unpinFromToolbar": "Odepnij z paska narzędzi",
"shareLink": "Udostępnij link",
@@ -1270,7 +1266,20 @@
"functionErrorDataRange": "{message} (Dane zwrócone dla: {start} do {end})",
"histogramErrorBtnLabel": "Kliknij, aby zobaczyć szczegóły błędu",
"applySearch": "Dostosuj parametry filtrów i kliknij 'Uruchom zapytanie'.",
"searchSavedView": "Szukaj w zapisanych widokach",
"savedViewsRowLabel": "Zapisane widoki",
"savedViewsSummaryViews": "{count} widok | {count} widoków",
"savedViewsSummaryFavorites": "{count} ulubiony | {count} ulubionych",
"savedViewsSummary": "{views}, {favorites}",
"savedViewsSearchCount": "Szukaj wśród {count} zapisanego widoku | Szukaj wśród {count} zapisanych widoków",
"savedViewsSearchPlaceholder": "Szukaj w zapisanych widokach",
"savedViewsFavoritesGroup": "Ulubione ({count})",
"savedViewsAllGroup": "Wszystkie zapisane widoki",
"savedViewsEmpty": "Brak zapisanych widoków",
"savedViewsNoMatch": "Żaden zapisany widok nie pasuje do wyszukiwania",
"savedViewApplied": "Obecnie zastosowany",
"favoriteViewNamed": "Dodaj {name} do ulubionych",
"deleteViewNamed": "Usuń {name}",
"overwriteSavedViewNamed": "Zastąp {name} bieżącym zapytaniem",
"searchSavedFunction": "Szukaj",
"streamNotExist": "Nie znaleziono następujących strumieni [STREAM_NAME]. Sprawdź, czy strumienie istnieją.",
"noFieldFound": "Nie znaleziono pól dla wybranego strumienia",
+17 -8
View File
@@ -1188,7 +1188,6 @@
"quickModeLabel": "Modo rápido",
"menuMore": "Mais",
"menuGroupSetOnce": "Definir uma vez",
"menuGroupSavedViews": "Visualizações guardadas",
"menuGroupHistory": "Histórico",
"menuGroupDownloads": "Transferências",
"menuGroupSchedule": "Agendamento",
@@ -1216,12 +1215,12 @@
"noOfRecordsTooltip": "O número de registos pode ser especificado, por exemplo: se o número de registos for 1000, o utilizador poderá obter no máximo 1000 registos",
"maxEventsScheduleJob": "Podem ser devolvidos no máximo 100.000 eventos no trabalho agendado",
"histogramDisabledScheduleJob": "O histograma será desativado para o trabalho agendado",
"favoriteViews": "Visualizações favoritas",
"region": "Região",
"deleteSavedView": "Eliminar visualização guardada",
"deleteSavedViewConfirm": "Tem a certeza de que pretende eliminar a vista guardada {name}?",
"updateSavedView": "Atualizar visualização guardada",
"updateSavedViewConfirm": "Tem a certeza de que pretende atualizar a visualização guardada? Esta ação substituirá a existente.",
"overwriteSavedView": "Substituir vista guardada",
"overwriteSavedViewConfirm": "Substituir a consulta guardada de {name} pela consulta atual?",
"overwriteSavedViewOk": "Substituir",
"collapse": "Recolher",
"expand": "Expandir",
"syntaxGuideLabel": "Guia de sintaxe",
@@ -1250,12 +1249,9 @@
"legendLabel": "Legenda",
"switchToLogs": "Voltar aos Logs",
"savedViewsLabel": "Visualizações guardadas",
"savedViewsNotFound": "A visualização guardada não foi encontrada!",
"savedViewName": "Nome da visualização",
"createSavedView": "Criar visualização guardada",
"listSavedViews": "Listar visualizações guardadas",
"manageSavedViews": "Gerir Visualizações Guardadas",
"updateSavedViewWithCurrent": "Atualizar esta visualização com a pesquisa atual",
"pinToToolbar": "Fixar na barra de ferramentas",
"unpinFromToolbar": "Desfixar da barra de ferramentas",
"shareLink": "Partilhar link",
@@ -1270,7 +1266,20 @@
"functionErrorDataRange": "{message} (Dados devolvidos para: {start} a {end})",
"histogramErrorBtnLabel": "Clique para ver os detalhes do erro",
"applySearch": "Ajuste os parâmetros do filtro e clique em “Executar consulta”.",
"searchSavedView": "Pesquisar visualizações guardadas",
"savedViewsRowLabel": "Visualizações guardadas",
"savedViewsSummaryViews": "{count} visualização | {count} visualizações",
"savedViewsSummaryFavorites": "{count} favorita | {count} favoritas",
"savedViewsSummary": "{views}, {favorites}",
"savedViewsSearchCount": "Pesquisar {count} visualização guardada | Pesquisar {count} visualizações guardadas",
"savedViewsSearchPlaceholder": "Pesquisar visualizações guardadas",
"savedViewsFavoritesGroup": "Favoritas ({count})",
"savedViewsAllGroup": "Todas as visualizações guardadas",
"savedViewsEmpty": "Ainda não há visualizações guardadas",
"savedViewsNoMatch": "Nenhuma visualização guardada corresponde à pesquisa",
"savedViewApplied": "Aplicada atualmente",
"favoriteViewNamed": "Marcar {name} como favorita",
"deleteViewNamed": "Eliminar {name}",
"overwriteSavedViewNamed": "Substituir {name} pela consulta atual",
"searchSavedFunction": "Pesquisar",
"streamNotExist": "Os seguintes streams [STREAM_NAME] não foram encontrados. Verifique se os streams existem.",
"noFieldFound": "Nenhum campo encontrado para o stream selecionado",
+17 -8
View File
@@ -1188,7 +1188,6 @@
"quickModeLabel": "Быстрый режим",
"menuMore": "Ещё",
"menuGroupSetOnce": "Установить один раз",
"menuGroupSavedViews": "Сохранённые представления",
"menuGroupHistory": "История",
"menuGroupDownloads": "Загрузки",
"menuGroupSchedule": "Расписание",
@@ -1216,12 +1215,12 @@
"noOfRecordsTooltip": "Можно указать количество записей, например: если количество записей равно 1000, пользователь может получить максимум 1000 записей",
"maxEventsScheduleJob": "В запланированной задаче может быть возвращено максимум 100 000 событий",
"histogramDisabledScheduleJob": "Гистограмма будет отключена для запланированной задачи",
"favoriteViews": "Избранные представления",
"region": "Регион",
"deleteSavedView": "Удалить сохранённое представление",
"deleteSavedViewConfirm": "Вы уверены, что хотите удалить сохранённое представление {name}?",
"updateSavedView": "Обновить сохранённое представление",
"updateSavedViewConfirm": "Вы уверены, что хотите обновить сохранённое представление? Это действие перезапишет существующее.",
"overwriteSavedView": "Перезаписать сохранённое представление",
"overwriteSavedViewConfirm": "Заменить сохранённый запрос представления {name} текущим запросом?",
"overwriteSavedViewOk": "Перезаписать",
"collapse": "Свернуть",
"expand": "Развернуть",
"syntaxGuideLabel": "Руководство по синтаксису",
@@ -1250,12 +1249,9 @@
"legendLabel": "Легенда",
"switchToLogs": "Вернуться к логам",
"savedViewsLabel": "Сохранённое представление",
"savedViewsNotFound": "Сохранённое представление не найдено!",
"savedViewName": "Имя представления",
"createSavedView": "Создать сохранённое представление",
"listSavedViews": "Список сохранённых представлений",
"manageSavedViews": "Управление сохранёнными представлениями",
"updateSavedViewWithCurrent": "Обновить это представление текущим запросом",
"pinToToolbar": "Закрепить на панели инструментов",
"unpinFromToolbar": "Открепить от панели инструментов",
"shareLink": "Поделиться ссылкой",
@@ -1270,7 +1266,20 @@
"functionErrorDataRange": "{message} (Данные за: {start} - {end})",
"histogramErrorBtnLabel": "Нажмите для просмотра ошибки",
"applySearch": "Настройте параметры фильтра и нажмите «Выполнить запрос».",
"searchSavedView": "Поиск сохранённых представлений",
"savedViewsRowLabel": "Сохранённые представления",
"savedViewsSummaryViews": "{count} представление | {count} представлений",
"savedViewsSummaryFavorites": "{count} избранное | {count} избранных",
"savedViewsSummary": "{views}, {favorites}",
"savedViewsSearchCount": "Поиск среди {count} сохранённого представления | Поиск среди {count} сохранённых представлений",
"savedViewsSearchPlaceholder": "Поиск сохранённых представлений",
"savedViewsFavoritesGroup": "Избранное ({count})",
"savedViewsAllGroup": "Все сохранённые представления",
"savedViewsEmpty": "Сохранённых представлений пока нет",
"savedViewsNoMatch": "Нет сохранённых представлений, соответствующих поиску",
"savedViewApplied": "Применено сейчас",
"favoriteViewNamed": "Добавить {name} в избранное",
"deleteViewNamed": "Удалить {name}",
"overwriteSavedViewNamed": "Перезаписать {name} текущим запросом",
"searchSavedFunction": "Поиск",
"streamNotExist": "Следующие потоки [STREAM_NAME] не найдены. Пожалуйста, проверьте, существуют ли потоки.",
"noFieldFound": "Для выбранного потока поля не найдены",
+17 -8
View File
@@ -1188,7 +1188,6 @@
"quickModeLabel": "Hızlı Mod",
"menuMore": "Daha",
"menuGroupSetOnce": "Bir Kez Ayarla",
"menuGroupSavedViews": "Kayıtlı Görünümler",
"menuGroupHistory": "Tarihçe",
"menuGroupDownloads": "İndirilenler",
"menuGroupSchedule": "Çizelge",
@@ -1216,12 +1215,12 @@
"noOfRecordsTooltip": "Kayıt sayısı belirtilebilir, örneğin: kayıt sayısı 1000 ise, kullanıcı maksimum 1000 kayıt alabilir",
"maxEventsScheduleJob": "Zamanlama işinde maksimum 100000 olay iade edilebilir",
"histogramDisabledScheduleJob": "Zaman çizelgesi işi için histogram devre dışı bırakılır",
"favoriteViews": "Favori Görünümler",
"region": "Bölge",
"deleteSavedView": "Kayıtlı Görünümü Sil",
"deleteSavedViewConfirm": "Kaydedilmiş {name} görünümünü silmek istediğinizden emin misiniz?",
"updateSavedView": "Kayıtlı Görünümü Güncelle",
"updateSavedViewConfirm": "Kaydedilen görünümü güncellemek istediğinizden emin misiniz? Bu işlem mevcut olanın üzerine yazacaktır.",
"overwriteSavedView": "Kayıtlı Görünümün Üzerine Yaz",
"overwriteSavedViewConfirm": "{name} görünümünün kayıtlı sorgusu geçerli sorguyla değiştirilsin mi?",
"overwriteSavedViewOk": "Üzerine yaz",
"collapse": "Daralt",
"expand": "Genişletin",
"syntaxGuideLabel": "Sözdizimi Kılavuzu",
@@ -1250,12 +1249,9 @@
"legendLabel": "Lejant",
"switchToLogs": "Günlüklere geri dön",
"savedViewsLabel": "Kayıtlı Görünümler",
"savedViewsNotFound": "Kaydedilen görünüm bulunamadı!",
"savedViewName": "Görünüm Adı",
"createSavedView": "Kayıtlı Görünüm Oluştur",
"listSavedViews": "Kayıtlı Görünümleri Listele",
"manageSavedViews": "Kayıtlı Görünümleri Yönet",
"updateSavedViewWithCurrent": "Bu görünümü geçerli arama ile güncelle",
"pinToToolbar": "Araç çubuğuna sabitleyin",
"unpinFromToolbar": "Araç çubuğundan sabitlemeyi kaldırın",
"shareLink": "Bağlantıyı Paylaş",
@@ -1270,7 +1266,20 @@
"functionErrorDataRange": "{message} (Döndürülen veri aralığı: {start} - {end})",
"histogramErrorBtnLabel": "Hata detayları için tıklayınız",
"applySearch": "Filtre parametrelerini ayarlayın ve 'Sorguyu çalıştır'ı tıklayın.",
"searchSavedView": "Kayıtlı Görünümde Ara",
"savedViewsRowLabel": "Kayıtlı Görünümler",
"savedViewsSummaryViews": "{count} görünüm | {count} görünüm",
"savedViewsSummaryFavorites": "{count} favori | {count} favori",
"savedViewsSummary": "{views}, {favorites}",
"savedViewsSearchCount": "{count} kayıtlı görünümde ara | {count} kayıtlı görünümde ara",
"savedViewsSearchPlaceholder": "Kayıtlı görünümlerde ara",
"savedViewsFavoritesGroup": "Favoriler ({count})",
"savedViewsAllGroup": "Tüm Kayıtlı Görünümler",
"savedViewsEmpty": "Henüz kayıtlı görünüm yok",
"savedViewsNoMatch": "Aramanızla eşleşen kayıtlı görünüm yok",
"savedViewApplied": "Şu anda uygulanıyor",
"favoriteViewNamed": "{name} görünümünü favorilere ekle",
"deleteViewNamed": "{name} görünümünü sil",
"overwriteSavedViewNamed": "{name} görünümünü geçerli sorguyla üzerine yaz",
"searchSavedFunction": "Ara",
"streamNotExist": "[STREAM_NAME] akışları bulunamadı. Lütfen akışların var olup olmadığını doğrulayın.",
"noFieldFound": "Seçilen akış için alan bulunamadı",
+17 -8
View File
@@ -1188,7 +1188,6 @@
"quickModeLabel": "Chế độ Nhanh",
"menuMore": "Thêm",
"menuGroupSetOnce": "Đặt một lần",
"menuGroupSavedViews": "Chế độ xem đã lưu",
"menuGroupHistory": "Lịch sử",
"menuGroupDownloads": "Tải xuống",
"menuGroupSchedule": "Lịch biểu",
@@ -1216,12 +1215,12 @@
"noOfRecordsTooltip": "Số lượng bản ghi có thể được chỉ định, ví dụ: nếu số bản ghi là 1000 thì người dùng có thể nhận tối đa 1000 bản ghi",
"maxEventsScheduleJob": "Tối đa 100000 sự kiện có thể được trả về trong công việc theo lịch",
"histogramDisabledScheduleJob": "Biểu đồ histogram sẽ bị vô hiệu hóa cho công việc theo lịch",
"favoriteViews": "Chế độ xem yêu thích",
"region": "Vùng",
"deleteSavedView": "Xóa chế độ xem đã lưu",
"deleteSavedViewConfirm": "Bạn có chắc chắn muốn xóa chế độ xem đã lưu {name} không?",
"updateSavedView": "Cập nhật chế độ xem đã lưu",
"updateSavedViewConfirm": "Bạn có chắc chắn muốn cập nhật chế độ xem đã lưu? Hành động này sẽ ghi đè lên chế độ xem hiện có.",
"overwriteSavedView": "Ghi đè chế độ xem đã lưu",
"overwriteSavedViewConfirm": "Thay thế truy vấn đã lưu của {name} bằng truy vấn hiện tại?",
"overwriteSavedViewOk": "Ghi đè",
"collapse": "Thu gọn",
"expand": "Mở rộng",
"syntaxGuideLabel": "Hướng dẫn cú pháp",
@@ -1250,12 +1249,9 @@
"legendLabel": "Chú thích",
"switchToLogs": "Quay lại Nhật ký",
"savedViewsLabel": "Chế độ xem đã lưu",
"savedViewsNotFound": "Không tìm thấy chế độ xem đã lưu!",
"savedViewName": "Tên chế độ xem",
"createSavedView": "Tạo chế độ xem đã lưu",
"listSavedViews": "Danh sách chế độ xem đã lưu",
"manageSavedViews": "Quản lý chế độ xem đã lưu",
"updateSavedViewWithCurrent": "Cập nhật chế độ xem này với tìm kiếm hiện tại",
"pinToToolbar": "Ghim vào thanh công cụ",
"unpinFromToolbar": "Bỏ ghim khỏi thanh công cụ",
"shareLink": "Chia sẻ liên kết",
@@ -1270,7 +1266,20 @@
"functionErrorDataRange": "{message} (Dữ liệu trả về từ {start} đến {end})",
"histogramErrorBtnLabel": "Nhấp để xem chi tiết lỗi",
"applySearch": "Điều chỉnh tham số bộ lọc và nhấp 'Chạy truy vấn'.",
"searchSavedView": "Tìm kiếm chế độ xem đã lưu",
"savedViewsRowLabel": "Chế độ xem đã lưu",
"savedViewsSummaryViews": "{count} chế độ xem | {count} chế độ xem",
"savedViewsSummaryFavorites": "{count} mục yêu thích | {count} mục yêu thích",
"savedViewsSummary": "{views}, {favorites}",
"savedViewsSearchCount": "Tìm trong {count} chế độ xem đã lưu | Tìm trong {count} chế độ xem đã lưu",
"savedViewsSearchPlaceholder": "Tìm kiếm chế độ xem đã lưu",
"savedViewsFavoritesGroup": "Yêu thích ({count})",
"savedViewsAllGroup": "Tất cả chế độ xem đã lưu",
"savedViewsEmpty": "Chưa có chế độ xem nào được lưu",
"savedViewsNoMatch": "Không có chế độ xem đã lưu nào khớp với tìm kiếm",
"savedViewApplied": "Đang được áp dụng",
"favoriteViewNamed": "Thêm {name} vào mục yêu thích",
"deleteViewNamed": "Xóa {name}",
"overwriteSavedViewNamed": "Ghi đè {name} bằng truy vấn hiện tại",
"searchSavedFunction": "Tìm kiếm",
"streamNotExist": "Các stream [STREAM_NAME] sau đây không được tìm thấy. Vui lòng kiểm tra xem các stream có tồn tại hay không.",
"noFieldFound": "Không tìm thấy trường nào cho stream đã chọn",
+17 -8
View File
@@ -1188,7 +1188,6 @@
"quickModeLabel": "快速模式",
"menuMore": "更多",
"menuGroupSetOnce": "设置一次",
"menuGroupSavedViews": "保存的视图",
"menuGroupHistory": "历史",
"menuGroupDownloads": "下载",
"menuGroupSchedule": "日程安排",
@@ -1216,12 +1215,12 @@
"noOfRecordsTooltip": "可以指定记录数,例如:如果记录数为 1000,则用户最多可以获得 1000 条记录",
"maxEventsScheduleJob": "调度任务中最多可以返回 100,000 个事件",
"histogramDisabledScheduleJob": "调度作业的直方图将被禁用",
"favoriteViews": "收藏的视图",
"region": "区域",
"deleteSavedView": "删除保存的视图",
"deleteSavedViewConfirm": "您确定要删除已保存视图 {name} 吗?",
"updateSavedView": "更新保存的视图",
"updateSavedViewConfirm": "您确定要更新保存的视图吗?此操作将覆盖现有操作。",
"overwriteSavedView": "覆盖保存的视图",
"overwriteSavedViewConfirm": "要用当前查询替换 {name} 已保存的查询吗?",
"overwriteSavedViewOk": "覆盖",
"collapse": "收起",
"expand": "扩大",
"syntaxGuideLabel": "语法提示",
@@ -1250,12 +1249,9 @@
"legendLabel": "图例",
"switchToLogs": "切换回日志",
"savedViewsLabel": "已保存的视图",
"savedViewsNotFound": "未找到已保存的视图!",
"savedViewName": "查看名称",
"createSavedView": "创建保存的视图",
"listSavedViews": "列出保存的视图",
"manageSavedViews": "管理已保存视图",
"updateSavedViewWithCurrent": "使用当前搜索更新此视图",
"pinToToolbar": "固定到工具栏",
"unpinFromToolbar": "从工具栏中取消固定",
"shareLink": "分享链接",
@@ -1270,7 +1266,20 @@
"functionErrorDataRange": "{message}(返回的数据范围:{start} 到 {end})",
"histogramErrorBtnLabel": "点击查看错误详情",
"applySearch": "调整过滤器参数,然后单击 “运行查询”。",
"searchSavedView": "搜索保存的视图",
"savedViewsRowLabel": "已保存的视图",
"savedViewsSummaryViews": "{count} 个视图 | {count} 个视图",
"savedViewsSummaryFavorites": "{count} 个收藏 | {count} 个收藏",
"savedViewsSummary": "{views},{favorites}",
"savedViewsSearchCount": "搜索 {count} 个已保存的视图 | 搜索 {count} 个已保存的视图",
"savedViewsSearchPlaceholder": "搜索已保存的视图",
"savedViewsFavoritesGroup": "收藏 ({count})",
"savedViewsAllGroup": "所有已保存的视图",
"savedViewsEmpty": "暂无已保存的视图",
"savedViewsNoMatch": "没有与搜索匹配的已保存视图",
"savedViewApplied": "当前已应用",
"favoriteViewNamed": "将 {name} 加入收藏",
"deleteViewNamed": "删除 {name}",
"overwriteSavedViewNamed": "用当前查询覆盖 {name}",
"searchSavedFunction": "搜索",
"streamNotExist": "以下 [STREAM_NAME] 流未找到。请验证这些流是否存在。",
"noFieldFound": "未找到所选数据流的字段",
+17 -8
View File
@@ -1188,7 +1188,6 @@
"quickModeLabel": "快速模式",
"menuMore": "更多",
"menuGroupSetOnce": "一次性設定",
"menuGroupSavedViews": "已儲存的檢視",
"menuGroupHistory": "歷史記錄",
"menuGroupDownloads": "下載",
"menuGroupSchedule": "排程",
@@ -1216,12 +1215,12 @@
"noOfRecordsTooltip": "可指定記錄數,例如:設為 1000 則最多可取得 1000 筆記錄",
"maxEventsScheduleJob": "排程工作最多可傳回 100000 筆事件",
"histogramDisabledScheduleJob": "排程工作將停用直方圖",
"favoriteViews": "收藏的檢視",
"region": "區域",
"deleteSavedView": "刪除已儲存的檢視",
"deleteSavedViewConfirm": "您確定要刪除已儲存的檢視 {name} 嗎?",
"updateSavedView": "更新已儲存的檢視",
"updateSavedViewConfirm": "確定要更新已儲存的檢視嗎?此操作會覆蓋現有的檢視。",
"overwriteSavedView": "覆寫已儲存的檢視",
"overwriteSavedViewConfirm": "要以目前的查詢取代 {name} 已儲存的查詢嗎?",
"overwriteSavedViewOk": "覆寫",
"collapse": "收合",
"expand": "展開",
"syntaxGuideLabel": "語法指南",
@@ -1250,12 +1249,9 @@
"legendLabel": "圖例",
"switchToLogs": "切換回日誌",
"savedViewsLabel": "已儲存的檢視",
"savedViewsNotFound": "找不到已儲存的檢視!",
"savedViewName": "檢視名稱",
"createSavedView": "建立已儲存的檢視",
"listSavedViews": "已儲存的檢視列表",
"manageSavedViews": "管理已儲存的檢視",
"updateSavedViewWithCurrent": "使用目前搜尋更新此檢視",
"pinToToolbar": "固定到工具列",
"unpinFromToolbar": "從工具列取消固定",
"shareLink": "分享連結",
@@ -1270,7 +1266,20 @@
"functionErrorDataRange": "{message}(資料回傳範圍:{start} 至 {end})",
"histogramErrorBtnLabel": "按一下檢視錯誤詳細資訊",
"applySearch": "調整篩選參數並按一下 [執行查詢]。",
"searchSavedView": "搜尋已儲存的檢視",
"savedViewsRowLabel": "已儲存的檢視",
"savedViewsSummaryViews": "{count} 個檢視 | {count} 個檢視",
"savedViewsSummaryFavorites": "{count} 個收藏 | {count} 個收藏",
"savedViewsSummary": "{views},{favorites}",
"savedViewsSearchCount": "搜尋 {count} 個已儲存的檢視 | 搜尋 {count} 個已儲存的檢視",
"savedViewsSearchPlaceholder": "搜尋已儲存的檢視",
"savedViewsFavoritesGroup": "收藏 ({count})",
"savedViewsAllGroup": "所有已儲存的檢視",
"savedViewsEmpty": "尚無已儲存的檢視",
"savedViewsNoMatch": "沒有符合搜尋的已儲存檢視",
"savedViewApplied": "目前已套用",
"favoriteViewNamed": "將 {name} 加入收藏",
"deleteViewNamed": "刪除 {name}",
"overwriteSavedViewNamed": "以目前查詢覆蓋 {name}",
"searchSavedFunction": "搜尋",
"streamNotExist": "找不到下列 [STREAM_NAME] 串流。請確認這些串流是否存在。",
"noFieldFound": "所選串流找不到任何欄位",
@@ -341,6 +341,12 @@ describe("SearchBar — auto-run wiring (item 2)", () => {
] as [string, string[]][]) {
await wrapper!.get(`[data-test="${trigger}"]`).trigger("click");
await flushPromises();
if (trigger === "logs-search-bar-utilities-menu-btn") {
document
.querySelector<HTMLElement>('[data-test="logs-search-bar-menu-list-saved-views-btn"]')!
.click();
await flushPromises();
}
for (const testId of ids) {
const element = document.querySelector<HTMLElement>(`[data-test="${testId}"]`)!;
expect(element).not.toBeNull();
@@ -45,6 +45,10 @@ describe("SearchBar — Run button ignores natural-language detection (#15049)",
name: "ODropdownItem",
template: '<div class="o-dropdown-item-stub" v-bind="$attrs"><slot /></div>',
},
ODropdownSub: {
name: "ODropdownSub",
template: '<div class="o-dropdown-sub-stub"><slot name="trigger" /><slot /></div>',
},
ODropdownSeparator: { name: "ODropdownSeparator", template: "<div />" },
};
@@ -46,6 +46,10 @@ describe("SearchBar — Refresh Cache & Run Query dropdown visibility (#14488)",
'<div class="o-dropdown-item-stub" v-bind="$attrs" @click="$emit(\'select\')"><slot name="icon-left" /><slot /></div>',
emits: ["select"],
},
ODropdownSub: {
name: "ODropdownSub",
template: '<div class="o-dropdown-sub-stub"><slot name="trigger" /><slot /></div>',
},
ODropdownSeparator: {
name: "ODropdownSeparator",
template: '<div class="o-dropdown-separator-stub" />',
@@ -19,18 +19,34 @@ 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";
import SavedViewsMenu from "@/components/savedViews/SavedViewsMenu.vue";
import SavedViewsSubmenu from "@/components/savedViews/SavedViewsSubmenu.vue";
const { mockSavedViewsGet, mockSavedViewsDelete } = vi.hoisted(() => ({
const {
mockSavedViewsGet,
mockSavedViewsDelete,
mockSavedViewsPost,
mockSavedViewsPut,
mockGetViewDetail,
} = vi.hoisted(() => ({
mockSavedViewsGet: vi.fn(),
mockSavedViewsDelete: vi.fn(),
mockSavedViewsPost: vi.fn(),
mockSavedViewsPut: vi.fn(),
mockGetViewDetail: 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 },
default: {
get: mockSavedViewsGet,
delete: mockSavedViewsDelete,
post: mockSavedViewsPost,
put: mockSavedViewsPut,
getViewDetail: mockGetViewDetail,
},
});
});
@@ -53,7 +69,7 @@ const logsView2 = {
view_type: "logs",
};
describe("SearchBar — saved views list dialog", () => {
describe("SearchBar — saved views submenu", () => {
let wrapper: VueWrapper<any> | undefined;
const mountSearchBar = () =>
@@ -65,7 +81,6 @@ describe("SearchBar — saved views list dialog", () => {
},
});
const dialog = () => wrapper!.findComponent(SavedViewsListDialog);
const favorites = () => JSON.parse(localStorage.getItem("savedViews") || "{}");
beforeEach(() => {
@@ -81,44 +96,208 @@ describe("SearchBar — saved views list dialog", () => {
vi.clearAllMocks();
});
it("passes the logs data-test prefix, views and favourites to the dialog", async () => {
it("passes the logs data-test prefix, views and favourites to the submenu", 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]);
wrapper.vm.appliedSavedViewId = "l2";
wrapper.vm.utilitiesMenuOpen = true;
await flushPromises();
const submenu = wrapper.findComponent(SavedViewsSubmenu);
expect(submenu.props()).toMatchObject({
dataTestPrefix: "logs-search-bar-menu-saved-views",
triggerTestId: "logs-search-bar-menu-list-saved-views-btn",
views: [logsView, logsView2],
favoriteIds: ["l1"],
});
expect(submenu.vm.$attrs["applied-view-id"]).toBe("l2");
document
.querySelector<HTMLElement>('[data-test="logs-search-bar-menu-list-saved-views-btn"]')!
.click();
await flushPromises();
const menu = wrapper.findComponent(SavedViewsMenu);
expect(menu.props()).toMatchObject({
views: [logsView, logsView2],
favoriteIds: ["l1"],
appliedViewId: "l2",
dataTestPrefix: "logs-search-bar-menu-saved-views",
});
});
it("toggles a favourite from the dialog", async () => {
const detail = (streamName = "default") => ({
status: 200,
data: {
data: {
data: {
resultGrid: { columns: [], colOrder: {}, colSizes: {} },
stream: {
streamType: "logs",
selectedStream: [streamName],
selectedFields: [],
},
datetime: {},
tempFunctionContent: "",
},
meta: { regions: [], showTransformEditor: false, logsVisualizeToggle: "logs" },
},
},
});
it("apply marks the view applied only after the apply path completes", async () => {
wrapper = mountSearchBar();
await flushPromises();
wrapper.vm.searchObj.data.stream.streamLists = [{ label: "default", value: "default" }];
wrapper.vm.searchObj.data.stream.streamType = "logs";
wrapper.vm.dateTimeRef = { setSavedDate: vi.fn() };
mockGetViewDetail.mockResolvedValueOnce(detail("missing"));
await wrapper.vm.applySavedView(logsView);
await flushPromises();
expect(wrapper.vm.appliedSavedViewId).toBeNull();
mockGetViewDetail.mockResolvedValueOnce(detail());
await wrapper.vm.applySavedView(logsView);
await flushPromises();
expect(mockGetViewDetail).toHaveBeenLastCalledWith("default", "l1");
expect(wrapper.vm.appliedSavedViewId).toBe("l1");
}, 20000);
it("create marks the new view applied from the response view_id", async () => {
mockSavedViewsPost.mockResolvedValue({
status: 200,
data: { view_id: "new1", org_id: "default" },
});
wrapper = mountSearchBar();
await flushPromises();
dialog().vm.$emit("toggle-favorite", logsView, false);
await wrapper.vm.createSavedViews("fresh view");
await flushPromises();
expect(dialog().props("favoriteIds")).toEqual(["l1"]);
expect(mockSavedViewsPost).toHaveBeenCalled();
expect(wrapper.vm.appliedSavedViewId).toBe("new1");
}, 20000);
it("overwrite closes both menus, confirms, then marks the view applied once it saves", async () => {
mockSavedViewsPut.mockResolvedValue({ status: 200, data: {} });
wrapper = mountSearchBar();
await flushPromises();
wrapper.vm.searchObj.data.stream.selectedStream = ["default"];
wrapper.vm.utilitiesMenuOpen = true;
wrapper.vm.savedViewsDropdownOpen = true;
wrapper.vm.requestUpdateSavedView(logsView2);
await flushPromises();
expect(wrapper.vm.utilitiesMenuOpen).toBe(false);
expect(wrapper.vm.savedViewsDropdownOpen).toBe(false);
expect(mockSavedViewsPut).not.toHaveBeenCalled();
const confirm = wrapper
.findAllComponents(ConfirmDialog)
.find((c) => c.props("modelValue") === true)!;
expect(confirm.props("title")).toBe(i18n.global.t("search.overwriteSavedView"));
expect(confirm.props()).toMatchObject({
messageKeypath: "search.overwriteSavedViewConfirm",
messageName: "slow requests",
focusCancel: true,
});
confirm.vm.$emit("update:ok");
await flushPromises();
expect(mockSavedViewsPut).toHaveBeenCalled();
expect(wrapper.vm.appliedSavedViewId).toBe("l2");
}, 20000);
it("a cancelled overwrite leaves the view untouched", async () => {
wrapper = mountSearchBar();
await flushPromises();
wrapper.vm.searchObj.data.stream.selectedStream = ["default"];
wrapper.vm.requestUpdateSavedView(logsView2);
await flushPromises();
const confirm = wrapper
.findAllComponents(ConfirmDialog)
.find((c) => c.props("modelValue") === true)!;
confirm.vm.$emit("update:cancel");
await flushPromises();
expect(mockSavedViewsPut).not.toHaveBeenCalled();
expect(wrapper.vm.confirmOverwrite).toBe(false);
expect(wrapper.vm.appliedSavedViewId).toBeNull();
}, 20000);
describe("applied check follows the live search", () => {
const applyView = async () => {
wrapper = mountSearchBar();
await flushPromises();
wrapper.vm.searchObj.data.stream.streamLists = [{ label: "default", value: "default" }];
wrapper.vm.searchObj.data.stream.streamType = "logs";
wrapper.vm.dateTimeRef = { setSavedDate: vi.fn() };
mockGetViewDetail.mockResolvedValueOnce(detail());
await wrapper.vm.applySavedView(logsView);
await flushPromises();
expect(wrapper.vm.appliedSavedViewId).toBe("l1");
wrapper.vm.searchObj.shouldIgnoreWatcher = false;
};
it("keeps the check through the changes the apply itself makes", async () => {
await applyView();
wrapper.vm.searchObj.shouldIgnoreWatcher = true;
wrapper.vm.searchObj.data.query = "select * from restored";
await flushPromises();
wrapper.vm.searchObj.shouldIgnoreWatcher = false;
expect(wrapper.vm.appliedSavedViewId).toBe("l1");
});
it("clears when the query text is edited", async () => {
await applyView();
wrapper.vm.searchObj.data.query = "level = 'error'";
await flushPromises();
expect(wrapper.vm.appliedSavedViewId).toBeNull();
});
it("clears when the stream changes", async () => {
await applyView();
wrapper.vm.searchObj.data.stream.selectedStream = ["other"];
await flushPromises();
expect(wrapper.vm.appliedSavedViewId).toBeNull();
});
it("clears on Reset", async () => {
await applyView();
wrapper.vm.resetFilters();
await flushPromises();
expect(wrapper.vm.appliedSavedViewId).toBeNull();
});
});
it("toggles a favourite from the submenu", async () => {
wrapper = mountSearchBar();
await flushPromises();
wrapper.vm.handleFavoriteSavedView(logsView, false);
await flushPromises();
expect(wrapper.vm.favoriteViews).toEqual(["l1"]);
expect(Object.keys(favorites())).toEqual(["l1"]);
dialog().vm.$emit("toggle-favorite", logsView, true);
wrapper.vm.handleFavoriteSavedView(logsView, true);
await flushPromises();
expect(dialog().props("favoriteIds")).toEqual([]);
expect(wrapper.vm.favoriteViews).toEqual([]);
expect(favorites()).toEqual({});
});
it("a confirmed delete from the dialog drops the favourite", async () => {
it("a confirmed delete from the submenu drops the favourite", async () => {
localStorage.setItem("savedViews", JSON.stringify({ l1: logsView }));
wrapper = mountSearchBar();
await flushPromises();
wrapper.vm.savedViewsListDialog = true;
wrapper.vm.handleDeleteSavedView(logsView);
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)!;
@@ -134,10 +313,49 @@ describe("SearchBar — saved views list dialog", () => {
await flushPromises();
expect(mockSavedViewsDelete).toHaveBeenCalledWith("default", "l1");
expect(dialog().props("favoriteIds")).toEqual([]);
expect(wrapper.vm.favoriteViews).toEqual([]);
expect(favorites()).toEqual({});
}, 20000);
it("clears the applied check when the applied view is deleted", async () => {
wrapper = mountSearchBar();
await flushPromises();
wrapper.vm.appliedSavedViewId = "l1";
wrapper.vm.deleteViewID = "l1";
wrapper.vm.deleteSavedViews();
await flushPromises();
expect(wrapper.vm.appliedSavedViewId).toBeNull();
}, 20000);
it("keeps the applied check when another view is deleted", async () => {
wrapper = mountSearchBar();
await flushPromises();
wrapper.vm.appliedSavedViewId = "l2";
wrapper.vm.deleteViewID = "l1";
wrapper.vm.deleteSavedViews();
await flushPromises();
expect(wrapper.vm.appliedSavedViewId).toBe("l2");
}, 20000);
it("closes both saved-views menus and queues the right view for delete", async () => {
wrapper = mountSearchBar();
await flushPromises();
wrapper.vm.utilitiesMenuOpen = true;
wrapper.vm.savedViewsDropdownOpen = true;
wrapper.vm.handleDeleteSavedView(logsView2);
await flushPromises();
expect(wrapper.vm.utilitiesMenuOpen).toBe(false);
expect(wrapper.vm.savedViewsDropdownOpen).toBe(false);
expect(wrapper.vm.deleteViewID).toBe("l2");
expect(wrapper.vm.deleteViewName).toBe("slow requests");
});
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" },
@@ -148,18 +366,18 @@ describe("SearchBar — saved views list dialog", () => {
wrapper = mountSearchBar();
wrapper.vm.searchObj.data.savedViews = [];
await flushPromises();
expect(dialog().props("favoriteIds")).toHaveLength(10);
expect(wrapper.vm.favoriteViews).toHaveLength(10);
wrapper.vm.openSavedViewsList();
wrapper.vm.onUtilitiesMenuOpenChange(true);
await flushPromises();
expect(mockSavedViewsGet).toHaveBeenCalledTimes(1);
expect(dialog().props("favoriteIds")).toEqual(["l1"]);
expect(wrapper.vm.favoriteViews).toEqual(["l1"]);
expect(Object.keys(favorites())).toEqual(["t1", "l1"]);
dialog().vm.$emit("toggle-favorite", logsView2, false);
wrapper.vm.handleFavoriteSavedView(logsView2, false);
await flushPromises();
expect(dialog().props("favoriteIds")).toEqual(["l1", "l2"]);
expect(wrapper.vm.favoriteViews).toEqual(["l1", "l2"]);
});
it("keeps every favourite when the list fails to load", async () => {
@@ -170,13 +388,13 @@ describe("SearchBar — saved views list dialog", () => {
wrapper.vm.searchObj.data.savedViews = [];
await flushPromises();
wrapper.vm.openSavedViewsList();
wrapper.vm.onUtilitiesMenuOpenChange(true);
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(wrapper.vm.searchObj.loadingSavedView).toBeFalsy();
expect(wrapper.vm.searchObj.data.savedViews).toEqual([]);
expect(wrapper.vm.favoriteViews).toEqual(["l1", "gone"]);
expect(favorites()).toEqual({ l1: logsView, gone });
});
});
@@ -149,11 +149,11 @@ describe("SearchBar — pinned saved views", () => {
await group().get('[data-test="logs-search-bar-saved-views-pinned-list-btn"]').trigger("click");
await flushPromises();
const button = document.querySelector(
'[data-test="logs-search-bar-saved-views-menu-update-Named view"]',
'[data-test="logs-search-bar-saved-views-menu-update-named"]',
)!;
expect(button).not.toBeNull();
expect(button.getAttribute("aria-label")).toBe(
i18n.global.t("search.updateSavedViewWithCurrent"),
i18n.global.t("search.overwriteSavedViewNamed", { name: "Named view" }),
);
expect(button.getAttribute("aria-disabled")).toBe("true");
expect(button.getAttribute("title")).toBeNull();
@@ -172,11 +172,11 @@ describe("SearchBar — pinned saved views", () => {
await group().get('[data-test="logs-search-bar-saved-views-pinned-list-btn"]').trigger("click");
await flushPromises();
const button = document.querySelector(
'[data-test="logs-search-bar-saved-views-menu-update-Named view"]',
'[data-test="logs-search-bar-saved-views-menu-update-named"]',
)!;
expect(button).not.toBeNull();
expect(button.getAttribute("aria-label")).toBe(
i18n.global.t("search.updateSavedViewWithCurrent"),
i18n.global.t("search.overwriteSavedViewNamed", { name: "Named view" }),
);
expect(button.getAttribute("aria-disabled")).toBeNull();
expect(button.getAttribute("disabled")).toBeNull();
@@ -192,11 +192,11 @@ describe("SearchBar — pinned saved views", () => {
useLogsAutoRun().invalidateExecuted("search-around");
await flushPromises();
const apply = vi.spyOn(wrapper.vm, "applySavedView").mockImplementation(() => {});
const update = vi.spyOn(wrapper.vm, "quickUpdateSavedView").mockImplementation(() => {});
const update = vi.spyOn(wrapper.vm, "requestUpdateSavedView").mockImplementation(() => {});
await group().get('[data-test="logs-search-bar-saved-views-pinned-list-btn"]').trigger("click");
await flushPromises();
const button = document.querySelector(
'[data-test="logs-search-bar-saved-views-menu-update-Named view"]',
'[data-test="logs-search-bar-saved-views-menu-update-named"]',
)!;
const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
button.dispatchEvent(event);
@@ -214,11 +214,11 @@ describe("SearchBar — pinned saved views", () => {
wrapper.vm.searchObj.meta.logsVisualizeToggle = "logs";
await flushPromises();
const apply = vi.spyOn(wrapper.vm, "applySavedView").mockImplementation(() => {});
const update = vi.spyOn(wrapper.vm, "quickUpdateSavedView").mockImplementation(() => {});
const update = vi.spyOn(wrapper.vm, "requestUpdateSavedView").mockImplementation(() => {});
await group().get('[data-test="logs-search-bar-saved-views-pinned-list-btn"]').trigger("click");
await flushPromises();
const button = document.querySelector(
'[data-test="logs-search-bar-saved-views-menu-update-Named view"]',
'[data-test="logs-search-bar-saved-views-menu-update-named"]',
)!;
expect(button.getAttribute("aria-disabled")).toBeNull();
button.dispatchEvent(
@@ -228,7 +228,7 @@ describe("SearchBar — pinned saved views", () => {
expect(update).toHaveBeenCalledExactlyOnceWith(view);
expect(apply).not.toHaveBeenCalled();
const row = document.querySelector(
'[data-test="logs-search-bar-saved-views-menu-apply-Named view"]',
'[data-test="logs-search-bar-saved-views-menu-apply-named"]',
)!;
row.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
-56
View File
@@ -1354,8 +1354,6 @@ describe("SearchBar.vue Actual Component Methods", () => {
savedViewDropdownModel: false,
deleteViewID: "view123",
confirmDelete: false,
updateViewObj: { view_id: "view456", view_name: "Test View" },
confirmUpdate: false,
customDownloadDialog: false,
downloadCustomRange: 100,
downloadCustomInitialNumber: 1,
@@ -1405,19 +1403,6 @@ describe("SearchBar.vue Actual Component Methods", () => {
componentInstance.confirmDelete = true;
}),
handleUpdateSavedView: vi.fn((item) => {
if (componentInstance.searchObj.data.stream.selectedStream.length === 0) {
componentInstance.notify({
type: "negative",
message: "No stream available to update save view.",
});
return;
}
componentInstance.savedViewDropdownModel = false;
componentInstance.updateViewObj = item;
componentInstance.confirmUpdate = true;
}),
confirmDeleteSavedViews: vi.fn(() => {
componentInstance.deleteSavedViews();
}),
@@ -1426,13 +1411,6 @@ describe("SearchBar.vue Actual Component Methods", () => {
componentInstance.customDownloadDialog = true;
}),
confirmUpdateSavedViews: vi.fn(() => {
componentInstance.updateSavedViews(
componentInstance.updateViewObj.view_id,
componentInstance.updateViewObj.view_name,
);
}),
downloadRangeData: vi.fn(() => {
let initNumber = parseInt(componentInstance.downloadCustomInitialNumber);
if (initNumber < 0) {
@@ -1965,31 +1943,6 @@ describe("SearchBar.vue Actual Component Methods", () => {
expect(componentInstance.confirmDelete).toBe(true);
});
// Test 81: handleUpdateSavedView with no streams
it("should notify when no streams available for update", () => {
componentInstance.searchObj.data.stream.selectedStream = [];
const item = { view_id: "view123", view_name: "Test View" };
componentInstance.handleUpdateSavedView(item);
expect(componentInstance.notify).toHaveBeenCalledWith({
type: "negative",
message: "No stream available to update save view.",
});
});
// Test 82: handleUpdateSavedView with streams available
it("should set up update confirmation when streams available", () => {
componentInstance.searchObj.data.stream.selectedStream = ["test-stream"];
const item = { view_id: "view123", view_name: "Test View" };
componentInstance.handleUpdateSavedView(item);
expect(componentInstance.savedViewDropdownModel).toBe(false);
expect(componentInstance.updateViewObj).toEqual(item);
expect(componentInstance.confirmUpdate).toBe(true);
});
// Test 83: toggleCustomDownloadDialog method
it("should show custom download dialog", () => {
componentInstance.toggleCustomDownloadDialog();
@@ -2875,15 +2828,6 @@ describe("SearchBar.vue Actual Component Methods", () => {
expect(componentInstance.searchObj.data.customDownloadQueryObj.query.size).toBe(100);
});
// Test 166: confirmUpdateSavedViews method
it("should call updateSavedViews with correct parameters", () => {
componentInstance.updateViewObj = { view_id: "123", view_name: "Test View" };
componentInstance.confirmUpdateSavedViews();
expect(componentInstance.updateSavedViews).toHaveBeenCalledWith("123", "Test View");
});
// Test 167: confirmDeleteSavedViews method
it("should call deleteSavedViews", () => {
componentInstance.confirmDeleteSavedViews();
+106 -174
View File
@@ -267,74 +267,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OTooltip :content="t('search.listSavedViews')" :side-offset="2" />
</OButton>
</template>
<ODropdownGroup :label="t('search.savedViewsLabel')">
<div
v-if="searchObj.loadingSavedView"
class="text-text-secondary flex items-center gap-2 px-3 py-1.5 text-sm"
>
<OSpinner size="xs" />
{{ t("confirmDialog.loading") }}
</div>
<div
v-else-if="sortedSavedViews.length"
class="max-h-72 overflow-y-auto overscroll-contain"
data-test="logs-search-bar-saved-views-menu-list"
>
<ODropdownItem
v-for="view in sortedSavedViews"
:key="view.view_id"
:data-test="`logs-search-bar-saved-views-menu-apply-${view.view_name}`"
@select="applySavedView(view)"
>
<template #icon-left>
<OIcon
:name="favoriteViews.includes(view.view_id) ? 'star' : 'saved-search'"
size="sm"
:class="favoriteViews.includes(view.view_id) ? 'text-favorite' : ''"
/>
</template>
<OTruncatedText class="max-w-56">{{ view.view_name }}</OTruncatedText>
<template #icon-right>
<OButton
variant="ghost"
size="icon-xs-sq"
icon-left="edit"
class="ms-auto"
:aria-label="t('search.updateSavedViewWithCurrent')"
:disabled="!!saveViewReason"
focusable-unavailable
:data-test="`logs-search-bar-saved-views-menu-update-${view.view_name}`"
@click.stop.prevent="quickUpdateSavedView(view)"
@keydown.enter.stop.prevent="quickUpdateSavedView(view)"
@keydown.space.stop
>
<template #unavailable-reason>{{ saveViewReason }}</template>
</OButton>
</template>
</ODropdownItem>
</div>
<ODropdownItem v-else disabled>
{{ t("search.savedViewsNotFound") }}
</ODropdownItem>
</ODropdownGroup>
<ODropdownSeparator />
<ODropdownItem
icon-left="save"
data-test="logs-search-bar-saved-views-menu-create"
:disabled="!!saveViewReason"
focusable-unavailable
:description="saveViewReason || saveViewNote"
@select="fnSavedView"
>
{{ t("search.createSavedView") }}
</ODropdownItem>
<ODropdownItem
icon-left="settings"
data-test="logs-search-bar-saved-views-menu-manage"
@select="openSavedViewsList"
>
{{ t("search.manageSavedViews") }}
</ODropdownItem>
<SavedViewsMenu
:views="searchObj.data.savedViews"
:favorite-ids="favoriteViews"
:applied-view-id="appliedSavedViewId"
:loading="!!searchObj.loadingSavedView"
:save-reason="saveViewReason"
:save-note="saveViewNote"
create-shortcut-id="logsSaveView"
data-test-prefix="logs-search-bar-saved-views-menu"
@apply="applySavedView"
@update="requestUpdateSavedView"
@toggle-favorite="handleFavoriteSavedView"
@create="fnSavedView"
@delete="handleDeleteSavedView"
/>
</ODropdown>
<OSeparator vertical />
<OButton
@@ -363,7 +310,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</div>
<!-- this is the button group responsible for showing all the utilities -->
<ODropdown class="flex-shrink-0" side="bottom" align="start">
<ODropdown
class="flex-shrink-0"
side="bottom"
align="start"
:open="utilitiesMenuOpen"
@update:open="onUtilitiesMenuOpenChange"
>
<template #trigger>
<OButton
data-test="logs-search-bar-utilities-menu-btn"
@@ -554,8 +507,24 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<ODropdownSeparator />
<!-- SAVED VIEWS -->
<ODropdownGroup :label="t('search.menuGroupSavedViews')">
<template #label-action>
<SavedViewsSubmenu
:views="searchObj.data.savedViews"
:favorite-ids="favoriteViews"
:applied-view-id="appliedSavedViewId"
:loading="!!searchObj.loadingSavedView"
:save-reason="saveViewReason"
:save-note="saveViewNote"
create-shortcut-id="logsSaveView"
data-test-prefix="logs-search-bar-menu-saved-views"
trigger-test-id="logs-search-bar-menu-list-saved-views-btn"
create-test-id="logs-search-bar-menu-create-saved-view-btn"
@apply="applySavedView"
@update="requestUpdateSavedView"
@toggle-favorite="handleFavoriteSavedView"
@create="fnSavedView"
@delete="handleDeleteSavedView"
>
<template #actions>
<OButton
data-test="logs-search-bar-menu-pin-saved-views-btn"
variant="ghost-neutral"
@@ -568,40 +537,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OIcon :name="isPinned('savedViews') ? 'keep' : 'keep-outline'" size="sm" />
</OButton>
</template>
<ODropdownItem
data-test="logs-search-bar-menu-list-saved-views-btn"
@select="openSavedViewsList"
>
<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="logs-search-bar-menu-create-saved-view-btn"
shortcut-id="logsSaveView"
:disabled="!!saveViewReason"
focusable-unavailable
:description="saveViewReason || saveViewNote"
@select="fnSavedView"
>
<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 />
</SavedViewsSubmenu>
<!-- SYNTAX GUIDE -->
<div class="flex w-full items-center pe-2">
@@ -1710,28 +1646,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-model="confirmDelete"
/>
<ConfirmDialog
:title="t('search.updateSavedView')"
:message="t('search.updateSavedViewConfirm')"
@update:ok="confirmUpdateSavedViews"
@update:cancel="confirmUpdate = false"
v-model="confirmUpdate"
:title="t('search.overwriteSavedView')"
message-keypath="search.overwriteSavedViewConfirm"
:message-name="overwriteViewName"
:ok-label="t('search.overwriteSavedViewOk')"
focus-cancel
@update:ok="confirmOverwriteSavedView"
@update:cancel="cancelOverwriteSavedView"
v-model="confirmOverwrite"
/>
<!-- Query Plan Dialog -->
<QueryPlanDialog v-model="showExplainDialog" :searchObj="searchObj" />
<SavedViewsListDialog
v-model:open="savedViewsListDialog"
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>
@@ -1885,9 +1810,7 @@ import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import ODropdownSeparator from "@/lib/overlay/Dropdown/ODropdownSeparator.vue";
import ODropdownGroup from "@/lib/overlay/Dropdown/ODropdownGroup.vue";
import { hasFieldCondition, removeFieldCondition } from "@/plugins/logs/filterUtils";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OSwitch from "@/lib/forms/Switch/OSwitch.vue";
@@ -1903,8 +1826,8 @@ 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 "@/components/savedViews/savedViewsSort";
import SavedViewsListDialog from "@/components/savedViews/SavedViewsListDialog.vue";
import SavedViewsMenu from "@/components/savedViews/SavedViewsMenu.vue";
import SavedViewsSubmenu from "@/components/savedViews/SavedViewsSubmenu.vue";
import { useFavoriteSavedViews } from "@/composables/useFavoriteSavedViews";
import { makeSavedFunctionSchema, type SavedFunctionForm } from "./SearchBar.SavedFunction.schema";
import { applySessionTimezone } from "@/utils/storage";
@@ -1988,9 +1911,7 @@ export default defineComponent({
OToggleGroup,
OToggleGroupItem,
OFormToggleGroup,
OSpinner,
OTooltip,
OTruncatedText,
OInput,
OSelect,
OForm,
@@ -1998,10 +1919,11 @@ export default defineComponent({
OFormSelect,
OSwitch,
OTree,
SavedViewsListDialog,
RecentSearchesButton,
RecentSearchesDrawer,
RecentSearchesFocusDropdown,
SavedViewsMenu,
SavedViewsSubmenu,
},
emits: [
"searchdata",
@@ -2033,25 +1955,12 @@ export default defineComponent({
},
handleDeleteSavedView(item: any) {
this.savedViewDropdownModel = false;
this.savedViewsListDialog = false;
this.savedViewsDropdownOpen = false;
this.utilitiesMenuOpen = false;
this.deleteViewID = item.view_id;
this.deleteViewName = item.view_name;
this.confirmDelete = true;
},
handleUpdateSavedView(item: any) {
if (this.searchObj.data.stream.selectedStream.length == 0) {
toast({
variant: "error",
message: this.t("logs.searchBar.noStreamUpdateView"),
});
return;
}
if (this.blockWithReason(this.saveViewReason)) return;
this.savedViewDropdownModel = false;
this.savedViewsListDialog = false;
this.updateViewObj = item;
this.confirmUpdate = true;
},
confirmDeleteSavedViews() {
this.deleteSavedViews();
},
@@ -2059,10 +1968,6 @@ export default defineComponent({
if (this.blockWithReason(this.customRangeReason || this.downloadReason)) return;
this.customDownloadDialog = true;
},
confirmUpdateSavedViews() {
this.updateSavedViews(this.updateViewObj.view_id, this.updateViewObj.view_name);
return;
},
downloadRangeData() {
let initNumber = parseInt(this.downloadCustomInitialNumber);
if (initNumber < 0) {
@@ -2421,7 +2326,6 @@ export default defineComponent({
const dateTimeRef = ref(null);
const {
favoriteIds: favoriteViews,
favoriteViews: localSavedViews,
toggleFavorite: handleFavoriteSavedView,
removeFavorite,
pruneFavorites,
@@ -2459,10 +2363,12 @@ export default defineComponent({
}));
const showExplainDialog = ref(false);
const confirmDelete = ref(false);
const confirmOverwrite = ref(false);
const overwriteViewId = ref("");
const overwriteViewName = ref("");
const deleteViewID = ref("");
const deleteViewName = ref("");
const savedViewDropdownModel = ref(false);
const savedViewsListDialog = ref(false);
const moreOptionsDropdownModel = ref(false);
const searchTerm = ref("");
@@ -2630,9 +2536,6 @@ export default defineComponent({
const vrlEditorNlpMode = ref(false); // Track VRL editor's AI mode
const confirmUpdate = ref(false);
const updateViewObj = ref({});
const transformTypes = computed(() => {
return [{ label: t("logs.searchBar.transformTypeFunction"), value: "function" }];
});
@@ -3539,11 +3442,6 @@ export default defineComponent({
savedViewDropdownModel.value = false;
};
const openSavedViewsList = () => {
loadSavedView();
savedViewsListDialog.value = true;
};
// ── Saved views quick dropdown (pinned toolbar) ──────────────────────
// Controlled open state so a quick update can close the menu itself.
const savedViewsDropdownOpen = ref(false);
@@ -3553,13 +3451,29 @@ export default defineComponent({
if (open) loadSavedView();
};
const sortedSavedViews = computed(() =>
sortSavedViews(searchObj.data.savedViews, favoriteViews.value),
);
const appliedSavedViewId = ref<string | null>(null);
const savedViewSignature = () =>
JSON.stringify([searchObj.data.query, searchObj.data.stream.selectedStream]);
let appliedSignature = "";
const markSavedViewApplied = (viewId: string) => {
appliedSignature = savedViewSignature();
appliedSavedViewId.value = viewId;
};
// While a view is being applied the search state is rewritten on purpose, so it re-baselines instead of clearing.
watch(savedViewSignature, (signature) => {
if (!appliedSavedViewId.value) return;
if (searchObj.shouldIgnoreWatcher) appliedSignature = signature;
else if (signature !== appliedSignature) appliedSavedViewId.value = null;
});
// One-click overwrite of a view with the current search state — no list
// dialog, no stacked confirm dialog.
const quickUpdateSavedView = (item: any) => {
const utilitiesMenuOpen = ref(false);
const onUtilitiesMenuOpenChange = (open: boolean) => {
utilitiesMenuOpen.value = open;
if (open) loadSavedView();
};
// Overwriting destroys the stored query, so it is confirmed first.
const requestUpdateSavedView = (item: any) => {
if (searchObj.data.stream.selectedStream.length == 0) {
toast({
variant: "error",
@@ -3569,7 +3483,19 @@ export default defineComponent({
}
if (blockWithReason(saveViewReason.value)) return;
savedViewsDropdownOpen.value = false;
updateSavedViews(item.view_id, item.view_name);
utilitiesMenuOpen.value = false;
overwriteViewId.value = item.view_id;
overwriteViewName.value = item.view_name;
confirmOverwrite.value = true;
};
const cancelOverwriteSavedView = () => {
confirmOverwrite.value = false;
};
const confirmOverwriteSavedView = () => {
confirmOverwrite.value = false;
updateSavedViews(overwriteViewId.value, overwriteViewName.value);
};
// Common function to restore visualization data and sync to URL
@@ -3982,6 +3908,7 @@ export default defineComponent({
message: t("logs.searchBar.viewAppliedSuccess", { name: item.view_name }),
variant: "success",
});
markSavedViewApplied(item.view_id);
setTimeout(async () => {
try {
searchObj.loadingHistogram = false;
@@ -4064,6 +3991,7 @@ export default defineComponent({
.then((res: any) => {
removeFavorite(deleteViewID.value);
if (res.status == 200) {
if (appliedSavedViewId.value === deleteViewID.value) appliedSavedViewId.value = null;
toast({
message: t("search.viewDeletedSuccessfully"),
variant: "success",
@@ -4187,6 +4115,7 @@ export default defineComponent({
message: t("search.viewCreatedSuccessfully"),
variant: "success",
});
if (res.data.view_id) markSavedViewApplied(res.data.view_id);
refreshSavedViews();
isSavedViewAction.value = "create";
} else {
@@ -4245,6 +4174,7 @@ export default defineComponent({
},
);
markSavedViewApplied(viewID);
toast({
message: t("search.viewUpdatedSuccessfully"),
variant: "success",
@@ -4326,6 +4256,7 @@ export default defineComponent({
}
const resetFilters = () => {
appliedSavedViewId.value = null;
flushEditorValue();
if (searchObj.meta.sqlMode == true) {
const parsedSQL = fnParsedSQL();
@@ -5179,12 +5110,9 @@ export default defineComponent({
updateTimezone,
dateTimeRef,
fnSavedView,
openSavedViewsList,
applySavedView,
savedViewsDropdownOpen,
onSavedViewsDropdownOpenChange,
sortedSavedViews,
quickUpdateSavedView,
isSavedViewAction,
// Saved-view OForm (schema returned from setup() so the Options-API
// template resolves :schema; a bare import would be out of scope).
@@ -5196,8 +5124,12 @@ export default defineComponent({
deleteViewID,
deleteViewName,
confirmDelete,
confirmOverwrite,
overwriteViewName,
requestUpdateSavedView,
confirmOverwriteSavedView,
cancelOverwriteSavedView,
savedViewDropdownModel,
savedViewsListDialog,
moreOptionsDropdownModel,
fnSavedFunctionDialog,
// Saved-function OForm (owner pattern, Rule ③): the form is created with
@@ -5222,7 +5154,9 @@ export default defineComponent({
confirmSavedViewDialogVisible,
handleFavoriteSavedView,
favoriteViews,
localSavedViews,
appliedSavedViewId,
onUtilitiesMenuOpenChange,
utilitiesMenuOpen,
loadSavedView,
config,
patternsAccess,
@@ -5268,8 +5202,6 @@ export default defineComponent({
navigateToSearchInspect,
searchTerm,
filteredFunctionOptions,
confirmUpdate,
updateViewObj,
updateSavedViews,
checkQuery,
checkFnQuery,
+178 -97
View File
@@ -303,7 +303,7 @@ vi.mock("@/composables/useTraces", () => ({
// ---------------------------------------------------------------------------
import SearchBar from "@/plugins/traces/SearchBar.vue";
import analytics from "@/services/product_analytics";
import SavedViewsListDialog from "@/components/savedViews/SavedViewsListDialog.vue";
import SavedViewsSubmenu from "@/components/savedViews/SavedViewsSubmenu.vue";
import { useToolbarPins } from "@/composables/useToolbarPins";
import { placeCaret } from "@/test/unit/helpers/monacoCaret";
@@ -418,6 +418,12 @@ const sharedStubs = {
template: '<div v-bind="$attrs"><slot name="label-action" /><slot /></div>',
},
ODropdownSeparator: { template: "<hr />" },
ODropdownSub: {
name: "ODropdownSub",
template:
'<div class="o-dropdown-sub-stub" v-bind="$attrs"><slot name="icon-left" /><slot name="trigger" /><slot name="icon-right" /><slot /></div>',
props: ["open", "textValue", "description"],
},
};
// ---------------------------------------------------------------------------
@@ -951,9 +957,9 @@ describe("SearchBar", () => {
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,
);
expect(
wrapper.find('[data-test="traces-search-bar-menu-list-saved-views-btn"]').exists(),
).toBe(true);
});
it("shrinks the toggle labels for the pinned RED Metrics width", async () => {
@@ -2003,6 +2009,16 @@ describe("SearchBar", () => {
});
});
it("marks a just-created view as applied when the response has its id", async () => {
wrapper = mountSearchBar();
await flushPromises();
(wrapper.vm as any).newViewName = "fresh";
await (wrapper.vm as any).saveAsNewView();
expect((wrapper.vm as any).appliedSavedViewId).toBe("n1");
});
it("apply emits apply-saved-view with the stored data", async () => {
mockGetViewDetail.mockResolvedValue({ data: { ...tracesView, data: expectedData() } });
wrapper = mountSearchBar();
@@ -2049,12 +2065,12 @@ describe("SearchBar", () => {
expect(wrapper.emitted("apply-saved-view")).toBeUndefined();
});
it("pinned dropdown has no delete action", async () => {
it("pinned dropdown offers a delete action beside update", async () => {
wrapper = mountSearchBar();
await flushPromises();
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);
expect(wrapper.find('[data-test="traces-saved-view-delete-t1"]').exists()).toBe(true);
});
it("pinned dropdown lists favourites first, then by name, with a star on favourites", async () => {
@@ -2067,13 +2083,10 @@ describe("SearchBar", () => {
.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");
const pressed = (id: string) =>
wrapper.find(`[data-test="traces-saved-view-favorite-${id}"]`).attributes("aria-pressed");
expect(pressed("t1")).toBe("true");
expect(pressed("t2")).toBe("false");
});
it("pinned dropdown shows a loading row while the list loads", async () => {
@@ -2081,56 +2094,37 @@ describe("SearchBar", () => {
wrapper = mountSearchBar();
await flushPromises();
expect(wrapper.find('[data-test="traces-saved-views-menu-loading"]').exists()).toBe(true);
expect(wrapper.findComponent(SavedViewsListDialog).props("loading")).toBe(true);
expect(wrapper.find('[data-test="traces-saved-view-loading"]').exists()).toBe(true);
});
describe("in the More menu", () => {
const moreGroup = () =>
wrapper.find('[data-test="traces-search-bar-menu-saved-views-group"]');
const moreGroup = () => wrapper.findComponent(SavedViewsSubmenu);
const toolbarGroup = () => wrapper.find('[data-test="traces-search-bar-saved-views"]');
const dialog = () => wrapper.findComponent(SavedViewsListDialog);
const dialog = () => wrapper.findComponent(SavedViewsSubmenu);
it("shows the saved-views group with List, Create and the pin button", async () => {
it("shows the saved-views submenu with 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(),
moreGroup().find('[data-test="traces-search-bar-menu-saved-views-delete-t1"]').exists(),
).toBe(true);
expect(
moreGroup().find('[data-test="traces-search-bar-menu-manage-saved-views-btn"]').exists(),
).toBe(false);
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);
expect(
moreGroup().find('[data-test="traces-search-bar-menu-saved-views-apply-t1"]').exists(),
).toBe(true);
});
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 () => {
it("deletes from the submenu through the confirmation flow while unpinned", async () => {
togglePin("savedViews");
wrapper = mountSearchBar();
await flushPromises();
@@ -2143,7 +2137,7 @@ describe("SearchBar", () => {
expect(mockSavedViewsDelete).toHaveBeenCalledWith(expect.any(String), "t1");
});
it("applies and updates views from the dialog", async () => {
it("applies and updates views from the submenu", async () => {
mockGetViewDetail.mockResolvedValue({ data: { ...tracesView, data: expectedData() } });
wrapper = mountSearchBar();
await flushPromises();
@@ -2160,33 +2154,37 @@ describe("SearchBar", () => {
});
});
it("refetches the saved views each time the dialog opens", async () => {
it("marks a view applied after a successful quick update, not after a failed one", async () => {
mockSavedViewsPut.mockRejectedValueOnce(new Error("boom"));
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");
dialog().vm.$emit("update", tracesView);
await flushPromises();
expect((wrapper.vm as any).appliedSavedViewId).toBeNull();
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");
dialog().vm.$emit("update", tracesView);
await flushPromises();
expect((wrapper.vm as any).appliedSavedViewId).toBe("t1");
});
expect(mockSavedViewsGet.mock.calls.length).toBe(callsBefore + 2);
expect(dialog().props("open")).toBe(true);
it("refetches the list when the More submenu or the pinned dropdown opens", async () => {
wrapper = mountSearchBar();
await flushPromises();
mockSavedViewsGet.mockClear();
dialog().vm.$emit("open");
await flushPromises();
expect(mockSavedViewsGet).toHaveBeenCalledTimes(1);
(wrapper.vm as any).onSavedViewsDropdownOpenChange(true);
await flushPromises();
expect(mockSavedViewsGet).toHaveBeenCalledTimes(2);
expect((wrapper.vm as any).savedViewsDropdownOpen).toBe(true);
(wrapper.vm as any).onSavedViewsDropdownOpenChange(false);
await flushPromises();
expect(mockSavedViewsGet).toHaveBeenCalledTimes(2);
});
it("prunes favourites deleted elsewhere once the list loads, freeing the cap", async () => {
@@ -2219,7 +2217,6 @@ describe("SearchBar", () => {
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({
@@ -2248,7 +2245,7 @@ describe("SearchBar", () => {
});
});
it("passes the traces data-test prefix, views and favourites to the dialog", async () => {
it("passes the traces data-test prefix, views and favourites to the submenu", async () => {
localStorage.setItem(
"savedViews",
JSON.stringify({ t1: tracesView, l1: { ...logsView, org_id: "default" } }),
@@ -2256,44 +2253,18 @@ describe("SearchBar", () => {
wrapper = mountSearchBar();
await flushPromises();
expect(dialog().props("dataTestPrefix")).toBe("traces-saved-views-dialog");
expect(dialog().props("loading")).toBe(false);
expect(dialog().props("dataTestPrefix")).toBe("traces-search-bar-menu-saved-views");
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 () => {
it("delete from the submenu 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",
messageKeypath: "search.deleteSavedViewConfirm",
@@ -2305,7 +2276,117 @@ describe("SearchBar", () => {
expect(JSON.parse(localStorage.getItem("savedViews") || "{}")).toEqual({});
});
it("delete from the dialog does nothing when not confirmed", async () => {
it("closes the More menu and the pinned dropdown before deleting", async () => {
let release!: (value: boolean) => void;
mockConfirm.mockReturnValueOnce(new Promise<boolean>((resolve) => (release = resolve)));
wrapper = mountSearchBar();
await flushPromises();
(wrapper.vm as any).moreMenuOpen = true;
(wrapper.vm as any).savedViewsDropdownOpen = true;
dialog().vm.$emit("delete", tracesView);
await flushPromises();
expect((wrapper.vm as any).moreMenuOpen).toBe(false);
expect((wrapper.vm as any).savedViewsDropdownOpen).toBe(false);
expect(mockSavedViewsDelete).not.toHaveBeenCalled();
release(false);
});
it("marks the applied view only after its detail loads", async () => {
mockGetViewDetail.mockRejectedValueOnce(new Error("boom"));
wrapper = mountSearchBar();
await flushPromises();
dialog().vm.$emit("apply", tracesView);
await flushPromises();
expect((wrapper.vm as any).appliedSavedViewId).toBeNull();
mockGetViewDetail.mockResolvedValueOnce({ data: { ...tracesView, data: expectedData() } });
dialog().vm.$emit("apply", tracesView);
await flushPromises();
expect((wrapper.vm as any).appliedSavedViewId).toBe("t1");
});
it("deleting the applied view clears the applied check", async () => {
mockGetViewDetail.mockResolvedValueOnce({ data: { ...tracesView, data: expectedData() } });
wrapper = mountSearchBar();
await flushPromises();
dialog().vm.$emit("apply", tracesView);
await flushPromises();
expect((wrapper.vm as any).appliedSavedViewId).toBe("t1");
dialog().vm.$emit("delete", tracesView);
await flushPromises();
expect((wrapper.vm as any).appliedSavedViewId).toBeNull();
});
describe("applied check follows the live search", () => {
const applyView = async () => {
mockGetViewDetail.mockResolvedValueOnce({
data: { ...tracesView, data: expectedData() },
});
wrapper = mountSearchBar();
await flushPromises();
dialog().vm.$emit("apply", tracesView);
await flushPromises();
expect((wrapper.vm as any).appliedSavedViewId).toBe("t1");
};
it("clears when the query text is edited", async () => {
await applyView();
searchObjInstance.data.editorValue = "span_status = 'OK'";
await flushPromises();
expect((wrapper.vm as any).appliedSavedViewId).toBeNull();
});
it("clears when the stream changes", async () => {
await applyView();
searchObjInstance.data.stream.selectedStream = { label: "other", value: "other" };
await flushPromises();
expect((wrapper.vm as any).appliedSavedViewId).toBeNull();
searchObjInstance.data.stream.selectedStream = { label: "default", value: "default" };
});
it("clears on Reset", async () => {
await applyView();
(wrapper.vm as any).resetFilters();
await flushPromises();
expect((wrapper.vm as any).appliedSavedViewId).toBeNull();
});
});
it("overwrite asks for confirmation naming the view and keeps focus on Cancel", async () => {
wrapper = mountSearchBar();
await flushPromises();
dialog().vm.$emit("update", tracesView);
await flushPromises();
expect(mockConfirm).toHaveBeenCalledWith(
expect.objectContaining({
messageKeypath: "search.overwriteSavedViewConfirm",
messageName: "checkout errors",
focusCancel: true,
}),
);
expect(mockSavedViewsPut).toHaveBeenCalledTimes(1);
});
it("a cancelled overwrite leaves the view untouched", async () => {
mockConfirm.mockResolvedValue(false);
wrapper = mountSearchBar();
await flushPromises();
dialog().vm.$emit("update", tracesView);
await flushPromises();
expect(mockSavedViewsPut).not.toHaveBeenCalled();
expect((wrapper.vm as any).appliedSavedViewId).toBeNull();
});
it("delete from the submenu does nothing when not confirmed", async () => {
mockConfirm.mockResolvedValue(false);
localStorage.setItem("savedViews", JSON.stringify({ t1: tracesView }));
wrapper = mountSearchBar();
@@ -2318,7 +2399,7 @@ describe("SearchBar", () => {
expect(dialog().props("favoriteIds")).toEqual(["t1"]);
});
it("toggles a favourite from the dialog", async () => {
it("toggles a favourite from the submenu", async () => {
wrapper = mountSearchBar();
await flushPromises();
+90 -104
View File
@@ -135,7 +135,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:open="savedViewsDropdownOpen"
side="bottom"
align="start"
@update:open="savedViewsDropdownOpen = $event"
@update:open="onSavedViewsDropdownOpenChange"
>
<template #trigger>
<OButton
@@ -148,30 +148,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<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>
<SavedViewsMenu
:views="tracesSavedViews"
:favorite-ids="favoriteIds"
:applied-view-id="appliedSavedViewId"
:loading="savedViewsLoading"
data-test-prefix="traces-saved-view"
@apply="applySavedView"
@update="requestUpdateSavedView"
@toggle-favorite="toggleFavorite"
@create="openSaveViewDialog"
@delete="deleteTracesSavedView"
/>
</ODropdown>
<OSeparator vertical />
<OButton
@@ -186,7 +174,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OButtonGroup>
</div>
<ODropdown side="bottom" align="start">
<ODropdown
:open="moreMenuOpen"
side="bottom"
align="start"
@update:open="moreMenuOpen = $event"
>
<template #trigger>
<OButton
data-test="traces-search-bar-more-menu-btn"
@@ -237,11 +230,22 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template v-if="isSearchMode">
<ODropdownSeparator />
<ODropdownGroup
:label="t('search.menuGroupSavedViews')"
data-test="traces-search-bar-menu-saved-views-group"
<SavedViewsSubmenu
:views="tracesSavedViews"
:favorite-ids="favoriteIds"
:applied-view-id="appliedSavedViewId"
:loading="savedViewsLoading"
data-test-prefix="traces-search-bar-menu-saved-views"
trigger-test-id="traces-search-bar-menu-list-saved-views-btn"
create-test-id="traces-search-bar-menu-create-saved-view-btn"
@open="refreshSavedViews"
@apply="applySavedView"
@update="requestUpdateSavedView"
@toggle-favorite="toggleFavorite"
@create="openSaveViewDialog"
@delete="deleteTracesSavedView"
>
<template #label-action>
<template #actions>
<OButton
data-test="traces-search-bar-menu-pin-saved-views-btn"
variant="ghost-neutral"
@@ -254,33 +258,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<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>
</SavedViewsSubmenu>
<ODropdownSeparator />
</template>
@@ -596,18 +574,6 @@ 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>
@@ -637,7 +603,6 @@ import OButtonGroup from "@/lib/core/Button/OButtonGroup.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import ODropdownGroup from "@/lib/overlay/Dropdown/ODropdownGroup.vue";
import ODropdownSeparator from "@/lib/overlay/Dropdown/ODropdownSeparator.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
@@ -650,9 +615,8 @@ import { useLockedAffordance } from "@/composables/useLockedAffordance";
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 SavedViewsMenu from "@/components/savedViews/SavedViewsMenu.vue";
import SavedViewsSubmenu from "@/components/savedViews/SavedViewsSubmenu.vue";
import { useFavoriteSavedViews } from "@/composables/useFavoriteSavedViews";
import { debounce } from "lodash-es";
@@ -710,7 +674,6 @@ export default defineComponent({
OIcon,
ODropdown,
ODropdownItem,
ODropdownGroup,
ODropdownSeparator,
ODialog,
OInput,
@@ -719,11 +682,11 @@ export default defineComponent({
OTooltip,
CodeQueryEditor: defineAsyncComponent(() => import("@/components/CodeQueryEditor.vue")),
SyntaxGuide,
TracesSavedViewsMenuItems,
SavedViewsListDialog,
RecentSearchesButton,
RecentSearchesDrawer,
RecentSearchesFocusDropdown,
SavedViewsMenu,
SavedViewsSubmenu,
},
emits: [
"searchdata",
@@ -1143,6 +1106,7 @@ export default defineComponent({
};
const resetFilters = () => {
appliedSavedView.value = null;
searchObj.data.editorValue = "";
searchObj.data.advanceFiltersQuery = "";
Object.values(searchObj.data.stream.fieldValues).forEach((field) => {
@@ -1233,7 +1197,7 @@ export default defineComponent({
(savedViewsList.data.value ?? []).filter((view) => view.view_type === "traces"),
);
const savedViewsLoading = computed(() => savedViewsList.isLoading.value);
const { favoriteIds, favoriteViews, toggleFavorite, removeFavorite, pruneFavorites } =
const { favoriteIds, toggleFavorite, removeFavorite, pruneFavorites } =
useFavoriteSavedViews("traces");
// Only a successful fetch is the complete list; pruning on error would drop every favourite.
watch(
@@ -1245,9 +1209,25 @@ export default defineComponent({
},
{ immediate: true },
);
const sortedTracesSavedViews = computed(() =>
sortSavedViews(tracesSavedViews.value, favoriteIds.value),
const appliedSavedView = ref<{ id: string; editorValue: string; streamValue: string } | null>(
null,
);
// Derived from the live state so any later edit, stream switch or reset drops the check.
const appliedSavedViewId = computed(() => {
const applied = appliedSavedView.value;
if (!applied) return null;
const matches =
searchObj.data.editorValue === applied.editorValue &&
searchObj.data.stream.selectedStream?.value === applied.streamValue;
return matches ? applied.id : null;
});
const markSavedViewApplied = (id: string, data: any) => {
appliedSavedView.value = {
id,
editorValue: data?.editorValue ?? "",
streamValue: data?.stream?.value ?? "",
};
};
const createSavedView = useMutation(() => createSavedViewMutation(orgId.value));
const updateSavedView = useMutation(() => updateSavedViewMutation(orgId.value));
const deleteSavedView = useMutation(() => deleteSavedViewMutation(orgId.value));
@@ -1282,12 +1262,6 @@ 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) {
@@ -1295,11 +1269,13 @@ export default defineComponent({
return;
}
try {
await createSavedView.mutateAsync({
const viewData = buildSavedViewData();
const res = await createSavedView.mutateAsync({
view_name: viewName,
view_type: "traces",
data: buildSavedViewData(),
data: viewData,
});
if (res?.data?.view_id) markSavedViewApplied(res.data.view_id, viewData);
saveViewDialogOpen.value = false;
toast({ message: t("search.viewCreatedSuccessfully"), variant: "success" });
} catch (err) {
@@ -1312,10 +1288,12 @@ export default defineComponent({
const updateSavedViewFromCurrent = async (view) => {
try {
const viewData = buildSavedViewData();
await updateSavedView.mutateAsync({
viewId: view.view_id,
view: { view_name: view.view_name, data: buildSavedViewData() },
view: { view_name: view.view_name, data: viewData },
});
markSavedViewApplied(view.view_id, viewData);
toast({ message: t("search.viewUpdatedSuccessfully"), variant: "success" });
} catch (err) {
toast({
@@ -1326,22 +1304,29 @@ export default defineComponent({
};
const savedViewsDropdownOpen = ref(false);
const quickUpdateSavedView = (view) => {
savedViewsDropdownOpen.value = false;
return updateSavedViewFromCurrent(view);
const moreMenuOpen = ref(false);
const refreshSavedViews = () => savedViewsList.refetch();
const onSavedViewsDropdownOpenChange = (open: boolean) => {
savedViewsDropdownOpen.value = open;
if (open) refreshSavedViews();
};
const confirmUpdateSavedView = async (view) => {
savedViewsDialogOpen.value = false;
const requestUpdateSavedView = async (view) => {
savedViewsDropdownOpen.value = false;
moreMenuOpen.value = false;
const confirmed = await confirm({
title: t("search.updateSavedView"),
message: t("search.updateSavedViewConfirm"),
title: t("search.overwriteSavedView"),
messageKeypath: "search.overwriteSavedViewConfirm",
messageName: view.view_name,
confirmLabel: t("search.overwriteSavedViewOk"),
focusCancel: true,
});
if (confirmed) await updateSavedViewFromCurrent(view);
if (!confirmed) return;
await updateSavedViewFromCurrent(view);
};
const deleteTracesSavedView = async (view) => {
savedViewsDialogOpen.value = false;
savedViewsDropdownOpen.value = false;
moreMenuOpen.value = false;
const confirmed = await confirm({
title: t("search.deleteSavedView"),
messageKeypath: "search.deleteSavedViewConfirm",
@@ -1352,6 +1337,7 @@ export default defineComponent({
try {
await deleteSavedView.mutateAsync(view.view_id);
removeFavorite(view.view_id);
if (appliedSavedView.value?.id === view.view_id) appliedSavedView.value = null;
toast({ message: t("search.viewDeletedSuccessfully"), variant: "success" });
} catch (err) {
toast({
@@ -1368,6 +1354,7 @@ export default defineComponent({
// Uncached on purpose, as in logs: applying must use the server's current copy.
const res = await savedViewsService.getViewDetail(orgId.value, view.view_id);
if (seq !== applySavedViewSeq) return;
markSavedViewApplied(view.view_id, res.data.data);
emit("apply-saved-view", res.data.data);
} catch (err) {
if (seq !== applySavedViewSeq) return;
@@ -1514,20 +1501,19 @@ export default defineComponent({
saveViewDialogOpen,
newViewName,
openSaveViewDialog,
savedViewsDialogOpen,
openSavedViewsDialog,
saveAsNewView,
updateSavedViewFromCurrent,
deleteTracesSavedView,
applySavedView,
savedViewsLoading,
favoriteIds,
favoriteViews,
toggleFavorite,
sortedTracesSavedViews,
appliedSavedViewId,
savedViewsDropdownOpen,
quickUpdateSavedView,
confirmUpdateSavedView,
onSavedViewsDropdownOpenChange,
refreshSavedViews,
moreMenuOpen,
requestUpdateSavedView,
serviceGraphAccess,
onSearchModeChange,
};
@@ -1,95 +0,0 @@
<!-- 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";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.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>
<OTruncatedText class="max-w-56">{{ view.view_name }}</OTruncatedText>
<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>