Files
openobserve/web/src/components/common/FieldValuesPanel.spec.ts
T
Omkar Kesarkhane 72c028b244 fix: Field list field-expansion fixes (#12731)
## What changed

- **FieldValuesPanel.vue** — Added `tw:relative` to the loading wrapper
so the `OInnerLoading` overlay (which is `absolute inset-0`) is
contained within the expanded section instead of spilling over the
entire field row.
- **IndexList.vue** — Reworked `cancelTraceId` to use
`cancelStreamQueryBasedOnRequestId` (HTTP stream abort) instead of the
now-removed `cancelSearchQueryBasedOnRequestId`. Updated
`cancelFilterCreator` to call `cancelTraceId`, clear `isLoading`, and
empty `traceIdMapper` entries.
- **FieldValuesPanel.spec.ts** — Regression test asserting the loading
wrapper carries `tw:relative`.
- **IndexList.spec.ts** — Un-skipped two previously skipped cancel tests
and updated them to assert on `mockCancelStreamQueryBasedOnRequestId`.
Added new regression tests for the `cancelFilterCreator` →
`cancelTraceId` flow on collapse.

## Why

The loading overlay (`OInnerLoading`) uses `absolute inset-0`
positioning. Without a positioned ancestor, it escaped to the nearest
parent (`.o-field-list__row`), covering the field row and the collapse
toggle. This made it impossible for users to collapse a slow-loading
field to cancel the request. The cancel logic itself was also broken —
it referenced a WebSocket-based cancel function that no longer matched
the actual HTTP-stream transport used for field values.
2026-06-17 10:04:10 +00:00

587 lines
24 KiB
TypeScript

// 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 { mount, VueWrapper } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { nextTick } from "vue";
import FieldValuesPanel from "@/components/common/FieldValuesPanel.vue";
vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
}));
vi.mock("@vueuse/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@vueuse/core")>();
return {
...actual,
watchDebounced: vi.fn((source: any, callback: any) => {
// Execute synchronously in tests so we can assert on emit calls
}),
};
});
vi.mock("@/utils/zincutils", () => ({
formatLargeNumber: vi.fn((n: number) => String(n)),
}));
vi.mock("@/components/icons/EqualIcon.vue", () => ({
default: { template: "<span class='equal-icon' />" },
}));
vi.mock("@/components/icons/NotEqualIcon.vue", () => ({
default: { template: "<span class='not-equal-icon' />" },
}));
vi.mock("@quasar/extras/material-icons-outlined", () => ({
"arrow-back-ios": "arrow_back_ios",
"arrow-forward-ios": "arrow_forward_ios",
}));
// ─── Fixtures ────────────────────────────────────────────────────────────────
const buildFieldValues = (count = 3, extra: Partial<any> = {}) => ({
isLoading: false,
values: Array.from({ length: count }, (_, i) => ({
key: `value-${i + 1}`,
count: (i + 1) * 10,
})),
errMsg: "",
hasMore: false,
...extra,
});
describe("FieldValuesPanel.vue", () => {
let wrapper: VueWrapper;
const createWrapper = (props: Record<string, any> = {}) =>
mount(FieldValuesPanel, {
props: {
fieldName: "status",
fieldValues: buildFieldValues(),
showMultiSelect: true,
defaultValuesCount: 10,
theme: "light",
...props,
},
});
afterEach(() => {
wrapper?.unmount();
vi.clearAllMocks();
});
// ─── Mounting ───────────────────────────────────────────────────────────────
describe("Mounting", () => {
it("mounts without errors", () => {
wrapper = createWrapper();
expect(wrapper.exists()).toBe(true);
});
it("renders the filter-values-container", () => {
wrapper = createWrapper();
expect(wrapper.find(".filter-values-container").exists()).toBe(true);
});
});
// ─── Props defaults ─────────────────────────────────────────────────────────
describe("Default props", () => {
it("defaults showMultiSelect to true", () => {
wrapper = mount(FieldValuesPanel, { props: { fieldName: "level" } });
expect(wrapper.props("showMultiSelect")).toBe(true);
});
it("defaults defaultValuesCount to 10", () => {
wrapper = mount(FieldValuesPanel, { props: { fieldName: "level" } });
expect(wrapper.props("defaultValuesCount")).toBe(10);
});
it("defaults theme to 'light'", () => {
wrapper = mount(FieldValuesPanel, { props: { fieldName: "level" } });
expect(wrapper.props("theme")).toBe("light");
});
});
// ─── Loading state ──────────────────────────────────────────────────────────
describe("Loading state", () => {
it("shows loading indicator when isLoading is true and no cached values", () => {
wrapper = createWrapper({
fieldValues: { isLoading: true, values: [], errMsg: "" },
});
const loading = wrapper.find('[data-test="field-values-panel-loading-indicator"]');
expect(loading.exists()).toBe(true);
});
it("confines the loading overlay to its own box via a positioned wrapper", () => {
// Regression: OInnerLoading is `absolute inset-0`, so its wrapper must be a
// positioning context. Without `tw:relative` the overlay escaped to the
// nearest positioned ancestor (`.o-field-list__row`) and covered the field
// row, blocking the click that collapses/cancels the request.
wrapper = createWrapper({
fieldValues: { isLoading: true, values: [], errMsg: "" },
});
const loading = wrapper.find(
'[data-test="field-values-panel-loading-indicator"]',
);
expect(loading.exists()).toBe(true);
const wrapperEl = loading.element.parentElement as HTMLElement;
expect(wrapperEl.classList.contains("tw:relative")).toBe(true);
});
it("hides values list while loading with no interim cache", () => {
wrapper = createWrapper({
fieldValues: { isLoading: true, values: [], errMsg: "" },
});
// After Quasar -> native HTML migration the values list is a <ul> that's
// always rendered, but its <li> children come from v-for over displayValues.
// With no cached values during loading, no row items should be present.
const valueRows = wrapper.findAll('[data-test^="logs-search-subfield-add-"]');
expect(valueRows.length).toBe(0);
});
});
// ─── No values state ────────────────────────────────────────────────────────
describe("No values state", () => {
it("shows 'No values found' when fieldValues.values is empty and not loading", () => {
wrapper = createWrapper({
fieldValues: { isLoading: false, values: [], errMsg: "" },
});
expect(wrapper.text()).toContain("search.fieldValuesEmpty");
expect(wrapper.text()).toContain("search.fieldValuesEmptyHint");
});
it("shows custom errMsg when provided and values list is empty", () => {
wrapper = createWrapper({
fieldValues: {
isLoading: false,
values: [],
errMsg: "Stream not found",
},
});
expect(wrapper.text()).toContain("Stream not found");
});
it("does not show error message when values are present", () => {
wrapper = createWrapper({
fieldValues: buildFieldValues(2),
});
// The "No values found" element uses v-show so it stays in the DOM;
// assert it is hidden rather than checking wrapper.text()
const noValuesEl = wrapper.find('[data-test="field-values-panel-no-values-msg"]');
expect(noValuesEl.exists()).toBe(true);
expect(noValuesEl.isVisible()).toBe(false);
});
});
// ─── Values list rendering ──────────────────────────────────────────────────
describe("Values list rendering", () => {
it("renders one list item per value", () => {
wrapper = createWrapper({ fieldValues: buildFieldValues(3) });
const items = wrapper.findAll(".filter-values-container [data-test]");
// 3 items — each has a data-test attribute for log-search-subfield-add
const subfields = items.filter((el) =>
el.attributes("data-test")?.startsWith("logs-search-subfield-add-")
);
expect(subfields.length).toBe(3);
});
it("renders value keys in the list", () => {
wrapper = createWrapper({ fieldValues: buildFieldValues(2) });
expect(wrapper.text()).toContain("value-1");
expect(wrapper.text()).toContain("value-2");
});
it("renders formatted count via formatLargeNumber", () => {
wrapper = createWrapper({ fieldValues: buildFieldValues(1) });
expect(wrapper.text()).toContain("10");
});
it("renders value label when provided instead of key", () => {
wrapper = createWrapper({
fieldValues: {
isLoading: false,
values: [{ key: "k1", count: 5, label: "Custom Label" }],
},
});
expect(wrapper.text()).toContain("Custom Label");
});
});
// ─── Multi-select controls ───────────────────────────────────────────────────
describe("Multi-select controls", () => {
it("renders checkboxes when showMultiSelect is true", () => {
wrapper = createWrapper({ showMultiSelect: true, fieldValues: buildFieldValues(2) });
expect(wrapper.findAllComponents({ name: "OCheckbox" }).length).toBeGreaterThan(0);
});
it("does not render checkboxes when showMultiSelect is false", () => {
wrapper = createWrapper({ showMultiSelect: false, fieldValues: buildFieldValues(2) });
expect(wrapper.findAllComponents({ name: "OCheckbox" }).length).toBe(0);
});
it("does not render include/exclude buttons when showMultiSelect is false", () => {
wrapper = createWrapper({ showMultiSelect: false, fieldValues: buildFieldValues(2) });
expect(
wrapper.find('[data-test="log-search-subfield-list-equal-status-field-btn"]').exists()
).toBe(false);
});
});
// ─── Emit: checkbox-driven add-multiple-search-terms ────────────────────────
describe("Emit: checkbox-driven add-multiple-search-terms", () => {
it("emits 'add-multiple-search-terms' with include when checkbox checked in include mode", async () => {
wrapper = createWrapper({ showMultiSelect: true, fieldValues: buildFieldValues(1) });
// filterMode defaults to 'include'
(wrapper.vm as any).handleUserCheckboxChange(["value-1"]);
await nextTick();
const emitted = wrapper.emitted("add-multiple-search-terms");
expect(emitted).toBeTruthy();
expect(emitted![0]).toEqual(["status", ["value-1"], "include"]);
});
it("emits 'add-multiple-search-terms' with exclude when checkbox checked in exclude mode", async () => {
wrapper = createWrapper({ showMultiSelect: true, fieldValues: buildFieldValues(1) });
// Set filterMode to 'exclude' while selectedValues is empty so watcher doesn't fire
(wrapper.vm as any).filterMode = "exclude";
await nextTick();
(wrapper.vm as any).handleUserCheckboxChange(["value-1"]);
await nextTick();
const emitted = wrapper.emitted("add-multiple-search-terms");
expect(emitted).toBeTruthy();
expect(emitted![0]).toEqual(["status", ["value-1"], "exclude"]);
});
});
// ─── Emit: load-more-values ──────────────────────────────────────────────────
describe("Emit: load-more-values", () => {
it("shows 'View more values' button when hasMore is true", () => {
wrapper = createWrapper({
fieldValues: buildFieldValues(3, { hasMore: true }),
});
const btn = wrapper.find('[data-test="log-search-subfield-load-more-status"]');
expect(btn.exists()).toBe(true);
});
it("hides 'View more values' button when hasMore is false", () => {
wrapper = createWrapper({ fieldValues: buildFieldValues(3, { hasMore: false }) });
const btn = wrapper.find('[data-test="log-search-subfield-load-more-status"]');
expect(btn.exists()).toBe(false);
});
it("emits 'load-more-values' with fieldName when button clicked", async () => {
wrapper = createWrapper({
fieldValues: buildFieldValues(3, { hasMore: true }),
});
const btn = wrapper.find('[data-test="log-search-subfield-load-more-status"]');
await btn.trigger("click");
expect(wrapper.emitted("load-more-values")![0]).toEqual(["status"]);
});
});
// ─── Multi-select action bar ─────────────────────────────────────────────────
describe("Multi-select action bar", () => {
it("is hidden when no values are selected", () => {
wrapper = createWrapper({ showMultiSelect: true, fieldValues: buildFieldValues(3) });
// selection-count is only shown when selectedValues.length > 0
expect(wrapper.find(".selection-count").exists()).toBe(false);
});
it("appears when at least one value is selected", async () => {
wrapper = createWrapper({ showMultiSelect: true, fieldValues: buildFieldValues(3) });
(wrapper.vm as any).selectedValues = ["value-1"];
await nextTick();
expect(wrapper.find(".selection-count").exists()).toBe(true);
});
it("shows count of selected values", async () => {
wrapper = createWrapper({ showMultiSelect: true, fieldValues: buildFieldValues(3) });
(wrapper.vm as any).selectedValues = ["value-1", "value-2"];
await nextTick();
expect(wrapper.find(".selection-count").text()).toContain("2 selected");
});
it("clears selection when clear button clicked", async () => {
wrapper = createWrapper({ showMultiSelect: true, fieldValues: buildFieldValues(3) });
(wrapper.vm as any).selectedValues = ["value-1"];
await nextTick();
const clearBtn = wrapper.find(
'[data-test="field-values-panel-clear-selection-btn"]'
);
await clearBtn.trigger("click");
expect((wrapper.vm as any).selectedValues).toEqual([]);
});
// Mode-switch emissions are debounced (300 ms) so fake timers are required
// to flush the debounce before asserting the emitted event.
describe("mode switch emits (debounced)", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("emits 'add-multiple-search-terms' with include when Include mode button clicked", async () => {
wrapper = createWrapper({ showMultiSelect: true, fieldValues: buildFieldValues(3) });
// Set filterMode to 'exclude' while selectedValues is empty so no premature emit
(wrapper.vm as any).filterMode = "exclude";
await nextTick();
(wrapper.vm as any).selectedValues = ["value-1", "value-2"];
await nextTick();
const includeBtn = wrapper.find(
'[data-test="field-values-panel-include-mode-btn"]'
);
await includeBtn.trigger("click");
vi.runAllTimers();
const emitted = wrapper.emitted("add-multiple-search-terms");
expect(emitted).toBeTruthy();
expect(emitted![0]).toEqual(["status", ["value-1", "value-2"], "include"]);
});
it("emits 'add-multiple-search-terms' with exclude when Exclude mode button clicked", async () => {
wrapper = createWrapper({ showMultiSelect: true, fieldValues: buildFieldValues(3) });
// filterMode defaults to 'include'; set selectedValues then click exclude
(wrapper.vm as any).selectedValues = ["value-1"];
await nextTick();
const excludeBtn = wrapper.find(
'[data-test="field-values-panel-exclude-mode-btn"]'
);
await excludeBtn.trigger("click");
vi.runAllTimers();
const emitted = wrapper.emitted("add-multiple-search-terms");
expect(emitted).toBeTruthy();
expect(emitted![0]).toEqual(["status", ["value-1"], "exclude"]);
});
});
it("does NOT clear selectedValues after applying multi-select include", async () => {
// selectedValues must not be wiped after a filter mode change.
// The watcher on allActiveValues (driven by activeIncludeValues /
// activeExcludeValues props) is responsible for syncing selection state
// once the parent query updates.
wrapper = createWrapper({ showMultiSelect: true, fieldValues: buildFieldValues(3) });
// Set filterMode to 'exclude' while empty, then add selectedValues
(wrapper.vm as any).filterMode = "exclude";
await nextTick();
(wrapper.vm as any).selectedValues = ["value-1"];
await nextTick();
const includeBtn = wrapper.find(
'[data-test="field-values-panel-include-mode-btn"]'
);
await includeBtn.trigger("click");
expect((wrapper.vm as any).selectedValues).toEqual(["value-1"]);
});
it("syncs selectedValues from activeIncludeValues when parent query updates", async () => {
// Simulates what happens after the query has been updated by the parent:
// selectedValues must reflect the new active filter state.
wrapper = createWrapper({
showMultiSelect: true,
fieldValues: buildFieldValues(3),
activeIncludeValues: ["value-1"],
});
await nextTick();
expect((wrapper.vm as any).selectedValues).toEqual(["value-1"]);
// Parent query changes (e.g. user clicks Include on a different value)
await wrapper.setProps({ activeIncludeValues: ["value-2"] });
await nextTick();
expect((wrapper.vm as any).selectedValues).toEqual(["value-2"]);
});
it("keeps selectedValues stable when Include is clicked a second time with the same selection", async () => {
// filterMode starts as 'include'; clicking include again is a no-op —
// Vue's watcher does not fire when the value doesn't change, so selectedValues
// remains untouched.
wrapper = createWrapper({
showMultiSelect: true,
fieldValues: buildFieldValues(3),
activeIncludeValues: ["value-1"],
});
await nextTick();
expect((wrapper.vm as any).selectedValues).toEqual(["value-1"]);
const includeBtn = wrapper.find(
'[data-test="field-values-panel-include-mode-btn"]'
);
await includeBtn.trigger("click");
// filterMode was already 'include', no reactive change, no emit
expect((wrapper.vm as any).selectedValues).toEqual(["value-1"]);
});
it("syncs selectedValues from activeExcludeValues when exclude is active", async () => {
wrapper = createWrapper({
showMultiSelect: true,
fieldValues: buildFieldValues(3),
activeExcludeValues: ["value-3"],
});
await nextTick();
expect((wrapper.vm as any).selectedValues).toEqual(["value-3"]);
});
});
// ─── Filter mode edge cases ──────────────────────────────────────────────────
describe("Filter mode edge cases", () => {
it("does not emit add-multiple-search-terms when selectedValues is empty", async () => {
wrapper = createWrapper({ showMultiSelect: true, fieldValues: buildFieldValues(2) });
// selectedValues is empty; changing filterMode should not emit add-multiple-search-terms
const excludeBtn = wrapper.find('[data-test="field-values-panel-exclude-mode-btn"]');
await excludeBtn.trigger("click");
await nextTick();
expect(wrapper.emitted("add-multiple-search-terms")).toBeFalsy();
});
});
// ─── Search bar (showValueSearch) ───────────────────────────────────────────
describe("Value search bar", () => {
it("hides search bar when there are no values", () => {
wrapper = createWrapper({
fieldValues: buildFieldValues(0),
});
expect(wrapper.find(".value-search-container").exists()).toBe(false);
});
it("shows search bar when values exist even below defaultValuesCount", () => {
wrapper = createWrapper({
defaultValuesCount: 10,
fieldValues: buildFieldValues(5),
});
expect(wrapper.find(".value-search-container").exists()).toBe(true);
});
it("keeps search bar visible when searching returns no values but cache exists", async () => {
wrapper = createWrapper({
fieldValues: buildFieldValues(2),
});
await nextTick();
await wrapper.setProps({
fieldValues: {
isLoading: false,
values: [],
errMsg: "",
},
});
await nextTick();
expect(wrapper.find(".value-search-container").exists()).toBe(true);
});
});
// ─── displayValues computed ──────────────────────────────────────────────────
describe("displayValues computed", () => {
it("returns fieldValues.values when not loading or no searchTerm", () => {
wrapper = createWrapper({ fieldValues: buildFieldValues(3) });
expect((wrapper.vm as any).displayValues).toHaveLength(3);
});
it("returns empty array when fieldValues is undefined", () => {
wrapper = createWrapper({ fieldValues: undefined });
expect((wrapper.vm as any).displayValues).toEqual([]);
});
it("returns locally filtered cache while loading with a search term", async () => {
wrapper = createWrapper({
defaultValuesCount: 2,
fieldValues: { isLoading: true, values: [], errMsg: "" },
});
// Pre-populate cache
(wrapper.vm as any).cachedValues = [
{ key: "error", count: 5 },
{ key: "info", count: 10 },
{ key: "warning", count: 3 },
];
(wrapper.vm as any).valueSearchTerm = "error";
await nextTick();
const display = (wrapper.vm as any).displayValues;
expect(display).toHaveLength(1);
expect(display[0].key).toBe("error");
});
});
// ─── reset() exposed method ──────────────────────────────────────────────────
describe("reset() exposed method", () => {
it("clears selectedValues", async () => {
wrapper = createWrapper({ showMultiSelect: true, fieldValues: buildFieldValues(2) });
(wrapper.vm as any).selectedValues = ["value-1"];
(wrapper.vm as any).reset();
await nextTick();
expect((wrapper.vm as any).selectedValues).toEqual([]);
});
it("clears valueSearchTerm", async () => {
wrapper = createWrapper({ fieldValues: buildFieldValues(2) });
(wrapper.vm as any).valueSearchTerm = "foo";
(wrapper.vm as any).reset();
await nextTick();
expect((wrapper.vm as any).valueSearchTerm).toBe("");
});
it("clears cachedValues", async () => {
wrapper = createWrapper({ fieldValues: buildFieldValues(2) });
(wrapper.vm as any).cachedValues = [{ key: "x", count: 1 }];
(wrapper.vm as any).reset();
await nextTick();
expect((wrapper.vm as any).cachedValues).toEqual([]);
});
});
// ─── Cache watch ─────────────────────────────────────────────────────────────
describe("cachedValues watcher", () => {
it("caches incoming values when valueSearchTerm is empty", async () => {
const fieldValues = buildFieldValues(2);
wrapper = createWrapper({ fieldValues });
// The watch triggers when fieldValues.values changes
await wrapper.setProps({
fieldValues: {
isLoading: false,
values: [{ key: "new-val", count: 99 }],
errMsg: "",
},
});
await nextTick();
// cachedValues should contain the new value
expect((wrapper.vm as any).cachedValues).toEqual(
expect.arrayContaining([expect.objectContaining({ key: "new-val" })])
);
});
});
// ─── Theme prop ──────────────────────────────────────────────────────────────
describe("Theme-aware styling", () => {
it("accepts 'dark' theme without errors", () => {
wrapper = createWrapper({ theme: "dark", fieldValues: buildFieldValues(1) });
expect(wrapper.exists()).toBe(true);
});
it("accepts 'light' theme without errors", () => {
wrapper = createWrapper({ theme: "light", fieldValues: buildFieldValues(1) });
expect(wrapper.exists()).toBe(true);
});
});
});