mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 00:08:55 +00:00
fix: logs sidebar table will be default view and draggable tabs (#13368)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
35e2a36fd2
commit
ef2efdf5fc
@@ -243,6 +243,7 @@ class UnflattenedPage {
|
||||
await this.page
|
||||
.locator('[data-test="logs-search-result-detail-dialog"][data-state="open"]')
|
||||
.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await this.openJsonDetailTab();
|
||||
// JSON content renders asynchronously after the drawer opens. Wait for the
|
||||
// first detail key to actually render instead of a fixed 3s sleep — the keys
|
||||
// appearing IS the signal callers need before probing for _o2_id, and the
|
||||
@@ -254,6 +255,33 @@ class UnflattenedPage {
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the JSON tab in the log detail drawer.
|
||||
*
|
||||
* The drawer now opens on the Table tab — see 4194865a63 "logs sidebar table
|
||||
* will be default view and draggable tabs", which flipped
|
||||
* SearchResult.vue's `detailTableInitialTab` and DetailTable's `initialTab`
|
||||
* default from "json" to "table". Everything this page object reads
|
||||
* (`_o2_id`, the per-key `log-expand-detail-key-*` rows, the unflattened
|
||||
* sub-tab) lives in the JSON panel, and `OTabPanels` is used with
|
||||
* `keep-alive`, so the inactive JSON panel stays mounted but `v-show`-hidden.
|
||||
* It is therefore present in the DOM yet never *visible*, which is what made
|
||||
* every `_o2_id` probe time out.
|
||||
*
|
||||
* The tab bar is user-reorderable (order persisted in localStorage), so
|
||||
* select by data-test rather than position.
|
||||
*/
|
||||
async openJsonDetailTab() {
|
||||
const jsonTab = this.page
|
||||
.locator('[data-test="logs-search-result-detail-dialog"] [data-test="log-detail-json-tab"]')
|
||||
.first();
|
||||
await jsonTab.waitFor({ state: 'visible', timeout: 10000 });
|
||||
if ((await jsonTab.getAttribute('data-state')) !== 'active') {
|
||||
await jsonTab.click();
|
||||
}
|
||||
await this.logDetailJsonContent.waitFor({ state: 'visible', timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the log detail drawer if it is open. Uses Escape as the fallback
|
||||
* when the close button is intercepted by an overlay.
|
||||
@@ -276,9 +304,18 @@ class UnflattenedPage {
|
||||
* drawer OPEN on the matching row so the caller can continue interacting
|
||||
* with the `_o2_id` element. Returns -1 if no row in the range contains
|
||||
* `_o2_id`.
|
||||
*
|
||||
* Pass `deadlineAt` (an absolute `Date.now()` value) to cap how long the
|
||||
* sweep may run. Each row costs a drawer open plus a probe, so a 15-row
|
||||
* sweep on a slow runner could otherwise outlive the whole test timeout and
|
||||
* get the test killed mid-action instead of returning -1.
|
||||
*/
|
||||
async findRowWithO2Id(maxRows = 5) {
|
||||
async findRowWithO2Id(maxRows = 5, { deadlineAt = null } = {}) {
|
||||
for (let rowIndex = 0; rowIndex < maxRows; rowIndex++) {
|
||||
if (deadlineAt && Date.now() >= deadlineAt) {
|
||||
console.warn(`findRowWithO2Id: time budget reached after scanning ${rowIndex} row(s)`);
|
||||
break;
|
||||
}
|
||||
const expandBtn = this.page.locator(
|
||||
`[data-test="log-table-column-${rowIndex}-_timestamp"] [data-test="table-row-expand-menu"]`
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js');
|
||||
const logData = require("../../fixtures/log.json");
|
||||
const PageManager = require('../../pages/page-manager.js');
|
||||
const testLogger = require('../utils/test-logger.js');
|
||||
const { getOrgIdentifier, isCloudEnvironment } = require('../utils/cloud-auth.js');
|
||||
@@ -10,21 +9,53 @@ const { ingestTestData: ingestion } = require('../utils/data-ingestion.js');
|
||||
|
||||
test.describe("Unflattened testcases", () => {
|
||||
let pageManager;
|
||||
// let logData;
|
||||
function removeUTFCharacters(text) {
|
||||
// Remove UTF characters using regular expression
|
||||
return text.replace(/[^\x00-\x7F]/g, " ");
|
||||
}
|
||||
// ── Time budget ───────────────────────────────────────────────────────────
|
||||
// beforeEach + body + afterEach all share ONE Playwright timeout. The waits
|
||||
// below used to be sized in isolation and optimistically — a 90s + 120s
|
||||
// readiness gate, then up to 3 scan attempts each fronted by a fresh
|
||||
// navigation and a 60s + 120s query wait — so their sum could exceed the
|
||||
// whole budget several times over. When it did, Playwright killed the test in
|
||||
// the middle of whatever it was doing and reported
|
||||
// "page.goto: Target page, context or browser has been closed" — the symptom
|
||||
// of the kill, not the cause. Every long wait is now clamped to the time
|
||||
// actually left, so a slow backend produces a diagnostic failure while the
|
||||
// browser is still alive, and the honest path has room to finish.
|
||||
let testStartedAt = 0;
|
||||
|
||||
// Held back so each test's "toggle Store Original Data back OFF" cleanup can
|
||||
// always run; leaving the stream with the flag ON leaks into the next test in
|
||||
// this serial file.
|
||||
const CLEANUP_RESERVE_MS = 45000;
|
||||
|
||||
// A scan attempt costs a row sweep plus, on a miss, a fresh navigation and a
|
||||
// full re-query. Only start another round when at least this much is left.
|
||||
const SCAN_ATTEMPT_COST_MS = 90000;
|
||||
|
||||
function msLeft(reserve = CLEANUP_RESERVE_MS) {
|
||||
const budget = test.info().timeout;
|
||||
if (!budget) return Number.MAX_SAFE_INTEGER; // timeout disabled
|
||||
return budget - (Date.now() - testStartedAt) - reserve;
|
||||
}
|
||||
|
||||
function clampToBudget(preferred, { min = 10000, reserve = CLEANUP_RESERVE_MS } = {}) {
|
||||
return Math.max(min, Math.min(preferred, msLeft(reserve)));
|
||||
}
|
||||
|
||||
async function applyQueryButton(page) {
|
||||
// runQueryAndWaitForResults already handles an in-flight auto-search internally:
|
||||
// it waits for the button to exit the "Cancel" state before clicking, and if the
|
||||
// state never clears it cancels and re-clicks Run itself. The old double-click
|
||||
// wrapper (3s sleep + run + 1s sleep + run) duplicated that logic and added ~5s
|
||||
// of fixed sleep to every call.
|
||||
await pageManager.logsPage.runQueryAndWaitForResults();
|
||||
await pageManager.logsPage.runQueryAndWaitForResults(clampToBudget(60000, { min: 20000 }));
|
||||
|
||||
// 2-minute timeout: cloud re-indexing after Store Original Data changes takes longer than the default 30s
|
||||
await pageManager.logsPage.waitForSearchResults(120000);
|
||||
// Up to 2 minutes: cloud re-indexing after Store Original Data changes takes
|
||||
// longer than the default 30s — but never longer than the budget left.
|
||||
await pageManager.logsPage.waitForSearchResults(clampToBudget(120000, { min: 20000 }));
|
||||
}
|
||||
|
||||
// Re-run the log search from a FRESH page load so the row scan below sees a
|
||||
@@ -38,9 +69,9 @@ test.describe("Unflattened testcases", () => {
|
||||
// otherwise fall back to the quick-mode interesting-fields-only query).
|
||||
async function runFreshLogSearch(page, { sqlSelectAll = false } = {}) {
|
||||
await page.goto(
|
||||
`${process.env.ZO_BASE_URL}/web/logs?org_identifier=${getOrgIdentifier()}&stream_type=logs&stream=e2e_automate`
|
||||
`${process.env.ZO_BASE_URL}/web/logs?org_identifier=${getOrgIdentifier()}&stream_type=logs&stream=e2e_automate`,
|
||||
{ waitUntil: 'domcontentloaded', timeout: clampToBudget(45000, { min: 15000 }) }
|
||||
);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
if (sqlSelectAll) {
|
||||
await pageManager.logsPage.typeQuery('SELECT * FROM "e2e_automate" ORDER BY _timestamp DESC');
|
||||
await pageManager.logsPage.waitForQueryEditorValue('ORDER BY _timestamp DESC');
|
||||
@@ -48,7 +79,77 @@ test.describe("Unflattened testcases", () => {
|
||||
await applyQueryButton(page);
|
||||
}
|
||||
|
||||
// Backend readiness gate for _o2_id. A single budget-bounded wait: the old
|
||||
// 90s-then-120s pair could consume 210s of a 300s test on its own, leaving
|
||||
// nothing for the UI work it was supposed to be preparing.
|
||||
async function waitForO2IdReady() {
|
||||
const ready = await pageManager.unflattenedPage.waitForO2IdQueryable({
|
||||
timeout: clampToBudget(90000, { min: 20000 }),
|
||||
});
|
||||
if (ready) {
|
||||
testLogger.info('_o2_id confirmed queryable via search API');
|
||||
} else {
|
||||
testLogger.warn('_o2_id readiness gate timed out — the UI scan below is unlikely to find it');
|
||||
}
|
||||
return ready;
|
||||
}
|
||||
|
||||
// Scan the rendered log rows for _o2_id, leaving the detail drawer open on the
|
||||
// matching row. A miss means UI/streaming lag, which only a fresh navigation +
|
||||
// re-query recovers (re-clicking Run in place does not recover a stale/partial
|
||||
// streaming result set), so each retry costs a full search — hence the budget
|
||||
// check before starting another one.
|
||||
async function scanForO2IdOrFail(page, { sqlSelectAll = false, backendReady = true, reserve = CLEANUP_RESERVE_MS } = {}) {
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
const matchedRow = await pageManager.unflattenedPage.findRowWithO2Id(15, {
|
||||
deadlineAt: Date.now() + clampToBudget(45000, { min: 15000, reserve }),
|
||||
});
|
||||
if (matchedRow !== -1) {
|
||||
testLogger.info(`Found _o2_id in row ${matchedRow} (attempt ${attempt})`);
|
||||
await pageManager.unflattenedPage.o2IdText.click();
|
||||
return;
|
||||
}
|
||||
|
||||
testLogger.warn(`_o2_id not found in the scanned rows on attempt ${attempt}`);
|
||||
await pageManager.unflattenedPage.closeLogDetailDrawerIfOpen();
|
||||
|
||||
// A fresh search only fixes UI-side staleness. If the backend never
|
||||
// surfaced _o2_id, retrying the UI cannot help — it just burns the budget
|
||||
// until Playwright kills the test mid-action.
|
||||
if (!backendReady) break;
|
||||
if (attempt === 3) break;
|
||||
if (msLeft(reserve) < SCAN_ATTEMPT_COST_MS) {
|
||||
testLogger.warn('Not enough test budget left for another scan attempt');
|
||||
break;
|
||||
}
|
||||
testLogger.info('Re-running the query from a fresh page before the next scan');
|
||||
await runFreshLogSearch(page, { sqlSelectAll });
|
||||
}
|
||||
|
||||
// Diagnostic: dump what the first row actually does contain. Skipped when
|
||||
// the budget is nearly gone — being killed here would replace the useful
|
||||
// error below with an opaque "browser has been closed".
|
||||
if (msLeft(0) > 30000) {
|
||||
try {
|
||||
await pageManager.unflattenedPage.openLogRowDetail(0);
|
||||
const allKeys = await pageManager.unflattenedPage.allLogDetailKeys.allTextContents();
|
||||
testLogger.error('Available fields in log detail', { fields: allKeys });
|
||||
} catch (e) {
|
||||
testLogger.error('Could not retrieve available fields');
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Failed to find _o2_id field in log details' +
|
||||
(backendReady
|
||||
? ''
|
||||
: ' — the search API never returned a record containing _o2_id, so "Store Original Data" had not taken effect on the backend') +
|
||||
` (${Math.round(Math.max(0, msLeft(0)) / 1000)}s of test budget left)`
|
||||
);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
testStartedAt = Date.now();
|
||||
pageManager = new PageManager(page);
|
||||
if (isCloudEnvironment()) {
|
||||
await navigateToBase(page);
|
||||
@@ -102,11 +203,14 @@ test.describe("Unflattened testcases", () => {
|
||||
await pageManager.unflattenedPage.closeButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.goto(
|
||||
`${logData.logsUrl}?org_identifier=${getOrgIdentifier()}`
|
||||
);
|
||||
await pageManager.logsPage.selectStream("e2e_automate");
|
||||
await applyQueryButton(page);
|
||||
// Leave the browser on the logs page with e2e_automate selected. Two costs
|
||||
// were removed here because nothing in either test consumes their result:
|
||||
// - the trailing search (up to 180s of query waiting) — both tests navigate
|
||||
// straight back to Streams from here;
|
||||
// - the explicit goto, since selectStream navigates to the logs page itself.
|
||||
// apiWaitMs=0 also skips selectStream's own stream-availability poll (up to
|
||||
// 120s on cloud) — waitForStreamAvailable above already established that.
|
||||
await pageManager.logsPage.selectStream("e2e_automate", 5, 0);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
@@ -153,54 +257,22 @@ test.describe("Unflattened testcases", () => {
|
||||
|
||||
// Deterministic readiness gate: poll the search API until _o2_id is queryable
|
||||
// instead of sleeping a fixed interval and hoping indexing has caught up.
|
||||
// This is what makes the single UI scan below succeed on the first attempt.
|
||||
// This is what makes the UI scan below succeed on the first attempt.
|
||||
testLogger.info('Waiting for _o2_id to become queryable via search API');
|
||||
// Respect the gate's result. On heavily contended CI runners indexing can
|
||||
// lag past the gate window; the gate returning false (ignored previously)
|
||||
// is exactly when the subsequent UI scan was doomed. Give the backend one
|
||||
// more long wait before falling through to the UI scan, so the UI step
|
||||
// isn't asked to find a field the backend hasn't surfaced yet.
|
||||
const o2idReady = await pageManager.unflattenedPage.waitForO2IdQueryable();
|
||||
if (o2idReady) {
|
||||
testLogger.info('_o2_id confirmed queryable via search API');
|
||||
} else {
|
||||
testLogger.warn('_o2_id readiness gate timed out; waiting once more before UI scan');
|
||||
const o2idReadyRetry = await pageManager.unflattenedPage.waitForO2IdQueryable({ timeout: 120000 });
|
||||
testLogger.info('_o2_id readiness gate second wait result', { ready: o2idReadyRetry });
|
||||
}
|
||||
const o2idReady = await waitForO2IdReady();
|
||||
|
||||
// Navigate directly with stream in URL — selectStream would deselect it because
|
||||
// the Pinia store already has e2e_automate selected from beforeEach
|
||||
testLogger.info('Navigating to logs with e2e_automate stream');
|
||||
await page.goto(`${process.env.ZO_BASE_URL}/web/logs?org_identifier=${getOrgIdentifier()}&stream_type=logs&stream=e2e_automate`);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.waitForTimeout(1000);
|
||||
await page.goto(
|
||||
`${process.env.ZO_BASE_URL}/web/logs?org_identifier=${getOrgIdentifier()}&stream_type=logs&stream=e2e_automate`,
|
||||
{ waitUntil: 'domcontentloaded', timeout: clampToBudget(45000, { min: 15000 }) }
|
||||
);
|
||||
await applyQueryButton(page);
|
||||
testLogger.info('Search query applied, logs should now contain _o2_id field');
|
||||
|
||||
testLogger.info('Searching log rows for _o2_id field (iterates first 15 rows per attempt)');
|
||||
let o2idFound = false;
|
||||
// The backend readiness gate above already guarantees _o2_id is queryable, so
|
||||
// a miss here is UI/streaming lag — recover with a FRESH log search (reload +
|
||||
// re-run) rather than re-ingesting. Re-clicking Run in place does not recover a
|
||||
// stale/partial streaming result set; a fresh navigation does (see runFreshLogSearch).
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
const matchedRow = await pageManager.unflattenedPage.findRowWithO2Id(15);
|
||||
if (matchedRow !== -1) {
|
||||
testLogger.info(`Found _o2_id in row ${matchedRow} (attempt ${attempt})`);
|
||||
await pageManager.unflattenedPage.o2IdText.click();
|
||||
o2idFound = true;
|
||||
break;
|
||||
}
|
||||
testLogger.warn(`_o2_id not found in first 15 rows on attempt ${attempt}, re-running query from a fresh page`);
|
||||
await pageManager.unflattenedPage.closeLogDetailDrawerIfOpen();
|
||||
if (attempt < 3) {
|
||||
await runFreshLogSearch(page);
|
||||
}
|
||||
}
|
||||
if (!o2idFound) {
|
||||
throw new Error('Failed to find _o2_id field in log details after 3 attempts');
|
||||
}
|
||||
testLogger.info('Searching log rows for _o2_id field');
|
||||
await scanForO2IdOrFail(page, { backendReady: o2idReady });
|
||||
|
||||
testLogger.info('Switching to unflattened tab');
|
||||
await pageManager.unflattenedPage.unflattenedTab.waitFor();
|
||||
@@ -286,26 +358,15 @@ test.describe("Unflattened testcases", () => {
|
||||
// instead of a fixed 15s sleep + UI re-ingestion retry loop. This was the
|
||||
// dominant source of the timeout flake on contended CI runners.
|
||||
testLogger.info('Waiting for _o2_id to become queryable via search API');
|
||||
// Respect the gate's result. On heavily contended CI runners indexing can
|
||||
// lag past the gate window; the gate returning false (ignored previously)
|
||||
// is exactly when the subsequent UI scan was doomed. Give the backend one
|
||||
// more long wait before falling through to the UI scan, so the UI step
|
||||
// isn't asked to find a field the backend hasn't surfaced yet.
|
||||
const o2idReady = await pageManager.unflattenedPage.waitForO2IdQueryable();
|
||||
if (o2idReady) {
|
||||
testLogger.info('_o2_id confirmed queryable via search API');
|
||||
} else {
|
||||
testLogger.warn('_o2_id readiness gate timed out; waiting once more before UI scan');
|
||||
const o2idReadyRetry = await pageManager.unflattenedPage.waitForO2IdQueryable({ timeout: 120000 });
|
||||
testLogger.info('_o2_id readiness gate second wait result', { ready: o2idReadyRetry });
|
||||
}
|
||||
const o2idReady = await waitForO2IdReady();
|
||||
|
||||
// Navigate directly with stream in URL — selectStream would deselect it because
|
||||
// the Pinia store already has e2e_automate selected from beforeEach
|
||||
testLogger.info('Navigating to logs with e2e_automate stream');
|
||||
await page.goto(`${process.env.ZO_BASE_URL}/web/logs?org_identifier=${getOrgIdentifier()}&stream_type=logs&stream=e2e_automate`);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.waitForTimeout(1000);
|
||||
await page.goto(
|
||||
`${process.env.ZO_BASE_URL}/web/logs?org_identifier=${getOrgIdentifier()}&stream_type=logs&stream=e2e_automate`,
|
||||
{ waitUntil: 'domcontentloaded', timeout: clampToBudget(45000, { min: 15000 }) }
|
||||
);
|
||||
|
||||
testLogger.info('Ensuring Quick Mode is on');
|
||||
await pageManager.logsPage.ensureQuickModeState(true);
|
||||
@@ -348,38 +409,12 @@ test.describe("Unflattened testcases", () => {
|
||||
testLogger.info('Executing SELECT * query to fetch fresh data with _o2_id');
|
||||
await applyQueryButton(page);
|
||||
|
||||
testLogger.info('Searching log rows for _o2_id field (iterates first 15 rows per attempt)');
|
||||
testLogger.info('Searching log rows for _o2_id field');
|
||||
// With ORDER BY _timestamp DESC the newest rows come first (the fixture carries
|
||||
// no _timestamp, so the Store-Original-Data re-ingest is genuinely newest). The
|
||||
// backend readiness gate above guarantees _o2_id is queryable, so a miss here is
|
||||
// UI/streaming lag — recover with a FRESH SELECT * search (reload + re-type +
|
||||
// re-run) rather than re-ingesting. Re-clicking Run in place does not recover a
|
||||
// stale/partial streaming result set; a fresh navigation does.
|
||||
let o2idFound = false;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
const matchedRow = await pageManager.unflattenedPage.findRowWithO2Id(15);
|
||||
if (matchedRow !== -1) {
|
||||
testLogger.info(`Found _o2_id in row ${matchedRow} (attempt ${attempt})`);
|
||||
await pageManager.unflattenedPage.o2IdText.click();
|
||||
o2idFound = true;
|
||||
break;
|
||||
}
|
||||
testLogger.warn(`_o2_id not found in first 15 rows on attempt ${attempt}, re-running query from a fresh page`);
|
||||
if (attempt === 3) {
|
||||
try {
|
||||
const allKeys = await pageManager.unflattenedPage.allLogDetailKeys.allTextContents();
|
||||
testLogger.error('Available fields in log detail', { fields: allKeys });
|
||||
} catch (e) {
|
||||
testLogger.error('Could not retrieve available fields');
|
||||
}
|
||||
break;
|
||||
}
|
||||
await pageManager.unflattenedPage.closeLogDetailDrawerIfOpen();
|
||||
await runFreshLogSearch(page, { sqlSelectAll: true });
|
||||
}
|
||||
if (!o2idFound) {
|
||||
throw new Error('Failed to find _o2_id field in log details after 3 attempts');
|
||||
}
|
||||
// no _timestamp, so the Store-Original-Data re-ingest is genuinely newest).
|
||||
// Larger reserve than the default: this test's tail does a third ingestion and
|
||||
// a full explorer walk after the cleanup toggle, so it needs more budget kept back.
|
||||
await scanForO2IdOrFail(page, { sqlSelectAll: true, backendReady: o2idReady, reserve: 90000 });
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
testLogger.info('Switching to unflattened tab');
|
||||
@@ -447,7 +482,9 @@ test.describe("Unflattened testcases", () => {
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
testLogger.info('Waiting for log detail panel to load');
|
||||
await pageManager.unflattenedPage.logDetailJsonContent.waitFor({ state: "visible", timeout: 10000 });
|
||||
// The drawer opens on the Table tab, so select JSON before waiting on the
|
||||
// JSON panel and its per-key rows (see openJsonDetailTab).
|
||||
await pageManager.unflattenedPage.openJsonDetailTab();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
testLogger.info('Looking for timestamp dropdown in log details');
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"totals": {
|
||||
"rawProjectRamp": 139,
|
||||
"rawVarInComponent": 585,
|
||||
"arbZ": 35,
|
||||
"arbZ": 34,
|
||||
"darkMechanism": 20,
|
||||
"unscopedStyle": 7,
|
||||
"stylePxUnit": 90
|
||||
@@ -382,9 +382,6 @@
|
||||
"lib/navigation/Tabs/OTab.vue": {
|
||||
"rawProjectRamp": 1
|
||||
},
|
||||
"lib/overlay/Dropdown/ODropdown.vue": {
|
||||
"arbZ": 1
|
||||
},
|
||||
"lib/overlay/Tooltip/OTooltip.vue": {
|
||||
"arbZ": 1
|
||||
},
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { h, nextTick } from "vue";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import OContextMenu from "./OContextMenu.vue";
|
||||
import OContextMenuItem from "./OContextMenuItem.vue";
|
||||
|
||||
// Reka UI portals content into <body>. Disable portal for unit tests.
|
||||
vi.mock("reka-ui", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("reka-ui")>();
|
||||
return {
|
||||
...actual,
|
||||
ContextMenuPortal: actual.ContextMenuContent, // render inline
|
||||
};
|
||||
});
|
||||
|
||||
function mountMenu(props: Record<string, unknown> = {}) {
|
||||
return mount(OContextMenu, {
|
||||
props,
|
||||
slots: {
|
||||
trigger: "<div data-testid='region'>Right click me</div>",
|
||||
default: {
|
||||
render: () => h(OContextMenuItem, {}, { default: () => "Copy" }),
|
||||
},
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
}
|
||||
|
||||
// ContextMenuTrigger awaits nextTick before opening, so callers need two ticks.
|
||||
async function rightClick(wrapper: ReturnType<typeof mountMenu>) {
|
||||
await wrapper.find('[data-testid="region"]').trigger("contextmenu");
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
describe("OContextMenu", () => {
|
||||
it("renders the trigger slot as-child without a wrapper element", () => {
|
||||
const wrapper = mountMenu();
|
||||
const region = wrapper.find('[data-testid="region"]');
|
||||
expect(region.exists()).toBe(true);
|
||||
// as-child means the slot root IS the trigger — it carries the state attr
|
||||
expect(region.attributes("data-state")).toBe("closed");
|
||||
});
|
||||
|
||||
it("stays closed until a contextmenu event fires", () => {
|
||||
const wrapper = mountMenu();
|
||||
expect(wrapper.text()).not.toContain("Copy");
|
||||
});
|
||||
|
||||
it("opens on right-click and renders menu content", async () => {
|
||||
const wrapper = mountMenu();
|
||||
await rightClick(wrapper);
|
||||
expect(wrapper.emitted("update:open")?.[0]).toEqual([true]);
|
||||
});
|
||||
|
||||
it("does not open when disabled", async () => {
|
||||
const wrapper = mountMenu({ disabled: true });
|
||||
await rightClick(wrapper);
|
||||
expect(wrapper.emitted("update:open")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits update:open(false) when Escape is pressed", async () => {
|
||||
const wrapper = mountMenu();
|
||||
await rightClick(wrapper);
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
||||
);
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
const events = wrapper.emitted("update:open") ?? [];
|
||||
expect(events.at(-1)).toEqual([false]);
|
||||
});
|
||||
|
||||
it("closes when an ancestor scrolls, since the menu is pointer-anchored", async () => {
|
||||
const wrapper = mountMenu();
|
||||
await rightClick(wrapper);
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
const events = wrapper.emitted("update:open") ?? [];
|
||||
expect(events.at(-1)).toEqual([false]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* OContextMenu.types.ts — public types for OContextMenu.
|
||||
*
|
||||
* OContextMenu is the right-click (and long-press on touch) counterpart to
|
||||
* ODropdown. Positioning is anchored to the pointer, not to the trigger
|
||||
* element, so the trigger can be an arbitrarily large region (a whole table,
|
||||
* a canvas, a list) and the menu still opens exactly where the user clicked.
|
||||
*/
|
||||
|
||||
import type { ContextMenuRootProps } from "reka-ui";
|
||||
|
||||
// Re-export family types so callers can import everything from one place if needed
|
||||
export type {
|
||||
ContextMenuItemVariant,
|
||||
ContextMenuItemProps,
|
||||
ContextMenuItemEmits,
|
||||
ContextMenuItemSlots,
|
||||
} from "./OContextMenuItem.types";
|
||||
export type { ContextMenuLabelSlots } from "./OContextMenuLabel.types";
|
||||
export type { ContextMenuSeparatorSlots } from "./OContextMenuSeparator.types";
|
||||
|
||||
export interface ContextMenuProps {
|
||||
/**
|
||||
* Disables the menu without unmounting it — right-click falls through to the
|
||||
* browser's native context menu. Use for read-only or permission-gated views.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/** Whether the menu blocks interaction with the rest of the page (default: false) */
|
||||
modal?: ContextMenuRootProps["modal"];
|
||||
/** Extra class(es) for the content (menu) element */
|
||||
contentClass?: string;
|
||||
}
|
||||
|
||||
export interface ContextMenuEmits {
|
||||
(e: "update:open", value: boolean): void;
|
||||
}
|
||||
|
||||
export interface ContextMenuSlots {
|
||||
/**
|
||||
* The region that responds to right-click — rendered as-child, so it keeps
|
||||
* its own tag and attributes (no wrapper element is introduced).
|
||||
*/
|
||||
trigger?: () => unknown;
|
||||
/** OContextMenuItem / OContextMenuLabel / OContextMenuSeparator children */
|
||||
default?: () => unknown;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ContextMenuProps,
|
||||
ContextMenuEmits,
|
||||
ContextMenuSlots,
|
||||
} from "./OContextMenu.types";
|
||||
import {
|
||||
ContextMenuRoot,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuPortal,
|
||||
ContextMenuContent,
|
||||
} from "reka-ui";
|
||||
import { onBeforeUnmount, ref, watch } from "vue";
|
||||
import {
|
||||
setActiveOverlay,
|
||||
clearActiveOverlay,
|
||||
} from "../Dropdown/ODropdown.context";
|
||||
|
||||
const props = withDefaults(defineProps<ContextMenuProps>(), {
|
||||
disabled: false,
|
||||
modal: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<ContextMenuEmits>();
|
||||
|
||||
defineSlots<ContextMenuSlots>();
|
||||
|
||||
// ContextMenuRoot owns `open` internally (it must, since the pointer position
|
||||
// is captured by the trigger at right-click time and there is no way to supply
|
||||
// it from outside). We mirror the state so we can participate in the shared
|
||||
// single-active-overlay coordination and close on scroll.
|
||||
const isOpen = ref(false);
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
isOpen.value = open;
|
||||
emit("update:open", open);
|
||||
}
|
||||
|
||||
// ContextMenuRoot owns `open` with no imperative close and no `open` prop, so
|
||||
// the only way to dismiss it from outside is the path reka-ui already listens
|
||||
// on: an Escape keystroke on `window` (DismissableLayer binds it there).
|
||||
function closeSelf() {
|
||||
if (!isOpen.value || typeof window === "undefined") return;
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
||||
);
|
||||
}
|
||||
|
||||
// Opening a context menu must dismiss any dropdown/select left open, and vice
|
||||
// versa — otherwise two unrelated overlays float at once.
|
||||
watch(isOpen, (open) => {
|
||||
if (open) setActiveOverlay(closeSelf);
|
||||
else clearActiveOverlay(closeSelf);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => clearActiveOverlay(closeSelf));
|
||||
|
||||
// The menu is anchored to a fixed pointer coordinate, so any scroll leaves it
|
||||
// stranded away from the content it describes. Close instead. Scrolls that
|
||||
// originate inside the menu itself (a long, scrollable menu) are ignored.
|
||||
function handleViewportScroll(event: Event) {
|
||||
if (!isOpen.value) return;
|
||||
const target = event.target;
|
||||
if (target instanceof Element && target.closest?.('[role="menu"]')) return;
|
||||
closeSelf();
|
||||
}
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (open) {
|
||||
window.addEventListener("scroll", handleViewportScroll, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
});
|
||||
} else {
|
||||
window.removeEventListener("scroll", handleViewportScroll, true);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("scroll", handleViewportScroll, true);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuRoot :modal="modal" @update:open="handleOpenChange">
|
||||
<ContextMenuTrigger :disabled="disabled" as-child>
|
||||
<slot name="trigger" />
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuPortal>
|
||||
<ContextMenuContent
|
||||
:hide-when-detached="true"
|
||||
:collision-padding="8"
|
||||
:class="[
|
||||
// Layout + stacking — z-menu is the shared overlay layer (tokens/base.css)
|
||||
'min-w-40 p-1 z-menu',
|
||||
// Surface — a context menu is visually the same object as a dropdown
|
||||
// menu, so it deliberately shares the dropdown token set rather than
|
||||
// duplicating one that would have to be kept in sync.
|
||||
'bg-dropdown-bg border border-dropdown-border rounded-default shadow-md',
|
||||
// Typography
|
||||
'text-sm text-dropdown-item-text',
|
||||
// Animation — same clip-path reveal as ODropdown, wiping away from
|
||||
// the pointer. Soft ease-out-expo in, quick wipe out.
|
||||
'data-[state=open]:animate-[o2-reveal-down-in_140ms_cubic-bezier(0.16,1,0.3,1)]',
|
||||
'data-[state=closed]:animate-[o2-reveal-down-out_100ms_cubic-bezier(0.4,0,1,1)]',
|
||||
'data-[side=top]:data-[state=open]:animate-[o2-reveal-up-in_140ms_cubic-bezier(0.16,1,0.3,1)]',
|
||||
'data-[side=top]:data-[state=closed]:animate-[o2-reveal-up-out_100ms_cubic-bezier(0.4,0,1,1)]',
|
||||
props.contentClass,
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuContent>
|
||||
</ContextMenuPortal>
|
||||
</ContextMenuRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { h, nextTick } from "vue";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import OContextMenu from "./OContextMenu.vue";
|
||||
import OContextMenuItem from "./OContextMenuItem.vue";
|
||||
|
||||
// Reka UI portals content into <body>. Disable portal for unit tests.
|
||||
vi.mock("reka-ui", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("reka-ui")>();
|
||||
return {
|
||||
...actual,
|
||||
ContextMenuPortal: actual.ContextMenuContent, // render inline
|
||||
};
|
||||
});
|
||||
|
||||
// OContextMenuItem requires a MenuRoot context — mount it inside an open menu.
|
||||
async function mountItemInMenu(
|
||||
itemProps: Record<string, unknown> = {},
|
||||
itemSlots: Record<string, () => ReturnType<typeof h>> = {},
|
||||
) {
|
||||
const wrapper = mount(OContextMenu, {
|
||||
slots: {
|
||||
trigger: "<div data-testid='region'>Right click me</div>",
|
||||
default: { render: () => h(OContextMenuItem, itemProps, itemSlots) },
|
||||
},
|
||||
attachTo: document.body,
|
||||
});
|
||||
await wrapper.find('[data-testid="region"]').trigger("contextmenu");
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe("OContextMenuItem", () => {
|
||||
it("renders slot content", async () => {
|
||||
const wrapper = await mountItemInMenu(
|
||||
{},
|
||||
{ default: () => h("span", "Copy value") },
|
||||
);
|
||||
expect(wrapper.text()).toContain("Copy value");
|
||||
});
|
||||
|
||||
it("renders icon-left slot", async () => {
|
||||
const wrapper = await mountItemInMenu(
|
||||
{},
|
||||
{ "icon-left": () => h("span", { "data-testid": "icon-left" }, "←") },
|
||||
);
|
||||
expect(wrapper.find('[data-testid="icon-left"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("renders icon-right slot", async () => {
|
||||
const wrapper = await mountItemInMenu(
|
||||
{},
|
||||
{ "icon-right": () => h("span", { "data-testid": "icon-right" }, "→") },
|
||||
);
|
||||
expect(wrapper.find('[data-testid="icon-right"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("sets data-disabled when disabled=true", async () => {
|
||||
const wrapper = await mountItemInMenu({ disabled: true });
|
||||
expect(wrapper.find("[data-disabled]").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("applies default variant classes", async () => {
|
||||
const wrapper = await mountItemInMenu(
|
||||
{},
|
||||
{ default: () => h("span", "Action") },
|
||||
);
|
||||
expect(wrapper.find(".text-dropdown-item-text").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("applies destructive variant classes", async () => {
|
||||
const wrapper = await mountItemInMenu(
|
||||
{ variant: "destructive" },
|
||||
{ default: () => h("span", "Delete") },
|
||||
);
|
||||
expect(wrapper.find(".text-dropdown-item-destructive-text").exists()).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits select when clicked", async () => {
|
||||
const wrapper = await mountItemInMenu(
|
||||
{},
|
||||
{ default: () => h("span", "Copy") },
|
||||
);
|
||||
const item = wrapper.findComponent(OContextMenuItem);
|
||||
await item.trigger("click");
|
||||
expect(item.emitted("select")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not emit select when disabled", async () => {
|
||||
const wrapper = await mountItemInMenu(
|
||||
{ disabled: true },
|
||||
{ default: () => h("span", "Copy") },
|
||||
);
|
||||
const item = wrapper.findComponent(OContextMenuItem);
|
||||
await item.trigger("click");
|
||||
expect(item.emitted("select")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* OContextMenuItem.types.ts — public types for OContextMenuItem.
|
||||
*/
|
||||
|
||||
import type { IconName } from "../../core/Icon/OIcon.icons";
|
||||
|
||||
/** Visual intent of the item */
|
||||
export type ContextMenuItemVariant = "default" | "destructive";
|
||||
|
||||
export interface ContextMenuItemProps {
|
||||
/** Visual intent — use destructive for Delete/Remove actions */
|
||||
variant?: ContextMenuItemVariant;
|
||||
/** Prevents the user from interacting with this item */
|
||||
disabled?: boolean;
|
||||
/** Text used for typeahead matching (overrides text content) */
|
||||
textValue?: string;
|
||||
/** Icon placed before the label — renders an OIcon internally */
|
||||
iconLeft?: IconName;
|
||||
/**
|
||||
* Renders a keyboard-shortcut hint (OShortcut keycaps) at the trailing edge
|
||||
* of the item. Accepts a pre-symbolised string (`"⌘I"`) or an array of
|
||||
* keycaps (`["⌘", "I"]`). Omit to show no hint.
|
||||
*/
|
||||
shortcut?: string | string[];
|
||||
/**
|
||||
* Registry shortcut id — resolves the hint keys from `shortcutRegistry.ts`
|
||||
* (the single source of truth) so a key change there updates this item too.
|
||||
* Prefer this over `shortcut`; `shortcut` wins if both are set.
|
||||
*/
|
||||
shortcutId?: string;
|
||||
}
|
||||
|
||||
export interface ContextMenuItemEmits {
|
||||
(e: "select", event: Event): void;
|
||||
}
|
||||
|
||||
export interface ContextMenuItemSlots {
|
||||
/** Icon placed before the label */
|
||||
"icon-left"?: () => unknown;
|
||||
/** Label text / content */
|
||||
default?: () => unknown;
|
||||
/** Icon placed after the label */
|
||||
"icon-right"?: () => unknown;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ContextMenuItemProps,
|
||||
ContextMenuItemEmits,
|
||||
ContextMenuItemSlots,
|
||||
} from "./OContextMenuItem.types";
|
||||
import { ContextMenuItem } from "reka-ui";
|
||||
import OIcon from "../../core/Icon/OIcon.vue";
|
||||
import OShortcut from "../../core/Shortcut/OShortcut.vue";
|
||||
|
||||
const props = withDefaults(defineProps<ContextMenuItemProps>(), {
|
||||
disabled: false,
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
const emit = defineEmits<ContextMenuItemEmits>();
|
||||
|
||||
defineSlots<ContextMenuItemSlots>();
|
||||
|
||||
const variantClasses: Record<
|
||||
NonNullable<ContextMenuItemProps["variant"]>,
|
||||
string
|
||||
> = {
|
||||
default:
|
||||
"text-dropdown-item-text data-[highlighted]:bg-dropdown-item-hover-bg",
|
||||
destructive:
|
||||
"text-dropdown-item-destructive-text data-[highlighted]:bg-dropdown-item-destructive-hover-bg",
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuItem
|
||||
:disabled="disabled"
|
||||
:text-value="textValue"
|
||||
:class="[
|
||||
'relative flex items-center gap-2',
|
||||
'w-full px-3 py-1.5 rounded-default',
|
||||
'cursor-pointer select-none outline-none',
|
||||
'transition-colors duration-150',
|
||||
variantClasses[variant],
|
||||
'data-[disabled]:text-dropdown-item-disabled data-[disabled]:cursor-not-allowed',
|
||||
]"
|
||||
@select="(e: Event) => emit('select', e)"
|
||||
>
|
||||
<slot name="icon-left">
|
||||
<OIcon v-if="props.iconLeft" :name="props.iconLeft" size="sm" />
|
||||
</slot>
|
||||
<slot />
|
||||
<slot name="icon-right" />
|
||||
<OShortcut
|
||||
v-if="props.shortcut || props.shortcutId"
|
||||
:keys="props.shortcut"
|
||||
:id="props.shortcutId"
|
||||
class="ms-auto ps-4"
|
||||
/>
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import OContextMenuLabel from "./OContextMenuLabel.vue";
|
||||
|
||||
describe("OContextMenuLabel", () => {
|
||||
it("renders slot content", () => {
|
||||
const wrapper = mount(OContextMenuLabel, {
|
||||
slots: { default: "kubernetes_namespace_name" },
|
||||
});
|
||||
expect(wrapper.text()).toContain("kubernetes_namespace_name");
|
||||
});
|
||||
|
||||
it("applies label token class", () => {
|
||||
const wrapper = mount(OContextMenuLabel);
|
||||
expect(wrapper.classes().join(" ")).toContain("text-dropdown-label");
|
||||
});
|
||||
|
||||
it("truncates long labels rather than widening the menu", () => {
|
||||
const wrapper = mount(OContextMenuLabel);
|
||||
expect(wrapper.classes().join(" ")).toContain("truncate");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* OContextMenuLabel.types.ts — public types for OContextMenuLabel.
|
||||
*/
|
||||
|
||||
export interface ContextMenuLabelSlots {
|
||||
/** Label content — a non-interactive heading for the group beneath it */
|
||||
default?: () => unknown;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContextMenuLabelSlots } from "./OContextMenuLabel.types";
|
||||
import { ContextMenuLabel } from "reka-ui";
|
||||
|
||||
defineSlots<ContextMenuLabelSlots>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuLabel
|
||||
class="px-3 py-1.5 text-xs font-medium text-dropdown-label select-none truncate"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuLabel>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import OContextMenuSeparator from "./OContextMenuSeparator.vue";
|
||||
|
||||
describe("OContextMenuSeparator", () => {
|
||||
it("renders a separator element", () => {
|
||||
const wrapper = mount(OContextMenuSeparator);
|
||||
expect(wrapper.element).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies separator bg class", () => {
|
||||
const wrapper = mount(OContextMenuSeparator);
|
||||
expect(wrapper.classes().join(" ")).toContain("bg-dropdown-separator");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* OContextMenuSeparator.types.ts — public types for OContextMenuSeparator.
|
||||
*/
|
||||
|
||||
export interface ContextMenuSeparatorSlots {
|
||||
/** Separators render no content; the slot exists for API symmetry only. */
|
||||
default?: () => unknown;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ContextMenuSeparator } from "reka-ui";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuSeparator class="my-1 h-px bg-dropdown-separator" />
|
||||
</template>
|
||||
@@ -282,8 +282,8 @@ onBeforeUnmount(() => {
|
||||
@pointer-down-outside="handlePointerDownOutside"
|
||||
@focus-outside="handleFocusOutside"
|
||||
:class="[
|
||||
// Layout + stacking (must be above the app header/drawer: 2000/3000)
|
||||
'min-w-40 p-1 z-[6000]',
|
||||
// Layout + stacking — z-menu is the shared overlay layer (tokens/base.css)
|
||||
'min-w-40 p-1 z-menu',
|
||||
// Surface
|
||||
'bg-dropdown-bg border border-dropdown-border rounded-default shadow-md',
|
||||
// Typography
|
||||
|
||||
@@ -296,6 +296,18 @@
|
||||
own knob so it holds independent of --radius-default. */
|
||||
--radius-checkbox: 0.25rem; /* 4px — checkbox tick box only */
|
||||
|
||||
/* --- Stacking scale ---
|
||||
Portaled floating surfaces need a shared knob, not a number typed at each
|
||||
call site. The app stack, for context: header 2000, drawer/dialog scrim
|
||||
5999, drawer/dialog content 6000 (+1000 per nesting level, computed in
|
||||
ODialog/ODrawer).
|
||||
--z-menu sits at the base overlay layer: a menu opened from the page must
|
||||
clear the header, and one opened from a dialog is portaled after that
|
||||
dialog's content, so equal z + later DOM order puts it on top.
|
||||
Consumed as the `z-menu` utility (registered in styles/tailwind.css) —
|
||||
never as an arbitrary z-index at the call site. */
|
||||
--z-menu: 6000; /* dropdown / context menu surfaces */
|
||||
|
||||
/* --- Shadow scale — hairline-border philosophy ---
|
||||
Only md/lg render real elevation (overlays: dialogs, menus). Cards use a
|
||||
1px border, not a drop shadow — a flat surface needs NO shadow class. */
|
||||
|
||||
@@ -8981,7 +8981,9 @@
|
||||
"cellActions": {
|
||||
"copy": "Copy",
|
||||
"includeTerm": "Include Term",
|
||||
"excludeTerm": "Exclude Term"
|
||||
"excludeTerm": "Exclude Term",
|
||||
"sendValueToAi": "Send Value to AI",
|
||||
"sendRowToAi": "Send Row to AI"
|
||||
},
|
||||
"indexList": {
|
||||
"noMatchingFields": "No matching fields found",
|
||||
|
||||
@@ -237,7 +237,7 @@ describe("DetailTable Component", () => {
|
||||
});
|
||||
|
||||
it("should initialize reactive data correctly", () => {
|
||||
expect(wrapper.vm.tab).toBe("json");
|
||||
expect(wrapper.vm.tab).toBe("table");
|
||||
expect(wrapper.vm.selectedRelativeValue).toBe(10);
|
||||
expect(wrapper.vm.shouldWrapValues).toBe(true);
|
||||
expect(wrapper.vm.recordSizeOptions).toEqual([
|
||||
@@ -307,10 +307,10 @@ describe("DetailTable Component", () => {
|
||||
});
|
||||
|
||||
// Test 11-15: Tab Functionality
|
||||
it("should start with JSON tab active", () => {
|
||||
expect(wrapper.vm.tab).toBe("json");
|
||||
const jsonTab = wrapper.find('[data-test="log-detail-json-tab"]');
|
||||
expect(jsonTab.exists()).toBe(true);
|
||||
it("should start with Table tab active", () => {
|
||||
expect(wrapper.vm.tab).toBe("table");
|
||||
const tableTab = wrapper.find('[data-test="log-detail-table-tab"]');
|
||||
expect(tableTab.exists()).toBe(true);
|
||||
// OTab uses Tailwind classes for active state
|
||||
});
|
||||
|
||||
@@ -442,15 +442,22 @@ describe("DetailTable Component", () => {
|
||||
expect(wrapper.vm.shouldWrapValues).toBe(!initialValue);
|
||||
});
|
||||
|
||||
// Asserts the handler's observable effect rather than spying on the instance
|
||||
// method: the template captures the handler into the vnode at render time, so
|
||||
// a spy installed on wrapper.vm afterwards is only picked up if something
|
||||
// forces a re-render — which made the old spy-based version silently depend
|
||||
// on `tab` actually changing here.
|
||||
it("should call toggleWrapLogDetails when wrap toggle changed", async () => {
|
||||
const spy = vi.spyOn(wrapper.vm, "toggleWrapLogDetails");
|
||||
wrapper.vm.tab = "table";
|
||||
await nextTick();
|
||||
|
||||
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
|
||||
const initialValue = wrapper.vm.shouldWrapValues;
|
||||
|
||||
const wrapToggle = wrapper.find('[data-test="log-detail-wrap-values-toggle-btn"]');
|
||||
await wrapToggle.trigger("click");
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
|
||||
expect(setItemSpy).toHaveBeenCalledWith(
|
||||
"wrap-log-details",
|
||||
String(!initialValue),
|
||||
);
|
||||
});
|
||||
|
||||
it("should update localStorage when toggleWrapLogDetails called", () => {
|
||||
|
||||
@@ -22,35 +22,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<!-- Single Tab Row -->
|
||||
<div class="flex justify-between items-center shrink-0">
|
||||
<div class="flex items-center gap-2 -mb-0.75">
|
||||
<OTabs v-model="tab" align="left">
|
||||
<OTabs
|
||||
v-model="tab"
|
||||
align="left"
|
||||
reorderable
|
||||
@reorder="onTabReorder"
|
||||
>
|
||||
<!-- Correlation tabs are only present when service streams are enabled
|
||||
and an enterprise license is active; see availableTabs. -->
|
||||
<OTab
|
||||
data-test="log-detail-json-tab"
|
||||
name="json"
|
||||
:label="t('common.json')"
|
||||
/>
|
||||
<OTab
|
||||
data-test="log-detail-table-tab"
|
||||
name="table"
|
||||
:label="t('common.table')"
|
||||
/>
|
||||
<!-- Correlation Tabs (only visible when service streams enabled and enterprise license) -->
|
||||
<OTab
|
||||
data-test="correlated-logs-tab"
|
||||
v-if="serviceStreamsEnabled && config.isEnterprise === 'true'"
|
||||
name="correlated-logs"
|
||||
:label="t('correlation.correlatedLogs')"
|
||||
/>
|
||||
<OTab
|
||||
data-test="correlated-metrics-tab"
|
||||
v-if="serviceStreamsEnabled && config.isEnterprise === 'true'"
|
||||
name="correlated-metrics"
|
||||
:label="t('correlation.correlatedMetrics')"
|
||||
/>
|
||||
<OTab
|
||||
data-test="correlated-traces-tab"
|
||||
v-if="serviceStreamsEnabled && config.isEnterprise === 'true'"
|
||||
name="correlated-traces"
|
||||
:label="t('correlation.correlatedTraces')"
|
||||
v-for="tabItem in tabOrder"
|
||||
:key="tabItem.name"
|
||||
:data-test="tabItem.dataTest"
|
||||
:name="tabItem.name"
|
||||
:label="tabItem.label"
|
||||
/>
|
||||
</OTabs>
|
||||
</div>
|
||||
@@ -318,7 +303,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:fts-fields="correlationProps.ftsFields"
|
||||
:time-range="correlationProps.timeRange"
|
||||
:hide-dimension-filters="true"
|
||||
@close="tab = 'json'"
|
||||
@close="tab = 'table'"
|
||||
/>
|
||||
<!-- Loading/Empty state when no data -->
|
||||
<div v-else class="flex items-center justify-center h-full py-20">
|
||||
@@ -351,7 +336,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:fts-fields="correlationProps.ftsFields"
|
||||
:time-range="correlationProps.timeRange"
|
||||
:hide-dimension-filters="true"
|
||||
@close="tab = 'json'"
|
||||
@close="tab = 'table'"
|
||||
/>
|
||||
<!-- Loading/Empty state when no data -->
|
||||
<div v-else class="flex items-center justify-center h-full py-20">
|
||||
@@ -513,7 +498,7 @@ export default defineComponent({
|
||||
},
|
||||
initialTab: {
|
||||
type: String,
|
||||
default: "json",
|
||||
default: "table",
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
@@ -546,7 +531,7 @@ export default defineComponent({
|
||||
const store = useStore();
|
||||
const { isDark } = useTheme();
|
||||
const tableDropdownOpenMap = reactive<Record<string, boolean>>({});
|
||||
const tab = ref(props.initialTab || "json");
|
||||
const tab = ref(props.initialTab || "table");
|
||||
const selectedRelativeValue = ref<number>(10);
|
||||
const recordSizeOptions = ref<Array<{ label: string; value: number }>>([
|
||||
{ label: "10", value: 10 },
|
||||
@@ -659,6 +644,118 @@ export default defineComponent({
|
||||
return store.state.zoConfig.service_streams_enabled !== false;
|
||||
});
|
||||
|
||||
// Tab bar is drag-to-reorder (same pattern as the home page): the list of
|
||||
// tabs that *may* be shown is derived from config, while the user's chosen
|
||||
// order lives in localStorage.
|
||||
const LS_TAB_ORDER_KEY = "o2_log_detail_tab_order";
|
||||
|
||||
const availableTabs = computed(() => {
|
||||
const tabs = [
|
||||
{
|
||||
name: "table",
|
||||
label: t("common.table"),
|
||||
dataTest: "log-detail-table-tab",
|
||||
},
|
||||
{
|
||||
name: "json",
|
||||
label: t("common.json"),
|
||||
dataTest: "log-detail-json-tab",
|
||||
},
|
||||
];
|
||||
if (serviceStreamsEnabled.value && config.isEnterprise === "true") {
|
||||
tabs.push(
|
||||
{
|
||||
name: "correlated-logs",
|
||||
label: t("correlation.correlatedLogs"),
|
||||
dataTest: "correlated-logs-tab",
|
||||
},
|
||||
{
|
||||
name: "correlated-metrics",
|
||||
label: t("correlation.correlatedMetrics"),
|
||||
dataTest: "correlated-metrics-tab",
|
||||
},
|
||||
{
|
||||
name: "correlated-traces",
|
||||
label: t("correlation.correlatedTraces"),
|
||||
dataTest: "correlated-traces-tab",
|
||||
},
|
||||
);
|
||||
}
|
||||
return tabs;
|
||||
});
|
||||
|
||||
type DetailTab = { name: string; label: string; dataTest: string };
|
||||
|
||||
const loadTabOrder = (): DetailTab[] => {
|
||||
try {
|
||||
const saved = window.localStorage.getItem(LS_TAB_ORDER_KEY);
|
||||
if (saved) {
|
||||
const names: string[] = JSON.parse(saved);
|
||||
const ordered = names
|
||||
.map((name) => availableTabs.value.find((t) => t.name === name))
|
||||
.filter(Boolean) as DetailTab[];
|
||||
// Append any tab that is available but missing from the saved order.
|
||||
availableTabs.value.forEach((t) => {
|
||||
if (!ordered.find((o) => o.name === t.name)) ordered.push(t);
|
||||
});
|
||||
return ordered;
|
||||
}
|
||||
} catch {
|
||||
/* ignore: fall back to the default tab order */
|
||||
}
|
||||
return [...availableTabs.value];
|
||||
};
|
||||
|
||||
const tabOrder = ref<DetailTab[]>(loadTabOrder());
|
||||
|
||||
// Enterprise/service-stream config arrives asynchronously, so re-sync the
|
||||
// order whenever the set of available tabs changes — keeping the user's
|
||||
// order and appending anything new.
|
||||
watch(
|
||||
() => availableTabs.value.map((t) => t.name).join(","),
|
||||
() => {
|
||||
const merged: DetailTab[] = [];
|
||||
tabOrder.value.forEach((t) => {
|
||||
const match = availableTabs.value.find((a) => a.name === t.name);
|
||||
if (match) merged.push(match);
|
||||
});
|
||||
availableTabs.value.forEach((a) => {
|
||||
if (!merged.find((m) => m.name === a.name)) merged.push(a);
|
||||
});
|
||||
tabOrder.value = merged;
|
||||
},
|
||||
);
|
||||
|
||||
// OTabs reports the intended move (dragged name → target name + which side
|
||||
// of the target); we apply it to our own list and persist it.
|
||||
const onTabReorder = ({
|
||||
from,
|
||||
to,
|
||||
before = true,
|
||||
}: {
|
||||
from: string | number;
|
||||
to: string | number;
|
||||
before?: boolean;
|
||||
}) => {
|
||||
if (from === to) return;
|
||||
const order = [...tabOrder.value];
|
||||
const fromIdx = order.findIndex((t) => t.name === from);
|
||||
if (fromIdx === -1) return;
|
||||
|
||||
const [moved] = order.splice(fromIdx, 1);
|
||||
// Recompute the target index after removal, then insert on the chosen side.
|
||||
let toIdx = order.findIndex((t) => t.name === to);
|
||||
if (toIdx === -1) return;
|
||||
if (!before) toIdx += 1;
|
||||
order.splice(toIdx, 0, moved);
|
||||
|
||||
tabOrder.value = order;
|
||||
window.localStorage.setItem(
|
||||
LS_TAB_ORDER_KEY,
|
||||
JSON.stringify(order.map((t) => t.name)),
|
||||
);
|
||||
};
|
||||
|
||||
onBeforeMount(() => {
|
||||
if (window.localStorage.getItem("wrap-log-details") === null) {
|
||||
window.localStorage.setItem("wrap-log-details", "true");
|
||||
@@ -897,6 +994,8 @@ export default defineComponent({
|
||||
tableColumns,
|
||||
tableRows,
|
||||
serviceStreamsEnabled,
|
||||
tabOrder,
|
||||
onTabReorder,
|
||||
config,
|
||||
getContentSize,
|
||||
getDisplayValue,
|
||||
|
||||
@@ -1145,7 +1145,7 @@ export default defineComponent({
|
||||
const correlationDashboardProps = ref<any>(null);
|
||||
const correlationLoading = ref(false);
|
||||
const correlationError = ref<string | null>(null);
|
||||
const detailTableInitialTab = ref<string>("json");
|
||||
const detailTableInitialTab = ref<string>("table");
|
||||
const { findRelatedTelemetry, semanticGroups } = useServiceCorrelation();
|
||||
|
||||
// Flag to prevent duplicate correlation API calls
|
||||
@@ -1441,7 +1441,7 @@ export default defineComponent({
|
||||
const openLogDetails = (props: any, index: number) => {
|
||||
searchObj.meta.showDetailTab = true;
|
||||
searchObj.meta.resultGrid.navigation.currentRowIndex = index;
|
||||
detailTableInitialTab.value = "json"; // Reset to default tab
|
||||
detailTableInitialTab.value = "table"; // Reset to default tab
|
||||
|
||||
// Prepare correlation context (but don't open panel automatically)
|
||||
const logData = searchObj.data.queryResults?.hits?.[index];
|
||||
|
||||
@@ -99,8 +99,6 @@ vi.mock("@/utils/logs/statusParser", () => ({
|
||||
// ── Stubs ──────────────────────────────────────────────────────────────────────
|
||||
const globalStubs = {
|
||||
JsonPreview: { template: "<div />" },
|
||||
CellActions: { template: "<div />" },
|
||||
O2AIContextAddBtn: { template: "<div />" },
|
||||
FlexRender: { template: "<span />" },
|
||||
};
|
||||
|
||||
@@ -147,9 +145,9 @@ describe("TenstackTable", () => {
|
||||
expect(wrapper.vm.isFunctionErrorOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("initializes activeCellActionId as empty string", () => {
|
||||
it("initializes contextCell as null", () => {
|
||||
const wrapper = mountComponent();
|
||||
expect(wrapper.vm.activeCellActionId).toBe("");
|
||||
expect(wrapper.vm.contextCell).toBeNull();
|
||||
});
|
||||
|
||||
it("initializes expandedRowIndices as empty Set", () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,14 @@
|
||||
|
||||
@source "../**/*.{vue,js,ts}";
|
||||
|
||||
/* Stacking utility for portaled floating surfaces. Tailwind v4 has no z-index
|
||||
theme namespace (`z-*` only takes bare numbers or arbitrary values), so the
|
||||
token is exposed as a real utility here rather than left as an arbitrary
|
||||
z-index at each call site. Value lives in tokens/base.css — change it there. */
|
||||
@utility z-menu {
|
||||
z-index: var(--z-menu);
|
||||
}
|
||||
|
||||
/*
|
||||
* Light↔dark mode switch — soft curtain sweep (View Transitions API, started
|
||||
* by switchThemeMode in utils/theme.ts). The outgoing theme holds fully opaque
|
||||
|
||||
Reference in New Issue
Block a user