mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-06 08:09:24 +00:00
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
312 lines
12 KiB
TypeScript
312 lines
12 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/>.
|
|
|
|
// StepPickerDialog is the SHARED searchable "add next step" picker (Pipelines +
|
|
// Workflows). It is fully prop-driven: `items` in, `pick`/`close` out.
|
|
|
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
import { mount } from "@vue/test-utils";
|
|
import i18n from "@/locales";
|
|
import store from "@/test/unit/helpers/store";
|
|
|
|
import StepPickerDialog from "@/components/flow/StepPickerDialog.vue";
|
|
|
|
const items = [
|
|
{
|
|
key: "condition",
|
|
title: "Condition",
|
|
description: "Filter records before continuing",
|
|
icon: "alt-route",
|
|
iconTint: "bg-amber-100 text-amber-700",
|
|
node_type: "condition",
|
|
},
|
|
{
|
|
key: "function",
|
|
title: "Function",
|
|
description: "Transform the payload with VRL",
|
|
icon: "code",
|
|
iconTint: "bg-blue-100",
|
|
node_type: "function",
|
|
},
|
|
{
|
|
key: "destination",
|
|
title: "Send To Destination",
|
|
description: "Deliver to an external destination",
|
|
icon: "share",
|
|
node_type: "destination",
|
|
},
|
|
];
|
|
|
|
const globalConfig = {
|
|
plugins: [i18n, store],
|
|
// The picker is an anchored popover teleported to <body>, so its content is
|
|
// outside the wrapper's tree. Stubbing Teleport renders it inline and keeps
|
|
// every query below working against `wrapper`.
|
|
stubs: { teleport: true },
|
|
};
|
|
|
|
function mountPicker(props: Record<string, any> = {}) {
|
|
return mount(StepPickerDialog as any, {
|
|
props: { items, ...props },
|
|
global: globalConfig,
|
|
});
|
|
}
|
|
|
|
const cards = (wrapper: any) => wrapper.findAll(".flow-step-card");
|
|
const panel = (wrapper: any) => wrapper.find('[data-test="flow-step-dialog"]');
|
|
const backdrop = (wrapper: any) => wrapper.find('[data-test="flow-step-backdrop"]');
|
|
const searchInput = (wrapper: any) => wrapper.find("input");
|
|
|
|
describe("StepPickerDialog", () => {
|
|
let wrapper: any = null;
|
|
|
|
afterEach(() => {
|
|
wrapper?.unmount();
|
|
wrapper = null;
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("dialog shell", () => {
|
|
it("mounts and renders the anchored panel", () => {
|
|
wrapper = mountPicker();
|
|
expect(wrapper.exists()).toBe(true);
|
|
expect(panel(wrapper).exists()).toBe(true);
|
|
});
|
|
|
|
it("opens at the anchor point, offset below it", () => {
|
|
wrapper = mountPicker({ anchor: { x: 300, y: 200 } });
|
|
const style = panel(wrapper).attributes("style") || "";
|
|
expect(style).toContain("--sp-x: 300px");
|
|
expect(style).toContain("--sp-y: 208px");
|
|
});
|
|
|
|
it('data-test on the dialog uses the default "flow-step" prefix', () => {
|
|
wrapper = mountPicker();
|
|
expect(wrapper.find('[data-test="flow-step-dialog"]').exists()).toBe(true);
|
|
});
|
|
|
|
it("data-test on the dialog honours a custom testPrefix", () => {
|
|
wrapper = mountPicker({ testPrefix: "workflow-step" });
|
|
expect(wrapper.find('[data-test="workflow-step-dialog"]').exists()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("item cards", () => {
|
|
it("renders one card per item", () => {
|
|
wrapper = mountPicker();
|
|
expect(cards(wrapper)).toHaveLength(3);
|
|
});
|
|
|
|
it("renders nothing but the empty state when items is empty", () => {
|
|
wrapper = mountPicker({ items: [] });
|
|
expect(cards(wrapper)).toHaveLength(0);
|
|
expect(wrapper.text()).toContain("No matches");
|
|
});
|
|
|
|
it("stamps `${testPrefix}-${item.key}` on each card", () => {
|
|
wrapper = mountPicker({ testPrefix: "workflow-step" });
|
|
expect(wrapper.find('[data-test="workflow-step-condition"]').exists()).toBe(true);
|
|
expect(wrapper.find('[data-test="workflow-step-function"]').exists()).toBe(true);
|
|
expect(wrapper.find('[data-test="workflow-step-destination"]').exists()).toBe(true);
|
|
});
|
|
|
|
it("renders the title and description of each item", () => {
|
|
wrapper = mountPicker();
|
|
const text = wrapper.text();
|
|
expect(text).toContain("Condition");
|
|
expect(text).toContain("Filter records before continuing");
|
|
expect(text).toContain("Send To Destination");
|
|
});
|
|
|
|
it("applies the item's iconTint classes to the icon square", () => {
|
|
wrapper = mountPicker();
|
|
const square = wrapper.find('[data-test="flow-step-condition"]').find("div");
|
|
expect(square.classes()).toContain("bg-amber-100");
|
|
expect(square.classes()).toContain("text-amber-700");
|
|
});
|
|
|
|
it("renders the item's icon", () => {
|
|
wrapper = mountPicker();
|
|
const icons = wrapper.findAllComponents({ name: "OIcon" });
|
|
const names = icons.map((i: any) => i.props("name"));
|
|
expect(names).toContain("alt-route");
|
|
expect(names).toContain("code");
|
|
expect(names).toContain("share");
|
|
});
|
|
|
|
it('falls back to the "help" icon when an item has no icon', () => {
|
|
wrapper = mountPicker({ items: [{ key: "x", title: "X" }] });
|
|
const names = wrapper.findAllComponents({ name: "OIcon" }).map((i: any) => i.props("name"));
|
|
expect(names).toContain("help");
|
|
});
|
|
|
|
it("omits the description line when the item has no description", () => {
|
|
wrapper = mountPicker({ items: [{ key: "x", title: "X" }] });
|
|
const card = wrapper.find('[data-test="flow-step-x"]');
|
|
expect(card.text()).toBe("X");
|
|
expect(card.findAll(".text-xs")).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("search / filter", () => {
|
|
it("shows every item when the search box is empty", () => {
|
|
wrapper = mountPicker();
|
|
expect(cards(wrapper)).toHaveLength(3);
|
|
});
|
|
|
|
it("filters by title (case-insensitive)", async () => {
|
|
wrapper = mountPicker();
|
|
await searchInput(wrapper).setValue("FUNC");
|
|
expect(cards(wrapper)).toHaveLength(1);
|
|
expect(wrapper.find('[data-test="flow-step-function"]').exists()).toBe(true);
|
|
});
|
|
|
|
it("filters by description", async () => {
|
|
wrapper = mountPicker();
|
|
await searchInput(wrapper).setValue("vrl");
|
|
expect(cards(wrapper)).toHaveLength(1);
|
|
expect(wrapper.find('[data-test="flow-step-function"]').exists()).toBe(true);
|
|
});
|
|
|
|
it("matches multiple items on a shared term", async () => {
|
|
wrapper = mountPicker();
|
|
await searchInput(wrapper).setValue("o");
|
|
expect(cards(wrapper).length).toBeGreaterThan(1);
|
|
});
|
|
|
|
it("trims whitespace-only input and treats it as no query", async () => {
|
|
wrapper = mountPicker();
|
|
await searchInput(wrapper).setValue(" ");
|
|
expect(cards(wrapper)).toHaveLength(3);
|
|
});
|
|
|
|
it("trims a padded query before matching", async () => {
|
|
wrapper = mountPicker();
|
|
await searchInput(wrapper).setValue(" condition ");
|
|
expect(cards(wrapper)).toHaveLength(1);
|
|
expect(wrapper.find('[data-test="flow-step-condition"]').exists()).toBe(true);
|
|
});
|
|
|
|
it("shows the empty state when nothing matches", async () => {
|
|
wrapper = mountPicker();
|
|
await searchInput(wrapper).setValue("zzz-nothing");
|
|
expect(cards(wrapper)).toHaveLength(0);
|
|
expect(wrapper.text()).toContain("No matches");
|
|
});
|
|
|
|
it("shows a custom noMatchText when nothing matches", async () => {
|
|
wrapper = mountPicker({ noMatchText: "Nothing Found" });
|
|
await searchInput(wrapper).setValue("zzz-nothing");
|
|
expect(wrapper.text()).toContain("Nothing Found");
|
|
});
|
|
|
|
it("tolerates items with missing title / description while filtering", async () => {
|
|
wrapper = mountPicker({
|
|
items: [{ key: "blank" } as any, { key: "keep", title: "Keep Me" }],
|
|
});
|
|
await searchInput(wrapper).setValue("keep");
|
|
expect(cards(wrapper)).toHaveLength(1);
|
|
expect(wrapper.find('[data-test="flow-step-keep"]').exists()).toBe(true);
|
|
});
|
|
|
|
it("restores every card when the query is cleared", async () => {
|
|
wrapper = mountPicker();
|
|
await searchInput(wrapper).setValue("function");
|
|
expect(cards(wrapper)).toHaveLength(1);
|
|
await searchInput(wrapper).setValue("");
|
|
expect(cards(wrapper)).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
describe("search input props", () => {
|
|
it("uses the default placeholder and testPrefix-derived data-test", () => {
|
|
wrapper = mountPicker();
|
|
const search = wrapper.findComponent({ name: "OSearchInput" });
|
|
// Defaults now come from the locale rather than a hardcoded English
|
|
// string — asserted against the key so a copy change doesn't break this,
|
|
// and a missing key (which renders the raw path) still fails.
|
|
const expected = i18n.global.t("common.search") as string;
|
|
expect(search.props("placeholder")).toBe(expected);
|
|
expect(expected).not.toBe("common.search");
|
|
expect(search.props("clearable")).toBe(true);
|
|
expect(wrapper.find('[data-test="flow-step-search"]').exists()).toBe(true);
|
|
});
|
|
|
|
it("honours a custom searchPlaceholder", () => {
|
|
wrapper = mountPicker({ searchPlaceholder: "Search Steps" });
|
|
expect(wrapper.findComponent({ name: "OSearchInput" }).props("placeholder")).toBe(
|
|
"Search Steps",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("emits", () => {
|
|
it("emits `pick` with the whole item (extra fields echoed back) when a card is clicked", async () => {
|
|
wrapper = mountPicker();
|
|
await wrapper.find('[data-test="flow-step-destination"]').trigger("click");
|
|
const picked = wrapper.emitted("pick");
|
|
expect(picked).toHaveLength(1);
|
|
expect(picked![0][0]).toMatchObject({
|
|
key: "destination",
|
|
title: "Send To Destination",
|
|
node_type: "destination",
|
|
});
|
|
});
|
|
|
|
it("emits `pick` with the filtered item after a search", async () => {
|
|
wrapper = mountPicker();
|
|
await searchInput(wrapper).setValue("condition");
|
|
await wrapper.find(".flow-step-card").trigger("click");
|
|
expect((wrapper.emitted("pick")![0][0] as any).key).toBe("condition");
|
|
});
|
|
|
|
it("does not emit `close` when a card is picked", async () => {
|
|
wrapper = mountPicker();
|
|
await wrapper.find('[data-test="flow-step-condition"]').trigger("click");
|
|
expect(wrapper.emitted("close")).toBeUndefined();
|
|
});
|
|
|
|
it("emits `close` when the backdrop is clicked", async () => {
|
|
wrapper = mountPicker();
|
|
await backdrop(wrapper).trigger("click");
|
|
expect(wrapper.emitted("close")).toHaveLength(1);
|
|
});
|
|
|
|
it("does NOT emit `close` when the click is inside the panel", async () => {
|
|
wrapper = mountPicker();
|
|
await panel(wrapper).trigger("click");
|
|
expect(wrapper.emitted("close")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("reactivity to the items prop", () => {
|
|
it("re-renders when items are replaced", async () => {
|
|
wrapper = mountPicker();
|
|
expect(cards(wrapper)).toHaveLength(3);
|
|
await wrapper.setProps({ items: [{ key: "only", title: "Only One" }] });
|
|
expect(cards(wrapper)).toHaveLength(1);
|
|
expect(wrapper.text()).toContain("Only One");
|
|
});
|
|
|
|
it("keeps the active query applied against the new items", async () => {
|
|
wrapper = mountPicker();
|
|
await searchInput(wrapper).setValue("function");
|
|
await wrapper.setProps({ items: [{ key: "only", title: "Only One" }] });
|
|
expect(cards(wrapper)).toHaveLength(0);
|
|
expect(wrapper.text()).toContain("No matches");
|
|
});
|
|
});
|
|
});
|