fix: alerts module migration with zod (#13227)

- alert module migration with zod

---------

Co-authored-by: ktx-vaidehi <134508096+ktx-vaidehi@users.noreply.github.com>
This commit is contained in:
ktx-mihir
2026-07-17 13:03:17 +00:00
committed by GitHub
co-authored by ktx-vaidehi
parent 5fbe938653
commit eaebff9e57
95 changed files with 13432 additions and 9728 deletions
@@ -227,7 +227,7 @@ export class AlertCreationWizard {
await expect(this.page.locator(this.locators.operatorSelect).first()).toBeVisible({ timeout: 5000 });
await this.page.locator(this.locators.operatorSelect).first().click();
await this.page.getByText('Contains', { exact: true }).click();
await this.page.getByRole('option', { name: 'Contains', exact: true }).click();
await this.page.locator(this.locators.conditionValueInput).first().locator('input').fill(value);
@@ -517,13 +517,16 @@ export class AlertCreationWizard {
await thresholdInput.fill('1');
testLogger.info('Set threshold value: 1');
// Period
const periodInput = this.page.locator('.period-input-container input[type="number"]');
await this.page.waitForTimeout(500);
if (await periodInput.isVisible({ timeout: 3000 })) {
await periodInput.fill('15');
testLogger.info('Set period: 15 minutes');
}
// Period — migrated to an OFormInput (data-test="alert-settings-period-input").
// The native input carries the auto-derived `-field` suffix; the old
// `.period-input-container` wrapper no longer exists. Fill it hard (not a
// soft isVisible-guard) so the alert saves with period=15 — otherwise it
// silently falls back to the default (10) and the "15 Mins" list cell never
// renders.
const periodInput = this.page.locator('[data-test="alert-settings-period-input-field"]').first();
await periodInput.waitFor({ state: 'visible', timeout: 5000 });
await periodInput.fill('15');
testLogger.info('Set period: 15 minutes');
// Destination selection using v3 data-test locator
const destinationDropdown = this.page.locator('[data-test="alert-destinations-select"]');
@@ -653,8 +656,8 @@ export class AlertCreationWizard {
await expect(operatorSelect1).toBeVisible({ timeout: 5000 });
await operatorSelect1.click();
await this.page.waitForTimeout(500);
await expect(this.page.getByText('Contains', { exact: true })).toBeVisible({ timeout: 5000 });
await this.page.getByText('Contains', { exact: true }).click();
await expect(this.page.getByRole('option', { name: 'Contains', exact: true })).toBeVisible({ timeout: 5000 });
await this.page.getByRole('option', { name: 'Contains', exact: true }).click();
await this.page.waitForTimeout(500);
// Use 'test' as value - matches the log field from ingested validation data
@@ -868,7 +871,7 @@ export class AlertCreationWizard {
await thresholdInput.fill('1');
testLogger.info('Set threshold value: 1');
const periodInput = this.page.locator('.period-input-container input[type="number"]');
const periodInput = this.page.locator('[data-test="alert-settings-period-input-field"]').first();
await this.page.waitForTimeout(500);
if (await periodInput.isVisible({ timeout: 3000 })) {
await periodInput.fill('15');
@@ -1023,8 +1026,8 @@ export class AlertCreationWizard {
const operatorSelect1 = this.page.locator(this.locators.operatorSelect).first();
await operatorSelect1.click();
await this.page.waitForTimeout(500);
await expect(this.page.getByText('Contains', { exact: true })).toBeVisible({ timeout: 5000 });
await this.page.getByText('Contains', { exact: true }).click();
await expect(this.page.getByRole('option', { name: 'Contains', exact: true })).toBeVisible({ timeout: 5000 });
await this.page.getByRole('option', { name: 'Contains', exact: true }).click();
await this.page.waitForTimeout(500);
const valueInput1 = this.page.locator(this.locators.conditionValueInput).first().locator('input');
@@ -1386,7 +1389,7 @@ export class AlertCreationWizard {
testLogger.info('Set threshold value: 1');
// Set shortest period (1 minute) for faster testing
const periodInput = this.page.locator('.period-input-container input[type="number"]');
const periodInput = this.page.locator('[data-test="alert-settings-period-input-field"]').first();
await this.page.waitForTimeout(500);
if (await periodInput.isVisible({ timeout: 5000 })) {
await periodInput.fill('1');
@@ -1547,7 +1550,7 @@ export class AlertCreationWizard {
// Select operator
await this.page.locator(this.locators.operatorSelect).first().click();
await this.page.getByText('Contains', { exact: true }).click();
await this.page.getByRole('option', { name: 'Contains', exact: true }).click();
await this.page.waitForTimeout(300);
// Fill condition value
@@ -1640,7 +1643,7 @@ export class AlertCreationWizard {
// trigger condition section exists below the Having groups section.
// Period
const periodInput = this.page.locator('.period-input-container input[type="number"]');
const periodInput = this.page.locator('[data-test="alert-settings-period-input-field"]').first();
if (await periodInput.isVisible({ timeout: 3000 })) {
await periodInput.fill('15');
}
@@ -1853,7 +1856,7 @@ export class AlertCreationWizard {
// No separate threshold section — just period and destination
// Set period
const periodInput = this.page.locator('.period-input-container input[type="number"]');
const periodInput = this.page.locator('[data-test="alert-settings-period-input-field"]').first();
await this.page.waitForTimeout(500);
if (await periodInput.isVisible({ timeout: 3000 })) {
await periodInput.fill('15');
@@ -1118,16 +1118,14 @@ export class AlertsPage {
await this.page.locator(this.locators.alertSubmitButton).click();
await this.page.waitForTimeout(500);
// v3 shows a toast notification when name is empty
// OToast renders the message in 3 elements (sr-only ARIA span, sr-only title div,
// visible message div) — scope to the visible `o-toast-message` data-test to avoid
// strict-mode violation per AGENT_RULES §2.
await expect(
this.page
.locator('[data-test="o-toast-message"]')
.filter({ hasText: 'Alert name is required.' })
.first()
).toBeVisible({ timeout: 10000 });
// Post-migration the alert form validates via useOForm (revalidateLogic):
// an empty name surfaces an INLINE schema error on the name OFormInput
// (data-test="add-alert-name-input" → auto-derived `-error` element), not a
// toast. Message is `alerts.nameRequired` = "Alert name is required." — the
// same copy the old toast used; only the delivery (toast → inline) changed.
const nameError = this.page.locator('[data-test="add-alert-name-input-error"]').first();
await expect(nameError).toBeVisible({ timeout: 10000 });
await expect(nameError).toContainText('Alert name is required');
testLogger.info('Invalid alert name validation working');
// Close with robust handling for dialog backdrops
@@ -23,11 +23,17 @@ class AnomalyFormValidationPage {
this.streamTypeSelect = '[data-test="add-alert-stream-type-select-dropdown"]';
// OSelect: stream name dropdown
this.streamNameSelect = '[data-test="add-alert-stream-name-select-dropdown"]';
// Next / Save / Cancel buttons shared across the wizard
this.nextBtn = '[data-test="add-alert-next-btn"]';
// Save / Cancel buttons shared across the wizard. There is no Next button:
// the V3 layout is a single pane navigated by tabs, not a stepper.
this.saveBtn = '[data-test="add-alert-submit-btn"]';
this.cancelBtn = '[data-test="add-alert-cancel-btn"]';
// ── Validation feedback ────────────────────────────────────────────
// OInput derives its error node's data-test from the consumer's own
// data-test, so the topbar OFormInput yields <name>-error.
this.anomalyNameError = '[data-test="add-anomaly-name-input-error"]';
this.toastMessage = '[data-test="o-toast-message"]';
// ── Step 2 — AnomalyDetectionConfig fields ─────────────────────────
this.queryTabsToggle = '[data-test="anomaly-query-tabs"]';
this.detectionFunction = '[data-test="anomaly-detection-function"]';
@@ -72,11 +78,6 @@ class AnomalyFormValidationPage {
testLogger.info('Add Anomaly wizard opened');
}
async clickNext() {
testLogger.info('Clicking Next in anomaly wizard');
await this.page.locator(this.nextBtn).click();
}
async clickSave() {
testLogger.info('Clicking Save in anomaly wizard');
await this.page.locator(this.saveBtn).click();
@@ -91,8 +92,22 @@ class AnomalyFormValidationPage {
getStreamTypeSelectLocator() { return this.page.locator(this.streamTypeSelect); }
getStreamNameSelectLocator() { return this.page.locator(this.streamNameSelect); }
getNextBtnLocator() { return this.page.locator(this.nextBtn); }
getSaveBtnLocator() { return this.page.locator(this.saveBtn); }
getAnomalyNameErrorLocator() { return this.page.locator(this.anomalyNameError); }
/**
* Toast message node, narrowed to the one we care about.
*
* Toasts STACK (OToastProvider v-for's over every open record) and error
* toasts live 30s, so an unrelated earlier error is very likely still on
* screen. Matching the bare selector would resolve to several nodes and trip
* Playwright strict mode, so always filter by the expected text.
*
* @param {string|RegExp} hasText text the target toast must contain
*/
getToastMessageLocator(hasText) {
return this.page.locator(this.toastMessage).filter({ hasText });
}
getCancelBtnLocator() { return this.page.locator(this.cancelBtn); }
getDetectionWindowErrorLocator() { return this.page.locator(this.detectionWindowError); }
getCustomSqlTimestampErrorLocator() { return this.page.locator(this.customSqlTimestampError); }
@@ -430,10 +430,10 @@ test.describe('Alerts Form Validation', { tag: ['@alerts-form-validation', '@P0'
await fvPage.addFilterCondition();
await fvPage.getFieldsInputRowLocator(0).waitFor({ state: 'visible', timeout: 10000 });
// Trigger value field blur without entering a value
const valueField = fvPage.getFilterConditionValueFieldLocator().first();
await valueField.click();
await page.keyboard.press('Tab');
// Post-migration the alert form validates on SUBMIT (useOForm/revalidateLogic),
// not on blur. Submit with an empty condition value so the schema-driven
// "Field is required!" error surfaces inline on the value OFormInput (R3).
await page.locator('[data-test="add-alert-submit-btn"]').click();
// Value error should become visible
const valueError = fvPage.getFilterConditionValueErrorLocator().first();
@@ -3,7 +3,7 @@
// Anomaly Detection domain — form validation E2E tests
//
// Covers:
// 1. Add Anomaly wizard — Next button disabled until stream is selected
// 1. Add Anomaly wizard — blank name blocks save and highlights the field
// 2. Query mode toggle — switching between filter and custom SQL modes
// 3. Detection window error — save button disabled or window error shown for invalid window
@@ -26,31 +26,35 @@ test.describe('Anomaly Detection form validation', { tag: ['@anomaly-form-valida
testLogger.info('Navigated to Anomaly Detection tab');
});
// ── TC001: Next button disabled when stream type is not selected ──────────
// ── TC001: blank name blocks save and highlights the name field ───────────
//
// The wizard does NOT gate save by disabling the button: OForm keeps submit
// enabled and validates on click, so the offending field gets painted rather
// than the user being left guessing why a gray button won't respond. The
// blank-name rule lives in AddAlert.schema.ts's anomaly branch.
test('should disable Next button when stream is not selected in step 1', {
test('should block save and highlight the name when it is blank', {
tag: ['@anomaly-form-validation', '@P0', '@smoke']
}, async ({ page }) => {
testLogger.info('TC001: Verify Next button disabled when no stream selected');
testLogger.info('TC001: Verify blank anomaly name blocks save and paints the field');
await pm.anomalyFormValidation.openAddAnomalyWizard();
// Stream type and name selects are visible; nothing is selected yet
// Wizard is open on step 1; name is blank and nothing is selected yet
await expect(pm.anomalyFormValidation.getStreamTypeSelectLocator()).toBeVisible();
// Next button should be disabled until a valid stream is selected
const nextBtn = pm.anomalyFormValidation.getNextBtnLocator();
const nextVisible = await nextBtn.isVisible().catch(() => false);
// Save stays enabled — validation runs on submit, not via a disabled button
const saveBtn = pm.anomalyFormValidation.getSaveBtnLocator();
await expect(saveBtn).toBeEnabled();
if (nextVisible) {
await expect(nextBtn).toBeDisabled();
testLogger.info('Next button correctly disabled when no stream selected');
} else {
// Some environments render a single-step form — check save is disabled
const saveBtn = pm.anomalyFormValidation.getSaveBtnLocator();
await expect(saveBtn).toBeDisabled();
testLogger.info('Save button correctly disabled when no stream selected');
}
await pm.anomalyFormValidation.clickSave();
// Blank name must paint the topbar field and toast, and must not save
await expect(pm.anomalyFormValidation.getAnomalyNameErrorLocator())
.toHaveText('Anomaly name is required.');
await expect(pm.anomalyFormValidation.getToastMessageLocator(/fix the highlighted fields/i))
.toBeVisible();
testLogger.info('Blank name correctly blocked save and highlighted the field');
await pm.anomalyFormValidation.clickCancel();
});
@@ -0,0 +1,220 @@
// Copyright 2026 OpenObserve Inc.
//
// Unit tests for the composed AddAlert orchestrator schema. These exercise the
// schema in isolation (no component mount) to prove the topbar rules + the
// reused QueryConfig / AlertSettings rules fire at the right nested paths and in
// the right is_real_time branch (Rule ④ validation parity).
import { describe, it, expect } from "vitest";
import i18n from "@/locales";
import { makeAddAlertSchema, defaultAddAlertMeta } from "./AddAlert.schema";
// Messages are i18n-driven now — resolve through the real locale so assertions
// verify the exact English (parity) AND that the keys exist.
const t = (key: string, named?: Record<string, unknown>): string =>
(i18n.global.t as any)(key, named);
const addAlertSchema = makeAddAlertSchema(t);
const ALERT_NAME_REQUIRED_MESSAGE = t("alerts.nameRequired");
const ALERT_NAME_SPECIAL_CHARS_MESSAGE = t("alerts.nameNoSpecialChars");
const STREAM_TYPE_REQUIRED_MESSAGE = t("alerts.validation.streamTypeRequired");
const STREAM_NAME_REQUIRED_MESSAGE = t("alerts.validation.streamNameRequired");
const validScheduled = (over: Record<string, any> = {}) => ({
name: "my_alert",
stream_type: "logs",
stream_name: "default",
is_real_time: "false",
destinations: ["dest1"],
creates_incident: false,
trigger_condition: {
period: 10,
operator: ">=",
frequency: 10,
cron: "",
threshold: 3,
silence: 10,
frequency_type: "minutes",
timezone: "UTC",
},
query_condition: {
conditions: {
filterType: "group",
logicalOperator: "AND",
groupId: "",
conditions: [],
},
sql: "",
promql: "",
type: "custom",
aggregation: null,
promql_condition: null,
vrl_function: null,
multi_time_range: [],
},
logGroupBy: [],
// DISPLAY value for the "Check every" input (minutes mode → same number as
// the stored trigger_condition.frequency minutes). The frequency rules key off
// this path — it is the one bound to the visible input.
_ui: { checkEvery: 10 },
_meta: defaultAddAlertMeta(),
...over,
});
/** Collect issue messages keyed by dot-path for easy assertions. */
const issuesByPath = (value: any): Record<string, string[]> => {
const res = addAlertSchema.safeParse(value);
const map: Record<string, string[]> = {};
if (!res.success) {
for (const issue of res.error.issues) {
const key = issue.path.join(".");
(map[key] ||= []).push(issue.message);
}
}
return map;
};
describe("addAlertSchema (composed orchestrator schema)", () => {
it("passes a complete valid scheduled alert", () => {
expect(addAlertSchema.safeParse(validScheduled()).success).toBe(true);
});
// ── Topbar (name §4 restore + stream required) ──────────────────────────────
it("blocks an empty name", () => {
const m = issuesByPath(validScheduled({ name: "" }));
expect(m["name"]).toContain(ALERT_NAME_REQUIRED_MESSAGE);
});
it("blocks a name with unsupported characters (§4 restore)", () => {
const m = issuesByPath(validScheduled({ name: "bad name" }));
expect(m["name"]).toContain(ALERT_NAME_SPECIAL_CHARS_MESSAGE);
// colon / hash / quotes / % / & are all rejected
expect(issuesByPath(validScheduled({ name: "a:b" }))["name"]).toBeTruthy();
expect(issuesByPath(validScheduled({ name: "a#b" }))["name"]).toBeTruthy();
expect(issuesByPath(validScheduled({ name: "a%b" }))["name"]).toBeTruthy();
});
it("blocks an empty stream_type and stream_name", () => {
const m = issuesByPath(
validScheduled({ stream_type: "", stream_name: "" }),
);
expect(m["stream_type"]).toContain(STREAM_TYPE_REQUIRED_MESSAGE);
expect(m["stream_name"]).toContain(STREAM_NAME_REQUIRED_MESSAGE);
});
// ── AlertSettings (reused) ──────────────────────────────────────────────────
it("requires at least one destination", () => {
const m = issuesByPath(validScheduled({ destinations: [] }));
expect(m["destinations"]?.length).toBeGreaterThan(0);
});
it("requires period ≥ 1 for scheduled alerts", () => {
const base = validScheduled();
base.trigger_condition.period = 0;
const m = issuesByPath(base);
expect(m["trigger_condition.period"]?.length).toBeGreaterThan(0);
});
it("does NOT enforce period ≥ 1 for realtime alerts", () => {
const base = validScheduled({
is_real_time: "true",
_meta: defaultAddAlertMeta({ isRealTime: "true" }),
});
base.trigger_condition.period = 0;
const m = issuesByPath(base);
expect(m["trigger_condition.period"]).toBeUndefined();
});
// ── QueryConfig (reused) ────────────────────────────────────────────────────
it("requires threshold ≥ 1 for scheduled alerts", () => {
const base = validScheduled();
base.trigger_condition.threshold = 0;
const m = issuesByPath(base);
expect(m["trigger_condition.threshold"]?.length).toBeGreaterThan(0);
});
it("restores promql operator + value requiredness in promql mode (§4)", () => {
const base = validScheduled({
_meta: defaultAddAlertMeta({ tab: "promql" }),
});
base.query_condition.type = "promql";
base.query_condition.promql_condition = { operator: "", value: "" };
const m = issuesByPath(base);
expect(
m["query_condition.promql_condition.operator"]?.length,
).toBeGreaterThan(0);
expect(
m["query_condition.promql_condition.value"]?.length,
).toBeGreaterThan(0);
});
it("promql value is zero-safe (0 passes)", () => {
const base = validScheduled({
_meta: defaultAddAlertMeta({ tab: "promql" }),
});
base.query_condition.type = "promql";
base.query_condition.promql_condition = { operator: ">=", value: 0 };
const m = issuesByPath(base);
expect(m["query_condition.promql_condition.value"]).toBeUndefined();
});
it("blocks a partially-filled condition row (custom mode)", () => {
const base = validScheduled();
base.query_condition.conditions = {
filterType: "group",
logicalOperator: "AND",
groupId: "",
conditions: [
{
filterType: "condition",
column: "",
operator: "=",
value: "",
logicalOperator: "AND",
},
],
};
const m = issuesByPath(base);
expect(
m["query_condition.conditions.conditions.0.column"]?.length,
).toBeGreaterThan(0);
});
// ── Anomaly branch: near-pass-through — `name` ONLY ─────────────────────────
// The detection-config fields live on AnomalyDetectionConfig's own form, so
// this schema still ignores stream/query/settings in anomaly mode. `name` is
// the exception: it is the one topbar field this form holds, and its blank
// rule is re-homed here from saveAnomalyDetection's toast so the field can
// actually highlight.
it("does NOT validate stream/query/settings in anomaly mode", () => {
const base = validScheduled({
name: "my_anomaly",
stream_type: "",
stream_name: "",
destinations: [],
is_real_time: "anomaly",
});
expect(addAlertSchema.safeParse(base).success).toBe(true);
});
it("requires name in anomaly mode", () => {
const base = validScheduled({
name: "",
stream_type: "",
stream_name: "",
destinations: [],
is_real_time: "anomaly",
});
const m = issuesByPath(base);
expect(m["name"]).toEqual(["Anomaly name is required."]);
// Still the ONLY issue — nothing else tightened alongside it.
expect(Object.keys(m)).toEqual(["name"]);
});
it("does NOT apply the alert special-chars rule to an anomaly name", () => {
const base = validScheduled({
name: "has space:and#chars",
is_real_time: "anomaly",
});
expect(addAlertSchema.safeParse(base).success).toBe(true);
});
});
@@ -0,0 +1,287 @@
// 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/>.
// ════════════════════════════════════════════════════════════════════════════
// AddAlert orchestrator schema (Phase 4 — DESIGN Y owner).
//
// AddAlert OWNS the ONE <OForm> for the whole wizard. The step children
// (QueryConfig, AlertSettings, Advanced, Deduplication) render as DESCENDANTS
// and bind their fields by nested `name=` into THIS form, so the ONE schema
// below must cover every nested path they bind. This file COMPOSES their
// exported rules (single source of truth — never re-hand-write them) and adds
// the topbar scalars:
//
// • name — required (min 1) + no ALERT_NAME_UNSUPPORTED_CHARS (§4)
// • stream_type — required
// • stream_name — required (min 1)
// + QueryConfig — queryConfigSchema (trigger_condition.*, query_condition.*
// incl. refineConditionsTree, promql §4 restores, aggregation,
// group_by[]) — self-gated by its `_meta` discriminators.
// + AlertSettings — createAlertSettingsSchema(isRealTime) (silence ≥ 0,
// destinations ≥ 1, period ≥ 1 SCHEDULED-only).
//
// DISCRIMINATOR — `is_real_time`: "false" (scheduled) | "true" (realtime) |
// "anomaly". The QueryConfig sub-schema already self-gates realtime-vs-scheduled
// via `_meta.isRealTime`; AlertSettings' period rule is applied scheduled-only
// by choosing createAlertSettingsSchema(isRealTime). The ANOMALY branch is a
// NEAR-pass-through: AnomalyDetectionConfig OWNS its OWN <OForm>+schema+validate()
// (verified live — it calls useOForm unconditionally, re-provides its own
// context, and writes back to props.config), and the anomaly SAVE path
// (saveAnomalyDetection) bypasses this form entirely and reads `anomalyConfig`.
// Composing the DETECTION-CONFIG fields here would be dead code (this form never
// holds them), so the anomaly branch enforces only `name` — which this form DOES
// hold (the topbar OFormInput binds `name` in both modes, and the
// formData.name → anomalyConfig.name watcher in useAlertForm feeds the value
// saveAnomalyDetection reads).
//
// `name` here is a RE-HOME, not a new rule (Rule ④): saveAnomalyDetection has
// always blocked a blank anomaly name, but only as an imperative toast — and a
// toast cannot paint a field, so the topbar highlighted nothing while the toast
// said "highlighted fields". Same rule, same message, now on the field. The
// blank check is deliberately the ONLY rule: the special-chars refine below is
// alert-only and adding it here would tighten anomaly vs. BEFORE.
// `stream_type`/`stream_name` stay unenforced in anomaly — unlike `name` those
// have NO pre-existing rule anywhere, so requiring them would be a real
// behavior change. Still OPEN (see the report's OPEN DECISION 7 note).
//
// Rule ④ — the composition reuses the step schemas VERBATIM (import, never
// re-declare) so the step and the orchestrator can never drift. Numbers still
// come out of OFormInput as strings; the sub-schemas own their z.coerce.number()
// / raw-value checks (nothing tightened here).
// ════════════════════════════════════════════════════════════════════════════
import { z } from "zod";
import {
makeQueryConfigSchema,
type QueryConfigMeta,
type Translator,
} from "./steps/QueryConfig.schema";
import { createAlertSettingsSchema } from "./steps/AlertSettings.schema";
// ── Topbar messages are i18n-driven, resolved via the injected `t`:
// name required → alerts.nameRequired
// name special chars → alerts.nameNoSpecialChars
// stream type → alerts.validation.streamTypeRequired
// stream name → alerts.validation.streamNameRequired
// (English values are byte-for-byte the pre-migration toast strings.) ──────
/** §4 restore — the exact regex useAlertForm used (RE_OFGA_UNSUPPORTED_NAME).
* Requiredness = min 1 AND this regex (truthy→Zod inversion, Rule ④). */
export const ALERT_NAME_UNSUPPORTED_CHARS = /[:#?\s'"%&]+/;
const isBlank = (v: unknown): boolean =>
v === undefined || v === null || (typeof v === "string" && v.trim() === "");
// ════════════════════════════════════════════════════════════════════════════
// Step-B field schemas (Phase 4b — the remaining wizard STEP children:
// Advanced / Deduplication / CompareWithPast). These are ADDITIVE and
// PERMISSIVE: none of these fields carried a required rule pre-migration, so
// nothing here tightens (Rule ④ parity — a previously-valid alert must still
// save). The step components bind these exact paths by nested `name=` into
// THIS orchestrator form (the composites below also drive the spec hosts).
// ════════════════════════════════════════════════════════════════════════════
/** One context-variable row. key/value are OPTIONAL (no pre-migration required
* rule — a blank row is allowed; getAlertPayload only copies non-blank key/value
* pairs into the payload object). `id` is kept for row identity in the help
* drawer / preview — it is NEVER used as the v-for `:key` (that must be the
* array index so an index-based `name=` binding survives a mid-list delete). */
export const contextAttributeRowSchema = z.object({
id: z.string().optional(),
key: z.string().optional(),
value: z.string().optional(),
});
/** `context_attributes` as the array of key/value rows the FORM holds. The
* payload transform (getAlertPayload) folds this array → an object, dropping
* blank rows — so this array shape is the form-side single source of truth. */
export const contextAttributesSchema = z.array(contextAttributeRowSchema);
/** Advanced-tab composite (Advanced.vue own-mode <OForm> schema). `looseObject`
* so the rest of the shared alert object passes through untouched. */
export const advancedShape = {
template: z.string().optional(),
context_attributes: contextAttributesSchema.optional(),
description: z.string().optional(),
row_template: z.string().optional(),
row_template_type: z.string().optional(),
} as const;
export const createAdvancedSchema = () => z.looseObject({ ...advancedShape });
export type AdvancedForm = z.infer<ReturnType<typeof createAdvancedSchema>>;
/** Deduplication (scheduled-only). All fields optional (no pre-migration
* required rule). `enabled` is DERIVED from `fingerprint_fields.length > 0` by
* the component; `time_window_minutes` is sanitized to number|undefined by the
* component — the raw form state may transiently hold a string, so the union
* keeps validation permissive (never rejects while typing). */
export const deduplicationSchema = z.object({
enabled: z.boolean().optional(),
fingerprint_fields: z.array(z.string()).optional(),
time_window_minutes: z
.union([z.number(), z.string(), z.undefined()])
.optional(),
});
export type DeduplicationForm = z.infer<typeof deduplicationSchema>;
/** Compare-with-past reference-window row. `offSet` is a relative-time string
* (e.g. "15m") edited by the BARE CustomDateTimePicker (a genuine non-form
* widget, kept out of OForm* and bridged). `uuid` is the row identity used as
* the v-for `:key` — correct HERE because the row is bound by object reference,
* NOT by an index-based OForm* `name=` (so the mid-list-delete index bug that
* forces `:key="index"` on OForm* arrays does not apply). */
export const multiTimeRangeRowSchema = z.object({
offSet: z.string(),
uuid: z.string().optional(),
});
export const multiTimeRangeSchema = z.array(multiTimeRangeRowSchema);
/**
* Full alert form schema. `looseObject` so all the non-form alert fields
* (template, context_attributes, enabled, description, row_template*, folder_id,
* multi_time_range, vrl_function, …) that the payload reads pass through
* untouched — requiredness lives entirely in the superRefine, so nothing
* tightens by accident (Rule ④) and an untouched alert object parses clean.
*
* `is_real_time` discriminates; the QueryConfig `_meta` discriminators (bridged
* into this form by QueryConfig's syncMeta watcher) gate the QueryConfig rules.
*/
export const makeAddAlertSchema = (t: Translator) =>
z
.looseObject({
name: z.string().optional(),
stream_type: z.string().optional(),
stream_name: z.string().optional(),
is_real_time: z.string().optional(),
destinations: z.array(z.string()).optional(),
creates_incident: z.boolean().optional(),
trigger_condition: z.looseObject({}).optional(),
query_condition: z.looseObject({}).optional(),
logGroupBy: z.array(z.string()).optional(),
// ── Step-B fields (Advanced / Deduplication tabs). Permissive/optional —
// documented pass-throughs, NOT validated (parity: no required rule
// pre-migration). The superRefine below adds NO issues for them, so the
// assembled payload + form validity stay byte-for-byte unchanged. ────
context_attributes: contextAttributesSchema.optional(),
deduplication: deduplicationSchema.optional(),
template: z.string().optional(),
description: z.string().optional(),
row_template: z.string().optional(),
row_template_type: z.string().optional(),
// Required by the composed queryConfigSchema; QueryConfig keeps it fresh.
_meta: z.looseObject({}).optional(),
})
.superRefine((val: any, ctx) => {
const mode = val.is_real_time;
// ── ANOMALY branch: the detection-config fields are validated by
// AnomalyDetectionConfig's own OForm; only `name` lives on THIS form. Blank
// check re-homed from saveAnomalyDetection's toast (same message) so the
// topbar field actually highlights. No special-chars refine here — that rule
// is alert-only and would tighten anomaly vs. BEFORE.
if (mode === "anomaly") {
if (isBlank(val.name)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["name"],
message: t("alerts.anomalyNameRequired"),
});
}
return;
}
// ── Topbar: name required + no special chars (§4), stream type/name required.
if (isBlank(val.name)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["name"],
message: t("alerts.nameRequired"),
});
} else if (ALERT_NAME_UNSUPPORTED_CHARS.test(String(val.name))) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["name"],
message: t("alerts.nameNoSpecialChars"),
});
}
if (isBlank(val.stream_type)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["stream_type"],
message: t("alerts.validation.streamTypeRequired"),
});
}
if (isBlank(val.stream_name)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["stream_name"],
message: t("alerts.validation.streamNameRequired"),
});
}
// ── QueryConfig rules (REUSED verbatim). Delegate to the exported schema and
// re-home every issue at its exact nested path so the descendant OForm*
// fields surface them. Its superRefine self-gates realtime vs scheduled via
// `_meta.isRealTime`, and the promql/threshold/frequency/group_by/conditions
// rules land only in the branch where they applied pre-migration.
const qc = makeQueryConfigSchema(t).safeParse(val);
if (!qc.success) {
for (const issue of qc.error.issues) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: issue.path as (string | number)[],
message: issue.message,
});
}
}
// ── AlertSettings rules (REUSED verbatim). Period is enforced ≥ 1 for
// scheduled only; realtime keeps period rule-free (createAlertSettingsSchema
// toggles it). silence ≥ 0 + destinations ≥ 1 apply in both non-anomaly modes.
const isRealTime = mode === "true";
const as = createAlertSettingsSchema(t, isRealTime).safeParse(val);
if (!as.success) {
for (const issue of as.error.issues) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: issue.path as (string | number)[],
message: issue.message,
});
}
}
});
export type AddAlertForm = z.infer<ReturnType<typeof makeAddAlertSchema>>;
/** A complete, valid `_meta` block for the form defaults (before QueryConfig
* mounts and takes over via its syncMeta watcher). Mirrors QueryConfig's
* initialMeta for a fresh custom/logs/scheduled alert. */
export const defaultAddAlertMeta = (
overrides: Partial<QueryConfigMeta> = {},
): QueryConfigMeta => ({
tab: "custom",
isRealTime: "false",
isEventBased: true,
selectedFunction: "total_events",
frequencyMode: "minutes",
hasConditions: false,
hasGroupBy: false,
aggregationEnabled: false,
// No org floor by default — the rule is skipped until QueryConfig/useAlertForm
// bridge the real zoConfig value in (never invent a floor).
minAutoRefreshInterval: 0,
...overrides,
});
File diff suppressed because it is too large Load Diff
+106 -50
View File
@@ -15,7 +15,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div class="w-full h-full">
<!-- AddAlert OWNS the ONE form (Rule ③ owner pattern): `form` is created in
setup() via useAlertForm's useOForm and handed to <OForm :form> so the
topbar OForm* fields and the already-migrated descendant steps
(QueryConfig / AlertSettings) bind by nested `name=` into it. -->
<OForm :form="form" class="w-full h-full">
<!-- ═══════════════════════════════════════════════════════════════════ -->
<!-- V3 "Single Pane of Glass" Layout (All alert types) -->
@@ -66,7 +70,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-if="anomalyConfig.last_detection_run && anomalyConfig.last_detection_run > 0"
class="text-[11px] whitespace-nowrap text-text-secondary"
>
Last run: {{ anomalyFormatTs(anomalyConfig.last_detection_run) }}
{{ t('alerts.lastRun', { time: anomalyFormatTs(anomalyConfig.last_detection_run) }) }}
</span>
<OButton v-if="anomalyConfig.status === 'failed'" variant="ghost-destructive" size="xs" :loading="anomalyRetraining" @click="anomalyTriggerRetrain" icon-left="replay">
{{ t('alerts.retry') }}
@@ -82,20 +86,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<div class="flex items-center gap-1.5 shrink-0">
<div class="text-xs font-semibold whitespace-nowrap" :class="store.state.theme === 'dark' ? 'text-[rgba(255,255,255,0.7)]' : 'text-[rgba(0,0,0,0.72)]'">{{ isAnomalyMode ? t('alerts.anomalyName') : t('alerts.incidents.alertName') }} <span class="text-text-primary">*</span></div>
<OInput
<OFormInput
v-if="!isAnomalyMode"
ref="step1Ref"
v-model="formData.name"
name="name"
data-test="add-alert-name-input"
:placeholder="t('alerts.alertNamePlaceholder')"
class="topbar-name-input text-sm h-[28px]! min-h-[28px]! min-w-[120px] max-w-[150px]"
:class="alertNameError ? 'field-error' : ''"
@update:model-value="alertNameError = false"
/>
<OInput
<!-- Anomaly name binds the SAME `name` field as the alert name, not
`anomalyConfig.name`: a bare OInput has no field for the schema
to paint, so a blank name could only ever toast. useAlertForm's
formData.name → anomalyConfig.name watcher still feeds the value
saveAnomalyDetection reads, and anomaly edit-load already seeds
it via setF("name", data.name). -->
<OFormInput
v-else
ref="anomalyNameRef"
v-model="anomalyConfig.name"
name="name"
data-test="add-anomaly-name-input"
:placeholder="t('alerts.anomalyNamePlaceholder')"
class="topbar-name-input text-sm h-[28px]! min-h-[28px]! min-w-[120px] max-w-[150px]"
/>
@@ -130,45 +139,43 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Stream Type -->
<div class="flex items-center gap-1.5">
<div class="text-xs font-semibold whitespace-nowrap" :class="store.state.theme === 'dark' ? 'text-[rgba(255,255,255,0.7)]' : 'text-[rgba(0,0,0,0.72)]'">{{ t("alerts.streamType") }} <span class="text-text-primary">*</span></div>
<OSelect
<OFormSelect
ref="streamTypeRef"
name="stream_type"
data-test="add-alert-stream-type-select-dropdown"
v-model="formData.stream_type"
:options="streamTypes"
:searchable="false"
class="stream-type-select h-[28px]! min-h-[28px]!"
:class="streamTypeError ? 'field-error' : ''"
class="stream-type-select"
:disabled="beingUpdated || anomalyEditMode"
@update:model-value="streamTypeError = false; updateStreams()"
@update:model-value="onStreamTypeChange"
/>
</div>
<!-- Stream Name -->
<div class="flex items-center gap-1.5">
<div class="text-xs font-semibold whitespace-nowrap" :class="store.state.theme === 'dark' ? 'text-[rgba(255,255,255,0.7)]' : 'text-[rgba(0,0,0,0.72)]'">{{ t("alerts.stream_name") }} <span class="text-text-primary">*</span></div>
<OSelect
<OFormSelect
ref="streamNameRef"
name="stream_name"
data-test="add-alert-stream-name-select-dropdown"
v-model="formData.stream_name"
:options="indexOptions"
:loading="isFetchingStreams"
class="stream-name-select h-[28px]! min-h-[28px]!"
:class="streamNameError ? 'field-error' : ''"
class="stream-name-select"
:disabled="beingUpdated || anomalyEditMode || !formData.stream_type"
@update:model-value="streamNameError = false; updateStreamFields($event)"
@update:model-value="updateStreamFields($event)"
/>
<OTooltip v-if="!formData.stream_type" :content="t('alerts.selectStreamTypeFirst')" />
</div>
<!-- Alert Type -->
<div class="flex items-center gap-1.5">
<div class="text-xs font-semibold whitespace-nowrap" :class="store.state.theme === 'dark' ? 'text-[rgba(255,255,255,0.7)]' : 'text-[rgba(0,0,0,0.72)]'">{{ t("alerts.alertType") || 'Alert Type' }}</div>
<OSelect
<div class="text-xs font-semibold whitespace-nowrap" :class="store.state.theme === 'dark' ? 'text-[rgba(255,255,255,0.7)]' : 'text-[rgba(0,0,0,0.72)]'">{{ t("alerts.alertType") }}</div>
<OFormSelect
data-test="add-alert-type-select-dropdown"
v-model="formData.is_real_time"
name="is_real_time"
:options="alertTypeOptions"
:disabled="beingUpdated || anomalyEditMode"
class="alert-type-select h-[28px]! min-h-[28px]!"
class="alert-type-select"
:searchable="false"
/>
</div>
@@ -203,7 +210,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Tab Content -->
<div class="flex-1 overflow-auto">
<!-- Alert Rules Tab (Conditions + Alert Settings merged) -->
<div v-show="activeTab === 'condition'" class="flex flex-col gap-4">
<!-- data-tab-pane: lets focusOnFirstError find the tab owning an
invalid field and bring it forward before focusing it. -->
<div v-show="activeTab === 'condition'" data-tab-pane="condition" class="flex flex-col gap-4">
<div>
<QueryConfig
ref="step2Ref"
@@ -228,17 +237,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@update-group="updateGroup"
@remove-group="removeConditionGroup"
@input:update="onInputUpdate"
@update:sqlQuery="(value) => (formData.query_condition.sql = value)"
@update:promqlQuery="(value) => (formData.query_condition.promql = value)"
@update:vrlFunction="(value) => (formData.query_condition.vrl_function = value)"
@update:sqlQuery="updateSqlQuery"
@update:promqlQuery="updatePromqlQuery"
@update:vrlFunction="updateVrlFunction"
@validate-sql="validateSqlQuery"
@clear-multi-windows="clearMultiWindows"
@editor-closed="handleEditorClosed"
@editor-state-changed="handleEditorStateChanged"
@update:isAggregationEnabled="(value) => (isAggregationEnabled = value)"
@update:aggregation="(value) => (formData.query_condition.aggregation = value)"
@update:promqlCondition="(val) => (formData.query_condition.promql_condition = val)"
@update:triggerCondition="(val) => (formData.trigger_condition = val)"
@update:aggregation="updateAggregation"
@update:promqlCondition="updatePromqlCondition"
@update:triggerCondition="updateTriggerCondition"
/>
</div>
@@ -251,17 +260,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:isAggregationEnabled="isAggregationEnabled"
:destinations="formData.destinations"
:formattedDestinations="getFormattedDestinations"
@update:trigger="(val) => (formData.trigger_condition = val)"
@update:aggregation="(val) => (formData.query_condition.aggregation = val)"
@update:trigger="updateTriggerCondition"
@update:aggregation="updateAggregation"
@update:isAggregationEnabled="(val) => (isAggregationEnabled = val)"
@update:promqlCondition="(val) => (formData.query_condition.promql_condition = val)"
@update:promqlCondition="updatePromqlCondition"
@update:destinations="updateDestinations"
@refresh:destinations="refreshDestinations"
/>
</div>
</div>
<div v-show="activeTab === 'advanced'" class="flex flex-col gap-4">
<div v-show="activeTab === 'advanced'" data-tab-pane="advanced" class="flex flex-col gap-4">
<!-- Additional Settings (first) -->
<div>
@@ -279,12 +288,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:streamType="formData.stream_type"
:triggerCondition="formData.trigger_condition"
:streamFields="filteredColumns"
@update:template="(val) => (formData.template = val)"
@update:template="updateTemplate"
@refresh:templates="refreshTemplates"
@update:contextAttributes="(val) => (formData.context_attributes = val)"
@update:description="(val) => (formData.description = val)"
@update:rowTemplate="(val) => (formData.row_template = val)"
@update:rowTemplateType="(val) => (formData.row_template_type = val)"
@update:contextAttributes="updateContextAttributes"
@update:description="updateDescription"
@update:rowTemplate="updateRowTemplate"
@update:rowTemplateType="updateRowTemplateType"
/>
</div>
@@ -298,7 +307,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:frequencyType="formData.trigger_condition.frequency_type"
:cron="formData.trigger_condition.cron"
:selectedTab="formData.query_condition.type || 'custom'"
@update:multiTimeRange="(val) => (formData.query_condition.multi_time_range = val)"
@update:multiTimeRange="updateMultiTimeRange"
/>
</div>
@@ -307,19 +316,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<Deduplication
:deduplication="formData.deduplication"
:columns="filteredColumns"
@update:deduplication="(val) => (formData.deduplication = val)"
@update:deduplication="updateDeduplication"
/>
</div>
</div>
<div v-show="activeTab === 'anomaly-config'">
<div v-show="activeTab === 'anomaly-config'" data-tab-pane="anomaly-config">
<AnomalyDetectionConfig
ref="anomalyStep2Ref"
:config="anomalyConfig"
/>
</div>
<div v-show="activeTab === 'anomaly-alerting'">
<div v-show="activeTab === 'anomaly-alerting'" data-tab-pane="anomaly-alerting">
<AnomalyAlerting
:config="anomalyConfig"
:destinations="destinations"
@@ -344,7 +353,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
variant="primary"
size="sm-action"
:loading="isAnomalyMode ? anomalySaving : false"
:disabled="isAnomalyMode ? !canSaveAlert : false"
@click="handleSave"
>{{ isAnomalyMode && !anomalyEditMode ? t('alerts.saveAndTrain') : t('alerts.save') }}</OButton>
</div>
@@ -359,7 +367,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div
class="flex items-center px-3 py-[0.625rem] select-none border-b border-border-default shrink-0 gap-2"
>
<span class="text-sm font-medium">{{ isAnomalyMode ? t('alerts.sqlPreview') : (t('alerts.preview') || 'Preview') }}</span>
<span class="text-sm font-medium">{{ isAnomalyMode ? t('alerts.sqlPreview') : t('alerts.preview') }}</span>
<template v-if="!isAnomalyMode && activeEvaluationStatus">
<div class="w-px h-4 bg-border-default" />
<OIcon :name="activeEvaluationStatus.wouldTrigger ? 'check-circle' : 'cancel'" :class="activeEvaluationStatus.wouldTrigger ? 'text-[var(--o2-positive)]' : 'text-[var(--o2-gray)]'" size="sm" />
@@ -401,7 +409,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div
class="flex items-center px-3 py-[0.625rem] select-none border-b border-border-default shrink-0"
>
<span class="text-sm font-medium">{{ t('alerts.summary.title') || 'Summary' }}</span>
<span class="text-sm font-medium">{{ t('alerts.summary.title') }}</span>
</div>
<div class="flex-1 min-h-0" style="overflow: auto;">
<AnomalySummary
@@ -425,7 +433,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</div>
</div>
</OForm>
<ODrawer data-test="add-alert-json-editor-drawer"
v-model:open="showJsonEditorDialog"
@@ -434,7 +442,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
persistent
>
<JsonEditor
:data="formData"
:data="jsonEditorData"
:title="t('alerts.editJson')"
:type="'alerts'"
:validation-errors="validationErrors"
@@ -469,7 +477,9 @@ import { useAlertForm, defaultAlertValue } from "@/composables/useAlertForm";
import ODrawer from "@/lib/overlay/Drawer/ODrawer.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OForm from "@/lib/forms/Form/OForm.vue";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import OFormSelect from "@/lib/forms/Select/OFormSelect.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import AppPageHeader from "@/components/common/AppPageHeader.vue";
@@ -521,7 +531,9 @@ export default defineComponent({
OTag,
OTooltip,
OInput,
OSelect,
OForm,
OFormInput,
OFormSelect,
AppPageHeader,
},
setup(props, { emit }) {
@@ -570,7 +582,7 @@ export default defineComponent({
const tabs = alertForm.isAnomalyMode.value
? [
{ key: "anomaly-config", label: alertForm.t("alerts.anomalyDetectionConfig"), required: true },
{ key: "anomaly-alerting", label: alertForm.t("alerts.alerting") || "Alerting", required: alertForm.anomalyConfig.value.alert_enabled },
{ key: "anomaly-alerting", label: alertForm.t("alerts.alerting"), required: alertForm.anomalyConfig.value.alert_enabled },
]
: [
{ key: "condition", label: alertForm.t("alerts.alertRules"), required: true },
@@ -594,6 +606,15 @@ export default defineComponent({
});
};
// Stream-type change: write the value into the ONE form explicitly (so the
// synchronous form store is current before updateStreams reads it — not
// relying on the OFormSelect field-change flush order), then refresh streams
// (which also resets stream_name via setF).
const onStreamTypeChange = (value: any) => {
alertForm.setF("stream_type", value);
alertForm.updateStreams();
};
return {
...alertForm,
isAnomalyDetectionEnabled,
@@ -601,6 +622,7 @@ export default defineComponent({
alertTabs,
activeFolderName,
goBackToAlertsList,
onStreamTypeChange,
activeEvaluationStatus,
};
},
@@ -609,6 +631,37 @@ export default defineComponent({
</script>
<style>
/* Compact rows: float the validation message so it never resizes the row.
A class passed to OInput/OSelect lands on the ROOT wrapper — the flex-col
holding the control AND its message — so an in-flow message would grow these
fixed-height rows, escape the card, and be painted over by the next card.
Out of flow, the row keeps its height and the message renders under the
control, above the neighbouring card.
`top:100%` resolves against the wrapper, so a wrapper whose height understates
the control it contains would tuck the message UNDER that control. The
topbar's h-[28px] genuinely compresses its input, so it anchors correctly; the
selects had to drop their (inert) clamp for the same to hold — see below.
pointer-events:none so the floating text can't swallow clicks on the tab bar
it overlaps. */
.topbar-name-input,
.stream-type-select,
.stream-name-select {
position: relative;
}
.topbar-name-input [role="alert"],
.stream-type-select [role="alert"],
.stream-name-select [role="alert"] {
position: absolute;
top: 100%;
left: 0;
margin-top: 2px;
white-space: nowrap;
pointer-events: none;
z-index: 10;
}
@container topbar (max-width: 1300px) {
.topbar-name-input { min-width: 100px; }
}
@@ -634,6 +687,9 @@ export default defineComponent({
cursor: pointer;
}
/* No h-[28px] clamp on these three: it only ever shrank the wrapper BOX, never
the trigger (which keeps its 34px design-system height and simply overflowed),
so it ate the row's bottom padding and mis-anchored the floating message. */
.stream-type-select { width: 150px; }
.stream-name-select { width: 160px; }
.alert-type-select { min-width: 110px; }
@@ -0,0 +1,209 @@
// Copyright 2026 OpenObserve Inc.
//
// Validation schema for AddDestination.vue — a destination form discriminated by
// the (bridged, non-<input>) `destination_type` card-selector + the `type` tabs.
// Built via a factory so messages stay i18n-driven (`t`) and the per-instance
// `isAlerts` prop can gate the alerts-only vs pipeline-only rules.
//
// Migration notes (form-migration-playbook / START-HERE Rule ①/②/④):
// • `name`/`url`/`emails` — genuinely user-typed → form-owned OFormInput; rules
// mirror the old hand-rolled `save()` error refs exactly.
// • `method`/`output_format`/`action_id`/`template` — form-owned OFormSelect.
// • `skip_tls_verify` — form-owned OFormSwitch (boolean).
// • `apiHeaders` — a Rule-① dynamic array-field (`apiHeaders[i].key/.value`),
// each row form-owned (playbook §2 array-field pattern); rebuilt into the
// `headers` map at submit. No per-row rule (blank starter row is valid).
// • `destination_type` / `type` — NOT <input>s: a custom card grid + app-tabs.
// Both are bridged into the form via `setFieldValue` from their own handlers
// (the sanctioned Rule-② bridge) so `superRefine` can branch on them.
//
// SINGLE-FORM design (prebuilt + custom share ONE <OForm id="add-destination-form">):
// there is NO nested child credential form any more. The per-type credential
// fields render (via PrebuiltDestinationForm, now a presentational descendant)
// as OForm* controls named `credentials.<key>` inside THIS form, so Enter/Save
// submit the one form from anywhere and the schema validates everything together.
// The prebuilt credential rules are composed in `superRefine` from the SAME
// `getPrebuiltConfig().credentialFields` single source of truth (via
// `makePrebuiltDestinationSchema`), re-homed under the `credentials.*` path.
//
// Rule-④ parity: the custom-only rules (url/method/email/action/output_format/
// template) are scoped to NON-prebuilt paths — a prebuilt destination keeps the
// default `type:"http"` (or `"email"` for the prebuilt email type), so without
// that scoping the `type==="http"` url/method rule (and the `type==="email"`
// emails rule) would fire on fields the prebuilt UI never renders and wrongly
// block the save. The prebuilt-side `name` stays UN-required (the pre-migration
// prebuilt save applied no name/template validation) — unchanged from before.
//
// Validation TIMING is owned by OForm (submit-then-change via revalidateLogic);
// this file only describes WHAT is valid.
import { z } from "zod";
import { isValidResourceName } from "@/utils/zincutils";
import { makePrebuiltDestinationSchema } from "./PrebuiltDestinationForm.schema";
// Mirrors the old `save()` name gate:
// !name ? nameRequired : (!isValidResourceName(name) ? charsMsg : "")
// nameRequired → i18n `common.nameRequired`; charsMsg → `alerts.validation.nameInvalidChars`.
// Byte-identical to the old inline email-list regex in save().
const EMAIL_LIST_REGEX =
/^([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})(\s*[;,]\s*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}))*$/;
// One row in the dynamic api-headers array-field. Both fields are free-form
// text (a blank starter row is valid — only non-empty rows persist on save).
export const headerRowSchema = z.object({
key: z.string(),
value: z.string(),
});
export const makeAddDestinationSchema = (
t: (_key: string, _named?: Record<string, unknown>) => string,
isAlerts: boolean,
) =>
z
.object({
// ── Discriminators (bridged from the card grid + tabs) ───────────────
destination_type: z.string().optional().default(""),
type: z.string().optional().default("http"),
// ── Form-owned fields (rules enforced in superRefine below) ──────────
name: z.string().optional().default(""),
template: z.string().optional().default(""),
url: z.string().optional().default(""),
method: z.string().optional().default("post"),
output_format: z.string().optional().default("json"),
emails: z.string().optional().default(""),
action_id: z.string().optional().default(""),
skip_tls_verify: z.boolean().optional().default(false),
// ── Form-owned dynamic api-headers rows ──────────────────────────────
apiHeaders: z.array(headerRowSchema).default([]),
// ── Prebuilt credentials (per-type record; validated in superRefine from
// the active type's credentialFields). Empty for custom/pipeline. ────
credentials: z.record(z.string(), z.unknown()).default({}),
})
.superRefine((val, ctx) => {
const trimmed = (s: unknown) => String(s ?? "").trim();
const isPrebuilt =
isAlerts && !!val.destination_type && val.destination_type !== "custom";
// name: required + resource-name check. Scoped to NON-prebuilt paths —
// the pre-migration form never validated the prebuilt-side name.
if (!isPrebuilt) {
if (!trimmed(val.name)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["name"],
message: t("common.nameRequired"),
});
} else if (!isValidResourceName(String(val.name))) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["name"],
message: t("alerts.validation.nameInvalidChars"),
});
}
}
// template: required only for custom alert destinations.
if (isAlerts && val.destination_type === "custom" && !val.template) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["template"],
message: t("alerts.validation.templateRequired"),
});
}
// url + method: required for HTTP destinations (custom + pipeline). NOT
// for prebuilt — those keep the default type:"http" but render no url/
// method inputs (their endpoint comes from the credential template).
if (!isPrebuilt && val.type === "http") {
if (!trimmed(val.url)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["url"],
message: t("alerts.validation.fieldRequired"),
});
}
if (!val.method) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["method"],
message: t("alerts.validation.fieldRequired"),
});
}
}
// output_format: required only for pipeline (non-alert) destinations.
if (!isAlerts && !val.output_format) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["output_format"],
message: t("alerts.validation.fieldRequired"),
});
}
// emails: required + valid list for the CUSTOM email type. NOT for the
// prebuilt email type — it collects recipients via `credentials.recipients`
// (selectDestinationType sets type:"email" for it, so scope this out).
if (!isPrebuilt && val.type === "email") {
if (!val.emails || !EMAIL_LIST_REGEX.test(String(val.emails))) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["emails"],
message: t("alerts.validation.validEmails"),
});
}
}
// action_id: required for action destinations.
if (!isPrebuilt && val.type === "action" && !val.action_id) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["action_id"],
message: t("alerts.validation.fieldRequired"),
});
}
// Prebuilt credentials: validate the active type's credential fields from
// the SAME config-driven schema, re-homed under `credentials.*` so each
// issue lands on its OFormInput (name=`credentials.<key>`). This is what
// makes the single-form Save/Enter gate on the credential rules.
if (isPrebuilt) {
const credResult = makePrebuiltDestinationSchema(
t,
String(val.destination_type),
).safeParse((val.credentials ?? {}) as Record<string, unknown>);
if (!credResult.success) {
for (const issue of credResult.error.issues) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["credentials", ...issue.path],
message: issue.message,
});
}
}
}
});
export type AddDestinationForm = z.infer<
ReturnType<typeof makeAddDestinationSchema>
>;
// Static (create-only) defaults. Edit prefill is applied at runtime via
// setFieldValue in setupDestinationData (see AddDestination.vue), not here.
export const addDestinationDefaults = (): AddDestinationForm => ({
destination_type: "",
type: "http",
name: "",
template: "",
url: "",
method: "post",
output_format: "json",
emails: "",
action_id: "",
skip_tls_verify: false,
apiHeaders: [{ key: "", value: "" }],
credentials: {},
});
+429 -151
View File
@@ -13,11 +13,21 @@
// 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, expect, it, beforeEach, vi } from "vitest";
// Behavior-first spec for the OForm + Zod migration of AddDestination.
// Drives the REAL parent <OForm> (schema-gated custom/pipeline save) + proves
// the apiHeaders field-array delete keeps the RENDERED inputs aligned, and the
// prebuilt coupling (child form @submit → composable save).
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { nextTick } from "vue";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
const hoisted = vi.hoisted(() => ({
mockCreatePrebuilt: vi.fn(),
mockUpdatePrebuilt: vi.fn(),
}));
vi.mock("vue-router", () => ({
useRouter: () => ({ push: vi.fn() }),
@@ -29,7 +39,6 @@ vi.mock("@/services/alert_destination", () => ({
create: vi.fn().mockResolvedValue({ data: { code: 200 } }),
update: vi.fn().mockResolvedValue({ data: { code: 200 } }),
test: vi.fn().mockResolvedValue({ data: { code: 200 } }),
getPrebuiltTypes: vi.fn().mockResolvedValue({ data: [] }),
},
}));
@@ -37,17 +46,47 @@ vi.mock("@/services/reodotdev_analytics", () => ({
useReo: () => ({ track: vi.fn() }),
}));
vi.mock("@/composables/useActions", () => ({
default: () => ({ getAllActions: vi.fn().mockResolvedValue([]) }),
}));
vi.mock("@/composables/usePrebuiltDestinations", async () => {
const { ref, computed } = await import("vue");
return {
usePrebuiltDestinations: () => ({
availableTypes: computed(() => [{ id: "slack", name: "Slack" }]),
popularTypes: computed(() => []),
validateCredentials: vi.fn(),
testDestination: vi.fn(),
createDestination: hoisted.mockCreatePrebuilt,
updateDestination: hoisted.mockUpdatePrebuilt,
generatePreview: vi.fn(),
isTestInProgress: ref(false),
lastTestResult: ref(null),
detectPrebuiltType: vi.fn(),
getPrebuiltConfig: vi.fn(),
isPrebuiltType: vi.fn(),
generateDestinationUrl: vi.fn(),
generateDestinationHeaders: vi.fn(),
}),
};
});
import AddDestination from "@/components/alerts/AddDestination.vue";
import destinationService from "@/services/alert_destination";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
async function mountComp(props: Record<string, any> = {}) {
let wrapper: any = null;
afterEach(() => {
wrapper?.unmount();
wrapper = null;
});
function mountComp(props: Record<string, any> = {}) {
return mount(AddDestination, {
props: {
templates: [],
destination: null,
isAlerts: true,
...props,
},
props: { templates: [], destination: null, isAlerts: true, ...props },
global: {
plugins: [i18n, store],
stubs: {
@@ -56,174 +95,413 @@ async function mountComp(props: Record<string, any> = {}) {
props: ["modelValue", "searchQuery"],
emits: ["update:modelValue", "select", "update:searchQuery"],
},
PrebuiltDestinationForm: {
template: '<div data-test="prebuilt-form-stub"></div>',
props: ["modelValue", "destinationType", "hideActions"],
emits: ["update:modelValue"],
},
DestinationTestResult: {
template: '<div data-test="destination-test-result-stub"></div>',
props: ["result"],
},
DestinationPreview: {
template: '<div data-test="destination-preview-stub"></div>',
props: ["destination"],
props: ["type", "templateContent"],
},
AppTabs: {
template: '<div data-test="app-tabs-stub"></div>',
props: ["tabs", "activeTab"],
emits: ["update:activeTab"],
},
},
},
});
}
describe("AddDestination - rendering (create mode)", () => {
beforeEach(() => vi.clearAllMocks());
// The REAL parent TanStack form (the first OForm — the child prebuilt form is
// only mounted for prebuilt types).
const getForm = (w: any) => w.findComponent({ name: "OForm" }).vm.form;
it("renders without errors", async () => {
const w = await mountComp();
expect(w.exists()).toBe(true);
// Put the parent form into the CUSTOM (alerts) http branch.
async function toCustomHttp(w: any) {
const form = getForm(w);
form.setFieldValue("destination_type", "custom");
form.setFieldValue("type", "http");
await nextTick();
return form;
}
describe("AddDestination - rendering", () => {
it("renders the title (create mode)", () => {
wrapper = mountComp({ destination: null });
expect(wrapper.find('[data-test="add-destination-title"]').exists()).toBe(
true,
);
});
it("renders the title element", async () => {
const w = await mountComp();
expect(w.find('[data-test="add-destination-title"]').exists()).toBe(true);
it("renders url/method/cancel in custom mode", async () => {
wrapper = mountComp();
await toCustomHttp(wrapper);
expect(wrapper.find('[data-test="add-destination-url-input"]').exists()).toBe(
true,
);
expect(
wrapper.find('[data-test="add-destination-method-select"]').exists(),
).toBe(true);
expect(
wrapper.find('[data-test="add-destination-cancel-btn"]').exists(),
).toBe(true);
});
it("shows add title when no destination prop", async () => {
const w = await mountComp({ destination: null });
const titleEl = w.find('[data-test="add-destination-title"]');
expect(titleEl.text()).toContain("New Destination");
it("Save button stays enabled (R3 — no :disabled)", async () => {
wrapper = mountComp();
await toCustomHttp(wrapper);
const save = wrapper.find('[data-test="add-destination-submit-btn"]');
expect(save.attributes("disabled")).toBeUndefined();
});
it("renders the cancel button", async () => {
const w = await mountComp();
// Set to custom mode to show url/method/cancel/submit
(w.vm as any).formData.destination_type = "custom";
await w.vm.$nextTick();
expect(w.find('[data-test="add-destination-cancel-btn"]').exists()).toBe(true);
});
it("renders the url input in custom mode", async () => {
const w = await mountComp();
(w.vm as any).formData.destination_type = "custom";
await w.vm.$nextTick();
expect(w.find('[data-test="add-destination-url-input"]').exists()).toBe(true);
});
it("renders the method select in custom mode", async () => {
const w = await mountComp();
(w.vm as any).formData.destination_type = "custom";
await w.vm.$nextTick();
expect(w.find('[data-test="add-destination-method-select"]').exists()).toBe(true);
});
});
describe("AddDestination - rendering (update mode)", () => {
const existingDest = {
name: "my-dest",
url: "https://hooks.slack.com/test",
method: "post",
headers: {},
template: "tmpl1",
destination_type: "custom",
};
it("shows update title when destination prop provided", async () => {
const w = await mountComp({ destination: existingDest });
await flushPromises();
const titleEl = w.find('[data-test="add-destination-title"]');
expect(titleEl.text()).toContain("Update");
});
});
describe("AddDestination - initial state", () => {
it("formData.name is empty by default", async () => {
const w = await mountComp();
expect((w.vm as any).formData.name).toBe("");
});
it("formData.method defaults to post", async () => {
const w = await mountComp();
expect((w.vm as any).formData.method).toBe("post");
});
it("isPrebuiltDestination is false by default", async () => {
const w = await mountComp();
expect((w.vm as any).isPrebuiltDestination).toBeFalsy();
});
});
describe("AddDestination - isPrebuiltDestination computed", () => {
it("returns false for empty destination_type", async () => {
const w = await mountComp();
(w.vm as any).formData.destination_type = "";
expect((w.vm as any).isPrebuiltDestination).toBeFalsy();
});
it("returns false for custom destination_type", async () => {
const w = await mountComp();
(w.vm as any).formData.destination_type = "custom";
expect((w.vm as any).isPrebuiltDestination).toBe(false);
});
it("returns true for prebuilt destination_type (e.g. slack)", async () => {
const w = await mountComp();
(w.vm as any).formData.destination_type = "slack";
expect((w.vm as any).isPrebuiltDestination).toBe(true);
});
});
describe("AddDestination - getDestinationTypeIcon", () => {
it("returns a string for known type", async () => {
const w = await mountComp();
const icon = (w.vm as any).getDestinationTypeIcon("slack");
expect(typeof icon).toBe("string");
});
it("returns a fallback string for unknown type", async () => {
const w = await mountComp();
const icon = (w.vm as any).getDestinationTypeIcon("unknown-type");
expect(typeof icon).toBe("string");
});
});
describe("AddDestination - getDestinationTypeName", () => {
it("returns typeId when no matching type found", async () => {
const w = await mountComp();
(w.vm as any).availableTypes = [];
const name = (w.vm as any).getDestinationTypeName("my-type");
expect(name).toBe("my-type");
});
it("returns type name when matching type found", async () => {
const w = await mountComp();
(w.vm as any).availableTypes = [{ id: "slack", name: "Slack" }];
const name = (w.vm as any).getDestinationTypeName("slack");
expect(name).toBe("Slack");
});
});
describe("AddDestination - cancel button", () => {
it("clicking cancel emits cancel:hideform", async () => {
const w = await mountComp();
(w.vm as any).formData.destination_type = "custom";
await w.vm.$nextTick();
await w.find('[data-test="add-destination-cancel-btn"]').trigger("click");
expect(w.emitted("cancel:hideform")).toBeTruthy();
wrapper = mountComp();
await toCustomHttp(wrapper);
await wrapper.find('[data-test="add-destination-cancel-btn"]').trigger("click");
expect(wrapper.emitted("cancel:hideform")).toBeTruthy();
});
});
describe("AddDestination - saveDestination", () => {
describe("AddDestination - custom path schema gating + payload parity", () => {
beforeEach(() => vi.clearAllMocks());
it("calls destinationService.create in create mode when form is filled", async () => {
const w = await mountComp({ destination: null, isAlerts: false });
it("blocks submit and does NOT call the service when required fields are empty", async () => {
wrapper = mountComp();
const form = await toCustomHttp(wrapper);
await form.handleSubmit();
await flushPromises();
(w.vm as any).formData.name = "dest1";
(w.vm as any).formData.url = "https://example.com/webhook";
(w.vm as any).formData.method = "post";
(w.vm as any).formData.template = "tmpl1";
(w.vm as any).formData.destination_type = "custom";
await (w.vm as any).saveDestination();
expect(form.state.isValid).toBe(false);
expect(destinationService.create).not.toHaveBeenCalled();
});
it("submits an exact custom HTTP (alerts) payload when valid", async () => {
wrapper = mountComp({ isAlerts: true });
const form = await toCustomHttp(wrapper);
form.setFieldValue("name", "dest1");
form.setFieldValue("url", "https://example.com/webhook");
form.setFieldValue("method", "post");
form.setFieldValue("template", "tmpl1");
form.setFieldValue("apiHeaders", [{ key: "H1", value: "V1" }]);
await nextTick();
await form.handleSubmit();
await flushPromises();
expect(destinationService.create).toHaveBeenCalled();
expect(form.state.isValid).toBe(true);
expect(destinationService.create).toHaveBeenCalledTimes(1);
expect(destinationService.create).toHaveBeenCalledWith({
org_identifier: "default",
destination_name: "dest1",
data: {
url: "https://example.com/webhook",
method: "post",
skip_tls_verify: false,
template: "tmpl1",
headers: { H1: "V1" },
name: "dest1",
},
});
});
it("email branch: invalid emails blocked; valid emails → array payload with type=email", async () => {
wrapper = mountComp({ isAlerts: true });
const form = getForm(wrapper);
form.setFieldValue("destination_type", "custom");
form.setFieldValue("type", "email");
form.setFieldValue("name", "dest-email");
form.setFieldValue("template", "tmpl1");
form.setFieldValue("emails", "not-an-email");
await nextTick();
await form.handleSubmit();
await flushPromises();
expect(form.state.isValid).toBe(false);
expect(destinationService.create).not.toHaveBeenCalled();
form.setFieldValue("emails", "a@b.com, c@d.com");
await nextTick();
await form.handleSubmit();
await flushPromises();
expect(form.state.isValid).toBe(true);
const payload = (destinationService.create as any).mock.calls[0][0].data;
expect(payload.type).toBe("email");
expect(payload.emails).toEqual(["a@b.com", "c@d.com"]);
expect(payload.name).toBe("dest-email");
});
it("action branch: missing action_id blocks the save", async () => {
// Enable the action tab for this org.
(store.state.zoConfig as any).actions_enabled = true;
wrapper = mountComp({ isAlerts: true });
const form = getForm(wrapper);
form.setFieldValue("destination_type", "custom");
form.setFieldValue("type", "action");
form.setFieldValue("name", "dest-action");
form.setFieldValue("template", "tmpl1");
await nextTick();
await form.handleSubmit();
await flushPromises();
expect(form.state.isValid).toBe(false);
expect(destinationService.create).not.toHaveBeenCalled();
});
it("template is required for custom alert destinations", async () => {
wrapper = mountComp({ isAlerts: true });
const form = await toCustomHttp(wrapper);
form.setFieldValue("name", "dest1");
form.setFieldValue("url", "https://example.com");
form.setFieldValue("method", "post");
// template left empty
await nextTick();
await form.handleSubmit();
await flushPromises();
expect(form.state.isValid).toBe(false);
expect(destinationService.create).not.toHaveBeenCalled();
});
it("rejects a name with invalid resource characters", async () => {
wrapper = mountComp({ isAlerts: true });
const form = await toCustomHttp(wrapper);
form.setFieldValue("name", "bad name?");
form.setFieldValue("url", "https://example.com");
form.setFieldValue("method", "post");
form.setFieldValue("template", "tmpl1");
await nextTick();
await form.handleSubmit();
await flushPromises();
expect(form.state.isValid).toBe(false);
expect(destinationService.create).not.toHaveBeenCalled();
});
});
describe("AddDestination - pipeline (!isAlerts) branch", () => {
beforeEach(() => vi.clearAllMocks());
it("requires output_format and ships it (with empty template) in the payload", async () => {
wrapper = mountComp({ isAlerts: false });
const form = getForm(wrapper);
// pipeline default type is http; destination_type stays "".
form.setFieldValue("name", "pipe-dest");
form.setFieldValue("url", "https://pipe.example.com");
form.setFieldValue("method", "post");
form.setFieldValue("output_format", "");
await nextTick();
await form.handleSubmit();
await flushPromises();
expect(form.state.isValid).toBe(false);
expect(destinationService.create).not.toHaveBeenCalled();
form.setFieldValue("output_format", "ndjson");
await nextTick();
await form.handleSubmit();
await flushPromises();
expect(form.state.isValid).toBe(true);
const payload = (destinationService.create as any).mock.calls[0][0].data;
expect(payload.output_format).toBe("ndjson");
expect(payload.template).toBe("");
expect(payload.name).toBe("pipe-dest");
expect(payload.url).toBe("https://pipe.example.com");
});
});
describe("AddDestination - apiHeaders field array (Rule ①)", () => {
it("deleting a NON-last row keeps the RENDERED inputs aligned (index :key)", async () => {
wrapper = mountComp({ isAlerts: true });
const form = await toCustomHttp(wrapper);
form.setFieldValue("apiHeaders", [
{ key: "ka", value: "va" },
{ key: "kb", value: "vb" },
{ key: "kc", value: "vc" },
]);
await nextTick();
// Delete the MIDDLE row (index 1 → key "kb").
await wrapper
.find('[data-test="add-destination-header-kb-delete-btn"]')
.trigger("click");
await nextTick();
// Read the RENDERED key inputs (OFormInput → OInput model-value), not the
// form.state.values — a stable-id :key bug leaves the data correct but the
// inputs shifted/blank.
const renderedKeys = wrapper
.findAllComponents(OFormInput)
.filter((c: any) => /^apiHeaders\[\d+\]\.key$/.test(String(c.props("name"))))
.map((c: any) => c.findComponent(OInput).props("modelValue"));
const renderedValues = wrapper
.findAllComponents(OFormInput)
.filter((c: any) => /^apiHeaders\[\d+\]\.value$/.test(String(c.props("name"))))
.map((c: any) => c.findComponent(OInput).props("modelValue"));
expect(renderedKeys).toEqual(["ka", "kc"]);
expect(renderedValues).toEqual(["va", "vc"]);
});
it("deleting the only row backfills a blank row", async () => {
wrapper = mountComp({ isAlerts: true });
const form = await toCustomHttp(wrapper);
form.setFieldValue("apiHeaders", [{ key: "only", value: "v" }]);
await nextTick();
await wrapper
.find('[data-test="add-destination-header-only-delete-btn"]')
.trigger("click");
await nextTick();
const rows = form.getFieldValue("apiHeaders");
expect(rows).toEqual([{ key: "", value: "" }]);
});
});
// Prebuilt destinations now live in the SAME single <OForm> (no nested child
// form): the credential inputs are `credentials.*` fields on the parent form, the
// parent schema validates them, and Enter/Save submit the ONE form. These tests
// drive that end-to-end path (the previously-untested submit wiring).
describe("AddDestination - prebuilt (single form, no nested <form>)", () => {
const VALID_SLACK = "https://hooks.slack.com/services/T000/B000/xxxxxxxx";
beforeEach(() => {
vi.clearAllMocks();
hoisted.mockCreatePrebuilt.mockResolvedValue(undefined);
hoisted.mockUpdatePrebuilt.mockResolvedValue(undefined);
});
// Put the ONE form into a prebuilt (slack) branch with valid credentials.
async function toValidSlack(w: any, name = "my-slack") {
const form = getForm(w);
form.setFieldValue("destination_type", "slack");
form.setFieldValue("type", "http");
form.setFieldValue("name", name);
form.setFieldValue("skip_tls_verify", true);
form.setFieldValue("credentials", { webhookUrl: VALID_SLACK, channel: "" });
await nextTick();
return form;
}
it("renders the credential fields inside the ONE form (no nested form)", async () => {
wrapper = mountComp({ isAlerts: true });
getForm(wrapper).setFieldValue("destination_type", "slack");
await nextTick();
expect(wrapper.find('[data-test="prebuilt-form"]').exists()).toBe(true);
// The credential input renders (it injected the parent form context).
expect(wrapper.find('[data-test="slack-webhook-url-input"]').exists()).toBe(
true,
);
// Exactly ONE <form> element — the nested prebuilt form is gone.
expect(wrapper.findAll("form").length).toBe(1);
});
it("Save targets the ONE parent form for prebuilt types too", async () => {
wrapper = mountComp({ isAlerts: true });
getForm(wrapper).setFieldValue("destination_type", "slack");
await nextTick();
const save = wrapper.find('[data-test="add-destination-submit-btn"]');
expect(save.attributes("form")).toBe("add-destination-form");
});
it("blocks the save when a required credential is empty (schema gates it)", async () => {
wrapper = mountComp({ isAlerts: true });
const form = getForm(wrapper);
form.setFieldValue("destination_type", "slack");
form.setFieldValue("name", "my-slack");
form.setFieldValue("credentials", { webhookUrl: "", channel: "" });
await nextTick();
await form.handleSubmit();
await flushPromises();
expect(form.state.isValid).toBe(false);
expect(hoisted.mockCreatePrebuilt).not.toHaveBeenCalled();
});
it("saves via createDestination when credentials + name are valid", async () => {
wrapper = mountComp({ isAlerts: true });
const form = await toValidSlack(wrapper);
await form.handleSubmit();
await flushPromises();
expect(form.state.isValid).toBe(true);
expect(hoisted.mockCreatePrebuilt).toHaveBeenCalledTimes(1);
expect(hoisted.mockCreatePrebuilt).toHaveBeenCalledWith(
"slack",
"my-slack",
{ webhookUrl: VALID_SLACK, channel: "" },
{},
true,
undefined,
);
expect(wrapper.emitted("get:destinations")).toBeTruthy();
expect(wrapper.emitted("cancel:hideform")).toBeTruthy();
});
it("EDIT mode: prefills credentials from a saved prebuilt destination and updates it", async () => {
const existingSlack: any = {
name: "old-slack",
url: VALID_SLACK,
type: "http",
method: "post",
template: "prebuilt_slack",
skip_tls_verify: false,
headers: {},
// prebuilt_type drives type resolution (Priority 1); credential_* metadata
// + the url are restored by extractPrebuiltCredentials.
metadata: { prebuilt_type: "slack", credential_channel: "#ops" },
};
wrapper = mountComp({ isAlerts: true, destination: existingSlack });
await flushPromises();
const form = getForm(wrapper);
// Prefill landed in the ONE form's credentials sub-object.
expect(form.state.values.destination_type).toBe("slack");
expect(form.state.values.credentials.webhookUrl).toBe(VALID_SLACK);
expect(form.state.values.credentials.channel).toBe("#ops");
await form.handleSubmit();
await flushPromises();
expect(form.state.isValid).toBe(true);
expect(hoisted.mockUpdatePrebuilt).toHaveBeenCalledTimes(1);
expect(hoisted.mockUpdatePrebuilt).toHaveBeenCalledWith(
"slack",
"old-slack", // original name
"old-slack", // (possibly new) name
{ webhookUrl: VALID_SLACK, channel: "#ops" },
{},
false,
"prebuilt_slack",
);
expect(hoisted.mockCreatePrebuilt).not.toHaveBeenCalled();
});
// The DOM wiring that makes Enter-in-any-field AND the footer Save submit the
// ONE form: the single credential-bearing <form> carries the OForm id, the Save
// button is type=submit associated to that same id, and there is no second form
// to steal the submit. (The submit→save behavior itself is proven deterministically
// by the handleSubmit test above; dispatching the native submit event is
// fire-and-forget/flaky per the playbook, so we assert the association instead.)
it("Enter/Save wiring: one <form id=add-destination-form> + a submit button bound to it", async () => {
wrapper = mountComp({ isAlerts: true });
await toValidSlack(wrapper);
const forms = wrapper.findAll("form");
expect(forms.length).toBe(1);
expect(forms[0].attributes("id")).toBe("add-destination-form");
const save = wrapper.find('[data-test="add-destination-submit-btn"]');
expect(save.attributes("type")).toBe("submit");
expect(save.attributes("form")).toBe("add-destination-form");
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,77 @@
// Copyright 2026 OpenObserve Inc.
//
// Validation schema for AddTemplate.vue (alert notification templates).
//
// Migration notes (form-migration-playbook / START-HERE Rule ②/④):
// • `name` — genuinely user-typed → form-owned OFormInput. Rules mirror the
// old hand-rolled `saveTemplate` gate: required (`common.nameRequired`) +
// the resource-name character check (isValidResourceName). Kept UNtrimmed so
// the payload can send `template_name` raw and `data.name` trimmed exactly
// like the pre-migration form.
// • `title` — form-owned OFormInput, optional at the object level; required
// ONLY when `type === 'email'` (enforced in `superRefine`, mirroring the old
// `type === 'email' && !title.trim()` gate).
// • `body` — a bare Monaco/`<query-editor>` (the sanctioned non-form widget),
// bridged into the form via `setFieldValue` from the editor's change handler
// so this schema's `min(1)` required rule covers it. The http JSON-validity
// check stays a submit-time toast side-effect in the component (NOT a schema
// rule) to preserve the exact pre-migration behaviour.
// • `type` — NOT an <input>: it is the app-tabs UI toggle. It is a schema
// enum discriminator bridged into the form via `setFieldValue` from the
// tab's own handler (the sanctioned Rule-② bridge) so `superRefine` can
// branch on it. Never rendered as an OForm* control.
//
// Validation TIMING is owned by OForm (submit-then-change via revalidateLogic);
// this file only describes WHAT is valid.
import { z } from "zod";
import { isValidResourceName } from "@/utils/zincutils";
// Mirrors the old `saveTemplate` name gate:
// !name.trim() ? nameRequired : (!isValidResourceName(name) ? charsMsg : "")
// nameRequired → i18n `common.nameRequired`; charsMsg → `alerts.validation.nameInvalidChars`.
export const makeAddTemplateSchema = (t: (_key: string) => string) =>
z
.object({
name: z
.string()
.min(1, t("common.nameRequired"))
.refine((val) => isValidResourceName(String(val)), {
message: t("alerts.validation.nameInvalidChars"),
}),
// Discriminator (bridged from the app-tabs toggle, never an <input>).
type: z.enum(["http", "email"]),
// Optional at the object level; required-when-email in superRefine below.
title: z.string().optional(),
// Bridged from the bare Monaco body editor; required.
// PARITY (R7): pre-migration `isTemplateFilled()` gated on
// `body.trim().trim().length`, so a WHITESPACE-ONLY body was INVALID —
// `.min(1)` alone would pass " " (length 3). Checked on the trimmed
// value but NOT transformed: `.trim()` / `z.string().trim()` would MUTATE
// the saved body, and pre-migration sent it RAW.
body: z.string().refine((v) => v.trim().length > 0, {
message: t("alerts.validation.fieldRequired"),
}),
})
.superRefine((val, ctx) => {
// Email templates require a subject title (old: `!title.trim()`).
if (val.type === "email" && !String(val.title ?? "").trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["title"],
message: t("alerts.validation.fieldRequired"),
});
}
});
export type AddTemplateForm = z.infer<ReturnType<typeof makeAddTemplateSchema>>;
// Static (create-only) defaults. Edit/clone prefill is applied at runtime via
// `form.reset(record)` (see AddTemplate.vue), not here.
export const addTemplateDefaults = (): AddTemplateForm => ({
name: "",
type: "http",
title: "",
body: "",
});
+343 -116
View File
@@ -15,10 +15,10 @@
import { describe, expect, it, beforeEach, vi } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { nextTick } from "vue";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
vi.mock("vue-router", () => ({
useRouter: () => ({ currentRoute: { value: { query: {} } }, push: vi.fn() }),
useRoute: () => ({ params: {}, query: {} }),
@@ -40,17 +40,43 @@ vi.mock("@/utils/templates/validation", () => ({
getTemplateValidationErrorMessage: vi.fn().mockReturnValue(""),
}));
// toast returns a `dismiss` function (used for the loading toast) — mock it so
// no real toast renders and so we can assert the http JSON-validity toast.
vi.mock("@/lib/feedback/Toast/useToast", () => ({
toast: vi.fn(() => () => {}),
}));
import AddTemplate from "@/components/alerts/AddTemplate.vue";
import templateService from "@/services/alert_templates";
import { validateTemplateBody } from "@/utils/templates/validation";
import { toast } from "@/lib/feedback/Toast/useToast";
const ORG = store.state.selectedOrganization.identifier;
// Counts editor CREATIONS. The real CodeQueryEditor reads `language` only at
// monaco.editor.create() and never watches the prop, so asserting the prop
// changed proves nothing — only a remount actually re-languages the editor.
let editorMounts = 0;
const editorStub = {
name: "QueryEditor",
template: '<div class="stub-editor"></div>',
props: ["query", "editorId", "language"],
emits: ["update:query"],
mounted() {
editorMounts += 1;
},
};
const appTabsStub = {
name: "AppTabs",
template: '<div data-test="app-tabs-stub"></div>',
props: ["tabs", "activeTab"],
emits: ["update:activeTab"],
};
async function mountComp(props: Record<string, any> = {}) {
return mount(AddTemplate, {
const wrapper = mount(AddTemplate, {
props: {
template: null,
...props,
@@ -59,16 +85,90 @@ async function mountComp(props: Record<string, any> = {}) {
plugins: [i18n, store],
stubs: {
QueryEditor: editorStub,
AppTabs: {
template: '<div data-test="app-tabs-stub"><slot /></div>',
props: ["tabs", "activeTab"],
emits: ["update:activeTab"],
},
AppTabs: appTabsStub,
},
},
});
await flushPromises();
return wrapper;
}
// Grab the REAL <OForm>'s underlying TanStack form (the single source of truth).
function getForm(wrapper: any) {
return (wrapper.findComponent({ name: "OForm" }).vm as any).form;
}
async function submit(wrapper: any) {
await getForm(wrapper).handleSubmit();
await flushPromises();
}
describe("AddTemplate - body editor language", () => {
beforeEach(() => {
vi.clearAllMocks();
editorMounts = 0;
});
// Pre-migration rendered TWO <query-editor> under v-if/v-else with hardcoded
// language="markdown"/"json", so flipping the type destroyed one and created
// the other. The migration collapsed them into one editor with a reactive
// :language — which the editor never watches. Without the :key the mount-time
// language sticks and a markdown body renders with JSON syntax errors.
it("remounts the body editor when the type flips (http → email)", async () => {
const w = await mountComp();
const mountsAfterInitial = editorMounts;
expect(w.findComponent({ name: "QueryEditor" }).props("language")).toBe(
"json",
);
await w
.findComponent({ name: "AppTabs" })
.vm.$emit("update:activeTab", "email");
await flushPromises();
expect(w.findComponent({ name: "QueryEditor" }).props("language")).toBe(
"markdown",
);
expect(editorMounts).toBe(mountsAfterInitial + 1);
});
it("remounts the body editor when the type flips back (email → http)", async () => {
const w = await mountComp();
await w
.findComponent({ name: "AppTabs" })
.vm.$emit("update:activeTab", "email");
await flushPromises();
const mountsAfterEmail = editorMounts;
await w
.findComponent({ name: "AppTabs" })
.vm.$emit("update:activeTab", "http");
await flushPromises();
expect(w.findComponent({ name: "QueryEditor" }).props("language")).toBe(
"json",
);
expect(editorMounts).toBe(mountsAfterEmail + 1);
});
// The remount must not cost the user their draft: monaco is recreated with
// `value: props.query`, so the form-owned body has to survive the swap.
it("preserves the typed body across the remount", async () => {
const w = await mountComp();
getForm(w).setFieldValue("body", "# hello");
await flushPromises();
await w
.findComponent({ name: "AppTabs" })
.vm.$emit("update:activeTab", "email");
await flushPromises();
expect(w.findComponent({ name: "QueryEditor" }).props("query")).toBe(
"# hello",
);
});
});
describe("AddTemplate - rendering (create mode)", () => {
beforeEach(() => vi.clearAllMocks());
@@ -77,35 +177,50 @@ describe("AddTemplate - rendering (create mode)", () => {
expect(w.exists()).toBe(true);
});
it("renders the title with add text when template is null", async () => {
it("renders the add title when template is null", async () => {
const w = await mountComp();
const titleEl = w.find('[data-test="add-template-title"]');
expect(titleEl.exists()).toBe(true);
expect(titleEl.text()).toContain("New template");
});
it("renders the name input", async () => {
it("preserves the core data-tests", async () => {
const w = await mountComp();
expect(w.find('[data-test="add-template-name-input"]').exists()).toBe(true);
});
it("renders the submit button", async () => {
const w = await mountComp();
expect(w.find('[data-test="add-template-submit-btn"]').exists()).toBe(true);
});
it("renders the cancel button", async () => {
const w = await mountComp();
expect(w.find('[data-test="add-template-cancel-btn"]').exists()).toBe(true);
expect(w.find('[data-test="add-template-body-input-title"]').exists()).toBe(
true,
);
expect(w.find('[data-test="template-body-editor"]').exists()).toBe(true);
});
it("renders the body input title", async () => {
it("hides the email title input for http (default) and shows it for email", async () => {
const w = await mountComp();
expect(w.find('[data-test="add-template-body-input-title"]').exists()).toBe(true);
expect(
w.find('[data-test="add-template-email-title-input"]').exists(),
).toBe(false);
// Bridge: driving the tabs toggle updates the form discriminator → v-if.
await w
.findComponent({ name: "AppTabs" })
.vm.$emit("update:activeTab", "email");
await nextTick();
expect(
w.find('[data-test="add-template-email-title-input"]').exists(),
).toBe(true);
});
it("Save button stays enabled (R3 — never disabled before submit)", async () => {
const w = await mountComp();
const saveBtn = w.find('[data-test="add-template-submit-btn"]');
expect(saveBtn.attributes("disabled")).toBeUndefined();
});
});
describe("AddTemplate - rendering (update mode)", () => {
beforeEach(() => vi.clearAllMocks());
const existingTemplate = {
name: "my-template",
body: JSON.stringify({ text: "Alert {{name}} fired" }),
@@ -113,129 +228,241 @@ describe("AddTemplate - rendering (update mode)", () => {
title: "",
};
it("shows update title when template prop is provided", async () => {
it("shows the update title when a template prop is provided", async () => {
const w = await mountComp({ template: existingTemplate });
await flushPromises();
const titleEl = w.find('[data-test="add-template-title"]');
expect(titleEl.text()).toContain("Update");
expect(w.find('[data-test="add-template-title"]').text()).toContain(
"Update",
);
});
it("sets isUpdatingTemplate to true when template prop provided", async () => {
it("marks the form as updating (name becomes readonly)", async () => {
const w = await mountComp({ template: existingTemplate });
await flushPromises();
expect((w.vm as any).isUpdatingTemplate).toBe(true);
});
it("populates formData.name from template", async () => {
it("prefills the form from the template via form.reset", async () => {
const w = await mountComp({ template: existingTemplate });
await flushPromises();
expect((w.vm as any).formData.name).toBe("my-template");
const form = getForm(w);
expect(form.state.values.name).toBe("my-template");
expect(form.state.values.body).toBe(existingTemplate.body);
expect(form.state.values.type).toBe("http");
});
it("populates formData.body from template", async () => {
const w = await mountComp({ template: existingTemplate });
await flushPromises();
expect((w.vm as any).formData.body).toBe(existingTemplate.body);
});
});
describe("AddTemplate - initial state", () => {
it("formData.name is empty by default", async () => {
const w = await mountComp();
expect((w.vm as any).formData.name).toBe("");
});
it("formData.type is http by default", async () => {
const w = await mountComp();
expect((w.vm as any).formData.type).toBe("http");
});
it("isUpdatingTemplate is false by default", async () => {
const w = await mountComp();
it("clone mode prefills but stays in create mode", async () => {
const w = await mountComp({ template: existingTemplate, isClone: true });
expect((w.vm as any).isUpdatingTemplate).toBe(false);
});
it("splitterModel is 75 by default", async () => {
const w = await mountComp();
expect((w.vm as any).splitterModel).toBe(75);
expect(getForm(w).state.values.name).toBe("my-template");
expect(w.find('[data-test="add-template-title"]').text()).toContain(
"Clone",
);
});
});
describe("AddTemplate - isTemplateFilled", () => {
it("returns false when name is empty", async () => {
describe("AddTemplate - validation (real OForm)", () => {
beforeEach(() => vi.clearAllMocks());
it("blocks submit and does NOT call the service when required fields are empty", async () => {
const w = await mountComp();
(w.vm as any).formData.name = "";
(w.vm as any).formData.body = "some body";
expect((w.vm as any).isTemplateFilled()).toBeFalsy();
const form = getForm(w);
await submit(w);
expect(form.state.isValid).toBe(false);
expect(templateService.create).not.toHaveBeenCalled();
expect(templateService.update).not.toHaveBeenCalled();
});
it("returns false when body is empty", async () => {
it("blocks submit when body is empty (body required)", async () => {
const w = await mountComp();
(w.vm as any).formData.name = "mytemplate";
(w.vm as any).formData.body = "";
expect((w.vm as any).isTemplateFilled()).toBeFalsy();
const form = getForm(w);
form.setFieldValue("name", "valid-name");
form.setFieldValue("body", "");
await submit(w);
expect(form.state.isValid).toBe(false);
expect(templateService.create).not.toHaveBeenCalled();
});
it("returns truthy when name and body both filled", async () => {
// R7 PARITY: pre-migration `isTemplateFilled()` gated on
// `body.trim().trim().length`, so a whitespace-only body was INVALID. A plain
// `.min(1)` passes " " (length 3) — this test fails without the `.refine`.
it("blocks submit when body is WHITESPACE-ONLY (parity: trim().length)", async () => {
const w = await mountComp();
(w.vm as any).formData.name = "mytemplate";
(w.vm as any).formData.body = '{"text": "alert"}';
expect((w.vm as any).isTemplateFilled()).toBeTruthy();
const form = getForm(w);
form.setFieldValue("name", "valid-name");
form.setFieldValue("body", " \n\t ");
await submit(w);
expect(form.state.isValid).toBe(false);
expect(templateService.create).not.toHaveBeenCalled();
});
// The body must be VALIDATED on the trimmed value but SAVED raw — a `.trim()`
// transform would mutate what pre-migration sent to the backend.
it("saves the body RAW (surrounding whitespace preserved, not trimmed)", async () => {
const w = await mountComp();
const form = getForm(w);
const rawBody = ' {"text":"x"} ';
form.setFieldValue("name", "valid-name");
form.setFieldValue("body", rawBody);
await submit(w);
expect(form.state.isValid).toBe(true);
expect(templateService.create).toHaveBeenCalledTimes(1);
const sent = (templateService.create as any).mock.calls[0][0];
expect(sent.data.body).toBe(rawBody);
});
it("blocks submit for an invalid resource name", async () => {
const w = await mountComp();
const form = getForm(w);
form.setFieldValue("name", "bad name/#"); // spaces + reserved chars
form.setFieldValue("body", '{"text":"x"}');
await submit(w);
expect(form.state.isValid).toBe(false);
expect(templateService.create).not.toHaveBeenCalled();
});
it("requires a title when type is email", async () => {
const w = await mountComp();
const form = getForm(w);
form.setFieldValue("name", "email-template");
form.setFieldValue("type", "email");
form.setFieldValue("body", "hello");
// title left empty
await submit(w);
expect(form.state.isValid).toBe(false);
expect(templateService.create).not.toHaveBeenCalled();
});
});
describe("AddTemplate - cancel button", () => {
it("clicking cancel emits cancel:hideform", async () => {
describe("AddTemplate - save payload parity", () => {
beforeEach(() => vi.clearAllMocks());
it("creates an http template with the EXACT payload", async () => {
const w = await mountComp();
const form = getForm(w);
form.setFieldValue("name", "new-template");
// Body is bridged from the Monaco editor's change handler.
await w
.findComponent({ name: "QueryEditor" })
.vm.$emit("update:query", '{"text":"alert"}');
await submit(w);
expect(templateService.create).toHaveBeenCalledTimes(1);
expect(templateService.create).toHaveBeenCalledWith({
org_identifier: ORG,
template_name: "new-template",
data: {
name: "new-template",
body: '{"text":"alert"}',
type: "http",
title: "",
},
});
expect(templateService.update).not.toHaveBeenCalled();
});
it("creates an email template carrying the title", async () => {
const w = await mountComp();
const form = getForm(w);
form.setFieldValue("name", "email-template");
form.setFieldValue("type", "email");
form.setFieldValue("title", "My subject");
form.setFieldValue("body", "Body text");
await submit(w);
expect(templateService.create).toHaveBeenCalledWith({
org_identifier: ORG,
template_name: "email-template",
data: {
name: "email-template",
body: "Body text",
type: "email",
title: "My subject",
},
});
});
it("updates in update mode with the EXACT payload", async () => {
const existingTemplate = {
name: "my-template",
body: '{"text":"existing"}',
type: "http",
title: "",
};
const w = await mountComp({ template: existingTemplate });
const form = getForm(w);
form.setFieldValue("body", '{"text":"updated"}');
await submit(w);
expect(templateService.update).toHaveBeenCalledTimes(1);
expect(templateService.update).toHaveBeenCalledWith({
org_identifier: ORG,
template_name: "my-template",
data: {
name: "my-template",
body: '{"text":"updated"}',
type: "http",
title: "",
},
});
expect(templateService.create).not.toHaveBeenCalled();
});
});
describe("AddTemplate - http JSON-validity toast (Rule ④ side-effect)", () => {
beforeEach(() => vi.clearAllMocks());
it("fires the error toast and blocks save when http body is invalid JSON", async () => {
vi.mocked(validateTemplateBody).mockReturnValueOnce({ valid: false });
const w = await mountComp();
const form = getForm(w);
form.setFieldValue("name", "http-template");
form.setFieldValue("body", "{ invalid json"); // non-empty → passes schema
await submit(w);
// Schema passed (name + body non-empty) so @submit ran, but the JSON toast
// guard blocked the actual create.
expect(form.state.isValid).toBe(true);
expect(templateService.create).not.toHaveBeenCalled();
expect(toast).toHaveBeenCalledWith(
expect.objectContaining({ variant: "error" }),
);
});
it("does NOT run the JSON check for email templates (non-JSON body allowed)", async () => {
const w = await mountComp();
const form = getForm(w);
form.setFieldValue("name", "email-template");
form.setFieldValue("type", "email");
form.setFieldValue("title", "Subject");
form.setFieldValue("body", "plain text, not json");
await submit(w);
expect(validateTemplateBody).not.toHaveBeenCalled();
expect(templateService.create).toHaveBeenCalledTimes(1);
});
});
describe("AddTemplate - cancel", () => {
beforeEach(() => vi.clearAllMocks());
it("emits cancel:hideform when cancel is clicked", async () => {
const w = await mountComp();
await w.find('[data-test="add-template-cancel-btn"]').trigger("click");
expect(w.emitted("cancel:hideform")).toBeTruthy();
});
});
describe("AddTemplate - saveTemplate", () => {
beforeEach(() => vi.clearAllMocks());
it("calls templateService.create in create mode when form is filled", async () => {
const w = await mountComp();
await flushPromises();
(w.vm as any).formData.name = "new-template";
(w.vm as any).formData.body = '{"text": "alert"}';
await (w.vm as any).saveTemplate();
await flushPromises();
expect(templateService.create).toHaveBeenCalled();
});
it("calls templateService.update in update mode", async () => {
const existingTemplate = {
name: "my-template",
body: '{"text": "existing"}',
type: "http",
title: "",
};
const w = await mountComp({ template: existingTemplate });
await flushPromises();
(w.vm as any).formData.body = '{"text": "updated"}';
await (w.vm as any).saveTemplate();
await flushPromises();
expect(templateService.update).toHaveBeenCalled();
});
it("does not call service when form is not filled", async () => {
const w = await mountComp();
(w.vm as any).formData.name = "";
(w.vm as any).formData.body = "";
await (w.vm as any).saveTemplate();
await flushPromises();
expect(templateService.create).not.toHaveBeenCalled();
expect(templateService.update).not.toHaveBeenCalled();
});
});
describe("AddTemplate - tabs computed", () => {
it("tabs includes http and email options", async () => {
const w = await mountComp();
const tabValues = (w.vm as any).tabs.map((t: any) => t.value ?? t.label);
// tabs should have HTTP and Email type options
expect(tabValues.length).toBeGreaterThanOrEqual(2);
});
});
+188 -161
View File
@@ -34,19 +34,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
style="height: calc(100vh - 106px)"
>
<template v-slot:before>
<div class="card-container h-full flex flex-col">
<OForm
:form="form"
v-slot="{ isSubmitting }"
class="card-container h-full flex flex-col"
>
<div class="p-3 overflow-auto">
<div class="w-full pb-2 pt-2 o2-input">
<OInput
<OFormInput
name="name"
data-test="add-template-name-input"
v-model="formData.name"
:label="t('alerts.name') + ' *'"
:label="t('alerts.name')"
required
:readonly="isUpdatingTemplate"
:disabled="isUpdatingTemplate"
tabindex="0"
:error="!!nameError"
:error-message="nameError"
@update:model-value="nameError = ''"
/>
</div>
<div class="w-full pb-3">
@@ -54,48 +56,43 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<app-tabs
class="tabs-selection-container"
:tabs="tabs"
v-model:active-tab="formData.type"
:active-tab="templateType"
@update:active-tab="onTypeChange"
/>
</div>
</div>
<div v-if="formData.type === 'email'" class="w-full pt-1 o2-input">
<OInput
<div v-if="templateType === 'email'" class="w-full pt-1 o2-input">
<OFormInput
name="title"
data-test="add-template-email-title-input"
v-model="formData.title"
:label="t('alerts.title') + ' *'"
:label="t('alerts.title')"
required
tabindex="0"
:error="!!titleError"
:error-message="titleError"
@update:model-value="titleError = ''"
/>
</div>
<div class="w-full py-3 ">
<div
class="pb-2 font-bold"
class="pb-2 font-bold flex items-center gap-0.5"
data-test="add-template-body-input-title"
>
{{ t("alert_templates.body") + " *" }}
<span>{{ t("alert_templates.body") }}</span>
<span aria-hidden="true" class="select-none">*</span>
</div>
<template v-if="formData.type === 'email'">
<query-editor
data-test="template-body-editor"
ref="queryEditorRef"
editor-id="template-body-editor"
class="w-full min-h-[310px]! rounded-[5px] border border-(--o2-border-color) resize-y overflow-auto mb-3"
language="markdown"
v-model:query="formData.body"
/>
</template>
<template v-else>
<query-editor
data-test="template-body-editor"
ref="queryEditorRef"
editor-id="template-body-editor"
class="w-full min-h-[310px]! rounded-[5px] border border-(--o2-border-color) resize-y overflow-auto mb-3"
language="json"
v-model:query="formData.body"
/>
</template>
<!-- `:key` forces a remount when the type flips. CodeQueryEditor
reads `language` only at monaco.editor.create() — it never
watches the prop, and setModelLanguage is used nowhere — so
without this the editor keeps its mount-time language and paints
a markdown body with JSON errors (pre-migration got the remount
for free from two v-if/v-else editors). -->
<query-editor
:key="bodyLanguage"
data-test="template-body-editor"
editor-id="template-body-editor"
class="w-full min-h-[310px]! rounded-[5px] border border-(--o2-border-color) resize-y overflow-auto mb-3"
:language="bodyLanguage"
:query="body"
@update:query="onBodyChange"
/>
</div>
</div>
<div
@@ -105,17 +102,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-close-popup
variant="outline"
size="sm-action"
:disabled="isSubmitting"
@click="$emit('cancel:hideform')"
data-test="add-template-cancel-btn"
>{{ t('alerts.cancel') }}</OButton>
<OButton
variant="primary"
size="sm-action"
@click="saveTemplate"
:loading="isSubmitting"
data-test="add-template-submit-btn"
@click="handleSave"
>{{ t('alerts.save') }}</OButton>
</div>
</div>
</OForm>
</template>
<template v-slot:after>
<div
@@ -135,9 +134,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
alert_trigger_time, alert_trigger_time_millis,
alert_trigger_time_seconds, alert_trigger_time_str
</div>
<div><b>rows</b> multiple lines of row template</div>
<div><b>All of the stream fields are variables.</b></div>
<div>{rows:N} {var:N} used to limit rows or string length.</div>
<div><b>rows</b> {{ t("alert_templates.variableRowsDescription") }}</div>
<div><b>{{ t("alert_templates.variableStreamFields") }}</b></div>
<div>{{ t("alert_templates.variableLimits") }}</div>
</div>
<div class="pb-3 px-1">
<div class="font-bold text-body-1 pb-2">
@@ -178,9 +177,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<script lang="ts" setup>
import {
ref,
onBeforeMount,
onActivated,
computed,
watch,
defineAsyncComponent,
} from "vue";
import type { Ref } from "vue";
@@ -190,10 +189,11 @@ import templateService from "@/services/alert_templates";
import { useStore } from "vuex";
import { copyToClipboard } from "@/utils/clipboard";
import OButton from '@/lib/core/Button/OButton.vue';
import OInput from '@/lib/forms/Input/OInput.vue';
import type { TemplateData, Template } from "@/ts/interfaces/index";
import OForm from "@/lib/forms/Form/OForm.vue";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import { useOForm } from "@/lib/forms/Form/useOForm";
import type { TemplateData } from "@/ts/interfaces/index";
import { useRouter } from "vue-router";
import { isValidResourceName } from "@/utils/zincutils";
import AppTabs from "@/components/common/AppTabs.vue";
import AppPageHeader from "@/components/common/AppPageHeader.vue";
import { useReo } from "@/services/reodotdev_analytics";
@@ -205,6 +205,11 @@ import OIcon from "@/lib/core/Icon/OIcon.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import OSeparator from '@/lib/core/Separator/OSeparator.vue';
import OSplitter from "@/lib/core/Splitter/OSplitter.vue";
import {
makeAddTemplateSchema,
addTemplateDefaults,
type AddTemplateForm,
} from "./AddTemplate.schema";
const props = withDefaults(
defineProps<{
@@ -225,18 +230,58 @@ const QueryEditor = defineAsyncComponent(
);
const { t } = useI18n();
const splitterModel: Ref<number> = ref(75);
const formData: Ref<Template> = ref({
name: "",
body: "",
type: "http",
title: "",
});
const store = useStore();
const editorRef: any = ref(null);
const router = useRouter();
const isUpdatingTemplate = ref(false);
const nameError = ref('');
const titleError = ref('');
const { track } = useReo();
// Owner pattern (Rule ③): AddTemplate OWNS <OForm>, and it needs to read `type`
// at setup to drive conditional rendering (the email title input v-if + the
// Monaco editor language). So the form is created here via useOForm and handed
// to <OForm :form="form">; state is read with form.useStore (single source of
// truth — no mirror), written with form.setFieldValue.
const addTemplateSchema = makeAddTemplateSchema(t);
const form = useOForm<AddTemplateForm>({
defaultValues: addTemplateDefaults(),
schema: addTemplateSchema,
onSubmit: saveTemplate,
});
// Reactive views of the two bridged values.
const templateType = form.useStore((s: any) => s.values.type as "http" | "email");
const body = form.useStore((s: any) => (s.values.body as string) ?? "");
const bodyLanguage = computed(() =>
templateType.value === "email" ? "markdown" : "json",
);
// app-tabs is a UI toggle whose value is the schema discriminator (not an
// <input>) → bridge it into the form (sanctioned Rule-② bridge).
const onTypeChange = (value: unknown) => {
form.setFieldValue("type", value as "http" | "email");
};
// PARITY with pre-migration saveTemplate (HEAD ~:334-346): an unfilled required
// field ALWAYS toasted "Please fill required fields". Post-migration the schema
// rejects the submit BEFORE onSubmit(saveTemplate) runs — and saveTemplate owns
// every toast — so a rejected save said nothing at all. `body` is the sharp edge:
// it's a bare Monaco bridged in via setFieldValue with no name= binding, so it has
// no field to highlight either. Restore the toast on a rejected submit.
const handleSave = async () => {
await form.handleSubmit();
if (!form.state.isValid) {
toast({
variant: "error",
message: t("common.fillRequiredFields"),
timeout: 1500,
});
}
};
// Bare Monaco body → bridge its value into the form so schema min(1) covers it.
const onBodyChange = (value: unknown) => {
form.setFieldValue("body", (value as string) ?? "");
};
const sampleTemplates = [
{
name: "Slack",
@@ -264,45 +309,57 @@ const sampleTemplates = [
]`,
},
];
onActivated(() => setupTemplateData());
onBeforeMount(() => {
setupTemplateData();
});
const tabs = computed(() => [
{
label: "Web Hook",
label: t("alerts.webhook"),
value: "http",
style: {},
icon: "webhook",
},
{
label: "Email",
label: t("alerts.email"),
value: "email",
style: {},
icon: "mail",
},
]);
const setupTemplateData = () => {
// Edit/clone prefill arrives via the `template` prop (possibly async). Seed the
// form with form.reset(record) when it loads — NOT a per-field setFieldValue
// loop (playbook §4 "Resetting / repopulating form values").
const applyTemplateData = () => {
const params = router.currentRoute.value.query;
const next = addTemplateDefaults();
if (props.template) {
// Clone mode pre-fills the form but stays in create mode so save
// produces a new template; edit mode would overwrite the original.
isUpdatingTemplate.value = !props.isClone;
formData.value.name = props.template.name;
formData.value.body = props.template.body;
formData.value.type = props.template.type;
formData.value.title = props.template.title;
next.name = props.template.name;
next.body = props.template.body;
next.type = props.template.type as "http" | "email";
next.title = props.template.title;
} else {
isUpdatingTemplate.value = false;
}
if (params.type) {
formData.value.type = params.type as "email" | "http";
// A ?type= query param (e.g. deep link) preselects the tab. Guard the cast so
// an unexpected value can never enter the enum-typed form.
if (params.type === "email" || params.type === "http") {
next.type = params.type;
}
form.reset(next);
};
const isTemplateBodyValid = () => {
const result = validateTemplateBody(formData.value.body);
watch(() => props.template, applyTemplateData, { immediate: true });
onActivated(() => applyTemplateData());
// http bodies must be valid JSON (with template placeholders). This stays a
// submit-time toast side-effect — NOT a schema rule — to preserve the exact
// pre-migration behaviour (Rule ④).
const isTemplateBodyValid = (bodyValue: string) => {
const result = validateTemplateBody(bodyValue);
if (!result.valid) {
toast({
@@ -315,112 +372,82 @@ const isTemplateBodyValid = () => {
return result.valid;
};
const router = useRouter();
const isTemplateFilled = () =>
formData.value.name.trim().trim().length &&
formData.value.body.trim().trim().length;
const saveTemplate = () => {
if (!isTemplateFilled()) {
const name = formData.value.name;
nameError.value = !name.trim() ? t('common.nameRequired')
: (!isValidResourceName(name) ? 'Characters like :, ?, /, #, and spaces are not allowed.' : '');
titleError.value = (formData.value.type === 'email' && !formData.value.title?.trim()) ? 'Field is required!' : '';
toast({
variant: "error",
message: "Please fill required fields",
timeout: 1500,
});
return;
}
// Here checking is template body json valid
if (formData.value.type !== "email" && !isTemplateBodyValid()) return;
// @submit(value) is the source of truth. OForm awaits this, so the inline Save
// button's spinner (v-slot isSubmitting) spans the whole request.
async function saveTemplate(value: AddTemplateForm) {
// Here checking is template body json valid (http only).
if (value.type !== "email" && !isTemplateBodyValid(value.body)) return;
const dismiss = toast({
variant: "loading",
message: "Please wait...",
timeout: 0,
});
message: t("common.pleaseWait"),
timeout: 0,
});
// Same payload the pre-migration form built (identical for http/email):
// template_name = raw name; data.name = trimmed name.
const data = {
name: value.name.trim(),
body: value.body,
type: value.type,
title: value.title,
};
const onSuccess = () => {
dismiss();
emit("get:templates");
emit("cancel:hideform");
toast({
variant: "success",
message: t("alert_templates.savedSuccessfully"),
});
};
const onError = (err: any) => {
if (err.response?.status == 403) {
return;
}
dismiss();
toast({
variant: "error",
message: err.response?.data?.error || err.response?.data?.message,
});
};
if (isUpdatingTemplate.value) {
templateService
const request = templateService
.update({
org_identifier: store.state.selectedOrganization.identifier,
template_name: formData.value.name,
data: {
name: formData.value.name.trim(),
body: formData.value.body,
type: formData.value.type,
title: formData.value.title,
},
template_name: value.name,
data,
})
.then(() => {
dismiss();
emit("get:templates");
emit("cancel:hideform");
toast({
variant: "success",
message: `Template Saved Successfully.`,
});
})
.catch((err) => {
if (err.response?.status == 403) {
return;
}
dismiss();
toast({
variant: "error",
message: err.response?.data?.error || err.response?.data?.message,
});
});
.then(onSuccess)
.catch(onError);
track("Button Click", {
button: "Update Template",
page: "Add Template",
});
} else {
{
templateService
.create({
org_identifier: store.state.selectedOrganization.identifier,
template_name: formData.value.name,
data: {
name: formData.value.name.trim(),
body: formData.value.body,
type: formData.value.type,
title: formData.value.title,
},
})
.then(() => {
dismiss();
emit("get:templates");
emit("cancel:hideform");
toast({
variant: "success",
message: `Template Saved Successfully.`,
});
})
.catch((err) => {
if (err.response?.status == 403) {
return;
}
dismiss();
toast({
variant: "error",
message: err.response?.data?.error || err.response?.data?.message,
});
});
track("Button Click", {
button: "Create Template",
page: "Add Template",
});
}
return request;
}
};
const request = templateService
.create({
org_identifier: store.state.selectedOrganization.identifier,
template_name: value.name,
data,
})
.then(onSuccess)
.catch(onError);
track("Button Click", {
button: "Create Template",
page: "Add Template",
});
return request;
}
const copyTemplateBody = (text: any) => {
copyToClipboard(JSON.parse(JSON.stringify(text)), {
successMessage: "Content Copied Successfully!",
successMessage: t("alert_templates.contentCopied"),
timeout: 1000,
});
};
+181 -1
View File
@@ -14,10 +14,17 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mount, VueWrapper } from "@vue/test-utils";
import { mount, flushPromises, VueWrapper } from "@vue/test-utils";
import { defineComponent } from "vue";
import { z } from "zod";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import FieldsInput from "@/components/alerts/FieldsInput.vue";
import OForm from "@/lib/forms/Form/OForm.vue";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import OFormSelect from "@/lib/forms/Select/OFormSelect.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
// ---------------------------------------------------------------------------
// helpers
@@ -268,3 +275,176 @@ describe("FieldsInput", () => {
});
});
});
// ---------------------------------------------------------------------------
// form mode (opt-in via `name-prefix` inside a REAL <OForm>)
// ---------------------------------------------------------------------------
const conditionRowSchema = z.object({
column: z.string(),
operator: z.string(),
value: z.string(),
});
const conditionsHostSchema = z.object({
conditions: z.array(conditionRowSchema),
});
// Host renders a REAL <OForm> (schema + default-values) around FieldsInput —
// form mode is driven purely by `name-prefix` + the injected form context.
const FormHost = defineComponent({
components: { OForm, FieldsInput },
props: {
rows: { type: Array, default: () => [] },
},
setup(props) {
return {
hostSchema: conditionsHostSchema,
hostDefaults: { conditions: JSON.parse(JSON.stringify(props.rows)) },
streamFields,
};
},
template: `
<OForm :schema="hostSchema" :default-values="hostDefaults" @submit="() => {}">
<FieldsInput name-prefix="conditions" :stream-fields="streamFields" />
</OForm>
`,
});
function buildFormWrapper(rows: any[] = []): VueWrapper<any> {
return mount(FormHost, {
props: { rows },
global: { plugins: [i18n, store] },
});
}
const getForm = (w: VueWrapper<any>) => (w.findComponent(OForm).vm as any).form;
/** RENDERED value-input model-values (OFormInput → OInput), in DOM order. */
const renderedValues = (w: VueWrapper<any>) =>
w
.findAllComponents(OFormInput)
.filter((c) => /^conditions\[\d+\]\.value$/.test(String(c.props("name"))))
.map((c) => c.findComponent(OInput).props("modelValue"));
/** RENDERED column-select model-values (OFormSelect → OSelect), in DOM order. */
const renderedColumns = (w: VueWrapper<any>) =>
w
.findAllComponents(OFormSelect)
.filter((c) => /^conditions\[\d+\]\.column$/.test(String(c.props("name"))))
.map((c) => c.findComponent(OSelect).props("modelValue"));
describe("FieldsInput — form mode (name-prefix inside a real <OForm>)", () => {
let wrapper: VueWrapper<any> | null = null;
afterEach(() => {
wrapper?.unmount();
wrapper = null;
});
const threeRows = [
{ column: "host", operator: "=", value: "v1" },
{ column: "level", operator: "!=", value: "v2" },
{ column: "message", operator: ">", value: "v3" },
];
it("renders one row per form-state entry, carrying the bare-mode data-tests", () => {
wrapper = buildFormWrapper(threeRows);
expect(wrapper.find('[data-test="alert-conditions-text"]').exists()).toBe(true);
expect(wrapper.find('[data-test="alert-conditions-1"]').exists()).toBe(true);
expect(wrapper.find('[data-test="alert-conditions-2"]').exists()).toBe(true);
expect(wrapper.find('[data-test="alert-conditions-3"]').exists()).toBe(true);
expect(wrapper.find('[data-test="alert-conditions-add-btn"]').exists()).toBe(false);
expect(wrapper.findAll('[data-test="alert-conditions-select-column"]')).toHaveLength(3);
expect(wrapper.findAll('[data-test="alert-conditions-operator-select"]')).toHaveLength(3);
expect(wrapper.findAll('[data-test="alert-conditions-value-input"]')).toHaveLength(3);
expect(wrapper.findAll('[data-test="alert-conditions-delete-condition-btn"]')).toHaveLength(3);
// inline add button only on the last row
expect(wrapper.findAll('[data-test="alert-conditions-add-condition-btn"]')).toHaveLength(1);
});
it("renders the row inputs from the form state (name=-owned, no props)", () => {
wrapper = buildFormWrapper(threeRows);
expect(renderedValues(wrapper)).toEqual(["v1", "v2", "v3"]);
expect(renderedColumns(wrapper)).toEqual(["host", "level", "message"]);
});
it("shows the empty-state Add Condition button and pushes a row into the form", async () => {
wrapper = buildFormWrapper([]);
expect(wrapper.find('[data-test="alert-conditions-add-btn"]').exists()).toBe(true);
expect(wrapper.find('[data-test="alert-conditions-1"]').exists()).toBe(false);
await wrapper.find('[data-test="alert-conditions-add-btn"]').trigger("click");
await flushPromises();
expect(getForm(wrapper).state.values.conditions).toEqual([
{ column: "", operator: "=", value: "" },
]);
expect(wrapper.find('[data-test="alert-conditions-1"]').exists()).toBe(true);
expect(wrapper.find('[data-test="alert-conditions-add-btn"]').exists()).toBe(false);
});
it("typing in a row's value input updates the form values", async () => {
wrapper = buildFormWrapper([threeRows[0]]);
await wrapper
.find('[data-test="alert-conditions-value-input"] input')
.setValue("changed");
await flushPromises();
expect(getForm(wrapper).state.values.conditions[0].value).toBe("changed");
});
it("the inline add button appends a row to the form array", async () => {
wrapper = buildFormWrapper([threeRows[0]]);
await wrapper.find('[data-test="alert-conditions-add-condition-btn"]').trigger("click");
await flushPromises();
expect(getForm(wrapper).state.values.conditions).toHaveLength(2);
expect(wrapper.find('[data-test="alert-conditions-2"]').exists()).toBe(true);
});
it("does not emit bare-mode events in form mode (form owns the rows)", async () => {
wrapper = buildFormWrapper(threeRows);
await wrapper.findAll('[data-test="alert-conditions-delete-condition-btn"]')[0].trigger("click");
await wrapper.find('[data-test="alert-conditions-add-condition-btn"]').trigger("click");
await flushPromises();
const fieldsInput = wrapper.findComponent(FieldsInput);
expect(fieldsInput.emitted("add")).toBeUndefined();
expect(fieldsInput.emitted("remove")).toBeUndefined();
expect(fieldsInput.emitted("input:update")).toBeUndefined();
});
// 🔑 Rule ① gate — delete a NON-last row and assert the RENDERED inputs
// (OFormInput→OInput / OFormSelect→OSelect model-values), NOT just
// form.state.values: a stable-id :key bug leaves the data correct while the
// inputs render shifted/blank.
it("Rule ① — deleting a NON-last row keeps the RENDERED inputs in sync", async () => {
wrapper = buildFormWrapper(threeRows);
expect(renderedValues(wrapper)).toEqual(["v1", "v2", "v3"]);
const deleteBtns = wrapper.findAll('[data-test="alert-conditions-delete-condition-btn"]');
expect(deleteBtns).toHaveLength(3);
await deleteBtns[1].trigger("click"); // delete the MIDDLE row
await flushPromises();
// the RENDERED inputs must match the remaining rows, in order
expect(renderedValues(wrapper)).toEqual(["v1", "v3"]);
expect(renderedColumns(wrapper)).toEqual(["host", "message"]);
expect(wrapper.findAll('[data-test="alert-conditions-value-input"]')).toHaveLength(2);
expect(wrapper.find('[data-test="alert-conditions-3"]').exists()).toBe(false);
// and the form data agrees
expect(getForm(wrapper).state.values.conditions).toEqual([
{ column: "host", operator: "=", value: "v1" },
{ column: "message", operator: ">", value: "v3" },
]);
});
});
+156 -10
View File
@@ -16,9 +16,103 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<div>
<!-- ── FORM MODE (opt-in via `name-prefix` inside a parent <OForm>) ──────
Rows are FORM-OWNED: rendered as OForm* fields with indexed names
(`${namePrefix}[i].column/operator/value`), added/removed through the
injected form (pushFieldValue/removeFieldValue). No v-model, no manual
error refs — the consuming form's schema owns validation (Rule ②). -->
<template v-if="formMode">
<div data-test="alert-conditions-text" class="font-bold">
{{ t('alerts.filters.conditions') + ' *' }} {{ t('alerts.filters.conditionsHint') }}
</div>
<template v-if="!formRows.length">
<OButton
data-test="alert-conditions-add-btn"
variant="outline"
size="sm"
class="mt-2"
@click="addFormRow"
icon-left="add"
>
{{ t('alerts.addCondition') }}
</OButton>
</template>
<template v-else>
<!-- 🔑 :key MUST be the array INDEX (Rule ①): the fields bind by
index-based `name` and form.Field resolves its name at CREATION,
so a stable-id key would leave surviving rows bound to their OLD
index after a mid-list delete (inputs shifted/blank). -->
<div
v-for="(row, index) in formRows"
:key="index"
class="flex justify-start items-end gap-2 pb-2"
:data-test="`alert-conditions-${index + 1}`"
>
<div class="ml-0 o2-input">
<OFormSelect
:name="`${namePrefix}[${index}].column`"
:options="props.streamFields"
class="py-2"
:placeholder="t('alerts.column')"
:creatable="props.enableNewValueMode"
style="min-width: 220px"
data-test="alert-conditions-select-column"
@create="(val: string) => setRowColumn(index, val)"
/>
</div>
<div class="ml-0 o2-input">
<OFormSelect
:name="`${namePrefix}[${index}].operator`"
:options="triggerOperators"
class="py-2"
style="min-width: 120px"
data-test="alert-conditions-operator-select"
/>
</div>
<div class="ml-0 flex items-end o2-input">
<OFormInput
:name="`${namePrefix}[${index}].value`"
:placeholder="t('common.value')"
class="py-2"
style="min-width: 150px"
data-test="alert-conditions-value-input"
/>
</div>
<div
class="ml-0 alerts-condition-action"
style="margin-bottom: 12px"
>
<OButton
data-test="alert-conditions-delete-condition-btn"
class="ml-1"
variant="ghost"
size="icon-circle-sm"
:title="t('alert_templates.edit')"
@click="removeFormRow(index)"
>
<OIcon name="delete" size="sm" />
</OButton>
<OButton
data-test="alert-conditions-add-condition-btn"
v-if="index === formRows.length - 1"
class="ml-1"
variant="ghost"
size="icon-circle-sm"
:title="t('alert_templates.edit')"
@click="addFormRow()"
>
<OIcon name="add" size="sm" />
</OButton>
</div>
</div>
</template>
</template>
<!-- ── BARE MODE (no name-prefix) — pre-migration behavior, unchanged.
Consumed bare by functions/logstream/pipeline forms; a legitimate
permanent pattern (see START-HERE 🔀). Do NOT delete. -->
<template v-else>
<div data-test="alert-conditions-text" class="font-bold">
Conditions * (AND operator is used by default to evaluate multiple
conditions)
{{ t('alerts.filters.conditions') + ' *' }} {{ t('alerts.filters.conditionsHint') }}
</div>
<template v-if="!fields.length">
<OButton
@@ -51,7 +145,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
style="min-width: 220px"
data-test="alert-conditions-select-column"
@create="(val: string) => { field.column = val; emits('input:update', 'conditions', field); }"
@update:model-value="(v: any) => { fieldErrors[`${field.uuid}-column`] = v ? '' : 'Field is required!'; emits('input:update', 'conditions', field); }"
@update:model-value="(v: any) => { fieldErrors[`${field.uuid}-column`] = v ? '' : t('alerts.validation.fieldRequired'); emits('input:update', 'conditions', field); }"
/>
</div>
<div class="ml-0 o2-input">
@@ -63,7 +157,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:error-message="fieldErrors[`${field.uuid}-operator`] || ''"
style="min-width: 120px"
data-test="alert-conditions-operator-select"
@update:model-value="(v: any) => { fieldErrors[`${field.uuid}-operator`] = v ? '' : 'Field is required!'; emits('input:update', 'conditions', field); }"
@update:model-value="(v: any) => { fieldErrors[`${field.uuid}-operator`] = v ? '' : t('alerts.validation.fieldRequired'); emits('input:update', 'conditions', field); }"
/>
</div>
<div class="ml-0 flex items-end o2-input">
@@ -75,7 +169,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:error-message="fieldErrors[`${field.uuid}-value`] || ''"
style="min-width: 150px"
data-test="alert-conditions-value-input"
@update:model-value="(v: any) => { fieldErrors[`${field.uuid}-value`] = v ? '' : 'Field is required!'; emits('input:update', 'conditions', field); }"
@update:model-value="(v: any) => { fieldErrors[`${field.uuid}-value`] = v ? '' : t('alerts.validation.fieldRequired'); emits('input:update', 'conditions', field); }"
/>
</div>
<div
@@ -106,23 +200,27 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</div>
</template>
</template>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, reactive } from "vue";
import { ref, computed, reactive, inject } from "vue";
import { useI18n } from "vue-i18n";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OButton from '@/lib/core/Button/OButton.vue';
import OInput from "@/lib/forms/Input/OInput.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import OFormSelect from "@/lib/forms/Select/OFormSelect.vue";
import { FORM_CONTEXT_KEY } from "@/lib/forms/Form/OForm.types";
import { useStore } from "vuex";
const props = defineProps({
fields: {
type: Array,
default: () => [],
required: true,
required: false,
},
streamFields: {
type: Array,
@@ -133,8 +231,19 @@ const props = defineProps({
type: Boolean,
default: false,
required: false,
}
},
/**
* OForm "form mode" opt-in: the path of the rows array inside the parent
* <OForm>'s values (e.g. "conditions" / "query_condition.conditions").
* When set AND a form context is injectable, rows render as OForm* fields
* with indexed names (`${namePrefix}[i].column|operator|value`). Without it
* the component behaves exactly as before (bare mode).
*/
namePrefix: {
type: String,
default: "",
required: false,
},
});
const fieldErrors = reactive<Record<string, string>>({});
@@ -154,6 +263,44 @@ const store = useStore();
const { t } = useI18n();
// ── OForm form mode (dual-mode; opt-in via namePrefix) ──────────────────────
// A consumer inside an <OForm> may inject a form context even when it renders
// this component bare (e.g. pipeline's Condition.vue bridge) — form mode
// therefore requires BOTH the injected form AND an explicit namePrefix.
const injectedForm = inject(FORM_CONTEXT_KEY, null);
const formMode = computed(() => !!(props.namePrefix && injectedForm));
/** Resolve a dotted/indexed path ("a.b[2].c") inside the form values. */
const resolvePath = (obj: any, path: string): any =>
path
.replace(/\[(\d+)\]/g, ".$1")
.split(".")
.filter(Boolean)
.reduce((acc: any, key: string) => (acc == null ? undefined : acc[key]), obj);
// ⚠️ MUST be form.useStore (reactive) — NOT form.state.values (a snapshot a
// computed won't track; playbook §2).
const formRows = injectedForm
? injectedForm.useStore((s: any) =>
props.namePrefix ? (resolvePath(s.values, props.namePrefix) ?? []) : [],
)
: ref<any[]>([]);
const makeConditionRow = () => ({ column: "", operator: "=", value: "" });
const addFormRow = () =>
injectedForm?.pushFieldValue(props.namePrefix, makeConditionRow());
const removeFormRow = (index: number) =>
injectedForm?.removeFieldValue(props.namePrefix, index);
// A `creatable` OSelect emits only `create` (no update:model-value), so write
// the created value straight into the form field (bare mode's @create parity).
const setRowColumn = (index: number, val: string) =>
injectedForm?.setFieldValue(`${props.namePrefix}[${index}].column`, val);
// ── Bare mode handlers (unchanged) ──────────────────────────────────────────
const deleteApiHeader = (field: any) => {
emits("remove", field);
emits("input:update", "conditions", field);
@@ -166,4 +313,3 @@ const addApiHeader = () => {
</script>
+223 -12
View File
@@ -1,9 +1,20 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { mount, flushPromises } from '@vue/test-utils';
import FilterCondition from './FilterCondition.vue';
import { createStore } from 'vuex';
import { createI18n } from 'vue-i18n';
import { nextTick } from 'vue';
import enMessages from '@/locales/languages/en.json';
import { nextTick, defineComponent, reactive } from 'vue';
import { z } from 'zod';
import OForm from '@/lib/forms/Form/OForm.vue';
import OFormSelect from '@/lib/forms/Select/OFormSelect.vue';
import OFormInput from '@/lib/forms/Input/OFormInput.vue';
import OSelect from '@/lib/forms/Select/OSelect.vue';
import OInput from '@/lib/forms/Input/OInput.vue';
import {
conditionGroupNodeSchema,
refineConditionsTree,
} from './steps/QueryConfig.schema';
const mockStore = createStore({
state: {
@@ -11,18 +22,12 @@ const mockStore = createStore({
},
});
// Mount the REAL en.json messages (not a hand-written stub): the component's
// user-facing strings are i18n keys, so a stub would make every t() fall back to
// its raw key path and silently weaken the text assertions below.
const mockI18n = createI18n({
locale: 'en',
messages: {
en: {
alerts: {
column: 'Column',
},
common: {
value: 'Value',
},
},
},
messages: { en: enMessages },
});
@@ -442,4 +447,210 @@ describe('FilterCondition.vue Branch Coverage', () => {
expect((wrapper.vm as any).computedLabel).toBe('');
});
});
});
// ─────────────────────────────────────────────────────────────────────────────
// FORM MODE (alerts-migration.md §A): namePrefix + injected OForm context.
// The three controls become OForm* fields name=-bound into the REAL TanStack
// form (no v-model, no manual error refs); the schema (refineConditionsTree)
// owns validation, surfaced only after the first submit (R3).
// ─────────────────────────────────────────────────────────────────────────────
describe('FilterCondition.vue Form Mode (namePrefix + OForm)', () => {
const streamFields = [
{ label: 'Field 1', value: 'field1' },
{ label: 'Field 2', value: 'field2' },
];
const makeCondition = (overrides: Record<string, unknown> = {}) => ({
filterType: 'condition',
column: '',
operator: '=',
value: '',
values: [],
logicalOperator: 'AND',
id: 'cond-1',
...overrides,
});
// Host with a REAL <OForm> whose schema runs the refineConditionsTree walker
// (basePath ["tree"] — the same wiring the QueryConfig phase will use with
// ["query_condition", "conditions"]).
const mountFormHost = (
condition: Record<string, unknown>,
{ namePrefix = 'tree.conditions[0]' }: { namePrefix?: string } = {},
) => {
const tree = reactive({
filterType: 'group',
logicalOperator: 'AND',
groupId: 'root',
conditions: [condition],
});
const onSubmit = vi.fn();
const testSchema = z
.object({ tree: conditionGroupNodeSchema })
.superRefine((val, ctx) =>
refineConditionsTree(val.tree, ctx, ['tree'], 'Field is required!'),
);
const Host = defineComponent({
components: { OForm, FilterCondition },
setup() {
return {
tree,
onSubmit,
testSchema,
defaults: { tree },
streamFields,
namePrefix,
};
},
template: `
<OForm :schema="testSchema" :default-values="defaults" @submit="onSubmit">
<FilterCondition
:condition="tree.conditions[0]"
:stream-fields="streamFields"
:index="0"
label="and"
:depth="0"
:is-first-in-group="true"
:name-prefix="namePrefix"
/>
</OForm>`,
});
const wrapper = mount(Host, {
global: {
plugins: [mockI18n],
provide: { store: mockStore },
},
});
const form = (wrapper.findComponent(OForm).vm as any).form;
return { wrapper, onSubmit, form, tree };
};
beforeEach(() => {
vi.clearAllMocks();
});
it('renders OFormSelect/OFormSelect/OFormInput name=-bound to `${namePrefix}.column/.operator/.value`', () => {
const { wrapper } = mountFormHost(
makeCondition({ column: 'field1', operator: '=', value: 'abc' }),
);
const selects = wrapper.findAllComponents(OFormSelect);
expect(selects.map((s) => s.props('name'))).toEqual([
'tree.conditions[0].column',
'tree.conditions[0].operator',
]);
const input = wrapper.findComponent(OFormInput);
expect(input.props('name')).toBe('tree.conditions[0].value');
// The rendered controls read their values from the FORM (name-bound).
expect(selects[0].findComponent(OSelect).props('modelValue')).toBe('field1');
expect(selects[1].findComponent(OSelect).props('modelValue')).toBe('=');
expect(input.findComponent(OInput).props('modelValue')).toBe('abc');
// data-tests are carried verbatim into form mode.
expect(wrapper.find('[data-test="alert-conditions-select-column"]').exists()).toBe(true);
expect(wrapper.find('[data-test="alert-conditions-operator-select"]').exists()).toBe(true);
expect(wrapper.find('[data-test="alert-conditions-value-input"]').exists()).toBe(true);
});
it('shows NO errors before the first submit (R3)', () => {
const { wrapper } = mountFormHost(
makeCondition({ column: '', operator: '', value: '' }),
);
expect(wrapper.text()).not.toContain('Field is required!');
for (const select of wrapper.findAllComponents(OSelect)) {
expect(select.props('error')).toBeFalsy();
}
expect(wrapper.findComponent(OInput).props('error')).toBeFalsy();
});
it('blocks submit and surfaces schema errors on empty column/operator after form.handleSubmit()', async () => {
const { wrapper, onSubmit, form } = mountFormHost(
makeCondition({ column: '', operator: '', value: 'ok' }),
);
await form.handleSubmit();
await flushPromises();
expect(onSubmit).not.toHaveBeenCalled();
expect(form.state.isValid).toBe(false);
const selects = wrapper.findAllComponents(OFormSelect);
expect(selects[0].findComponent(OSelect).props('error')).toBe(true); // column
expect(selects[1].findComponent(OSelect).props('error')).toBe(true); // operator
// value was filled → no error there.
expect(wrapper.findComponent(OFormInput).findComponent(OInput).props('error')).toBe(false);
expect(wrapper.text()).toContain('Field is required!');
});
it('blocks submit on an empty value ("") with the error on the value input', async () => {
const { wrapper, onSubmit, form } = mountFormHost(
makeCondition({ column: 'field1', operator: '=', value: '' }),
);
await form.handleSubmit();
await flushPromises();
expect(onSubmit).not.toHaveBeenCalled();
expect(form.state.isValid).toBe(false);
expect(wrapper.findComponent(OFormInput).findComponent(OInput).props('error')).toBe(true);
const selects = wrapper.findAllComponents(OFormSelect);
expect(selects[0].findComponent(OSelect).props('error')).toBe(false);
expect(selects[1].findComponent(OSelect).props('error')).toBe(false);
});
it('value is ZERO-SAFE: numeric 0 passes and the form submits', async () => {
const { wrapper, onSubmit, form } = mountFormHost(
makeCondition({ column: 'field1', operator: '=', value: 0 }),
);
await form.handleSubmit();
await flushPromises();
expect(form.state.isValid).toBe(true);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0].tree.conditions[0].value).toBe(0);
expect(wrapper.text()).not.toContain('Field is required!');
});
it('falls back to BARE mode when no namePrefix is passed — even inside an OForm (pipeline safety)', () => {
const { wrapper } = mountFormHost(makeCondition({ column: 'field1' }), {
namePrefix: '',
});
// No OForm* wrappers — the bare v-model controls render instead, so a
// form-owning consumer (pipeline's Condition.vue) is untouched until it
// opts in by passing a prefix.
expect(wrapper.findAllComponents(OFormSelect)).toHaveLength(0);
expect(wrapper.findAllComponents(OFormInput)).toHaveLength(0);
expect(wrapper.findAllComponents(OSelect).length).toBe(2);
expect(wrapper.findComponent(OInput).exists()).toBe(true);
});
it('falls back to BARE mode when namePrefix is set but no OForm context exists', () => {
const wrapper = mount(FilterCondition, {
props: {
condition: makeCondition({ column: 'field1' }),
streamFields,
index: 0,
label: 'and',
depth: 0,
isFirstInGroup: true,
namePrefix: 'tree.conditions[0]',
},
global: {
plugins: [mockI18n],
provide: { store: mockStore },
},
});
expect(wrapper.findAllComponents(OFormSelect)).toHaveLength(0);
expect(wrapper.findAllComponents(OFormInput)).toHaveLength(0);
expect(wrapper.findAllComponents(OSelect).length).toBe(2);
expect(wrapper.findComponent(OInput).exists()).toBe(true);
});
});
+143 -61
View File
@@ -5,7 +5,7 @@
<div class="flex items-center justify-center mt-1 min-w-[60px]">
<!-- First condition in root group -->
<template v-if="index === 0 && depth === 0">
<span class="text-sm">if</span>
<span class="text-sm">{{ t('alerts.filters.if') }}</span>
</template>
<!-- First condition in nested groups: empty space for alignment -->
@@ -31,60 +31,112 @@
@click="toggleOperator"
>
<OIcon name="restart-alt" size="sm" />
<OTooltip content="Toggle between and/or" />
<OTooltip :content="t('alerts.filters.toggleOperatorTooltip')" />
</OButton>
</template>
</div>
<div class="ml-0">
<OSelect
v-model="condition.column"
:options="filteredFields"
:dropdownStyle="{ textTransform: 'lowercase' }"
searchable
:searchDebounce="400"
labelKey="label"
valueKey="value"
width="xs"
:placeholder="t('alerts.column')"
:creatable="props.allowCustomColumns"
:class="[inputWidth ? inputWidth : '']"
:error="!!columnError"
:error-message="columnError"
data-test="alert-conditions-select-column"
@search="filterColumns"
@update:model-value="() => { columnError = ''; emits('input:update', 'conditions', condition) }"
@blur="validateColumn"
/>
<OTooltip v-if="condition.column && store.state.isAiChatEnabled" :content="condition.column" />
</div>
<div class="ml-0">
<OSelect
v-model="condition.operator"
:options="triggerOperators"
:dropdownStyle="{ textTransform: 'capitalize' }"
:class="[inputWidth ? inputWidth : (store.state.isAiChatEnabled ? 'w-[70px]' : computedInputWidth)]"
:error="!!operatorError"
:searchable="false"
:error-message="operatorError"
data-test="alert-conditions-operator-select"
@update:model-value="() => { operatorError = ''; emits('input:update', 'conditions', condition) }"
@blur="validateOperator"
/>
<OTooltip v-if="condition.operator && store.state.isAiChatEnabled" :content="condition.operator" />
</div>
<div class="ml-0">
<OInput
v-model="condition.value"
:placeholder="t('common.value')"
:error="!!valueError"
:error-message="valueError"
:class="[inputWidth ? inputWidth : (store.state.isAiChatEnabled ? 'w-[110px]' : computedValueWidth)]"
data-test="alert-conditions-value-input"
@update:model-value="() => { valueError = ''; emits('input:update', 'conditions', condition) }"
@blur="validateValue"
/>
<OTooltip v-if="condition.value && store.state.isAiChatEnabled" :content="condition.value" />
</div>
<!-- FORM MODE (namePrefix + an injected OForm context): the three
controls are name=-owned by the TanStack form — no v-model, no
manual error refs; schema errors surface post-submit via the
OForm* wrappers (R3). Bare mode below stays byte-compatible
(pipeline's NodeForm consumes these bare — permanent, sanctioned). -->
<template v-if="formMode">
<div class="ml-0">
<OFormSelect
:name="`${namePrefix}.column`"
:options="filteredFields"
:dropdownStyle="{ textTransform: 'lowercase' }"
searchable
:searchDebounce="400"
labelKey="label"
valueKey="value"
width="xs"
:placeholder="t('alerts.column')"
:creatable="props.allowCustomColumns"
:class="[inputWidth ? inputWidth : '']"
data-test="alert-conditions-select-column"
@search="filterColumns"
@update:model-value="() => emits('input:update', 'conditions', condition)"
/>
<OTooltip v-if="condition.column && store.state.isAiChatEnabled" :content="condition.column" />
</div>
<div class="ml-0">
<OFormSelect
:name="`${namePrefix}.operator`"
:options="triggerOperators"
:dropdownStyle="{ textTransform: 'capitalize' }"
:class="[inputWidth ? inputWidth : (store.state.isAiChatEnabled ? 'w-[70px]' : computedInputWidth)]"
:searchable="false"
data-test="alert-conditions-operator-select"
@update:model-value="() => emits('input:update', 'conditions', condition)"
/>
<OTooltip v-if="condition.operator && store.state.isAiChatEnabled" :content="condition.operator" />
</div>
<div class="ml-0">
<OFormInput
:name="`${namePrefix}.value`"
:placeholder="t('common.value')"
:class="[inputWidth ? inputWidth : (store.state.isAiChatEnabled ? 'w-[110px]' : computedValueWidth)]"
data-test="alert-conditions-value-input"
@update:model-value="() => emits('input:update', 'conditions', condition)"
/>
<OTooltip v-if="condition.value && store.state.isAiChatEnabled" :content="condition.value" />
</div>
</template>
<!-- BARE MODE (no namePrefix / no OForm context) — today's markup,
unchanged. Do NOT degrade: pipeline consumes it permanently. -->
<template v-else>
<div class="ml-0">
<OSelect
v-model="condition.column"
:options="filteredFields"
:dropdownStyle="{ textTransform: 'lowercase' }"
searchable
:searchDebounce="400"
labelKey="label"
valueKey="value"
width="xs"
:placeholder="t('alerts.column')"
:creatable="props.allowCustomColumns"
:class="[inputWidth ? inputWidth : '']"
:error="!!columnError"
:error-message="columnError"
data-test="alert-conditions-select-column"
@search="filterColumns"
@update:model-value="() => { columnError = ''; emits('input:update', 'conditions', condition) }"
@blur="validateColumn"
/>
<OTooltip v-if="condition.column && store.state.isAiChatEnabled" :content="condition.column" />
</div>
<div class="ml-0">
<OSelect
v-model="condition.operator"
:options="triggerOperators"
:dropdownStyle="{ textTransform: 'capitalize' }"
:class="[inputWidth ? inputWidth : (store.state.isAiChatEnabled ? 'w-[70px]' : computedInputWidth)]"
:error="!!operatorError"
:searchable="false"
:error-message="operatorError"
data-test="alert-conditions-operator-select"
@update:model-value="() => { operatorError = ''; emits('input:update', 'conditions', condition) }"
@blur="validateOperator"
/>
<OTooltip v-if="condition.operator && store.state.isAiChatEnabled" :content="condition.operator" />
</div>
<div class="ml-0">
<OInput
v-model="condition.value"
:placeholder="t('common.value')"
:error="!!valueError"
:error-message="valueError"
:class="[inputWidth ? inputWidth : (store.state.isAiChatEnabled ? 'w-[110px]' : computedValueWidth)]"
data-test="alert-conditions-value-input"
@update:model-value="() => { valueError = ''; emits('input:update', 'conditions', condition) }"
@blur="validateValue"
/>
<OTooltip v-if="condition.value && store.state.isAiChatEnabled" :content="condition.value" />
</div>
</template>
</div>
</template>
@@ -92,7 +144,10 @@
import OButton from '@/lib/core/Button/OButton.vue';
import OSelect from '@/lib/forms/Select/OSelect.vue';
import OInput from '@/lib/forms/Input/OInput.vue';
import OFormSelect from '@/lib/forms/Select/OFormSelect.vue';
import OFormInput from '@/lib/forms/Input/OFormInput.vue';
import OTooltip from '@/lib/overlay/Tooltip/OTooltip.vue';
import { FORM_CONTEXT_KEY } from '@/lib/forms/Form/OForm.types';
const props = defineProps({
condition: {
type: Object,
@@ -140,9 +195,22 @@
required: false,
validator: (value: string) => ['alerts', 'pipelines'].includes(value),
},
/**
* Dual-mode switch (alerts-migration.md §A). When set AND an OForm context
* is injectable, the three controls render as OForm* fields name=-bound to
* `${namePrefix}.column` / `.operator` / `.value` — the TanStack form owns
* their values (no v-model, no manual error refs). When empty (default)
* the component renders today's BARE markup unchanged (pipeline's
* NodeForm/Condition.vue consumes it bare — a permanent, sanctioned mode).
*/
namePrefix: {
type: String,
default: '',
required: false,
},
});
import { ref, computed, watch } from "vue";
import { ref, computed, watch, inject } from "vue";
import { useI18n } from "vue-i18n";
import { useStore } from "vuex";
import OIcon from "@/lib/core/Icon/OIcon.vue";
@@ -172,19 +240,26 @@ const store = useStore();
const { t } = useI18n();
// Inline error state
// Dual-mode: form mode only when BOTH a namePrefix is passed AND an OForm
// context is actually available (a standalone/spec mount without <OForm>
// auto-falls-back to bare mode, by design).
const form = inject(FORM_CONTEXT_KEY, null);
const formMode = computed(() => !!(props.namePrefix && form));
// Inline error state — BARE MODE ONLY. In form mode the schema owns validation
// and the OForm* wrappers surface errors (R3).
const columnError = ref('');
const operatorError = ref('');
const valueError = ref('');
const validateColumn = () => {
columnError.value = !props.condition.column ? 'Field is required!' : '';
columnError.value = !props.condition.column ? t('alerts.validation.fieldRequired') : '';
};
const validateOperator = () => {
operatorError.value = !props.condition.operator ? 'Field is required!' : '';
operatorError.value = !props.condition.operator ? t('alerts.validation.fieldRequired') : '';
};
const validateValue = () => {
valueError.value = !props.condition.value ? 'Field is required!' : '';
valueError.value = !props.condition.value ? t('alerts.validation.fieldRequired') : '';
};
defineExpose({
@@ -222,12 +297,19 @@ const computedLabel = computed(() => {
return props.label;
});
// Toggle operator between AND/OR for this condition
// Toggle operator between AND/OR for this condition. In FORM mode (alerts) the
// condition is the READONLY form read-view, so mutating it in place silently
// fails ("target is readonly") — write through the injected form by its name
// path instead. In BARE mode (pipeline) it's plain reactive, so mutate in place.
const toggleOperator = () => {
if (props.condition.logicalOperator) {
props.condition.logicalOperator = props.condition.logicalOperator === 'AND' ? 'OR' : 'AND';
emits('input:update', 'conditions', props.condition);
if (!props.condition.logicalOperator) return;
const next = props.condition.logicalOperator === 'AND' ? 'OR' : 'AND';
if (formMode.value && form) {
form.setFieldValue(`${props.namePrefix}.logicalOperator`, next);
} else {
props.condition.logicalOperator = next;
}
emits('input:update', 'conditions', props.condition);
};
const computedInputWidth = computed(() => {
+330 -13
View File
@@ -1,9 +1,20 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { mount, flushPromises } from '@vue/test-utils';
import FilterGroup from './FilterGroup.vue';
import { createStore } from 'vuex';
import { createI18n } from 'vue-i18n';
import { nextTick } from 'vue';
import enMessages from '@/locales/languages/en.json';
import { nextTick, defineComponent, reactive, ref } from 'vue';
import { z } from 'zod';
import OForm from '@/lib/forms/Form/OForm.vue';
import OFormSelect from '@/lib/forms/Select/OFormSelect.vue';
import OFormInput from '@/lib/forms/Input/OFormInput.vue';
import OSelect from '@/lib/forms/Select/OSelect.vue';
import OInput from '@/lib/forms/Input/OInput.vue';
import {
conditionGroupNodeSchema,
refineConditionsTree,
} from './steps/QueryConfig.schema';
// Mock getUUID
vi.mock('@/utils/zincutils', () => ({
@@ -17,18 +28,12 @@ const mockStore = createStore({
},
});
// Mount the REAL en.json messages (not a hand-written stub): the component's
// user-facing strings are i18n keys, so a stub would make every t() fall back to
// its raw key path and silently weaken the text assertions below.
const mockI18n = createI18n({
locale: 'en',
messages: {
en: {
alerts: {
column: 'Column',
},
common: {
value: 'Value',
},
},
},
messages: { en: enMessages },
});
@@ -284,7 +289,7 @@ describe('FilterGroup.vue Comprehensive Coverage', () => {
expect(wrapper.emitted('add-condition')?.[0][0]).toEqual(wrapper.vm.groups);
});
it('should add multiple conditions correctly', () => {
it('should add multiple conditions correctly', async () => {
const wrapper = mount(FilterGroup, {
props: defaultProps,
global: {
@@ -300,10 +305,60 @@ describe('FilterGroup.vue Comprehensive Coverage', () => {
const initialLength = wrapper.vm.groups.conditions.length;
wrapper.vm.addCondition('test-group');
// Structural ops chain THROUGH the parent: each emitted group is written
// back to `group` before the next op, which re-syncs the working clone.
// The handler refreshes the clone from the live prop so bare-mode in-place
// leaf edits aren't lost — so a second add without that write-back would
// (correctly) start from the un-updated prop. Simulate the write-back here.
await wrapper.setProps({ group: wrapper.emitted('add-condition')![0][0] });
wrapper.vm.addCondition('test-group');
expect(wrapper.vm.groups.conditions).toHaveLength(initialLength + 2);
});
// Regression: bare-mode consumers (pipeline NodeForm/Condition.vue) edit the
// leaf conditions of `group` IN PLACE via v-model. A structural op must emit
// those in-place edits, not the stale working clone — otherwise the ancestor
// writes the stale clone back and wipes the user's typed values (which made
// the pipeline condition-node fail to save).
it('preserves in-place bare-mode leaf edits when a structural op emits', () => {
const group = {
groupId: 'g-1',
filterType: 'group',
logicalOperator: 'AND',
conditions: [
{
id: 'c-1',
filterType: 'condition',
column: '',
operator: '=',
value: '',
logicalOperator: 'AND',
},
],
};
const wrapper = mount(FilterGroup, {
props: { ...defaultProps, group },
global: {
plugins: [mockI18n],
provide: { store: mockStore },
stubs: { FilterCondition: true },
},
});
// Simulate bare-mode v-model editing the leaf IN PLACE (no new reference,
// so the non-deep sync watch does not fire — the clone would go stale).
group.conditions[0].column = 'kubernetes_container_name';
group.conditions[0].value = 'prometheus';
// A structural op (add) must carry the in-place edits into its payload.
wrapper.vm.addCondition('g-1');
const emitted = wrapper.emitted('add-condition')![0][0] as any;
expect(emitted.conditions[0].column).toBe('kubernetes_container_name');
expect(emitted.conditions[0].value).toBe('prometheus');
expect(emitted.conditions).toHaveLength(2);
});
});
describe('addGroup Function', () => {
@@ -1939,4 +1994,266 @@ describe('FilterGroup.vue Comprehensive Coverage', () => {
expect(wrapper.vm.isGroup(wrapper.vm.groups.conditions[1])).toBeFalsy();
});
});
});
// ─────────────────────────────────────────────────────────────────────────────
// FORM MODE (alerts-migration.md §A): the group passes the namePrefix down
// recursively — the child at index i binds under `${namePrefix}.conditions[${i}]`
// into the injected REAL OForm; the v-for :key stays the array INDEX (Rule ①).
// ─────────────────────────────────────────────────────────────────────────────
describe('FilterGroup.vue Form Mode (namePrefix + OForm)', () => {
const streamFields = [
{ label: 'Field 1', value: 'field1' },
{ label: 'Field 2', value: 'field2' },
{ label: 'User Name', value: 'username' },
];
const makeLeaf = (
id: string,
column: string,
operator: string,
value: unknown,
) => ({
filterType: 'condition',
column,
operator,
value,
values: [],
logicalOperator: 'AND',
id,
});
const makeGroup = (
groupId: string,
conditions: unknown[],
logicalOperator = 'AND',
) => ({
filterType: 'group',
logicalOperator,
groupId,
conditions,
});
// Host with a REAL <OForm>. The tree object is BOTH the FilterGroup :group
// prop and the form's `tree` default value (one object graph — single source
// of truth). FilterGroup's structural ops (delete/add/reorder) mutate the
// group in place and emit; the host relays that to the form with a
// setFieldValue poke — exactly the wiring the QueryConfig phase will add on
// its existing @add-condition/@add-group/@remove-group handlers.
const mountFormHost = (
conditions: unknown[],
{ namePrefix = 'tree' }: { namePrefix?: string } = {},
) => {
const tree = reactive({
filterType: 'group',
logicalOperator: 'AND',
groupId: 'root',
conditions,
});
const onSubmit = vi.fn();
const testSchema = z
.object({ tree: conditionGroupNodeSchema })
.superRefine((val, ctx) =>
refineConditionsTree(val.tree, ctx, ['tree'], 'Field is required!'),
);
const Host = defineComponent({
components: { OForm, FilterGroup },
setup() {
const oform = ref<any>(null);
const treeChanged = (updated: any) => {
// A structural change arrives as the NEW tree (add-condition/add-group).
// Mirror useAlertForm: write it to the TanStack form so name-bound
// fields re-read it. In the app `props.group` is the form read-view, so
// it updates automatically; the host's `:group="tree"` is a separate
// ref, so replace its contents too (keeping identity) to model that.
// (remove-group emits a groupId string; delete-to-empty isn't exercised
// by these host tests.)
if (!updated || typeof updated !== 'object') return;
const next = JSON.parse(JSON.stringify(updated));
tree.filterType = next.filterType;
tree.logicalOperator = next.logicalOperator;
tree.conditions = next.conditions;
oform.value?.form.setFieldValue('tree', next, {
dontUpdateMeta: true,
});
};
return {
oform,
treeChanged,
tree,
onSubmit,
testSchema,
defaults: { tree },
streamFields,
namePrefix,
};
},
template: `
<OForm ref="oform" :schema="testSchema" :default-values="defaults" @submit="onSubmit">
<FilterGroup
:group="tree"
:stream-fields="streamFields"
:depth="0"
:name-prefix="namePrefix"
@add-condition="treeChanged"
@add-group="treeChanged"
@remove-group="treeChanged"
/>
</OForm>`,
});
const wrapper = mount(Host, {
global: {
plugins: [mockI18n],
provide: { store: mockStore },
},
});
const form = (wrapper.findComponent(OForm).vm as any).form;
return { wrapper, onSubmit, form, tree };
};
const renderedValueInputs = (wrapper: ReturnType<typeof mount>) =>
wrapper
.findAllComponents(OFormInput)
.filter((c) => /\.value$/.test(String(c.props('name'))));
const renderedColumnSelects = (wrapper: ReturnType<typeof mount>) =>
wrapper
.findAllComponents(OFormSelect)
.filter((c) => /\.column$/.test(String(c.props('name'))));
const renderedOperatorSelects = (wrapper: ReturnType<typeof mount>) =>
wrapper
.findAllComponents(OFormSelect)
.filter((c) => /\.operator$/.test(String(c.props('name'))));
beforeEach(() => {
vi.clearAllMocks();
});
it('passes the prefix recursively — child at index i binds `${namePrefix}.conditions[${i}]` (incl. nested groups)', () => {
const { wrapper } = mountFormHost([
makeLeaf('a', 'field1', '=', 'va'),
makeGroup('g-1', [makeLeaf('n', 'field2', '!=', 'vn')], 'OR'),
]);
expect(
renderedColumnSelects(wrapper).map((c) => c.props('name')),
).toEqual([
'tree.conditions[0].column',
'tree.conditions[1].conditions[0].column',
]);
expect(renderedValueInputs(wrapper).map((c) => c.props('name'))).toEqual([
'tree.conditions[0].value',
'tree.conditions[1].conditions[0].value',
]);
// Rendered controls read from the form.
expect(
renderedColumnSelects(wrapper).map((c) =>
c.findComponent(OSelect).props('modelValue'),
),
).toEqual(['field1', 'field2']);
expect(
renderedValueInputs(wrapper).map((c) =>
c.findComponent(OInput).props('modelValue'),
),
).toEqual(['va', 'vn']);
});
it('🔑 Rule-① gate: deleting a NON-last row keeps the RENDERED inputs in sync with the remaining rows (index :key + index names)', async () => {
const { wrapper } = mountFormHost([
makeLeaf('a', 'field1', '=', 'va'),
makeLeaf('b', 'field2', '>', 'vb'),
makeLeaf('c', 'username', '<', 'vc'),
]);
await flushPromises();
// Sanity: 3 rows rendered, index-named, in order.
expect(renderedValueInputs(wrapper).map((c) => c.props('name'))).toEqual([
'tree.conditions[0].value',
'tree.conditions[1].value',
'tree.conditions[2].value',
]);
// Delete the MIDDLE row (index 1 — a NON-last row).
const deleteBtns = wrapper.findAll(
'[data-test="alert-conditions-delete-condition-btn"]',
);
expect(deleteBtns).toHaveLength(3);
await deleteBtns[1].trigger('click');
await flushPromises();
// Assert the RENDERED inputs (each OForm* → inner control model-value),
// NOT just form.state.values — a stable-id :key would leave these
// shifted/blank while the data stays correct.
const columnValues = renderedColumnSelects(wrapper).map((c) =>
c.findComponent(OSelect).props('modelValue'),
);
const operatorValues = renderedOperatorSelects(wrapper).map((c) =>
c.findComponent(OSelect).props('modelValue'),
);
const valueValues = renderedValueInputs(wrapper).map((c) =>
c.findComponent(OInput).props('modelValue'),
);
expect(columnValues).toEqual(['field1', 'username']);
expect(operatorValues).toEqual(['=', '<']);
expect(valueValues).toEqual(['va', 'vc']);
// And the surviving rows re-bound to the compacted index names.
expect(renderedValueInputs(wrapper).map((c) => c.props('name'))).toEqual([
'tree.conditions[0].value',
'tree.conditions[1].value',
]);
});
it('submit routes each schema error to the exact row (partial row blocks save)', async () => {
const { wrapper, onSubmit, form } = mountFormHost([
makeLeaf('a', 'field1', '=', 'va'),
makeLeaf('b', '', '=', 'vb'), // column missing on row 1
]);
await form.handleSubmit();
await flushPromises();
expect(onSubmit).not.toHaveBeenCalled();
expect(form.state.isValid).toBe(false);
const columnErrors = renderedColumnSelects(wrapper).map((c) =>
c.findComponent(OSelect).props('error'),
);
expect(columnErrors).toEqual([false, true]);
expect(wrapper.text()).toContain('Field is required!');
});
it('a complete tree submits (zero-safe value 0 included)', async () => {
const { onSubmit, form } = mountFormHost([
makeLeaf('a', 'field1', '=', 0),
makeGroup('g-1', [makeLeaf('n', 'field2', '!=', 'vn')]),
]);
await form.handleSubmit();
await flushPromises();
expect(form.state.isValid).toBe(true);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0].tree.conditions[0].value).toBe(0);
});
it('stays BARE without a namePrefix — even inside an OForm (pipeline safety)', () => {
const { wrapper } = mountFormHost(
[makeLeaf('a', 'field1', '=', 'va')],
{ namePrefix: '' },
);
// No OForm* wrappers anywhere in the tree: a form-owning consumer
// (pipeline's Condition.vue) keeps its bare bridge untouched until it
// opts in by passing a prefix.
expect(wrapper.findAllComponents(OFormSelect)).toHaveLength(0);
expect(wrapper.findAllComponents(OFormInput)).toHaveLength(0);
expect(wrapper.findAllComponents(OSelect).length).toBeGreaterThan(0);
expect(wrapper.findComponent(OInput).exists()).toBe(true);
});
});
+99 -14
View File
@@ -6,7 +6,7 @@
<div class="flex items-start gap-1 min-w-0">
<span class="font-medium text-xs flex-shrink-0 leading-[1.3]"
:class="store.state.theme === 'dark' ? 'text-gray-300' : 'text-gray-700'">
Preview:
{{ t('alerts.filters.previewLabel') }}
</span>
<span class="text-[10px] font-mono leading-[1.3] min-w-0 break-words"
:class="store.state.theme === 'dark' ? 'text-gray-400' : 'text-gray-600'">
@@ -66,6 +66,7 @@
:condition-input-width="props.conditionInputWidth"
:allow-custom-columns="props.allowCustomColumns"
:module="props.module"
:name-prefix="childNamePrefix(index)"
@input:update="(name, field) => inputUpdate(name, field)"
/>
<div
@@ -84,6 +85,7 @@
:is-first-in-group="index === 0"
:allow-custom-columns="props.allowCustomColumns"
:module="props.module"
:name-prefix="childNamePrefix(index)"
/>
<OButton data-test="alert-conditions-delete-condition-btn" size="icon-xs-circle" variant="ghost" @click="removeCondition(item.id)">
<OIcon name="close" size="sm" />
@@ -102,7 +104,7 @@
@click="addCondition(props.group.groupId)"
>
<OIcon class="mr-1 font-bold" size="xs" style="border-radius: 50%; border: 1px solid;" name="add" />
<span class="text-[0.75rem] font-bold">Condition</span>
<span class="text-[0.75rem] font-bold">{{ t('alerts.conditions.condition') }}</span>
<OTooltip :delay="300" :content="t('alerts.conditions.addConditionTooltip')" />
</OButton>
<OButton
@@ -126,8 +128,8 @@
@click="reorderItems()"
>
<OIcon class="mr-1 font-bold" size="xs" name="swap-vert" />
<span class="text-[0.75rem] font-bold">Reorder</span>
<OTooltip :delay="300" content="Reorder items: Conditions first, then Groups" />
<span class="text-[0.75rem] font-bold">{{ t('alerts.filters.reorder') }}</span>
<OTooltip :delay="300" :content="t('alerts.filters.reorderTooltip')" />
</OButton>
</div>
</div>
@@ -144,6 +146,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { cloneDeep } from 'lodash-es';
import FilterCondition from './FilterCondition.vue';
import { useStore } from 'vuex';
import OButton from '@/lib/core/Button/OButton.vue';
@@ -209,8 +212,27 @@
required: false,
validator: (value: string) => ['alerts', 'pipelines'].includes(value),
},
/**
* Dual-mode switch (alerts-migration.md §A). When set, this group passes
* the prefix down recursively — the child at index i (leaf condition OR
* nested group) gets `${namePrefix}.conditions[${i}]` — so every
* FilterCondition binds its OForm* fields into the injected TanStack form
* at the exact nested path. When empty (default): today's BARE behavior,
* unchanged (pipeline's NodeForm/Condition.vue consumes it bare —
* a permanent, sanctioned mode).
*
* 🔑 The v-for `:key` MUST stay the array INDEX: the OForm* fields bind by
* index-based name and do NOT re-bind when the name changes — a stable-id
* key would leave rendered inputs shifted/blank after a mid-list delete
* (START-HERE Rule ①).
*/
namePrefix: {
type: String,
default: '',
required: false,
},
});
const emit = defineEmits<{
(e: 'add-condition', groupId: any): void;
(e: 'add-group', groupId: any): void;
@@ -220,7 +242,10 @@
}>();
const isOpen = ref(true);
const groups = ref(props.group);
// Mutable deep clone — props.group is the readonly form read-view in alerts
// mode; the handlers mutate this clone and emit it, and the ancestor writes it
// back through the form (which re-syncs via the watch below).
const groups = ref(cloneDeep(props.group));
const showPreview = ref(true);
const store = useStore();
@@ -237,12 +262,38 @@
okCallback: () => {},
});
// Watch for prop changes to keep groups in sync with parent
// Keep the local working copy in sync with the parent. The clone is required:
// props.group is the form's READONLY read-view and this component mutates
// `groups` in place (performRemoveCondition et al) — assigning it raw makes
// those writes silently fail ("target is readonly").
//
// NOT deep. `deep: true` was correct pre-migration, when formData was a plain
// `ref()` mutated IN PLACE — the object identity never changed, so a deep watch
// was the only way to see an edit. The form store replaces values IMMUTABLY on
// every change, so props.group already arrives as a new reference and a
// reference watch sees every edit.
//
// Keeping both was the worst of the two models: deep fired on every nested
// mutation, and each firing cloneDeep'd the whole subtree — once per nested
// FilterGroup. That is what put ~213ms of scripting into a single click.
watch(() => props.group, (newGroup) => {
groups.value = newGroup;
groups.value = cloneDeep(newGroup);
// V2: Use logicalOperator instead of label
label.value = newGroup.logicalOperator?.toLowerCase() || 'and';
}, { deep: true });
});
// Bare-mode consumers (e.g. pipeline's NodeForm/Condition.vue) edit props.group's
// leaf conditions IN PLACE via v-model. That never changes props.group's
// reference, so the intentionally non-deep sync watch above does not fire and
// `groups` (the working clone) goes STALE — missing the typed column/operator/
// value. The structural handlers below run only on explicit button clicks
// (add / remove / toggle / reorder), so refreshing the clone from the live prop
// at that point is cheap and captures those in-place edits BEFORE we mutate and
// emit. Without it, emitting the stale clone makes the ancestor replace the whole
// group and wipe the user's typed values on every structural change.
const syncWorkingCopyFromProp = () => {
groups.value = cloneDeep(props.group);
};
const tabOptions = computed(() => [
{
@@ -255,6 +306,14 @@
},
]);
// Dual-mode: the child at index i — a leaf FilterCondition OR a nested
// FilterGroup — binds under `${namePrefix}.conditions[${i}]`. Empty prefix
// (bare mode) propagates as empty so every descendant stays bare.
// (index is `number | string` because the template's v-for iterates an
// Object-typed prop — same pre-existing looseness as `:index="index"`.)
const childNamePrefix = (index: number | string) =>
props.namePrefix ? `${props.namePrefix}.conditions[${index}]` : '';
function isGroup(item: any) {
// V2: Check for filterType === "group" with conditions array
if (item && item.filterType === "group" && item.conditions && Array.isArray(item.conditions)) {
@@ -267,7 +326,14 @@
return false;
}
// NOTE: `groups` is a MUTABLE deep clone of `props.group` (which is the READONLY
// form read-view in alerts mode). The handlers mutate the clone in place and
// emit it; the ancestor writes it back through the form (setFieldValue), which
// re-syncs `props.group` → the clone via the watch below. Mutating the readonly
// prop directly silently fails ("target is readonly") so add/remove did nothing.
const addCondition = (groupId: string) => {
// Capture any in-place bare-mode leaf edits before mutating + emitting.
syncWorkingCopyFromProp();
// V2: Create condition with filterType and logicalOperator
const newCondition = {
filterType: 'condition',
@@ -281,8 +347,10 @@
groups.value.conditions.push(newCondition);
emit('add-condition', groups.value);
};
const addGroup = (groupId: string) => {
// Capture any in-place bare-mode leaf edits before mutating + emitting.
syncWorkingCopyFromProp();
// V2: Create group with filterType, logicalOperator, and conditions array
const newGroup = {
filterType: 'group',
@@ -303,9 +371,11 @@
groups.value.conditions.push(newGroup);
emit('add-group', groups.value);
};
// Toggle AND/OR
const toggleLabel = (newLabel?: string) => {
// Capture any in-place bare-mode leaf edits before mutating + emitting.
syncWorkingCopyFromProp();
// V2: Use logicalOperator instead of label
// If newLabel is provided, use it; otherwise toggle
if (newLabel) {
@@ -318,6 +388,8 @@
};
const removeCondition = (id: string) => {
// Capture any in-place bare-mode leaf edits before reading/mutating + emitting.
syncWorkingCopyFromProp();
// V2: Use conditions array instead of items
// First, check what will happen after removing this condition
const itemsAfterRemoval = groups.value.conditions.filter((item: any) => item.id !== id);
@@ -331,9 +403,17 @@
if (!hasConditionsAfterRemoval && subGroupCount > 0) {
confirmDialog.value = {
show: true,
title: 'Delete Condition',
message: 'Deleting this condition will remove the entire condition group.',
warningMessage: `This will also delete ${subGroupCount} sub-group${subGroupCount > 1 ? 's' : ''} nested under this group. This action cannot be undone.`,
title: t('alerts.filters.deleteConditionTitle'),
message: t('alerts.filters.deleteConditionMessage'),
// Pluralized by vue-i18n (`one | other`). This branch is guarded by
// `subGroupCount > 0`, so n is always >= 1 and vue-i18n's default rule
// (1 -> one, >=2 -> other) reproduces the previous hand-rolled
// `subGroupCount > 1 ? 's' : ''` output exactly.
warningMessage: t(
'alerts.filters.deleteConditionSubGroupWarning',
{ count: subGroupCount },
subGroupCount,
),
okCallback: () => {
// User confirmed, proceed with deletion
performRemoveCondition(id);
@@ -347,6 +427,9 @@
};
const performRemoveCondition = (id: string) => {
// Capture any in-place bare-mode leaf edits before mutating + emitting (this
// may run deferred from the confirm-dialog okCallback).
syncWorkingCopyFromProp();
// V2: Use conditions array instead of items
groups.value.conditions = groups.value.conditions.filter((item: any) => item.id !== id);
@@ -367,6 +450,8 @@
};
const reorderItems = () => {
// Capture any in-place bare-mode leaf edits before mutating + emitting.
syncWorkingCopyFromProp();
// V2: Use conditions array instead of items
// Separate conditions and groups
const conditions = groups.value.conditions.filter((item: any) => !isGroup(item));
@@ -18,6 +18,7 @@ import { shallowMount } from "@vue/test-utils";
import ImportDestination from "./ImportDestination.vue";
import { createStore } from "vuex";
import { createI18n } from "vue-i18n";
import enMessages from "@/locales/languages/en.json";
import { ref } from "vue";
// ─── Service mocks ───────────────────────────────────────────────────────────
@@ -74,9 +75,12 @@ const mockStore = createStore({
},
});
// Mount the REAL en.json messages (not a hand-written stub): the component's
// user-facing strings are i18n keys, so a stub would make every t() fall back to
// its raw key path and silently weaken the text assertions below.
const mockI18n = createI18n({
locale: "en",
messages: { en: { alert_destinations: { skip_tls_verify: "Skip TLS Verify" } } },
messages: { en: enMessages },
});
// ─── BaseImport stub ─────────────────────────────────────────────────────────
@@ -369,6 +373,35 @@ describe("ImportDestination", () => {
});
});
// ─── getCorrectionRequiredError — single source for the "required" rule ─────
describe("getCorrectionRequiredError (folded correction required rule)", () => {
it("returns 'Field is required!' for an empty/falsy correction value", () => {
expect(wrapper.vm.getCorrectionRequiredError("")).toBe("Field is required!");
expect(wrapper.vm.getCorrectionRequiredError(undefined)).toBe("Field is required!");
expect(wrapper.vm.getCorrectionRequiredError(null)).toBe("Field is required!");
});
it("returns an empty string once a correction value is supplied", () => {
expect(wrapper.vm.getCorrectionRequiredError("template1")).toBe("");
expect(wrapper.vm.getCorrectionRequiredError("action1")).toBe("");
});
it("validateDestinationInputs surfaces a template_name error for a missing template (single validation source)", async () => {
wrapper.vm.destinationErrorsToDisplay = [];
const result = await wrapper.vm.validateDestinationInputs(
{ name: "no-tpl", type: "http", url: "https://x.com", method: "post", skip_tls_verify: false },
1,
);
expect(result).toBe(false);
const flatErrors = wrapper.vm.destinationErrorsToDisplay.flat();
expect(
flatErrors.some(
(e: any) => typeof e === "object" && e.field === "template_name",
),
).toBe(true);
});
});
// ─── importJson — JSON parsing edge cases ─────────────────────────────────
describe("importJson — JSON parsing edge cases", () => {
beforeEach(() => {
+76 -42
View File
@@ -17,7 +17,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<base-import
ref="baseImportRef"
title="Import Destination"
:title="t('alert_destinations.import.title')"
test-prefix="destination"
:is-importing="isDestinationImporting"
container-class=""
@@ -33,9 +33,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-if="destinationErrorsToDisplay.length > 0 || destinationCreators.length > 0"
class="text-center text-[0.9375rem] font-semibold text-text-primary py-3 shrink-0"
>
{{ destinationErrorsToDisplay.length > 0 ? 'Error Validations' : 'Output Messages' }}
{{ destinationErrorsToDisplay.length > 0 ? t('alert_destinations.import.errorValidations') : t('alert_destinations.import.outputMessages') }}
</div>
<div v-else class="text-center text-[0.9375rem] font-semibold text-text-primary py-3 shrink-0">Output Messages</div>
<div v-else class="text-center text-[0.9375rem] font-semibold text-text-primary py-3 shrink-0">{{ t('alert_destinations.import.outputMessages') }}</div>
<OSeparator class="mt-1 shrink-0" />
<div class="flex-1 min-h-0 overflow-auto [resize:none] w-full min-w-100">
<!-- Destination Errors Section -->
@@ -74,7 +74,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
userSelectedDestinationName[index] = val;
updateDestinationName(val, index);
}"
:label="'Destination Name *'"
:label="t('alert_destinations.import.destinationName') + ' *'"
class="showLabelOnTop"
tabindex="0"
/>
@@ -98,7 +98,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
userSelectedDestinationUrl[index] = val;
updateDestinationUrl(val, index);
}"
:label="'Destination URL *'"
:label="t('alert_destinations.import.destinationUrl') + ' *'"
class="showLabelOnTop"
tabindex="0"
/>
@@ -123,7 +123,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
updateDestinationType(val, index);
}"
:options="destinationTypes"
:label="'Destination Type *'"
:label="t('alert_destinations.destination_type') + ' *'"
class="py-2 showLabelOnTop no-case"
/>
</div>
@@ -147,7 +147,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
updateDestinationMethod(val, index);
}"
:options="destinationMethods"
:label="'Destination Method *'"
:label="t('alert_destinations.import.destinationMethod') + ' *'"
class="py-2 showLabelOnTop no-case"
/>
</div>
@@ -169,10 +169,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@update:model-value="(val) => {
userSelectedTemplates[index] = val;
updateDestinationTemplate(val, index);
templateErrors[index] = val ? '' : 'Field is required!';
templateErrors[index] = getCorrectionRequiredError(val);
}"
:options="filteredTemplates"
label="Templates *"
:label="t('alert_destinations.import.templates') + ' *'"
class="py-2 showLabelOnTop no-case"
:error="!!templateErrors[index]"
:error-message="templateErrors[index]"
@@ -199,7 +199,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
userSelectedEmails[index] = val;
updateDestinationEmails(val, index);
}"
:label="'Emails (comma separated) *'"
:label="t('alert_destinations.import.emails') + ' *'"
class="showLabelOnTop"
tabindex="0"
/>
@@ -222,10 +222,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@update:model-value="(val) => {
userSelectedActionId[index] = val;
updateDestinationAction(val, index);
actionErrors[index] = val ? '' : 'Field is required!';
actionErrors[index] = getCorrectionRequiredError(val);
}"
:options="filteredActions"
label="Actions *"
:label="t('alert_destinations.import.actions') + ' *'"
labelKey="label"
valueKey="value"
class="py-2 showLabelOnTop no-case"
@@ -269,7 +269,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="text-base mb-2.5 uppercase text-primary"
data-test="destination-import-creation-title"
>
Destination Creation
{{ t('alert_destinations.import.destinationCreation') }}
</div>
<div
class="error-list"
@@ -551,13 +551,19 @@ export default defineComponent({
destinationErrorsToDisplay.value.forEach((errorGroup, idx) => {
for (const msg of errorGroup) {
if (typeof msg === 'object') {
if (msg.field === 'template_name' && !userSelectedTemplates.value[idx]) {
templateErrors[idx] = 'Field is required!';
hasCorrectionErrors = true;
if (msg.field === 'template_name') {
const requiredError = getCorrectionRequiredError(userSelectedTemplates.value[idx]);
if (requiredError) {
templateErrors[idx] = requiredError;
hasCorrectionErrors = true;
}
}
if (msg.field === 'action_id' && !userSelectedActionId.value[idx]) {
actionErrors[idx] = 'Field is required!';
hasCorrectionErrors = true;
if (msg.field === 'action_id') {
const requiredError = getCorrectionRequiredError(userSelectedActionId.value[idx]);
if (requiredError) {
actionErrors[idx] = requiredError;
hasCorrectionErrors = true;
}
}
}
}
@@ -572,7 +578,7 @@ export default defineComponent({
try {
if (!jsonString || jsonString.trim() === "") {
throw new Error("JSON string is empty");
throw new Error(t("alert_destinations.import.jsonStringEmpty"));
}
const parsedJson = JSON.parse(jsonString);
@@ -581,7 +587,7 @@ export default defineComponent({
: [parsedJson];
} catch (e: any) {
toast({
message: e.message || "Invalid JSON format",
message: e.message || t("alert_destinations.import.invalidJsonFormat"),
variant: "error",
});
// Reset BaseImport's importing flag on validation error
@@ -604,7 +610,7 @@ export default defineComponent({
if (successCount === totalCount) {
toast({
message: `Successfully imported destination(s)`,
message: t("alert_destinations.import.importSuccess"),
variant: "success",
});
@@ -642,13 +648,20 @@ export default defineComponent({
return false;
} catch (e: any) {
toast({
message: "Error importing Destination please check the JSON",
message: t("alert_destinations.import.importError"),
variant: "error",
});
return false;
}
};
// Single source of truth for the correction "required" rule. The template
// and action correction controls plus the pre-import correction gate below
// all defer to this instead of re-deriving the required message inline, so
// the destination JS validator owns the rule in one place.
const getCorrectionRequiredError = (value: any): string =>
value ? "" : t("alerts.validation.fieldRequired");
const validateDestinationInputs = async (input: any, index: number) => {
let destinationErrors: (string | { message: string; field: string })[] =
[];
@@ -660,7 +673,7 @@ export default defineComponent({
input.name.trim() === ""
) {
destinationErrors.push({
message: `Destination - ${index}: The "name" field is required and should be a valid string.`,
message: t("alert_destinations.import.nameRequired", { index }),
field: "destination_name",
});
} else if (
@@ -669,7 +682,10 @@ export default defineComponent({
)
) {
destinationErrors.push({
message: `Destination - ${index}: "${input.name}" already exists`,
message: t("alert_destinations.import.nameExists", {
index,
name: input.name,
}),
field: "destination_name",
});
}
@@ -682,7 +698,7 @@ export default defineComponent({
input.type !== "action")
) {
destinationErrors.push({
message: `Destination - ${index}: The "type" field must be either "email", "http", or "action"`,
message: t("alert_destinations.import.typeInvalid", { index }),
field: "type",
});
}
@@ -690,7 +706,7 @@ export default defineComponent({
// Check if action type is supported when actions are not enabled
if (input.type === "action" && !isActionsEnabled.value) {
destinationErrors.push(
`Destination - ${index}: 'action' type is not supported.`,
t("alert_destinations.import.actionTypeNotSupported", { index }),
);
}
@@ -705,7 +721,11 @@ export default defineComponent({
!availableActions.includes(input.action_id)
) {
destinationErrors.push({
message: `Destination - ${index}: action "${input.action_id}" does not exist for type "${input.type}"`,
message: t("alert_destinations.import.actionNotFound", {
index,
actionId: input.action_id,
type: input.type,
}),
field: "action_id",
});
}
@@ -714,7 +734,7 @@ export default defineComponent({
if (input.type === "http") {
if (!input.url || typeof input.url !== "string" || input.url.trim() === "") {
destinationErrors.push({
message: `Destination - ${index}: The "url" field is required for http type destinations.`,
message: t("alert_destinations.import.urlRequired", { index }),
field: "url",
});
}
@@ -724,14 +744,14 @@ export default defineComponent({
!["post", "get", "put"].includes(input.method.toLowerCase())
) {
destinationErrors.push({
message: `Destination - ${index}: The "method" field must be one of "post", "get", or "put"`,
message: t("alert_destinations.import.methodInvalid", { index }),
field: "method",
});
}
if (!input.template || typeof input.template !== "string") {
destinationErrors.push({
message: `Destination - ${index}: The "template" field is required for http type destinations.`,
message: t("alert_destinations.import.templateRequiredHttp", { index }),
field: "template_name",
});
}
@@ -742,7 +762,7 @@ export default defineComponent({
typeof input.skip_tls_verify !== "boolean"
) {
destinationErrors.push({
message: `Destination - ${index}: The "skip_tls_verify" field is required and should be a boolean value`,
message: t("alert_destinations.import.skipTlsVerifyRequired", { index }),
field: "skip_tls_verify",
});
}
@@ -751,7 +771,7 @@ export default defineComponent({
if (input.headers !== undefined) {
if (typeof input.headers !== "object" || Array.isArray(input.headers)) {
destinationErrors.push(
`Destination - ${index}: 'headers' should be an object for http type`,
t("alert_destinations.import.headersMustBeObject", { index }),
);
}
}
@@ -762,7 +782,7 @@ export default defineComponent({
// Validate URL should not be present for email type
if (input.url) {
destinationErrors.push(
`Destination - ${index}: 'url' should not be provided for email type`,
t("alert_destinations.import.urlNotAllowedEmail", { index }),
);
}
@@ -772,26 +792,26 @@ export default defineComponent({
Object.keys(input.headers).length !== 0
) {
destinationErrors.push(
`Destination - ${index}: 'headers' should not be provided for email type`,
t("alert_destinations.import.headersNotAllowedEmail", { index }),
);
}
// Validate emails array with stricter validation
if (!input.emails || !Array.isArray(input.emails) || input.emails.length === 0) {
destinationErrors.push({
message: `Destination - ${index}: The "emails" field is required and should be an array for email type destinations.`,
message: t("alert_destinations.import.emailsRequired", { index }),
field: "email_input",
});
} else if (input.emails.some((email: any) => typeof email !== "string")) {
destinationErrors.push({
message: `Destination - ${index}: 'emails' should be an array of strings for email type`,
message: t("alert_destinations.import.emailsMustBeStrings", { index }),
field: "email_input",
});
}
if (!input.template || typeof input.template !== "string") {
destinationErrors.push({
message: `Destination - ${index}: The "template" field is required for email type destinations.`,
message: t("alert_destinations.import.templateRequiredEmail", { index }),
field: "template_name",
});
}
@@ -809,7 +829,11 @@ export default defineComponent({
if (!availableTemplates.includes(input.template)) {
destinationErrors.push({
message: `Destination - ${index}: template "${input.template}" does not exist for type "${input.type}"`,
message: t("alert_destinations.import.templateNotFound", {
index,
template: input.template,
type: input.type,
}),
field: "template_name",
});
}
@@ -819,7 +843,7 @@ export default defineComponent({
if (isActionsEnabled.value) {
if (input.action_id && typeof input.action_id !== "string") {
destinationErrors.push({
message: `Destination - ${index}: The "action_id" field must be a valid string.`,
message: t("alert_destinations.import.actionIdInvalid", { index }),
field: "action_id",
});
}
@@ -842,7 +866,10 @@ export default defineComponent({
});
destinationCreators.value.push({
message: `Destination - ${index}: "${input.name}" created successfully \nNote: please remove the created destination object ${input.name} from the json file `,
message: t("alert_destinations.import.createSuccess", {
index,
name: input.name,
}),
success: true,
});
@@ -851,7 +878,13 @@ export default defineComponent({
return true;
} catch (error: any) {
destinationCreators.value.push({
message: `Destination - ${index}: "${input.name}" creation failed --> \n Reason: ${error?.response?.data?.message || "Unknown Error"}`,
message: t("alert_destinations.import.createFailed", {
index,
name: input.name,
reason:
error?.response?.data?.message ||
t("alert_destinations.import.unknownError"),
}),
success: false,
});
return false;
@@ -900,6 +933,7 @@ export default defineComponent({
filterTemplates,
filterActions,
getServiceActions,
getCorrectionRequiredError,
arrowBackFn,
isDestinationImporting,
store,
@@ -0,0 +1,15 @@
// Copyright 2026 OpenObserve Inc.
/**
* OFormTagInput props — everything TagInput accepts except `modelValue`
* (auto-bound to the OForm field by `name`). TagInput has no exported types
* file, so its (small) prop surface is mirrored here.
*/
export interface FormTagInputProps {
/** Field name — must match a key in the parent OForm's defaultValues */
name: string;
/** Placeholder shown when there are no tags (forwarded to TagInput) */
placeholder?: string;
/** Floating label (forwarded to TagInput) */
label?: string;
}
@@ -0,0 +1,55 @@
<script setup lang="ts">
// Copyright 2026 OpenObserve Inc.
//
// Form-aware wrapper around TagInput (alerts-owned) — the OForm* variant for a
// `string[]` tag input. Modeled on OFormSelect / OFormToggleGroup: inject the
// OForm context, render `form.Field` by `name`, bind model-value ←
// field.state.value and route changes → field.handleChange.
//
// Notes specific to TagInput:
// • TagInput emits no `blur`, and the `fields` field carries no required rule,
// so there is no blur/error wiring to do today. A form-level error div is
// still included (shows only when errors.length > 0) so the wrapper behaves
// correctly if a rule is ever added — it renders nothing in the common case,
// keeping the DOM identical to a bare TagInput.
// • `field.state.value` may be undefined (the field is optional) — coerce to
// `[]` because TagInput requires a non-null `string[]`.
import { inject } from "vue";
import TagInput from "./TagInput.vue";
import { FORM_CONTEXT_KEY } from "@/lib/forms/Form/OForm.types";
import { firstFieldError } from "@/lib/forms/Form/fieldError";
import type { FormTagInputProps } from "./OFormTagInput.types";
defineOptions({ inheritAttrs: false });
const props = defineProps<FormTagInputProps>();
const form = inject(FORM_CONTEXT_KEY, null);
if (import.meta.env.DEV && !form) {
console.warn(
"[OFormTagInput] must be rendered inside <OForm>. No form context found.",
);
}
</script>
<template>
<component v-if="form" :is="form.Field" :name="props.name">
<template #default="{ field }">
<TagInput
v-bind="$attrs"
:placeholder="props.placeholder"
:label="props.label"
:model-value="field.state.value ?? []"
@update:model-value="field.handleChange"
/>
<div
v-if="field.state.meta.errors.length > 0"
class="text-xs text-input-error-text mt-1"
>
{{ firstFieldError(field.state.meta.errors) }}
</div>
</template>
</component>
</template>
@@ -0,0 +1,78 @@
// Copyright 2026 OpenObserve Inc.
//
// Validation schema for OrganizationDeduplicationSettings.vue — the inline
// org-level alert deduplication settings form.
//
// Fields:
// • enabled / alert_dedup_enabled — booleans (OFormCheckbox).
// • alert_fingerprint_groups — the selected semantic-group ids, modelled as a
// string[] driven by an OFormCheckboxGroup (each per-group OCheckbox is a
// group member keyed by its id).
// • time_window_minutes — the correlation window. The pre-migration form had
// NO validation rule on it (only an HTML `min="1"` hint) and sanitised
// empty/NaN → null at save. Kept permissive + nullable so a blank / cleared
// field still saves (as null); z.coerce.number() only validates that a typed
// value is numeric (the number <input> can emit a numeric string or "").
// The @submit handler does the empty/NaN → null conversion for payload
// parity, so the schema itself never blocks this field.
//
// Restores the pre-migration cross-alert guard (the imperative saveSettings
// toast + the inline `fingerprintGroupsRequired` error) as a superRefine rule:
// when org-level dedup AND cross-alert dedup are both on, at least one
// fingerprint group must be selected.
//
// Validation TIMING is owned by OForm (submit-then-change via revalidateLogic);
// this file only describes WHAT is valid.
import { z } from "zod";
/** The persisted org deduplication config shape (edit-prefill source). */
export interface OrganizationDeduplicationConfig {
enabled: boolean;
alert_dedup_enabled?: boolean;
alert_fingerprint_groups?: string[];
time_window_minutes?: number | null;
}
export const makeOrgDedupSettingsSchema = (t: (_key: string) => string) =>
z
.object({
enabled: z.boolean(),
alert_dedup_enabled: z.boolean(),
alert_fingerprint_groups: z.array(z.string()).default([]),
// No pre-migration rule (see file header) — permissive + nullable.
time_window_minutes: z.coerce.number().nullish(),
})
.superRefine((val, ctx) => {
if (
val.enabled &&
val.alert_dedup_enabled &&
(!val.alert_fingerprint_groups ||
val.alert_fingerprint_groups.length === 0)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["alert_fingerprint_groups"],
message: t("alerts.correlation.fingerprintGroupsRequired"),
});
}
});
export type OrgDedupSettingsForm = z.infer<
ReturnType<typeof makeOrgDedupSettingsSchema>
>;
/**
* Typed defaults, mapped from an optional persisted config. Mirrors the
* pre-migration localConfig mapping used by init / loadConfig / the props.config
* watch — one place, so all three code paths stay in sync. A null config yields
* the create-time defaults (enabled on, everything else empty).
*/
export const orgDedupSettingsDefaults = (
config?: OrganizationDeduplicationConfig | null,
): OrgDedupSettingsForm => ({
enabled: config?.enabled ?? true,
alert_dedup_enabled: config?.alert_dedup_enabled ?? false,
alert_fingerprint_groups: config?.alert_fingerprint_groups ?? [],
time_window_minutes: config?.time_window_minutes ?? undefined,
});
@@ -61,6 +61,10 @@ async function mountComp(props: Record<string, any> = {}) {
});
}
// The component OWNS its <OForm> (Rule ③ owner pattern) — the single source of
// truth is the TanStack form on the OForm instance. Drive behavior through it.
const getForm = (w: any) => (w.findComponent({ name: "OForm" }).vm as any).form;
describe("OrganizationDeduplicationSettings - rendering", () => {
beforeEach(() => vi.clearAllMocks());
@@ -97,7 +101,7 @@ describe("OrganizationDeduplicationSettings - rendering", () => {
it("does not call service when config prop is provided", async () => {
vi.clearAllMocks();
const config = { enabled: true, alert_dedup_enabled: false, alert_fingerprint_groups: [] };
const w = await mountComp({ config });
await mountComp({ config });
await flushPromises();
expect(alertsService.getOrganizationDeduplicationConfig).not.toHaveBeenCalled();
});
@@ -109,8 +113,8 @@ describe("OrganizationDeduplicationSettings - cross-alert checkbox visibility",
it("shows cross-alert checkbox when enabled=true", async () => {
const w = await mountComp();
await flushPromises();
(w.vm as any).localConfig.enabled = true;
await w.vm.$nextTick();
getForm(w).setFieldValue("enabled", true);
await flushPromises();
expect(
w.find('[data-test="organizationdeduplication-enable-cross-alert-checkbox"]').exists(),
).toBe(true);
@@ -119,21 +123,21 @@ describe("OrganizationDeduplicationSettings - cross-alert checkbox visibility",
it("hides cross-alert checkbox when enabled=false", async () => {
const w = await mountComp();
await flushPromises();
(w.vm as any).localConfig.enabled = false;
await w.vm.$nextTick();
getForm(w).setFieldValue("enabled", false);
await flushPromises();
expect(
w.find('[data-test="organizationdeduplication-enable-cross-alert-checkbox"]').exists(),
).toBe(false);
});
});
describe("OrganizationDeduplicationSettings - saveSettings", () => {
describe("OrganizationDeduplicationSettings - save (real OForm submit)", () => {
beforeEach(() => vi.clearAllMocks());
it("calls setOrganizationDeduplicationConfig on save", async () => {
it("calls setOrganizationDeduplicationConfig on a valid submit", async () => {
const w = await mountComp();
await flushPromises();
await (w.vm as any).saveSettings();
await getForm(w).handleSubmit();
await flushPromises();
expect(alertsService.setOrganizationDeduplicationConfig).toHaveBeenCalledWith(
"default",
@@ -141,95 +145,72 @@ describe("OrganizationDeduplicationSettings - saveSettings", () => {
);
});
it("emits saved after successful save", async () => {
it("emits saved after a successful save", async () => {
const w = await mountComp();
await flushPromises();
await (w.vm as any).saveSettings();
await getForm(w).handleSubmit();
await flushPromises();
expect(w.emitted("saved")).toBeTruthy();
});
it("validates: blocks save when cross-alert enabled but no fingerprint groups", async () => {
it("blocks save when cross-alert enabled but no fingerprint groups (superRefine restore)", async () => {
const w = await mountComp();
await flushPromises();
(w.vm as any).localConfig.alert_dedup_enabled = true;
(w.vm as any).localConfig.alert_fingerprint_groups = [];
await (w.vm as any).saveSettings();
const form = getForm(w);
form.setFieldValue("enabled", true);
form.setFieldValue("alert_dedup_enabled", true);
form.setFieldValue("alert_fingerprint_groups", []);
await flushPromises();
await form.handleSubmit();
await flushPromises();
expect(form.state.isValid).toBe(false);
expect(alertsService.setOrganizationDeduplicationConfig).not.toHaveBeenCalled();
});
it("allows save when cross-alert enabled and fingerprint groups set", async () => {
const w = await mountComp();
await flushPromises();
(w.vm as any).localConfig.alert_dedup_enabled = true;
(w.vm as any).localConfig.alert_fingerprint_groups = ["group1"];
await (w.vm as any).saveSettings();
const form = getForm(w);
form.setFieldValue("enabled", true);
form.setFieldValue("alert_dedup_enabled", true);
form.setFieldValue("alert_fingerprint_groups", ["group1"]);
await flushPromises();
await form.handleSubmit();
await flushPromises();
expect(alertsService.setOrganizationDeduplicationConfig).toHaveBeenCalled();
});
it("sets saving to false after completion", async () => {
const w = await mountComp();
await flushPromises();
await (w.vm as any).saveSettings();
await flushPromises();
expect((w.vm as any).saving).toBe(false);
});
});
describe("OrganizationDeduplicationSettings - cancel", () => {
it("emits cancel when cancel button is clicked", async () => {
it("emits cancel when cancel is triggered", async () => {
const w = await mountComp();
await flushPromises();
// Trigger cancel emit directly (button uses @click="$emit('cancel')")
await w.vm.$emit("cancel");
expect(w.emitted("cancel")).toBeTruthy();
});
});
describe("OrganizationDeduplicationSettings - toggleFingerprintGroup", () => {
describe("OrganizationDeduplicationSettings - fingerprint groups", () => {
beforeEach(() => vi.clearAllMocks());
it("adds group to alert_fingerprint_groups when checked=true", async () => {
it("renders a checkbox per semantic group when cross-alert dedup is on", async () => {
const w = await mountComp();
await flushPromises();
(w.vm as any).localConfig.alert_fingerprint_groups = [];
(w.vm as any).toggleFingerprintGroup("group1", true);
expect((w.vm as any).localConfig.alert_fingerprint_groups).toContain("group1");
});
it("removes group from alert_fingerprint_groups when checked=false", async () => {
const w = await mountComp();
getForm(w).setFieldValue("alert_dedup_enabled", true);
await flushPromises();
(w.vm as any).localConfig.alert_fingerprint_groups = ["group1", "group2"];
(w.vm as any).toggleFingerprintGroup("group1", false);
expect((w.vm as any).localConfig.alert_fingerprint_groups).not.toContain("group1");
expect((w.vm as any).localConfig.alert_fingerprint_groups).toContain("group2");
});
it("initializes alert_fingerprint_groups if undefined", async () => {
const w = await mountComp();
await flushPromises();
(w.vm as any).localConfig.alert_fingerprint_groups = undefined;
(w.vm as any).toggleFingerprintGroup("group1", true);
expect((w.vm as any).localConfig.alert_fingerprint_groups).toContain("group1");
});
it("does not add duplicate group IDs", async () => {
const w = await mountComp();
await flushPromises();
(w.vm as any).localConfig.alert_fingerprint_groups = ["group1"];
(w.vm as any).toggleFingerprintGroup("group1", true);
const groups = (w.vm as any).localConfig.alert_fingerprint_groups;
expect(groups.filter((g: string) => g === "group1")).toHaveLength(1);
expect(
w.find('[data-test="organizationdeduplication-fingerprint-group1-checkbox"]').exists(),
).toBe(true);
expect(
w.find('[data-test="organizationdeduplication-fingerprint-group2-checkbox"]').exists(),
).toBe(true);
});
});
describe("OrganizationDeduplicationSettings - watcher", () => {
describe("OrganizationDeduplicationSettings - external prop sync", () => {
beforeEach(() => vi.clearAllMocks());
it("syncs localConfig when config prop changes", async () => {
it("resets the form from the config prop when it changes", async () => {
const w = await mountComp();
await flushPromises();
await w.setProps({
@@ -241,29 +222,34 @@ describe("OrganizationDeduplicationSettings - watcher", () => {
},
});
await flushPromises();
expect((w.vm as any).localConfig.enabled).toBe(false);
expect((w.vm as any).localConfig.time_window_minutes).toBe(60);
const form = getForm(w);
expect(form.state.values.enabled).toBe(false);
expect(form.state.values.time_window_minutes).toBe(60);
});
});
describe("OrganizationDeduplicationSettings - time_window sanitization", () => {
describe("OrganizationDeduplicationSettings - time_window sanitization (payload parity)", () => {
beforeEach(() => vi.clearAllMocks());
it("saves null when time_window_minutes is empty string", async () => {
it("saves null when time_window_minutes is empty", async () => {
const w = await mountComp();
await flushPromises();
(w.vm as any).localConfig.time_window_minutes = "";
await (w.vm as any).saveSettings();
const form = getForm(w);
form.setFieldValue("time_window_minutes", "");
await flushPromises();
await form.handleSubmit();
await flushPromises();
const callArg = (alertsService.setOrganizationDeduplicationConfig as any).mock.calls[0][1];
expect(callArg.time_window_minutes).toBeNull();
});
it("saves number when time_window_minutes is valid", async () => {
it("saves the number when time_window_minutes is valid", async () => {
const w = await mountComp();
await flushPromises();
(w.vm as any).localConfig.time_window_minutes = 45;
await (w.vm as any).saveSettings();
const form = getForm(w);
form.setFieldValue("time_window_minutes", 45);
await flushPromises();
await form.handleSubmit();
await flushPromises();
const callArg = (alertsService.setOrganizationDeduplicationConfig as any).mock.calls[0][1];
expect(callArg.time_window_minutes).toBe(45);
@@ -1,4 +1,4 @@
<!-- Copyright 2026 OpenObserve Inc.
<!-- 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
@@ -15,7 +15,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div class="w-full h-full px-2 bg-(--o2-card-bg) flex flex-col">
<!-- Inline form: OForm renders the <form>, so the footer Save uses
type="submit" (Enter submits natively — no form-id needed). The component
OWNS the form (it reads `enabled`/`alert_dedup_enabled` to drive the
conditional sections) → Rule ③ owner pattern: useOForm + form.useStore +
<OForm :form>. -->
<OForm
id="organization-deduplication-settings-form"
:form="form"
v-slot="{ isSubmitting }"
class="w-full h-full px-2 bg-(--o2-card-bg) flex flex-col"
>
<!-- Scrollable content area -->
<div class="flex-1 overflow-y-auto pr-2">
<div class="mb-6">
@@ -38,30 +48,28 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Enable Deduplication -->
<div class="mb-6">
<OCheckbox
<OFormCheckbox
name="enabled"
data-test="organization-deduplication-enable-checkbox"
v-model="localConfig.enabled"
:label="t('alerts.correlation.enableOrgLevel')"
@update:model-value="emitUpdate"
>
<OTooltip :content="t('alerts.correlation.enableOrgLevelTooltip')" />
</OCheckbox>
</OFormCheckbox>
</div>
<!-- Cross-Alert Deduplication -->
<div class="mb-6" v-if="localConfig.enabled">
<OCheckbox
<div class="mb-6" v-if="enabled">
<OFormCheckbox
name="alert_dedup_enabled"
data-test="organizationdeduplication-enable-cross-alert-checkbox"
v-model="localConfig.alert_dedup_enabled"
:label="t('alerts.correlation.enableCrossAlert')"
@update:model-value="emitUpdate"
>
<OTooltip :content="t('alerts.correlation.enableCrossAlertTooltip')" />
</OCheckbox>
</OFormCheckbox>
</div>
<!-- Cross-Alert Fingerprint Groups -->
<div class="mb-6" v-if="localConfig.enabled && localConfig.alert_dedup_enabled">
<div class="mb-6" v-if="enabled && alertDedupEnabled">
<div class="font-semibold pb-2 flex items-center">
{{ t('alerts.correlation.fingerprintGroups') }} <span class="text-red-500 ml-1">*</span>
<OIcon
@@ -80,22 +88,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="text-sm text-gray-600 dark:text-gray-400 mb-2">
{{ t('alerts.correlation.fingerprintGroupsHint') }}
</div>
<div class="flex flex-col gap-2">
<!-- The selected group ids ARE the form's alert_fingerprint_groups
array. Each per-group OCheckbox is a group member (value = id); the
group's superRefine "at least one required" error renders below. -->
<OFormCheckboxGroup name="alert_fingerprint_groups">
<OCheckbox
v-for="group in localSemanticGroups"
:data-test="'organizationdeduplication-fingerprint-' + group.id + '-checkbox'"
:key="group.id"
:model-value="localConfig.alert_fingerprint_groups?.includes(group.id)"
@update:model-value="(val) => toggleFingerprintGroup(group.id, val)"
:value="group.id"
:label="`${group.display} (${group.id})`"
/>
<div
v-if="!localConfig.alert_fingerprint_groups || localConfig.alert_fingerprint_groups.length === 0"
class="text-red-500 text-sm mt-1"
>
{{ t('alerts.correlation.fingerprintGroupsRequired') }}
</div>
</div>
</OFormCheckboxGroup>
</div>
<!-- Time Window -->
@@ -117,9 +121,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="text-sm text-gray-600 dark:text-gray-400 mb-2">
{{ t('alerts.correlation.defaultWindowDescription') }}
</div>
<OInput
<OFormInput
name="time_window_minutes"
data-test="organizationdeduplication-default-window-input"
v-model.number="localConfig.time_window_minutes"
type="number"
min="1"
width="md"
@@ -129,22 +133,26 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
? 'input-box-bg-dark input-border-dark'
: 'input-box-bg-light input-border-light'
"
@update:model-value="emitUpdate"
/>
</div>
</div>
<!-- Sticky footer with buttons -->
<div class="flex justify-end gap-3 pt-4 pb-2 border-t border-gray-200 dark:border-gray-700 bg-inherit sticky bottom-0">
<OButton variant="outline" size="sm-action" @click="$emit('cancel')">{{ t('alerts.correlation.cancelButton') }}</OButton>
<OButton
variant="outline"
size="sm-action"
:disabled="isSubmitting"
@click="$emit('cancel')"
>{{ t('alerts.correlation.cancelButton') }}</OButton>
<OButton
variant="primary"
size="sm-action"
@click="saveSettings"
:loading="saving"
type="submit"
:loading="isSubmitting"
>{{ t('alerts.correlation.saveButton') }}</OButton>
</div>
</div>
</OForm>
</template>
<script setup lang="ts">
@@ -154,12 +162,21 @@ import { useI18n } from "vue-i18n";
import alertsService from "@/services/alerts";
import GroupHeader from "@/components/common/GroupHeader.vue";
import OButton from '@/lib/core/Button/OButton.vue';
import OInput from '@/lib/forms/Input/OInput.vue';
import OTooltip from '@/lib/overlay/Tooltip/OTooltip.vue';
import OCheckbox from '@/lib/forms/Checkbox/OCheckbox.vue';
import OIcon from "@/lib/core/Icon/OIcon.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
import OSeparator from '@/lib/core/Separator/OSeparator.vue';
import OForm from "@/lib/forms/Form/OForm.vue";
import OFormCheckbox from "@/lib/forms/Checkbox/OFormCheckbox.vue";
import OFormCheckboxGroup from "@/lib/forms/Checkbox/OFormCheckboxGroup.vue";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import { useOForm } from "@/lib/forms/Form/useOForm";
import {
makeOrgDedupSettingsSchema,
orgDedupSettingsDefaults,
type OrgDedupSettingsForm,
} from "./OrganizationDeduplicationSettings.schema";
const store = useStore();
const { t } = useI18n();
@@ -177,7 +194,7 @@ interface OrganizationDeduplicationConfig {
enabled: boolean;
alert_dedup_enabled?: boolean;
alert_fingerprint_groups?: string[];
time_window_minutes?: number;
time_window_minutes?: number | null;
}
interface Props {
@@ -194,68 +211,47 @@ const emit = defineEmits<{
(e: "cancel"): void;
}>();
const saving = ref(false);
const localConfig = ref<OrganizationDeduplicationConfig>({
enabled: true,
alert_dedup_enabled: props.config?.alert_dedup_enabled ?? false,
alert_fingerprint_groups: props.config?.alert_fingerprint_groups ?? [],
time_window_minutes: props.config?.time_window_minutes ?? undefined,
// Owner pattern (Rule ③): create the ONE form here so the template can read it
// reactively (form.useStore) to drive the conditional sections. Loading is
// form-driven (isSubmitting) — no manual `saving` ref.
const orgDedupSettingsSchema = makeOrgDedupSettingsSchema(t);
const form = useOForm<OrgDedupSettingsForm>({
defaultValues: orgDedupSettingsDefaults(props.config),
schema: orgDedupSettingsSchema,
// Forward to saveSettings (defined below) — arrow avoids a setup-time TDZ ref.
onSubmit: (value) => saveSettings(value),
});
// Reactive reads for the parent-side v-if sections (single source of truth).
const enabled = form.useStore((s: any) => s.values.enabled);
const alertDedupEnabled = form.useStore((s: any) => s.values.alert_dedup_enabled);
// Available semantic groups (the option list rendered as checkboxes) — NOT form
// data, so it stays a plain local ref.
const localSemanticGroups = ref<FieldAlias[]>([]);
const toggleFingerprintGroup = (groupId: string, checked: boolean) => {
if (!localConfig.value.alert_fingerprint_groups) {
localConfig.value.alert_fingerprint_groups = [];
}
if (checked) {
if (!localConfig.value.alert_fingerprint_groups.includes(groupId)) {
localConfig.value.alert_fingerprint_groups.push(groupId);
}
} else {
localConfig.value.alert_fingerprint_groups =
localConfig.value.alert_fingerprint_groups.filter((id) => id !== groupId);
}
};
const emitUpdate = () => {
// Just update local state, don't auto-save
};
const saveSettings = async () => {
// Validate cross-alert dedup requires fingerprint groups
if (localConfig.value.enabled && localConfig.value.alert_dedup_enabled) {
if (!localConfig.value.alert_fingerprint_groups ||
localConfig.value.alert_fingerprint_groups.length === 0) {
toast({
variant: "error",
message: "Please select at least one semantic group for cross-alert deduplication",
});
return;
}
}
saving.value = true;
const saveSettings = async (value: OrgDedupSettingsForm) => {
// @submit fires only when the schema passes, so the cross-alert fingerprint
// guard is already enforced (superRefine) — no imperative toast-guard here.
try {
const rawWindow = localConfig.value.time_window_minutes;
const configToSave = {
...localConfig.value,
// Payload parity: build with explicit keys (no {...value} leak) and coerce
// time_window_minutes exactly like the pre-migration form (empty/NaN → null,
// else a number).
const rawWindow = value.time_window_minutes as unknown;
const payload = {
enabled: value.enabled,
alert_dedup_enabled: value.alert_dedup_enabled,
alert_fingerprint_groups: value.alert_fingerprint_groups,
time_window_minutes:
rawWindow == null || rawWindow === "" || isNaN(Number(rawWindow))
? null
: Number(rawWindow),
};
await alertsService.setOrganizationDeduplicationConfig(
props.orgId,
configToSave,
);
await alertsService.setOrganizationDeduplicationConfig(props.orgId, payload);
toast({
variant: "success",
message:
"Organization deduplication settings saved successfully",
message: t("alerts.correlation.settingsSaved"),
});
emit("saved");
@@ -263,34 +259,23 @@ const saveSettings = async () => {
console.error("Error saving deduplication settings:", error);
toast({
variant: "error",
message: error?.message || "Failed to save settings",
message: error?.message || t("alerts.correlation.settingsSaveError"),
});
} finally {
saving.value = false;
}
};
// Fetch config on mount if not provided
// Fetch config on mount if not provided. Async data arrives → form.reset() (not
// a setFieldValue loop / local mirror). Also wired to the refresh button (its
// exact behavior is preserved; being type="button" it never submits/validates).
const loadConfig = async () => {
if (!props.config) {
try {
// Load dedup config (does NOT contain semantic groups)
const response = await alertsService.getOrganizationDeduplicationConfig(props.orgId);
const config = response.data;
localConfig.value = {
enabled: config.enabled ?? true,
alert_dedup_enabled: config.alert_dedup_enabled ?? false,
alert_fingerprint_groups: config.alert_fingerprint_groups ?? [],
time_window_minutes: config.time_window_minutes ?? undefined,
};
form.reset(orgDedupSettingsDefaults(response.data));
} catch (error) {
// No dedup config exists yet — use defaults
localConfig.value = {
enabled: true,
alert_dedup_enabled: false,
alert_fingerprint_groups: [],
time_window_minutes: undefined,
};
form.reset(orgDedupSettingsDefaults(null));
}
// Always load semantic groups from system_settings (single source of truth)
@@ -307,20 +292,16 @@ const loadConfig = async () => {
// Load config on mount
loadConfig();
// Watch for external changes
// Watch for external changes — reset the form from the EXTERNAL source (props),
// not a local mirror. Initial values are already seeded via useOForm's
// defaultValues, so this only handles subsequent prop changes.
watch(
() => props.config,
(newVal) => {
if (newVal) {
localConfig.value = {
enabled: newVal.enabled ?? true,
alert_dedup_enabled: newVal.alert_dedup_enabled ?? false,
alert_fingerprint_groups: newVal.alert_fingerprint_groups ?? [],
time_window_minutes: newVal.time_window_minutes ?? undefined,
};
form.reset(orgDedupSettingsDefaults(newVal));
}
},
{ deep: true, immediate: true },
{ deep: true },
);
</script>
@@ -0,0 +1,165 @@
// 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/>.
// Schema-level spec for the config-driven prebuilt credential rules.
// PrebuiltDestinationForm is now a presentational DESCENDANT (no own form); the
// credential validation + toggle round-trip live entirely in these pure schema
// helpers, which the PARENT AddDestination form composes. Testing them directly
// keeps that coverage robust and mount-free.
import { describe, expect, it } from "vitest";
import {
makePrebuiltDestinationSchema,
prebuiltDestinationDefaults,
} from "@/components/alerts/PrebuiltDestinationForm.schema";
import { generateDestinationUrl } from "@/utils/prebuilt-templates";
// Passthrough translator — the tests assert validity/paths, not message copy.
const t = (key: string, named?: Record<string, unknown>): string =>
named ? `${key} ${JSON.stringify(named)}` : key;
const VALID_SLACK = "https://hooks.slack.com/services/T000/B000/xxxxxxxx";
const US_OPSGENIE_URL = "https://api.opsgenie.com/v2/alerts";
const EU_OPSGENIE_URL = "https://api.eu.opsgenie.com/v2/alerts";
const OPSGENIE_KEY = "x".repeat(40);
describe("prebuilt credential schema - required + per-type validators", () => {
it("slack: empty webhookUrl fails at the webhookUrl path", () => {
const res = makePrebuiltDestinationSchema(t, "slack").safeParse({
webhookUrl: "",
channel: "",
});
expect(res.success).toBe(false);
if (!res.success) {
expect(res.error.issues.some((i) => i.path[0] === "webhookUrl")).toBe(
true,
);
}
});
it("slack: a valid webhook passes", () => {
const res = makePrebuiltDestinationSchema(t, "slack").safeParse({
webhookUrl: VALID_SLACK,
channel: "#alerts",
});
expect(res.success).toBe(true);
});
it("slack: a non-slack URL is rejected by the per-type validator", () => {
const res = makePrebuiltDestinationSchema(t, "slack").safeParse({
webhookUrl: "https://example.com/not-slack",
channel: "",
});
expect(res.success).toBe(false);
});
it("pagerduty: requires a 32-char integration key + severity", () => {
expect(
makePrebuiltDestinationSchema(t, "pagerduty").safeParse({
integrationKey: "short",
severity: "critical",
}).success,
).toBe(false);
expect(
makePrebuiltDestinationSchema(t, "pagerduty").safeParse({
integrationKey: "a".repeat(32),
severity: "critical",
}).success,
).toBe(true);
});
it("email: requires recipients", () => {
expect(
makePrebuiltDestinationSchema(t, "email").safeParse({ recipients: "" })
.success,
).toBe(false);
expect(
makePrebuiltDestinationSchema(t, "email").safeParse({
recipients: "user@example.com",
}).success,
).toBe(true);
});
it("opsgenie: optional priority/euRegion do not block a valid apiKey", () => {
const res = makePrebuiltDestinationSchema(t, "opsgenie").safeParse({
apiKey: OPSGENIE_KEY,
});
expect(res.success).toBe(true);
});
});
// ── Toggle round-trip through STRING metadata ───────────────────────────────
// Credentials persist into destination metadata via String(v), so an edit-load
// hands a toggle back as the STRING "true"/"false", never a boolean. Two hazards:
// 1. A bare z.boolean() would REJECT the string → the (now parent) form could
// not save → the destination is permanently unsaveable (Rule ④ break).
// 2. Leaving the string "false" in the value reads TRUTHY at generateDestinationUrl
// → a US Opsgenie instance is misrouted to the EU endpoint.
describe("prebuilt credential defaults - euRegion string round-trip", () => {
it.each([
["false", false],
["true", true],
[false, false],
[true, true],
])("seeds stored euRegion=%o as the boolean %o", (stored: any, expected) => {
const seeded = prebuiltDestinationDefaults("opsgenie", {
apiKey: OPSGENIE_KEY,
euRegion: stored,
});
expect(seeded.euRegion).toBe(expected);
expect(typeof seeded.euRegion).toBe("boolean");
});
it("non-toggle fields are coerced to strings", () => {
const seeded = prebuiltDestinationDefaults("pagerduty", {
integrationKey: 12345,
});
expect(seeded.integrationKey).toBe("12345");
});
it("only the active type's fields are present (no stale cross-type keys)", () => {
const seeded = prebuiltDestinationDefaults("slack", {
webhookUrl: VALID_SLACK,
apiKey: "leaked-from-another-type",
});
expect(seeded).toHaveProperty("webhookUrl", VALID_SLACK);
expect(seeded).not.toHaveProperty("apiKey");
});
it("the schema accepts the STRING toggle an edit-load provides", () => {
const res = makePrebuiltDestinationSchema(t, "opsgenie").safeParse({
apiKey: OPSGENIE_KEY,
euRegion: "false",
});
expect(res.success).toBe(true);
});
// END-TO-END routing proof: the defaults the parent seeds feed
// generateDestinationUrl, whose euRegion read is TRUTHY — a leaked string
// "false" would send a US instance to the EU endpoint.
it.each([
["false", US_OPSGENIE_URL],
[false, US_OPSGENIE_URL],
["true", EU_OPSGENIE_URL],
[true, EU_OPSGENIE_URL],
])("stored euRegion=%o routes to %s", (stored: any, expectedUrl) => {
const seeded = prebuiltDestinationDefaults("opsgenie", {
apiKey: OPSGENIE_KEY,
euRegion: stored,
});
expect(generateDestinationUrl("opsgenie", seeded)).toBe(expectedUrl);
});
});
@@ -0,0 +1,139 @@
// Copyright 2026 OpenObserve Inc.
//
// Validation schema for PrebuiltDestinationForm.vue (config-driven credentials).
//
// The schema is built DYNAMICALLY from `getPrebuiltConfig(type).credentialFields`
// for the ACTIVE destination type — preserving that config as the SINGLE SOURCE
// OF TRUTH for the per-type required + validator rules (exactly mirroring the old
// hand-rolled `validate()` loop this migration removes). The parent remounts this
// form (its `:key` includes destination_type) whenever the type changes, so the
// schema is always rebuilt for the active type at setup.
//
// Per credential field (byte-for-byte the pre-migration `validate()` semantics):
// • required + empty → `${label} is required` (validator NOT run)
// • required/optional + present → run `field.validator` (if any); its returned
// i18n message becomes the field's error
// • optional + empty → OK (no issue)
//
// The config modules are Vue-less and cannot translate, so they carry i18n KEYS
// (`labelKey`, and a `{ key, params }` from each validator). This factory is one
// of the two sites that owns a `t` and resolves them — see `CredentialField`.
//
// Validation TIMING is owned by OForm (submit-then-change via revalidateLogic);
// this file only describes WHAT is valid.
import { z } from "zod";
import { getPrebuiltConfig } from "@/utils/prebuilt-templates";
import type { CredentialField } from "@/utils/prebuilt-templates/types";
/** Credentials are a dynamic, per-type record keyed by credentialField `key`. */
export type PrebuiltCredentials = Record<string, unknown>;
const credentialFieldsFor = (destinationType: string): CredentialField[] =>
getPrebuiltConfig(destinationType)?.credentialFields ?? [];
/** True only for a genuine `true` or the STRING "true". Everything else (incl.
* the string "false") is false. */
const toBool = (v: unknown): boolean => v === true || v === "true";
/**
* A toggle credential.
*
* ⚠️ Toggles round-trip through destination METADATA as STRINGS: the persist
* path (`usePrebuiltDestinations.ts` — the `credential_${k}` flattening at
* :504/:531/:628/:653) writes every credential with `String(v)`, so a stored
* toggle comes back as `"true"` / `"false"`, never a boolean. A bare
* `z.boolean()` REJECTS that on edit-prefill, which blocks the child's submit
* emit and makes the destination permanently unsaveable.
*
* ⚠️ Do NOT "simplify" this to `z.coerce.boolean()`: `Boolean("false") === true`,
* which would flip a stored-off toggle ON — for Opsgenie's `euRegion` that
* silently reroutes a US instance to the EU endpoint (see
* `generateDestinationUrl` in utils/prebuilt-templates/index.ts, which reads the
* flag TRUTHILY).
*/
const toggleSchema = () =>
z.preprocess((v) => toBool(v), z.boolean()).optional();
/**
* Build the Zod schema for one destination type from its `credentialFields`.
* Toggle fields → boolean; every other field → string. The required + custom
* validator rules are enforced in a single object-level `superRefine` that
* reproduces the old `validate()` loop exactly (so TanStack routes each issue to
* the field whose `name` matches the credential key).
*/
export const makePrebuiltDestinationSchema = (
t: (_key: string, _named?: Record<string, unknown>) => string,
destinationType: string,
) => {
const fields = credentialFieldsFor(destinationType);
const shape: Record<string, z.ZodTypeAny> = {};
for (const field of fields) {
shape[field.key] =
field.type === "toggle" ? toggleSchema() : z.string().optional();
}
return z.object(shape).superRefine((val, ctx) => {
const values = val as Record<string, unknown>;
for (const field of fields) {
const value = values[field.key];
const isEmpty =
value === undefined ||
value === null ||
value.toString().trim() === "";
// Required + empty → "<label> is required" (do NOT run the validator).
if (field.required && isEmpty) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [field.key],
message: t("alerts.validation.credentialFieldRequired", {
field: t(field.labelKey),
}),
});
continue;
}
// Optional + empty → nothing to validate.
if (!field.required && isEmpty) continue;
// Present → run the per-type validator (if any); resolve its i18n message.
if (field.validator && value) {
const result = field.validator(value.toString());
if (result !== true) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [field.key],
message: t(result.key, result.params),
});
}
}
}
});
};
/**
* Typed default values for the active type, seeded from an existing credentials
* object (edit-prefill). Toggle → boolean (default false); every other field →
* string. Only the active type's fields are included, so the payload never
* carries stale credential keys from another type.
*/
export const prebuiltDestinationDefaults = (
destinationType: string,
modelValue: Record<string, unknown> = {},
): PrebuiltCredentials => {
const fields = credentialFieldsFor(destinationType);
const out: PrebuiltCredentials = {};
for (const field of fields) {
if (field.type === "toggle") {
// Coerce at RUNTIME — a `as boolean | undefined` cast is compile-time only
// and would leave the persisted STRING "false" in the form verbatim, which
// then reads TRUTHY downstream (generateDestinationUrl → EU endpoint for a
// US Opsgenie instance). See `toggleSchema` above for the full round-trip.
out[field.key] = toBool(modelValue[field.key]);
} else {
const v = modelValue[field.key];
out[field.key] = v !== undefined && v !== null ? String(v) : "";
}
}
return out;
};
@@ -13,276 +13,213 @@
// 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/>.
// PrebuiltDestinationForm is a presentational DESCENDANT now: it renders the
// active type's credential inputs (named `credentials.<key>`) into a PARENT
// <OForm> — it owns no form/schema/v-model of its own (the credential validation
// lives in AddDestination.schema, covered by PrebuiltDestinationForm.schema.spec).
// These tests mount it inside a host <OForm> and assert the fields render + bind
// into the ONE parent form, and that the preview/test buttons emit.
import { describe, expect, it, afterEach } from "vitest";
import { mount } from "@vue/test-utils";
import { mount, flushPromises } from "@vue/test-utils";
import { defineComponent, h, nextTick } from "vue";
import { z } from "zod";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import PrebuiltDestinationForm from "@/components/alerts/PrebuiltDestinationForm.vue";
import { prebuiltDestinationDefaults } from "@/components/alerts/PrebuiltDestinationForm.schema";
import OForm from "@/lib/forms/Form/OForm.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import { useOForm } from "@/lib/forms/Form/useOForm";
let wrapper: ReturnType<typeof mount> | null = null;
let wrapper: any = null;
afterEach(() => {
wrapper?.unmount();
wrapper = null;
});
async function mountComp(props: Record<string, any> = {}) {
return mount(PrebuiltDestinationForm, {
props: {
destinationType: "slack",
modelValue: {},
isTesting: false,
hideActions: false,
...props,
},
global: { plugins: [i18n] },
// A minimal parent-form host: provides the FORM_CONTEXT_KEY the descendant's
// OForm* fields inject, seeded with the active type's credential defaults. The
// schema is permissive — the credential rules are exercised in the schema spec.
const Host = defineComponent({
name: "Host",
components: { OForm, PrebuiltDestinationForm },
props: {
destinationType: { type: String, default: "slack" },
modelValue: { type: Object, default: () => ({}) },
hideActions: { type: Boolean, default: false },
},
setup(props) {
const form = useOForm<any>({
defaultValues: {
credentials: prebuiltDestinationDefaults(
props.destinationType,
props.modelValue as Record<string, unknown>,
),
},
schema: z.object({ credentials: z.record(z.string(), z.unknown()) }),
onSubmit: () => {},
});
return { form, props };
},
render() {
return h(OForm, { form: this.form, id: "host" }, () => [
h(PrebuiltDestinationForm, {
destinationType: this.props.destinationType,
hideActions: this.props.hideActions,
}),
]);
},
});
function mountComp(props: Record<string, any> = {}) {
return mount(Host, {
props: { destinationType: "slack", hideActions: false, ...props },
global: { plugins: [i18n, store] },
});
}
const hostForm = (w: any) => w.findComponent({ name: "OForm" }).vm.form;
describe("PrebuiltDestinationForm - base rendering", () => {
it("renders the form wrapper", async () => {
wrapper = await mountComp();
expect(wrapper.find('[data-test="prebuilt-destination-form"]').exists()).toBe(true);
it("renders the wrapper", () => {
wrapper = mountComp();
expect(
wrapper.find('[data-test="prebuilt-destination-form"]').exists(),
).toBe(true);
});
it("shows preview and test buttons when hideActions=false", async () => {
wrapper = await mountComp({ destinationType: "slack", hideActions: false });
expect(wrapper.find('[data-test="destination-preview-button"]').exists()).toBe(true);
expect(wrapper.find('[data-test="destination-test-button"]').exists()).toBe(true);
it("shows preview/test buttons when hideActions=false", () => {
wrapper = mountComp({ hideActions: false });
expect(
wrapper.find('[data-test="destination-preview-button"]').exists(),
).toBe(true);
expect(
wrapper.find('[data-test="destination-test-button"]').exists(),
).toBe(true);
});
it("hides preview and test buttons when hideActions=true", async () => {
wrapper = await mountComp({ destinationType: "slack", hideActions: true });
expect(wrapper.find('[data-test="destination-preview-button"]').exists()).toBe(false);
expect(wrapper.find('[data-test="destination-test-button"]').exists()).toBe(false);
it("hides preview/test buttons when hideActions=true", () => {
wrapper = mountComp({ hideActions: true });
expect(
wrapper.find('[data-test="destination-preview-button"]').exists(),
).toBe(false);
});
it("preview button emits preview event", async () => {
wrapper = await mountComp({ destinationType: "slack" });
await wrapper.find('[data-test="destination-preview-button"]').trigger("click");
expect(wrapper.emitted("preview")).toBeTruthy();
});
it("test button emits test event", async () => {
wrapper = await mountComp({ destinationType: "slack" });
it("preview/test buttons emit their events", async () => {
wrapper = mountComp();
const child = wrapper.findComponent(PrebuiltDestinationForm);
await wrapper
.find('[data-test="destination-preview-button"]')
.trigger("click");
await wrapper.find('[data-test="destination-test-button"]').trigger("click");
expect(wrapper.emitted("test")).toBeTruthy();
expect(child.emitted("preview")).toBeTruthy();
expect(child.emitted("test")).toBeTruthy();
});
});
describe("PrebuiltDestinationForm - Slack type", () => {
it("renders webhook URL input for slack", async () => {
wrapper = await mountComp({ destinationType: "slack" });
expect(wrapper.find('[data-test="slack-webhook-url-input"]').exists()).toBe(true);
describe("PrebuiltDestinationForm - rendering per type", () => {
it("renders slack webhook + channel inputs", () => {
wrapper = mountComp({ destinationType: "slack" });
expect(wrapper.find('[data-test="slack-webhook-url-input"]').exists()).toBe(
true,
);
expect(wrapper.find('[data-test="slack-channel-input"]').exists()).toBe(
true,
);
expect(
wrapper.find('[data-test="discord-webhook-url-input"]').exists(),
).toBe(false);
});
it("renders channel input for slack", async () => {
wrapper = await mountComp({ destinationType: "slack" });
expect(wrapper.find('[data-test="slack-channel-input"]').exists()).toBe(true);
it("renders discord webhook + username inputs", () => {
wrapper = mountComp({ destinationType: "discord" });
expect(
wrapper.find('[data-test="discord-webhook-url-input"]').exists(),
).toBe(true);
expect(wrapper.find('[data-test="discord-username-input"]').exists()).toBe(
true,
);
});
it("does not render discord fields for slack", async () => {
wrapper = await mountComp({ destinationType: "slack" });
it("renders msteams webhook input", () => {
wrapper = mountComp({ destinationType: "msteams" });
expect(
wrapper.find('[data-test="msteams-webhook-url-input"]').exists(),
).toBe(true);
});
expect(wrapper.find('[data-test="discord-webhook-url-input"]').exists()).toBe(false);
it("renders pagerduty integration key + severity select", () => {
wrapper = mountComp({ destinationType: "pagerduty" });
expect(
wrapper.find('[data-test="pagerduty-integration-key-input"]').exists(),
).toBe(true);
expect(
wrapper.find('[data-test="pagerduty-severity-select"]').exists(),
).toBe(true);
});
it("renders opsgenie api key + priority + eu-region toggle", () => {
wrapper = mountComp({ destinationType: "opsgenie" });
expect(wrapper.find('[data-test="opsgenie-api-key-input"]').exists()).toBe(
true,
);
expect(
wrapper.find('[data-test="opsgenie-priority-select"]').exists(),
).toBe(true);
expect(
wrapper.find('[data-test="opsgenie-eu-region-toggle"]').exists(),
).toBe(true);
});
it("renders servicenow url/username/password/assignment-group", () => {
wrapper = mountComp({ destinationType: "servicenow" });
expect(
wrapper.find('[data-test="servicenow-instance-url-input"]').exists(),
).toBe(true);
expect(
wrapper.find('[data-test="servicenow-password-input"]').exists(),
).toBe(true);
});
it("renders email recipients input", () => {
wrapper = mountComp({ destinationType: "email" });
expect(wrapper.find('[data-test="email-recipients-input"]').exists()).toBe(
true,
);
});
it("keeps type=password on sensitive inputs", () => {
wrapper = mountComp({ destinationType: "pagerduty" });
const input = wrapper
.find('[data-test="pagerduty-integration-key-input"]')
.find("input");
expect(input.attributes("type")).toBe("password");
});
});
describe("PrebuiltDestinationForm - Discord type", () => {
it("renders discord webhook URL input", async () => {
wrapper = await mountComp({ destinationType: "discord" });
expect(wrapper.find('[data-test="discord-webhook-url-input"]').exists()).toBe(true);
});
it("renders discord username input", async () => {
wrapper = await mountComp({ destinationType: "discord" });
expect(wrapper.find('[data-test="discord-username-input"]').exists()).toBe(true);
});
it("does not render slack fields for discord", async () => {
wrapper = await mountComp({ destinationType: "discord" });
expect(wrapper.find('[data-test="slack-webhook-url-input"]').exists()).toBe(false);
});
});
describe("PrebuiltDestinationForm - MS Teams type", () => {
it("renders msteams webhook URL input", async () => {
wrapper = await mountComp({ destinationType: "msteams" });
expect(wrapper.find('[data-test="msteams-webhook-url-input"]').exists()).toBe(true);
});
it("does not render slack fields for msteams", async () => {
wrapper = await mountComp({ destinationType: "msteams" });
expect(wrapper.find('[data-test="slack-webhook-url-input"]').exists()).toBe(false);
});
});
describe("PrebuiltDestinationForm - PagerDuty type", () => {
it("renders integration key input for pagerduty", async () => {
wrapper = await mountComp({ destinationType: "pagerduty" });
expect(wrapper.find('[data-test="pagerduty-integration-key-input"]').exists()).toBe(true);
});
it("renders severity select for pagerduty", async () => {
wrapper = await mountComp({ destinationType: "pagerduty" });
expect(wrapper.find('[data-test="pagerduty-severity-select"]').exists()).toBe(true);
});
});
describe("PrebuiltDestinationForm - Email type", () => {
it("renders email recipients input", async () => {
wrapper = await mountComp({ destinationType: "email" });
expect(wrapper.find('[data-test="email-recipients-input"]').exists()).toBe(true);
});
it("does not render slack fields for email", async () => {
wrapper = await mountComp({ destinationType: "email" });
expect(wrapper.find('[data-test="slack-webhook-url-input"]').exists()).toBe(false);
});
});
describe("PrebuiltDestinationForm - Opsgenie type", () => {
it("renders opsgenie api key input", async () => {
wrapper = await mountComp({ destinationType: "opsgenie" });
expect(wrapper.find('[data-test="opsgenie-api-key-input"]').exists()).toBe(true);
});
it("renders opsgenie priority select", async () => {
wrapper = await mountComp({ destinationType: "opsgenie" });
expect(wrapper.find('[data-test="opsgenie-priority-select"]').exists()).toBe(true);
});
it("renders opsgenie EU region toggle", async () => {
wrapper = await mountComp({ destinationType: "opsgenie" });
expect(wrapper.find('[data-test="opsgenie-eu-region-toggle"]').exists()).toBe(true);
});
});
describe("PrebuiltDestinationForm - ServiceNow type", () => {
it("renders servicenow instance URL input", async () => {
wrapper = await mountComp({ destinationType: "servicenow" });
expect(wrapper.find('[data-test="servicenow-instance-url-input"]').exists()).toBe(true);
});
it("renders servicenow username input", async () => {
wrapper = await mountComp({ destinationType: "servicenow" });
expect(wrapper.find('[data-test="servicenow-username-input"]').exists()).toBe(true);
});
it("renders servicenow password input", async () => {
wrapper = await mountComp({ destinationType: "servicenow" });
expect(wrapper.find('[data-test="servicenow-password-input"]').exists()).toBe(true);
});
it("renders servicenow assignment group input", async () => {
wrapper = await mountComp({ destinationType: "servicenow" });
expect(wrapper.find('[data-test="servicenow-assignment-group-input"]').exists()).toBe(true);
});
});
describe("PrebuiltDestinationForm - validate()", () => {
it("returns false when required slack webhookUrl is empty", async () => {
wrapper = await mountComp({ destinationType: "slack", modelValue: {} });
const result = (wrapper.vm as any).validate();
expect(result).toBe(false);
});
it("returns true when all required slack fields are provided", async () => {
wrapper = await mountComp({
describe("PrebuiltDestinationForm - binds into the ONE parent form", () => {
it("seeds credential inputs from the parent form's credentials", () => {
wrapper = mountComp({
destinationType: "slack",
modelValue: { webhookUrl: "https://hooks.slack.com/test" },
modelValue: { webhookUrl: "https://hooks.slack.com/x", channel: "#a" },
});
const result = (wrapper.vm as any).validate();
expect(result).toBe(true);
const input = wrapper
.find('[data-test="slack-webhook-url-input"]')
.findComponent(OInput);
expect(input.props("modelValue")).toBe("https://hooks.slack.com/x");
});
it("returns false for pagerduty when integrationKey is empty", async () => {
wrapper = await mountComp({
destinationType: "pagerduty",
modelValue: {},
});
const result = (wrapper.vm as any).validate();
expect(result).toBe(false);
});
it("returns false for email when recipients is empty", async () => {
wrapper = await mountComp({
destinationType: "email",
modelValue: {},
});
const result = (wrapper.vm as any).validate();
expect(result).toBe(false);
});
it("returns true for email when recipients is provided", async () => {
wrapper = await mountComp({
destinationType: "email",
modelValue: { recipients: "user@example.com" },
});
const result = (wrapper.vm as any).validate();
expect(result).toBe(true);
});
});
describe("PrebuiltDestinationForm - severityOptions and priorityOptions", () => {
it("severityOptions has 4 entries", async () => {
wrapper = await mountComp({ destinationType: "pagerduty" });
expect((wrapper.vm as any).severityOptions).toHaveLength(4);
});
it("priorityOptions has 5 entries", async () => {
wrapper = await mountComp({ destinationType: "opsgenie" });
expect((wrapper.vm as any).priorityOptions).toHaveLength(5);
});
});
describe("PrebuiltDestinationForm - v-model sync", () => {
it("emits update:modelValue when credentials change", async () => {
wrapper = await mountComp({
destinationType: "slack",
modelValue: { webhookUrl: "" },
});
// Directly trigger update through computed setter
(wrapper.vm as any).credentials = { webhookUrl: "https://hooks.slack.com/new" };
expect(wrapper.emitted("update:modelValue")).toBeTruthy();
it("typing a credential updates the parent form (name=credentials.*)", async () => {
wrapper = mountComp({ destinationType: "slack" });
await wrapper
.find('[data-test="slack-webhook-url-input"] input')
.setValue("https://hooks.slack.com/typed");
await nextTick();
await flushPromises();
expect(hostForm(wrapper).state.values.credentials.webhookUrl).toBe(
"https://hooks.slack.com/typed",
);
});
});
@@ -15,25 +15,30 @@ limitations under the License.
<template>
<div data-test="prebuilt-destination-form" class="prebuilt-destination-form">
<!-- Presentational DESCENDANT (Rule ③): this component renders NO <OForm> of
its own. The per-type credential inputs are OForm* controls named
`credentials.<key>`, so they inject the PARENT AddDestination form
(FORM_CONTEXT_KEY) and become part of the ONE form — Enter/Save submit
that single form and its schema (AddDestination.schema.ts) validates
these credentials via `makePrebuiltDestinationSchema`. No nested <form>. -->
<!-- Slack Fields -->
<template v-if="destinationType === 'slack'">
<div class="w-1/2 py-1">
<OInput
v-model="credentials.webhookUrl"
<OFormInput
name="credentials.webhookUrl"
data-test="slack-webhook-url-input"
label="Slack Webhook URL *"
helpText="Get your webhook URL from Slack App settings"
:label="t('alerts.prebuiltDestinations.slackWebhookUrl')"
required
:helpText="t('alerts.prebuiltDestinations.slackWebhookUrlHelp')"
tabindex="0"
:error="!!fieldErrors.webhookUrl"
:error-message="fieldErrors.webhookUrl"
/>
</div>
<div class="w-1/2 py-1">
<OInput
v-model="credentials.channel"
<OFormInput
name="credentials.channel"
data-test="slack-channel-input"
label="Channel (optional)"
helpText="e.g., #alerts"
:label="t('alerts.prebuiltDestinations.slackChannel')"
:helpText="t('alerts.prebuiltDestinations.slackChannelHelp')"
tabindex="0"
/>
</div>
@@ -42,22 +47,21 @@ limitations under the License.
<!-- Discord Fields -->
<template v-if="destinationType === 'discord'">
<div class="w-1/2 py-1">
<OInput
v-model="credentials.webhookUrl"
<OFormInput
name="credentials.webhookUrl"
data-test="discord-webhook-url-input"
label="Discord Webhook URL *"
helpText="Get your webhook URL from Discord channel settings"
:label="t('alerts.prebuiltDestinations.discordWebhookUrl')"
required
:helpText="t('alerts.prebuiltDestinations.discordWebhookUrlHelp')"
tabindex="0"
:error="!!fieldErrors.webhookUrl"
:error-message="fieldErrors.webhookUrl"
/>
</div>
<div class="w-1/2 py-1">
<OInput
v-model="credentials.username"
<OFormInput
name="credentials.username"
data-test="discord-username-input"
label="Bot Username (optional)"
helpText="Custom username for the webhook bot"
:label="t('alerts.prebuiltDestinations.discordUsername')"
:helpText="t('alerts.prebuiltDestinations.discordUsernameHelp')"
tabindex="0"
/>
</div>
@@ -66,14 +70,13 @@ limitations under the License.
<!-- MS Teams Fields -->
<template v-if="destinationType === 'msteams'">
<div class="w-1/2 py-1">
<OInput
v-model="credentials.webhookUrl"
<OFormInput
name="credentials.webhookUrl"
data-test="msteams-webhook-url-input"
label="Microsoft Teams Webhook URL *"
helpText="Get your webhook URL from Teams channel connectors"
:label="t('alerts.prebuiltDestinations.msteamsWebhookUrl')"
required
:helpText="t('alerts.prebuiltDestinations.msteamsWebhookUrlHelp')"
tabindex="0"
:error="!!fieldErrors.webhookUrl"
:error-message="fieldErrors.webhookUrl"
/>
</div>
</template>
@@ -81,26 +84,26 @@ limitations under the License.
<!-- PagerDuty Fields -->
<template v-if="destinationType === 'pagerduty'">
<div class="w-1/2 py-1">
<OInput
v-model="credentials.integrationKey"
<OFormInput
name="credentials.integrationKey"
data-test="pagerduty-integration-key-input"
label="Integration Key *"
:label="t('alerts.prebuiltDestinations.pagerdutyIntegrationKey')"
required
type="password"
helpText="Get your integration key from PagerDuty service settings"
:helpText="t('alerts.prebuiltDestinations.pagerdutyIntegrationKeyHelp')"
tabindex="0"
:error="!!fieldErrors.integrationKey"
:error-message="fieldErrors.integrationKey"
/>
</div>
<div class="w-1/2 py-1">
<OSelect
v-model="credentials.severity"
<OFormSelect
name="credentials.severity"
data-test="pagerduty-severity-select"
:options="severityOptions"
label="Default Severity *"
:label="t('alerts.prebuiltDestinations.pagerdutySeverity')"
required
labelKey="label"
valueKey="value"
helpText="Select the default severity for PagerDuty incidents"
:helpText="t('alerts.prebuiltDestinations.pagerdutySeverityHelp')"
tabindex="0"
/>
</div>
@@ -109,45 +112,42 @@ limitations under the License.
<!-- ServiceNow Fields -->
<template v-if="destinationType === 'servicenow'">
<div class="w-1/2 py-1">
<OInput
v-model="credentials.instanceUrl"
<OFormInput
name="credentials.instanceUrl"
data-test="servicenow-instance-url-input"
label="ServiceNow Instance URL *"
helpText="https://your-instance.service-now.com/api/now/table/incident"
:label="t('alerts.prebuiltDestinations.servicenowInstanceUrl')"
required
:helpText="t('alerts.prebuiltDestinations.servicenowInstanceUrlHelp')"
tabindex="0"
:error="!!fieldErrors.instanceUrl"
:error-message="fieldErrors.instanceUrl"
/>
</div>
<div class="w-1/2 py-1">
<OInput
v-model="credentials.username"
<OFormInput
name="credentials.username"
data-test="servicenow-username-input"
label="Username *"
helpText="ServiceNow username with incident creation permissions"
:label="t('common.username')"
required
:helpText="t('alerts.prebuiltDestinations.servicenowUsernameHelp')"
tabindex="0"
:error="!!fieldErrors.username"
:error-message="fieldErrors.username"
/>
</div>
<div class="w-1/2 py-1">
<OInput
v-model="credentials.password"
<OFormInput
name="credentials.password"
data-test="servicenow-password-input"
label="Password *"
:label="t('common.password')"
required
type="password"
helpText="ServiceNow password or API token"
:helpText="t('alerts.prebuiltDestinations.servicenowPasswordHelp')"
tabindex="0"
:error="!!fieldErrors.password"
:error-message="fieldErrors.password"
/>
</div>
<div class="w-1/2 py-1">
<OInput
v-model="credentials.assignmentGroup"
<OFormInput
name="credentials.assignmentGroup"
data-test="servicenow-assignment-group-input"
label="Assignment Group (optional)"
helpText="Group to assign incidents to (e.g., IT Operations)"
:label="t('alerts.prebuiltDestinations.servicenowAssignmentGroup')"
:helpText="t('alerts.prebuiltDestinations.servicenowAssignmentGroupHelp')"
tabindex="0"
/>
</div>
@@ -156,91 +156,56 @@ limitations under the License.
<!-- Email Fields -->
<template v-if="destinationType === 'email'">
<div class="w-1/2 py-1">
<OInput
v-model="credentials.recipients"
<OFormInput
name="credentials.recipients"
data-test="email-recipients-input"
label="Recipient Email Addresses *"
helpText="Comma-separated email addresses"
:label="t('alerts.prebuiltDestinations.emailRecipients')"
required
:helpText="t('alerts.prebuiltDestinations.emailRecipientsHelp')"
tabindex="0"
:error="!!fieldErrors.recipients"
:error-message="fieldErrors.recipients"
/>
</div>
<!-- CC and Subject fields hidden - not supported by backend Email struct -->
<!-- <div class="w-1/2 py-1">
<OInput
v-model="credentials.ccRecipients"
data-test="email-cc-input"
label="CC Recipients (optional)"
class="showLabelOnTop"
tabindex="0"
>
<template v-slot:hint>
<span class="text-xs text-gray-400">
Comma-separated CC email addresses
</span>
</template>
</OInput>
</div>
<div class="w-1/2 py-1">
<OInput
v-model="credentials.subject"
data-test="email-subject-input"
label="Email Subject (optional)"
class="showLabelOnTop"
tabindex="0"
>
<template v-slot:hint>
<span class="text-xs text-gray-400">
Custom subject line (defaults to alert name)
</span>
</template>
</OInput>
</div> -->
</template>
<!-- Opsgenie Fields -->
<template v-if="destinationType === 'opsgenie'">
<div class="w-1/2 py-1">
<OInput
v-model="credentials.apiKey"
<OFormInput
name="credentials.apiKey"
data-test="opsgenie-api-key-input"
label="Opsgenie API Key *"
:label="t('alerts.prebuiltDestinations.opsgenieApiKey')"
required
type="password"
helpText="Get your API key from Opsgenie integration settings"
:helpText="t('alerts.prebuiltDestinations.opsgenieApiKeyHelp')"
tabindex="0"
:error="!!fieldErrors.apiKey"
:error-message="fieldErrors.apiKey"
/>
</div>
<div class="w-1/2 py-1">
<OSelect
v-model="credentials.priority"
<OFormSelect
name="credentials.priority"
data-test="opsgenie-priority-select"
:options="priorityOptions"
label="Default Priority"
:label="t('alerts.prebuiltDestinations.opsgeniePriority')"
labelKey="label"
valueKey="value"
helpText="Select the default priority for Opsgenie alerts"
:helpText="t('alerts.prebuiltDestinations.opsgeniePriorityHelp')"
tabindex="0"
/>
</div>
<div class="w-full py-1">
<OSwitch
v-model="credentials.euRegion"
<OFormSwitch
name="credentials.euRegion"
data-test="opsgenie-eu-region-toggle"
label="EU Region"
>
<template v-slot:hint>
<span class="text-xs text-gray-400">
Enable for EU-based Opsgenie instances
</span>
</template>
</OSwitch>
:label="t('alerts.prebuiltDestinations.opsgenieEuRegion')"
/>
<span class="text-xs text-gray-400">
{{ t('alerts.prebuiltDestinations.opsgenieEuRegionHelp') }}
</span>
</div>
</template>
<!-- Test and Preview Actions -->
<!-- Test and Preview Actions (plain event-emitting buttons, not form fields). -->
<div v-if="!hideActions" class="w-full py-3">
<div class="flex items-center gap-2">
<OButton
@@ -250,7 +215,7 @@ limitations under the License.
@click="$emit('preview')"
icon-left="preview"
>
Preview
{{ t('alerts.preview') }}
</OButton>
<OButton
data-test="destination-test-button"
@@ -260,7 +225,7 @@ limitations under the License.
@click="$emit('test')"
icon-left="send"
>
Test
{{ t('common.test') }}
</OButton>
</div>
</div>
@@ -268,80 +233,52 @@ limitations under the License.
</template>
<script lang="ts" setup>
import { computed, reactive } from 'vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import OButton from '@/lib/core/Button/OButton.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';
import type { PropType } from 'vue';
import { getPrebuiltConfig } from '@/utils/prebuilt-templates';
import type { PrebuiltTypeId } from '@/utils/prebuilt-templates/types';
import OFormInput from '@/lib/forms/Input/OFormInput.vue';
import OFormSelect from '@/lib/forms/Select/OFormSelect.vue';
import OFormSwitch from '@/lib/forms/Switch/OFormSwitch.vue';
const props = defineProps({
// Presentational only: renders the active type's credential OForm* fields into
// the parent AddDestination form (they carry `name="credentials.<key>"` and
// inject the parent's FORM_CONTEXT_KEY). Requiredness + validators live in the
// parent schema (AddDestination.schema → makePrebuiltDestinationSchema), so this
// component owns no form, no schema, and no v-model — the single source of truth
// is the parent form's `credentials` sub-object.
defineProps({
destinationType: {
type: String,
required: true
},
modelValue: {
type: Object as PropType<Record<string, any>>,
default: () => ({})
required: true,
},
isTesting: {
type: Boolean,
default: false
default: false,
},
hideActions: {
type: Boolean,
default: false
}
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'preview', 'test']);
defineEmits(['preview', 'test']);
// Computed credentials that sync with modelValue
const credentials = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const { t } = useI18n();
const fieldErrors = reactive<Record<string, string>>({});
// PagerDuty severity options — computed so the labels re-resolve on locale change.
const severityOptions = computed(() => [
{ label: t('alerts.prebuiltDestinations.severityCritical'), value: 'critical' },
{ label: t('alerts.prebuiltDestinations.severityError'), value: 'error' },
{ label: t('alerts.prebuiltDestinations.severityWarning'), value: 'warning' },
{ label: t('alerts.prebuiltDestinations.severityInfo'), value: 'info' }
]);
function validate(): boolean {
Object.keys(fieldErrors).forEach((k) => delete fieldErrors[k]);
const config = getPrebuiltConfig(props.destinationType as PrebuiltTypeId);
if (!config) return true;
for (const field of config.credentialFields) {
const value = credentials.value[field.key];
if (field.required && (!value || value.toString().trim() === '')) {
fieldErrors[field.key] = `${field.label} is required`;
continue;
}
if (!field.required && (!value || value.toString().trim() === '')) continue;
if (field.validator && value) {
const result = field.validator(value.toString());
if (result !== true) fieldErrors[field.key] = result as string;
}
}
return Object.keys(fieldErrors).length === 0;
}
defineExpose({ validate });
// PagerDuty severity options
const severityOptions = [
{ label: 'Critical', value: 'critical' },
{ label: 'Error', value: 'error' },
{ label: 'Warning', value: 'warning' },
{ label: 'Info', value: 'info' }
];
// Opsgenie priority options
const priorityOptions = [
{ label: 'P1 (Critical)', value: 'P1' },
{ label: 'P2 (High)', value: 'P2' },
{ label: 'P3 (Moderate)', value: 'P3' },
{ label: 'P4 (Low)', value: 'P4' },
{ label: 'P5 (Informational)', value: 'P5' }
];
// Opsgenie priority options — computed so the labels re-resolve on locale change.
const priorityOptions = computed(() => [
{ label: t('alerts.prebuiltDestinations.priorityP1'), value: 'P1' },
{ label: t('alerts.prebuiltDestinations.priorityP2'), value: 'P2' },
{ label: t('alerts.prebuiltDestinations.priorityP3'), value: 'P3' },
{ label: t('alerts.prebuiltDestinations.priorityP4'), value: 'P4' },
{ label: t('alerts.prebuiltDestinations.priorityP5'), value: 'P5' }
]);
</script>
@@ -224,40 +224,45 @@ describe("QueryEditorDialog - updateVrlFunction", () => {
});
});
describe("QueryEditorDialog - filterFunctionOptions", () => {
it("resets to all functions when val is empty", async () => {
const w = await mountComp({ savedFunctions: [{ name: "fn1" }, { name: "fn2" }] });
(w.vm as any).functionOptions = [];
(w.vm as any).filterFunctionOptions("", (fn: any) => fn());
expect((w.vm as any).functionOptions).toHaveLength(2);
});
it("filters functions case-insensitively", async () => {
const w = await mountComp({ savedFunctions: [{ name: "myFunc" }, { name: "otherFn" }] });
(w.vm as any).filterFunctionOptions("MY", (fn: any) => fn());
expect((w.vm as any).functionOptions).toHaveLength(1);
expect((w.vm as any).functionOptions[0].name).toBe("myFunc");
});
it("returns empty array when no match", async () => {
const w = await mountComp({ savedFunctions: [{ name: "fn1" }] });
(w.vm as any).filterFunctionOptions("xyz_not_exist", (fn: any) => fn());
expect((w.vm as any).functionOptions).toHaveLength(0);
});
});
// The saved-functions OSelect is configured with valueKey="name", so it emits
// the function NAME, never the option object. These tests must pass a name —
// passing an object here is what let the "select does nothing" bug through.
describe("QueryEditorDialog - onFunctionSelect", () => {
it("sets vrlFunctionContent from function.function", async () => {
const w = await mountComp();
(w.vm as any).onFunctionSelect({ name: "myFn", function: ".level = upcase(.level)" });
const FUNCS = [
{ name: "myFn", function: ".level = upcase(.level)" },
{ name: "otherFn", function: ".a = 1" },
];
it("sets vrlFunctionContent from the selected function's body", async () => {
const w = await mountComp({ savedFunctions: FUNCS });
(w.vm as any).onFunctionSelect("myFn");
expect((w.vm as any).vrlFunctionContent).toBe(".level = upcase(.level)");
});
it("does nothing when func has no function property", async () => {
const w = await mountComp({ vrlFunction: "existing" });
(w.vm as any).onFunctionSelect({ name: "myFn" });
// vrlFunctionContent should remain unchanged
it("emits update:vrlFunction so the parent form keeps the selection", async () => {
const w = await mountComp({ savedFunctions: FUNCS });
(w.vm as any).onFunctionSelect("myFn");
expect(w.emitted("update:vrlFunction")?.at(-1)).toEqual([
".level = upcase(.level)",
]);
});
it("replaces the body when switching to a different function", async () => {
const w = await mountComp({ savedFunctions: FUNCS });
(w.vm as any).onFunctionSelect("myFn");
(w.vm as any).onFunctionSelect("otherFn");
expect((w.vm as any).vrlFunctionContent).toBe(".a = 1");
});
it("falls back to `body` when the function has no `function` field", async () => {
const w = await mountComp({ savedFunctions: [{ name: "bodyFn", body: ".b = 2" }] });
(w.vm as any).onFunctionSelect("bodyFn");
expect((w.vm as any).vrlFunctionContent).toBe(".b = 2");
});
it("does nothing when the name matches no saved function", async () => {
const w = await mountComp({ vrlFunction: "existing", savedFunctions: FUNCS });
(w.vm as any).onFunctionSelect("nope");
expect((w.vm as any).vrlFunctionContent).toBe("existing");
});
});
+22 -35
View File
@@ -41,16 +41,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Stream Type + Stream Name -->
<div class="flex items-center gap-2">
<div v-if="streamType" class="inline-flex flex-row items-center gap-[5px] py-[3px] px-[10px] rounded-md" :class="store.state.theme === 'dark' ? 'bg-[rgba(59,130,246,0.12)] border border-[rgba(59,130,246,0.3)]' : 'bg-[rgba(59,130,246,0.08)] border border-[rgba(59,130,246,0.25)]'">
<span class="text-[11px] font-semibold" :class="store.state.theme === 'dark' ? 'text-[#94a3b8]' : 'text-[#64748b]'">Stream Type</span>
<span class="text-[11px] font-semibold" :class="store.state.theme === 'dark' ? 'text-[#94a3b8]' : 'text-[#64748b]'">{{ t("alerts.streamType") }}</span>
<span class="text-[11px] opacity-30" :class="store.state.theme === 'dark' ? 'text-[#64748b]' : 'text-[#94a3b8]'">:</span>
<span class="text-xs font-bold" :class="store.state.theme === 'dark' ? 'text-[#60a5fa]' : 'text-[#2563eb]'">{{ streamType }}</span>
</div>
<span v-if="streamType && streamName" class="opacity-20 select-none">|</span>
<div class="inline-flex flex-row items-center gap-[5px] py-[3px] px-[10px] rounded-md" :class="store.state.theme === 'dark' ? 'bg-[rgba(139,92,246,0.12)] border border-[rgba(139,92,246,0.3)]' : 'bg-[rgba(139,92,246,0.08)] border border-[rgba(139,92,246,0.25)]'">
<span class="text-[11px] font-semibold" :class="store.state.theme === 'dark' ? 'text-[#94a3b8]' : 'text-[#64748b]'">Stream Name</span>
<span class="text-[11px] font-semibold" :class="store.state.theme === 'dark' ? 'text-[#94a3b8]' : 'text-[#64748b]'">{{ t("alerts.stream_name") }}</span>
<span class="text-[11px] opacity-30" :class="store.state.theme === 'dark' ? 'text-[#64748b]' : 'text-[#94a3b8]'">:</span>
<span v-if="streamName" class="text-xs font-bold" :class="store.state.theme === 'dark' ? 'text-[#a78bfa]' : 'text-[#7c3aed]'">{{ streamName }}</span>
<span v-else class="text-xs font-bold opacity-40 italic" :class="store.state.theme === 'dark' ? 'text-[#a78bfa]' : 'text-[#7c3aed]'">none</span>
<span v-else class="text-xs font-bold opacity-40 italic" :class="store.state.theme === 'dark' ? 'text-[#a78bfa]' : 'text-[#7c3aed]'">{{ t("alerts.queryEditor.noStream") }}</span>
</div>
</div>
</div>
@@ -117,7 +117,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<div class="flex items-center gap-2">
<div class="w-[3px] h-[14px] rounded-[2px] shrink-0 bg-(--q-primary)" />
<span class="text-xs font-semibold">{{ localTab === 'sql' ? 'SQL Editor' : 'PromQL Editor' }}</span>
<span class="text-xs font-semibold">{{ localTab === 'sql' ? t('alerts.sqlEditor') : t('alerts.promqlEditor') }}</span>
</div>
<div class="flex items-center gap-2">
<OSwitch
@@ -127,7 +127,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:class="store.state.theme === 'dark' ? 'o2-toggle-button-xs-dark' : 'o2-toggle-button-xs-light'"
@update:model-value="(val) => val ? restoreVrlEditor() : (sqlEditorMaximized = true)"
>
<OTooltip :delay="400" :content="sqlEditorMaximized ? 'show VRL editor' : 'hide VRL editor'" />
<OTooltip :delay="400" :content="sqlEditorMaximized ? t('alerts.queryEditor.showVrlEditor') : t('alerts.queryEditor.hideVrlEditor')" />
</OSwitch>
<OButton
data-test="alert-run-query-btn"
@@ -188,23 +188,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<template v-else-if="sqlStatusState === 'sql-status-bar--loading'">
<OSpinner size="xs" class="shrink-0" />
<span>Fetching results...</span>
<span>{{ t('alerts.queryEditor.fetchingResults') }}</span>
</template>
<template v-else-if="sqlStatusState === 'sql-status-bar--hint'">
<OIcon name="edit" size="xs" style="flex-shrink:0;opacity:0.6;" />
<span>Write a query to get started</span>
<span>{{ t('alerts.queryEditor.writeQueryHint') }}</span>
</template>
<template v-else-if="sqlStatusState === 'sql-status-bar--idle'">
<OIcon name="play-arrow" size="xs" style="flex-shrink:0;opacity:0.7;" />
<span>Press Run Query to see results</span>
<span>{{ t('alerts.queryEditor.runQueryHint') }}</span>
</template>
<template v-else-if="sqlStatusState === 'sql-status-bar--empty'">
<OIcon name="search-off" size="xs" style="flex-shrink:0;" />
<span>Query ran successfully — no matching events</span>
<span>{{ t('alerts.queryEditor.noMatchingEvents') }}</span>
</template>
<template v-else-if="sqlStatusState === 'sql-status-bar--success'">
<OIcon name="check-circle" size="xs" style="flex-shrink:0;" />
<span>{{ sqlResultCount }} event{{ sqlResultCount === 1 ? '' : 's' }} found</span>
<span>{{ t('alerts.queryEditor.eventsFound', sqlResultCount) }}</span>
</template>
</div>
<OTooltip
@@ -234,7 +234,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<div class="flex items-center gap-2">
<div class="w-[3px] h-[14px] rounded-[2px] shrink-0 bg-(--q-secondary)" />
<span class="text-xs font-semibold">VRL Editor</span>
<span class="text-xs font-semibold">{{ t('alerts.vrlEditor') }}</span>
</div>
<div v-if="!sqlEditorMaximized" class="flex gap-2 items-center">
<!-- Saved functions -->
@@ -244,7 +244,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="alert-saved-vrl-function-select"
labelKey="name"
valueKey="name"
@filter="filterFunctionOptions"
@update:modelValue="onFunctionSelect"
class="mini-select alert-v3-select"
clearable
@@ -325,12 +324,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<div class="flex items-center gap-2">
<div class="w-[3px] h-[14px] rounded-[2px] shrink-0 bg-(--q-primary)" />
<span class="text-xs font-semibold">Query Result</span>
<span class="text-xs font-semibold">{{ t('alerts.queryEditor.queryResult') }}</span>
<span
v-if="multiTimeRange && multiTimeRange.length > 0"
class="text-[10px] font-bold py-px px-[7px] rounded tracking-[0.04em]"
:class="store.state.theme === 'dark' ? 'bg-blue-500/[0.12] border border-blue-500/30 text-[#60a5fa]' : 'bg-blue-500/[0.08] border border-blue-500/25 text-[#2563eb]'"
>results across all time windows</span>
>{{ t('alerts.queryEditor.resultsAcrossWindows') }}</span>
</div>
</div>
@@ -382,7 +381,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<div class="flex items-center gap-2">
<div class="w-[3px] h-[14px] rounded-[2px] shrink-0 bg-(--q-secondary)" />
<span class="text-xs font-semibold">Combined Output</span>
<span class="text-xs font-semibold">{{ t('alerts.queryEditor.combinedOutput') }}</span>
<span
class="text-[10px] font-bold py-px px-[7px] rounded tracking-[0.04em]"
:class="store.state.theme === 'dark' ? 'bg-violet-500/[0.15] border border-violet-500/30 text-[#a78bfa]' : 'bg-violet-500/[0.10] border border-violet-500/25 text-[#7c3aed]'"
@@ -391,7 +390,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Running indicator -->
<div v-if="runFnQueryLoading" class="flex items-center gap-1">
<span class="w-[6px] h-[6px] rounded-full bg-emerald-500 [animation:pulse_1.5s_ease-in-out_infinite]" />
<span class="text-[10px] font-semibold text-emerald-400">Running</span>
<span class="text-[10px] font-semibold text-emerald-400">{{ t('alerts.queryEditor.running') }}</span>
</div>
</div>
@@ -714,20 +713,6 @@ watch(() => props.savedFunctions, (newVal) => {
functionOptions.value = [...newVal];
});
// Filter functions
const filterFunctionOptions = (val: string, update: any) => {
update(() => {
if (val === "") {
functionOptions.value = [...props.savedFunctions];
} else {
const needle = val.toLowerCase();
functionOptions.value = props.savedFunctions.filter((v: any) =>
v.name.toLowerCase().indexOf(needle) > -1
);
}
});
};
// Update handlers
const updateSqlQuery = (value: string) => {
localSqlQuery.value = value;
@@ -787,11 +772,13 @@ const handleAlertFunctionEditorGenerationSuccess = (payload: {
// Function code is already updated via @update:query handler
};
// Column and function selection
const onFunctionSelect = (func: any) => {
if (func && func.function) {
vrlFunctionContent.value = func.function;
updateVrlFunction(func.function);
// Column and function selection.
// OSelect emits the resolved `valueKey` (the function name), not the option
// object — look the function up before reading its body.
const onFunctionSelect = (name: any) => {
const func = props.savedFunctions.find((f: any) => f.name === name);
if (func) {
updateVrlFunction(func.function || func.body || "");
}
};
@@ -0,0 +1,53 @@
// 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 { z } from "zod";
// Co-located Zod schema for SemanticGroupItem (the inline correlation "semantic
// field group" row). Only three controls are form-owned:
// • display → required (pre-migration rule: `!!v || t('common.name') + ' is required'`)
// • is_workload_type → optional boolean (no rule today)
// • fields (TagInput) → optional string[] (NO required rule today — do NOT add one)
//
// The generated `id` and the parent-supplied `group` (category) are NOT form
// fields — `id` is a slugify side-effect of `display` and `group` comes from the
// prop; both are merged back into the emitted group object, never validated.
//
// Message parity: the live message was `t('common.name') + ' is required'`, which
// resolves to "Name is required" — the same English as the shared
// `common.nameRequired` i18n key used here (factory takes the component's `t`).
export const makeSemanticGroupItemSchema = (t: (_key: string) => string) =>
z.object({
display: z.string().min(1, t("common.nameRequired")),
is_workload_type: z.boolean().optional(),
fields: z.array(z.string()).optional(),
});
export type SemanticGroupItemForm = z.infer<
ReturnType<typeof makeSemanticGroupItemSchema>
>;
/**
* Typed default values for the form, projected from the (optional) group record
* the row is editing. This is an edit-prefill form (values come from a prop), so
* the defaults are derived from that record rather than a create-only blank.
*/
export const semanticGroupItemDefaults = (
group?: Partial<Pick<SemanticGroupItemForm, "display" | "is_workload_type" | "fields">>,
): SemanticGroupItemForm => ({
display: group?.display ?? "",
is_workload_type: group?.is_workload_type ?? false,
fields: group?.fields ? [...group.fields] : [],
});
@@ -82,17 +82,83 @@ describe("SemanticGroupItem - delete", () => {
});
});
describe("SemanticGroupItem - handleDisplayChange", () => {
it("emits update with new display name", async () => {
describe("SemanticGroupItem - live update emit", () => {
// Rule ④ event parity: the pre-migration row emitted "update" on every field
// edit so the parent keeps a live copy. Now the change flows through the form
// (name=-owned) and the deep values-watch re-emits the assembled group.
it("emits update with the new display name when the display field changes", async () => {
const w = await mountComp();
(w.vm as any).localGroup.display = "New Name";
(w.vm as any).handleDisplayChange();
expect(w.emitted("update")).toBeTruthy();
const payload = (w.emitted("update") as any[][])[0][0];
(w.vm as any).form.setFieldValue("display", "New Name");
await flushPromises();
const updates = w.emitted("update") as any[][] | undefined;
expect(updates).toBeTruthy();
const payload = updates![updates!.length - 1][0];
expect(payload.display).toBe("New Name");
});
});
describe("SemanticGroupItem - schema validation (real OForm)", () => {
// Proves the schema is actually wired (empty required blocks) — the key
// anti-regression test (a schema that resolves to undefined would always pass).
it("empty display → form invalid + 'Name is required' after submit", async () => {
const w = await mountComp({ group: makeGroup({ display: "" }) });
const form = (w.findComponent({ name: "OForm" }).vm as any).form;
await form.handleSubmit();
await flushPromises();
expect(form.state.isValid).toBe(false);
expect(w.text()).toContain("Name is required");
});
it("valid display → form valid on submit", async () => {
const w = await mountComp({ group: makeGroup({ display: "Infra" }) });
const form = (w.findComponent({ name: "OForm" }).vm as any).form;
await form.handleSubmit();
await flushPromises();
expect(form.state.isValid).toBe(true);
});
});
// The two tests above drive handleSubmit() directly — but NOTHING in the app
// does: the row has no submit button and its only consumer wires @update/@delete
// only. So they pass while the field stays silent for a real user. These drive
// the actual blur, the way pre-migration `handleDisplayBlur` was reached.
describe("SemanticGroupItem - required cue on blur (real user path)", () => {
const displayInput = (w: any) =>
w.find('[data-test="semantic-group-display-input"] input');
it("blurring an empty display shows 'Name is required'", async () => {
const w = await mountComp({ group: makeGroup({ display: "" }) });
expect(w.text()).not.toContain("Name is required");
await displayInput(w).trigger("blur");
await flushPromises();
expect(w.text()).toContain("Name is required");
});
it("typing after the error clears it", async () => {
const w = await mountComp({ group: makeGroup({ display: "" }) });
await displayInput(w).trigger("blur");
await flushPromises();
expect(w.text()).toContain("Name is required");
await displayInput(w).setValue("Infra");
await flushPromises();
expect(w.text()).not.toContain("Name is required");
});
it("blurring a filled display shows no error and still regenerates the id", async () => {
const w = await mountComp({ group: makeGroup({ display: "", id: "" }) });
await displayInput(w).setValue("My Group");
await displayInput(w).trigger("blur");
await flushPromises();
expect(w.text()).not.toContain("Name is required");
expect(w.text()).toContain("my-group");
});
});
describe("SemanticGroupItem - generateIdFromDisplay", () => {
it("converts spaces to hyphens", async () => {
const w = await mountComp();
@@ -122,18 +188,29 @@ describe("SemanticGroupItem - generateIdFromDisplay", () => {
});
});
describe("SemanticGroupItem - watcher syncs props", () => {
it("syncs localGroup when group prop changes", async () => {
describe("SemanticGroupItem - external prop sync", () => {
// The form is the single source of truth (no localGroup mirror). An external
// group change (import merge / category switch) resets the form to the new
// values; a self-echo (parent replaying our own emit) is skipped so it never
// resets mid-edit.
it("resets the form when the group prop changes display", async () => {
const w = await mountComp();
await w.setProps({ group: makeGroup({ display: "Updated Display" }) });
await flushPromises();
expect((w.vm as any).localGroup.display).toBe("Updated Display");
expect((w.vm as any).form.state.values.display).toBe("Updated Display");
});
it("syncs fields when group prop changes", async () => {
it("resets the form when the group prop changes fields", async () => {
const w = await mountComp();
await w.setProps({ group: makeGroup({ fields: ["new_field"] }) });
await flushPromises();
expect((w.vm as any).localGroup.fields).toEqual(["new_field"]);
expect((w.vm as any).form.state.values.fields).toEqual(["new_field"]);
});
it("does not re-emit update on a self-echo prop change (identical values)", async () => {
const w = await mountComp();
await w.setProps({ group: makeGroup() });
await flushPromises();
expect(w.emitted("update")).toBeFalsy();
});
});
+159 -92
View File
@@ -16,75 +16,81 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<div class="semantic-group-item p-3 mb-2 rounded-lg transition-all duration-200 w-full max-w-full bg-(--o2-card-bg) border border-(--o2-border-color,rgba(0,0,0,0.12))">
<div class="grid grid-cols-[200px_1fr_auto] gap-4 items-start w-full overflow-hidden">
<!-- Left Column: Display Name only (ID is internal/read-only) -->
<div class="flex flex-col gap-1 min-w-0 justify-center">
<div class="input-wrapper">
<OInput
data-test="semantic-group-display-input"
v-model="localGroup.display"
:label="t('common.name') + ' *'"
:error="!!displayError"
:error-message="displayError"
class="showLabelOnTop"
@update:model-value="handleDisplayChange"
@blur="handleDisplayBlur"
/>
</div>
<!-- Show ID as read-only caption for existing groups -->
<div v-if="localGroup.id" class="text-xs text-gray-400">
{{ t("common.id") }}: {{ localGroup.id }}
</div>
<OSwitch
v-model="localGroup.is_workload_type"
:label="t('correlation.isWorkloadType')"
class="mt-1"
@update:model-value="emitUpdate"
>
<OTooltip :content="t('correlation.isWorkloadTypeTooltip')" />
</OSwitch>
</div>
<!-- Right Column: Field Names spanning both rows -->
<div class="flex flex-col h-full min-w-0 overflow-hidden">
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
<TagInput
v-model="localGroup.fields"
:placeholder="t('correlation.fieldNamePlaceholder') + ' *'"
@update:model-value="emitUpdate"
/>
</div>
</div>
<!-- Actions Column: Delete -->
<div class="flex flex-col justify-between min-h-full">
<div class="flex justify-end">
<OButton
data-test="semantic-group-remove-group-btn"
:variant="isProtected ? 'ghost-muted' : 'ghost-destructive'"
size="icon-circle-sm"
:disabled="isProtected"
@click="!isProtected && emit('delete')"
<OForm :form="form">
<div class="grid grid-cols-[200px_1fr_auto] gap-4 items-start w-full overflow-hidden">
<!-- Left Column: Display Name only (ID is internal/read-only) -->
<div class="flex flex-col gap-1 min-w-0 justify-center">
<div class="input-wrapper">
<OFormInput
name="display"
data-test="semantic-group-display-input"
:label="t('common.name')"
required
class="showLabelOnTop"
@blur="handleDisplayBlur"
/>
</div>
<!-- Show ID as read-only caption for existing groups -->
<div v-if="currentId" class="text-xs text-gray-400">
{{ t("common.id") }}: {{ currentId }}
</div>
<OFormSwitch
name="is_workload_type"
:label="t('correlation.isWorkloadType')"
class="mt-1"
>
<OIcon name="delete" size="sm" />
<OTooltip :content="isProtected ? t('correlation.serviceGroupProtected') : t('correlation.removeSemanticGroup')" />
</OButton>
<OTooltip :content="t('correlation.isWorkloadTypeTooltip')" />
</OFormSwitch>
</div>
<!-- Right Column: Field Names spanning both rows -->
<div class="flex flex-col h-full min-w-0 overflow-hidden">
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
<OFormTagInput
name="fields"
:placeholder="t('correlation.fieldNamePlaceholder')"
/>
</div>
</div>
<!-- Actions Column: Delete -->
<div class="flex flex-col justify-between min-h-full">
<div class="flex justify-end">
<OButton
data-test="semantic-group-remove-group-btn"
type="button"
:variant="isProtected ? 'ghost-muted' : 'ghost-destructive'"
size="icon-circle-sm"
:disabled="isProtected"
@click="!isProtected && emit('delete')"
>
<OIcon name="delete" size="sm" />
<OTooltip :content="isProtected ? t('correlation.serviceGroupProtected') : t('correlation.removeSemanticGroup')" />
</OButton>
</div>
</div>
</div>
</div>
</OForm>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from "vue";
import { useI18n } from "vue-i18n";
import OButton from '@/lib/core/Button/OButton.vue';
import OButton from "@/lib/core/Button/OButton.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import OSwitch from "@/lib/forms/Switch/OSwitch.vue";
import TagInput from "./TagInput.vue";
import OForm from "@/lib/forms/Form/OForm.vue";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import OFormSwitch from "@/lib/forms/Switch/OFormSwitch.vue";
import OFormTagInput from "./OFormTagInput.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import { useOForm } from "@/lib/forms/Form/useOForm";
import {
makeSemanticGroupItemSchema,
semanticGroupItemDefaults,
type SemanticGroupItemForm,
} from "./SemanticGroupItem.schema";
const { t } = useI18n();
@@ -108,21 +114,8 @@ const emit = defineEmits<{
const isProtected = computed(() => props.group.id === "service");
const normalizeGroup = (g: SemanticGroup): SemanticGroup => ({
...g,
is_workload_type: g.is_workload_type ?? false,
});
const localGroup = ref<SemanticGroup>(normalizeGroup(props.group));
const displayError = ref("");
watch(
() => props.group,
(newGroup) => {
localGroup.value = normalizeGroup(newGroup);
},
{ deep: true },
);
// `id` is a slugify side-effect of `display` (+ category), NOT a form field.
const currentId = ref<string>(props.group.id);
const slugify = (s: string): string =>
s.toLowerCase().trim().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
@@ -130,34 +123,108 @@ const slugify = (s: string): string =>
// Generate ID as "{category-slug}-{display-slug}" for new groups
const generateIdFromDisplay = (display: string): string => {
const displaySlug = slugify(display);
const categorySlug = slugify(localGroup.value.group || "");
const categorySlug = slugify(props.group.group || "");
return categorySlug ? `${categorySlug}-${displaySlug}` : displaySlug;
};
// Handle display name change (just emit, don't generate ID yet)
const handleDisplayChange = () => {
displayError.value = "";
emitUpdate();
// This row OWNS its <OForm> (Rule ③ owner pattern): it must read form state
// reactively to re-emit the parent-collected "update" on every field change and
// to run the id-generation side-effect. Wire the save into `onSubmit` so a
// programmatic submit (or Enter inside the inline form) emits the assembled
// group only when the schema passes.
const form = useOForm<SemanticGroupItemForm>({
defaultValues: semanticGroupItemDefaults(props.group),
schema: makeSemanticGroupItemSchema(t),
// emitIfChanged, not emitUpdate: handleDisplayBlur submits on every blur to
// paint the required error, and the values watcher has usually emitted already
// — so an unconditional emit would fire a redundant "update" per blur.
onSubmit: () => emitIfChanged(),
});
// Assemble the emitted group from the outside-form context (id + category from
// the prop) merged with the form-owned values — explicit keys, no schema leak.
const buildGroup = (): SemanticGroup => {
const values = form.state.values as SemanticGroupItemForm;
return {
...props.group,
id: currentId.value,
display: values.display ?? "",
is_workload_type: values.is_workload_type ?? false,
fields: values.fields ? [...values.fields] : [],
};
};
// Handle display name blur - generate ID on focus out
const handleDisplayBlur = () => {
if (!localGroup.value.display) {
displayError.value = t('common.name') + ' is required';
// Guard against redundant / self-echo emits (the parent round-trips the group
// back into the `group` prop after each emit) so the empty-form submit test can
// assert "not fired" and typing doesn't loop.
const lastEmitted = ref<string>(JSON.stringify(buildGroup()));
const emitUpdate = () => {
const group = buildGroup();
lastEmitted.value = JSON.stringify(group);
emit("update", group);
};
const emitIfChanged = () => {
if (JSON.stringify(buildGroup()) !== lastEmitted.value) {
emitUpdate();
}
// Generate ID from display name if display is not empty
// If display is empty, keep the current ID (UUID or previous display-based ID)
if (localGroup.value.display) {
const newId = generateIdFromDisplay(localGroup.value.display);
// Only update if ID actually changed
if (localGroup.value.id !== newId) {
localGroup.value.id = newId;
};
// Re-emit "update" on every field change (parent collects rows live — Rule ④
// event parity with the pre-migration per-field @update handlers).
const formValues = form.useStore((s: any) => s.values);
watch(formValues, () => emitIfChanged(), { deep: true });
// Regenerate the id from the display, then surface the schema's "Name is
// required" on the field — pre-migration `handleDisplayBlur` did BOTH.
//
// The submit is what paints it: useOForm wires revalidateLogic({mode:"submit"}),
// so nothing validates until a first submit, and this inline row has no submit
// button (its only consumer wires @update/@delete). Without this call the rule
// exists but can never fire. `modeAfterSubmission:"change"` then re-validates on
// every keystroke, which is what clears the message as the user types — matching
// the old `handleDisplayChange` reset.
const handleDisplayBlur = () => {
const display = (form.state.values as SemanticGroupItemForm).display;
if (display) {
const newId = generateIdFromDisplay(display);
if (currentId.value !== newId) {
currentId.value = newId;
emitUpdate();
}
}
form.handleSubmit();
};
const emitUpdate = () => {
emit("update", { ...localGroup.value });
};
// External changes to the group prop (import merge, category switch) reset the
// form; a self-echo (the parent replaying our own emit) is skipped so it never
// resets mid-edit (which would flash/jump). Only the form-owned fields + id are
// compared — the `group` category is not a form field.
watch(
() => props.group,
(newGroup) => {
const values = form.state.values as SemanticGroupItemForm;
const incoming = JSON.stringify({
id: newGroup.id ?? "",
display: newGroup.display ?? "",
is_workload_type: newGroup.is_workload_type ?? false,
fields: newGroup.fields ?? [],
});
const current = JSON.stringify({
id: currentId.value,
display: values.display ?? "",
is_workload_type: values.is_workload_type ?? false,
fields: values.fields ?? [],
});
if (incoming !== current) {
currentId.value = newGroup.id;
form.reset(semanticGroupItemDefaults(newGroup));
// Rebaseline so the deep values-watch that follows the reset does NOT
// re-emit "update" (the original prop-sync did not emit either).
lastEmitted.value = JSON.stringify(buildGroup());
}
},
{ deep: true },
);
</script>
@@ -14,10 +14,15 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, it, expect, afterEach } from "vitest";
import { mount, VueWrapper } from "@vue/test-utils";
import { mount, flushPromises, VueWrapper } from "@vue/test-utils";
import { defineComponent } from "vue";
import { z } from "zod";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import VariablesInput from "@/components/alerts/VariablesInput.vue";
import OForm from "@/lib/forms/Form/OForm.vue";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
// ---------------------------------------------------------------------------
// helpers
@@ -285,3 +290,171 @@ describe("VariablesInput", () => {
});
});
});
// ---------------------------------------------------------------------------
// form mode (opt-in via `name-prefix` inside a REAL <OForm>)
// ---------------------------------------------------------------------------
const variableRowSchema = z.object({
key: z.string(),
value: z.string(),
});
const variablesHostSchema = z.object({
variables: z.array(variableRowSchema),
});
// Host renders a REAL <OForm> (schema + default-values) around VariablesInput —
// form mode is driven purely by `name-prefix` + the injected form context.
const FormHost = defineComponent({
components: { OForm, VariablesInput },
props: {
rows: { type: Array, default: () => [] },
},
setup(props) {
return {
hostSchema: variablesHostSchema,
hostDefaults: { variables: JSON.parse(JSON.stringify(props.rows)) },
};
},
template: `
<OForm :schema="hostSchema" :default-values="hostDefaults" @submit="() => {}">
<VariablesInput name-prefix="variables" />
</OForm>
`,
});
function buildFormWrapper(rows: any[] = []): VueWrapper<any> {
return mount(FormHost, {
props: { rows },
global: { plugins: [i18n, store] },
});
}
const getForm = (w: VueWrapper<any>) => (w.findComponent(OForm).vm as any).form;
/** RENDERED model-values (OFormInput → OInput) for a row field, in DOM order. */
const renderedField = (w: VueWrapper<any>, field: "key" | "value") =>
w
.findAllComponents(OFormInput)
.filter(
(c) =>
/^variables\[\d+\]\.(key|value)$/.test(String(c.props("name"))) &&
String(c.props("name")).endsWith(`.${field}`),
)
.map((c) => c.findComponent(OInput).props("modelValue"));
describe("VariablesInput — form mode (name-prefix inside a real <OForm>)", () => {
let wrapper: VueWrapper<any> | null = null;
afterEach(() => {
wrapper?.unmount();
wrapper = null;
});
const threeRows = [
{ key: "k1", value: "v1" },
{ key: "k2", value: "v2" },
{ key: "k3", value: "v3" },
];
it("renders one row per form-state entry, carrying the bare-mode data-tests", () => {
wrapper = buildFormWrapper(threeRows);
expect(wrapper.find('[data-test="alert-variables-1"]').exists()).toBe(true);
expect(wrapper.find('[data-test="alert-variables-2"]').exists()).toBe(true);
expect(wrapper.find('[data-test="alert-variables-3"]').exists()).toBe(true);
expect(wrapper.find('[data-test="alert-variables-add-btn"]').exists()).toBe(false);
expect(wrapper.findAll('[data-test="alert-variables-key-input"]')).toHaveLength(3);
expect(wrapper.findAll('[data-test="alert-variables-value-input"]')).toHaveLength(3);
expect(wrapper.findAll('[data-test="alert-variables-delete-variable-btn"]')).toHaveLength(3);
// inline add button only on the last row
expect(wrapper.findAll('[data-test="alert-variables-add-variable-btn"]')).toHaveLength(1);
});
it("renders the row inputs from the form state (name=-owned, no props)", () => {
wrapper = buildFormWrapper(threeRows);
expect(renderedField(wrapper, "key")).toEqual(["k1", "k2", "k3"]);
expect(renderedField(wrapper, "value")).toEqual(["v1", "v2", "v3"]);
});
it("shows the empty-state Add Variable button and pushes a row into the form", async () => {
wrapper = buildFormWrapper([]);
expect(wrapper.find('[data-test="alert-variables-add-btn"]').exists()).toBe(true);
expect(wrapper.find('[data-test="alert-variables-1"]').exists()).toBe(false);
await wrapper.find('[data-test="alert-variables-add-btn"]').trigger("click");
await flushPromises();
expect(getForm(wrapper).state.values.variables).toEqual([{ key: "", value: "" }]);
expect(wrapper.find('[data-test="alert-variables-1"]').exists()).toBe(true);
expect(wrapper.find('[data-test="alert-variables-add-btn"]').exists()).toBe(false);
});
it("typing in a row's key/value inputs updates the form values", async () => {
wrapper = buildFormWrapper([threeRows[0]]);
await wrapper
.find('[data-test="alert-variables-key-input"] input')
.setValue("typedKey");
await wrapper
.find('[data-test="alert-variables-value-input"] input')
.setValue("typedValue");
await flushPromises();
expect(getForm(wrapper).state.values.variables[0]).toEqual({
key: "typedKey",
value: "typedValue",
});
});
it("the inline add button appends a row to the form array", async () => {
wrapper = buildFormWrapper([threeRows[0]]);
await wrapper.find('[data-test="alert-variables-add-variable-btn"]').trigger("click");
await flushPromises();
expect(getForm(wrapper).state.values.variables).toHaveLength(2);
expect(wrapper.find('[data-test="alert-variables-2"]').exists()).toBe(true);
});
it("does not emit bare-mode events in form mode (form owns the rows)", async () => {
wrapper = buildFormWrapper(threeRows);
await wrapper.findAll('[data-test="alert-variables-delete-variable-btn"]')[0].trigger("click");
await wrapper.find('[data-test="alert-variables-add-variable-btn"]').trigger("click");
await flushPromises();
const variablesInput = wrapper.findComponent(VariablesInput);
expect(variablesInput.emitted("add:variable")).toBeUndefined();
expect(variablesInput.emitted("remove:variable")).toBeUndefined();
});
// 🔑 Rule ① gate — delete a NON-last row and assert the RENDERED inputs
// (OFormInput→OInput model-values), NOT just form.state.values: a stable-id
// :key bug leaves the data correct while the inputs render shifted/blank.
it("Rule ① — deleting a NON-last row keeps the RENDERED inputs in sync", async () => {
wrapper = buildFormWrapper(threeRows);
expect(renderedField(wrapper, "key")).toEqual(["k1", "k2", "k3"]);
const deleteBtns = wrapper.findAll('[data-test="alert-variables-delete-variable-btn"]');
expect(deleteBtns).toHaveLength(3);
await deleteBtns[1].trigger("click"); // delete the MIDDLE row
await flushPromises();
// the RENDERED inputs must match the remaining rows, in order
expect(renderedField(wrapper, "key")).toEqual(["k1", "k3"]);
expect(renderedField(wrapper, "value")).toEqual(["v1", "v3"]);
expect(wrapper.findAll('[data-test="alert-variables-key-input"]')).toHaveLength(2);
expect(wrapper.find('[data-test="alert-variables-3"]').exists()).toBe(false);
// and the form data agrees
expect(getForm(wrapper).state.values.variables).toEqual([
{ key: "k1", value: "v1" },
{ key: "k3", value: "v3" },
]);
});
});
+148 -6
View File
@@ -15,21 +15,114 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div class="w-full py-2 variables-input "
<!-- ── FORM MODE (opt-in via `name-prefix` inside a parent <OForm>) ────────
Rows are FORM-OWNED: rendered as OFormInput fields with indexed names
(`${namePrefix}[i].key/value`), added/removed through the injected form
(pushFieldValue/removeFieldValue). No v-model, no local mirrors — the
consuming form's schema owns validation (Rule ②). -->
<div v-if="formMode" class="w-full py-2 variables-input "
:class="{
'flex gap-2 items-center w-full': variables.length == 0,
'flex gap-2 items-center w-full': formRows.length == 0,
}"
>
<div class="pb-1 custom-input-label font-bold">
<span>
Variable
{{ t("alerts.variables.label") }}
</span>
<OButton
variant="ghost-muted"
size="icon-sm"
>
<OIcon name="info-outline" size="sm" />
<OTooltip content="Variables are used to pass data from the alert to the destination." />
<OTooltip :content="t('alerts.advanced.variablesTooltip')" />
</OButton>
</div>
<template v-if="!formRows.length">
<div class="flex justify-between items-center">
<OButton
data-test="alert-variables-add-btn"
size="sm"
variant="outline"
@click="addFormRow"
>
<OIcon name="add" size="sm" />
<span>{{ t("alerts.advanced.addVariable") }}</span>
</OButton>
</div>
</template>
<template v-else>
<!-- 🔑 :key MUST be the array INDEX (Rule ①): the fields bind by
index-based `name` and form.Field resolves its name at CREATION, so
a stable-id key would leave surviving rows bound to their OLD index
after a mid-list delete (inputs shifted/blank). -->
<div
v-for="(row, index) in formRows"
:key="index"
class="gap-2 pb-2 flex items-center"
:data-test="`alert-variables-${index + 1}`"
>
<div class="ml-0">
<OFormInput
data-test="alert-variables-key-input"
:name="`${namePrefix}[${index}].key`"
:placeholder="t('common.name')"
tabindex="0"
/>
</div>
<div class="ml-0">
<OFormInput
data-test="alert-variables-value-input"
:name="`${namePrefix}[${index}].value`"
:placeholder="t('common.value')"
tabindex="0"
style="min-width: 250px"
/>
</div>
<div class="w-1/6 ml-0">
<OButton
data-test="alert-variables-delete-variable-btn"
class="ml-1"
variant="ghost"
size="icon-circle-sm"
:title="t('alert_templates.edit')"
@click="removeFormRow(index)"
>
<OIcon name="delete" size="sm" />
</OButton>
<OButton
data-test="alert-variables-add-variable-btn"
v-if="index === formRows.length - 1"
class="ml-1"
variant="ghost"
size="icon-circle-sm"
:title="t('alert_templates.edit')"
@click="addFormRow"
>
<OIcon name="add" size="sm" />
</OButton>
</div>
</div>
</template>
</div>
<!-- ── BARE MODE (no name-prefix) — pre-migration behavior, unchanged.
Consumed bare by pipeline/actionScripts/reports forms; a legitimate
permanent pattern (see START-HERE 🔀). Do NOT delete. -->
<div v-else class="w-full py-2 variables-input "
:class="{
'flex gap-2 items-center w-full': variables.length == 0,
}"
>
<div class="pb-1 custom-input-label font-bold">
<span>
{{ t("alerts.variables.label") }}
</span>
<OButton
variant="ghost-muted"
size="icon-sm"
>
<OIcon name="info-outline" size="sm" />
<OTooltip :content="t('alerts.advanced.variablesTooltip')" />
</OButton>
</div>
<template v-if="!variables.length">
@@ -42,7 +135,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@click="addVariable"
>
<OIcon name="add" size="sm" />
<span>Add Variable</span>
<span>{{ t("alerts.advanced.addVariable") }}</span>
</OButton>
</div>
</template>
@@ -99,17 +192,33 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<script setup lang="ts">
import { computed, inject, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useStore } from "vuex";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OButton from '@/lib/core/Button/OButton.vue';
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import { FORM_CONTEXT_KEY } from "@/lib/forms/Form/OForm.types";
const props = defineProps({
variables: {
type: Array,
required: true,
required: false,
default: () => [],
},
/**
* OForm "form mode" opt-in: the path of the rows array inside the parent
* <OForm>'s values (e.g. "variables" / "context_attributes"). When set AND a
* form context is injectable, rows render as OFormInput fields with indexed
* names (`${namePrefix}[i].key|value`). Without it the component behaves
* exactly as before (bare mode).
*/
namePrefix: {
type: String,
default: "",
required: false,
},
});
@@ -119,6 +228,39 @@ const store = useStore();
const { t } = useI18n();
// ── OForm form mode (dual-mode; opt-in via namePrefix) ──────────────────────
// A consumer inside an <OForm> may inject a form context even when it renders
// this component bare (e.g. pipeline's Condition.vue bridge) — form mode
// therefore requires BOTH the injected form AND an explicit namePrefix.
const injectedForm = inject(FORM_CONTEXT_KEY, null);
const formMode = computed(() => !!(props.namePrefix && injectedForm));
/** Resolve a dotted/indexed path ("a.b[2].c") inside the form values. */
const resolvePath = (obj: any, path: string): any =>
path
.replace(/\[(\d+)\]/g, ".$1")
.split(".")
.filter(Boolean)
.reduce((acc: any, key: string) => (acc == null ? undefined : acc[key]), obj);
// ⚠️ MUST be form.useStore (reactive) — NOT form.state.values (a snapshot a
// computed won't track; playbook §2).
const formRows = injectedForm
? injectedForm.useStore((s: any) =>
props.namePrefix ? (resolvePath(s.values, props.namePrefix) ?? []) : [],
)
: ref<any[]>([]);
const makeVariableRow = () => ({ key: "", value: "" });
const addFormRow = () =>
injectedForm?.pushFieldValue(props.namePrefix, makeVariableRow());
const removeFormRow = (index: number) =>
injectedForm?.removeFieldValue(props.namePrefix, index);
// ── Bare mode handlers (unchanged) ──────────────────────────────────────────
const removeVariable = (variable: any) => {
emits("remove:variable", variable);
};
+178 -639
View File
@@ -13,678 +13,217 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, it, expect, beforeEach, vi } from "vitest";
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
import Advanced, { type Variable } from "./Advanced.vue";
// Behavior spec for the Advanced step. The step is a DESCENDANT of the ONE
// AddAlert <OForm>: an ancestor form provides FORM_CONTEXT_KEY and the fields
// bind by nested `name=` (template / context_attributes[i].* / description /
// row_template / row_template_type) into it. The host below mirrors that
// wiring with the same composed schema fragment AddAlert.schema.ts exports.
//
// Includes the MANDATORY Rule-① field-array delete test: with ≥3 context-variable
// rows, deleting a NON-last row must leave the RENDERED inputs (not just
// form.state.values) shifted correctly — proving the v-for `:key` is the array
// index, not a stable id.
import { describe, it, expect, vi } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { defineComponent, nextTick } from "vue";
import Advanced from "./Advanced.vue";
import OForm from "@/lib/forms/Form/OForm.vue";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import { createAdvancedSchema } from "@/components/alerts/AddAlert.schema";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
vi.mock("@/utils/zincutils", async () => {
const actual: any = await vi.importActual("@/utils/zincutils");
let n = 0;
return {
...actual,
getUUID: vi.fn(() => `uuid-${++n}`),
};
});
// Mock getUUID
vi.mock("@/utils/zincutils", () => ({
getUUID: vi.fn(() => "mock-uuid-" + Math.random()),
b64EncodeUnicode: vi.fn((str: string) => btoa(str)),
b64DecodeUnicode: vi.fn((str: string) => atob(str)),
}));
const createMockStore = (overrides = {}) => ({
state: {
theme: "light",
zoConfig: {
build_type: "opensource",
},
...overrides,
// The help drawer pulls extra deps we do not exercise here.
const globalCfg = {
global: {
plugins: [i18n, store],
stubs: { AlertSettingsHelpDrawer: true },
},
dispatch: vi.fn(),
commit: vi.fn(),
});
};
describe("Advanced.vue", () => {
let wrapper: VueWrapper<any>;
let mockStore: any;
// ── DESCENDANT host (binds into an ancestor OForm — the app wiring) ─────────
beforeEach(() => {
mockStore = createMockStore();
wrapper = mount(Advanced, {
global: {
mocks: {
$store: mockStore,
},
provide: {
store: mockStore,
},
plugins: [i18n],
},
props: {
contextAttributes: [],
function makeDescendantHost(initialRows: any[] = []) {
return defineComponent({
components: { OForm, Advanced },
setup() {
const schema = createAdvancedSchema();
const defaultValues = {
template: "",
context_attributes: initialRows,
description: "",
rowTemplate: "",
rowTemplateType: "String",
},
});
row_template: "",
row_template_type: "String",
};
return { schema, defaultValues };
},
template: `
<OForm :schema="schema" :default-values="defaultValues" @submit="() => {}">
<Advanced :context-attributes="[]" />
</OForm>
`,
});
}
function mountDescendant(initialRows: any[] = []) {
return mount(makeDescendantHost(initialRows), globalCfg);
}
const hostForm = (host: any) =>
(host.findComponent({ name: "OForm" }).vm as any).form;
describe("Advanced — descendant (binds into ancestor OForm) mode", () => {
it("does NOT render its own <OForm> — fields bind into the parent", () => {
const host = mountDescendant();
expect(host.findAllComponents({ name: "OForm" }).length).toBe(1);
expect(host.findComponent(Advanced).exists()).toBe(true);
expect(host.find(".step-advanced").exists()).toBe(true);
});
describe("Initialization", () => {
it("should mount component successfully", () => {
expect(wrapper.exists()).toBe(true);
expect(wrapper.vm).toBeTruthy();
});
it("typing a variable key updates the PARENT form's state", async () => {
const host = mountDescendant([{ id: "1", key: "", value: "" }]);
const parentForm = hostForm(host);
it("should render with correct theme class (light mode)", () => {
expect(wrapper.classes()).toContain("light-mode");
});
await host
.find('[data-test="alert-variables-key-input"] input')
.setValue("env");
await flushPromises();
it("should render with correct theme class (dark mode)", async () => {
mockStore = createMockStore({ theme: "dark" });
wrapper = mount(Advanced, {
global: {
mocks: { $store: mockStore },
provide: { store: mockStore },
plugins: [i18n],
},
props: {
contextAttributes: [],
description: "",
rowTemplate: "",
rowTemplateType: "String",
},
});
expect(wrapper.classes()).toContain("dark-mode");
});
it("should initialize with empty variables", () => {
expect(wrapper.vm.localVariables).toEqual([]);
});
it("should initialize with empty description", () => {
expect(wrapper.vm.localDescription).toBe("");
});
it("should initialize with empty row template", () => {
expect(wrapper.vm.localRowTemplate).toBe("");
});
it("should initialize row template type as String", () => {
expect(wrapper.vm.localRowTemplateType).toBe("String");
});
expect(parentForm.state.values.context_attributes[0].key).toBe("env");
});
describe("Props", () => {
it("should accept contextAttributes prop", async () => {
const variables: Variable[] = [
{ id: "1", key: "env", value: "production" },
];
await wrapper.setProps({ contextAttributes: variables });
expect(wrapper.vm.localVariables).toEqual(variables);
});
it("shows the empty-state add button; adding pushes a row into the PARENT form (no emit)", async () => {
const host = mountDescendant();
const parentForm = hostForm(host);
it("should accept description prop", async () => {
await wrapper.setProps({ description: "Test description" });
expect(wrapper.vm.localDescription).toBe("Test description");
});
expect(host.find('[data-test="alert-variables-add-btn"]').exists()).toBe(
true,
);
await host.find('[data-test="alert-variables-add-btn"]').trigger("click");
await flushPromises();
it("should accept rowTemplate prop", async () => {
await wrapper.setProps({ rowTemplate: "Alert: {message}" });
expect(wrapper.vm.localRowTemplate).toBe("Alert: {message}");
});
it("should accept rowTemplateType prop", async () => {
await wrapper.setProps({ rowTemplateType: "Json" });
expect(wrapper.vm.localRowTemplateType).toBe("Json");
});
it("should use default empty array for contextAttributes", () => {
expect(wrapper.props("contextAttributes")).toEqual([]);
});
it("should use default empty string for description", () => {
expect(wrapper.props("description")).toBe("");
});
it("should use default empty string for rowTemplate", () => {
expect(wrapper.props("rowTemplate")).toBe("");
});
it("should use default String for rowTemplateType", () => {
expect(wrapper.props("rowTemplateType")).toBe("String");
});
it("should handle contextAttributes prop updates", async () => {
const variables: Variable[] = [
{ id: "1", key: "env", value: "staging" },
];
await wrapper.setProps({ contextAttributes: variables });
await flushPromises();
expect(wrapper.vm.localVariables).toEqual(variables);
});
it("should handle description prop updates", async () => {
await wrapper.setProps({ description: "Updated description" });
await flushPromises();
expect(wrapper.vm.localDescription).toBe("Updated description");
});
it("should handle rowTemplate prop updates", async () => {
await wrapper.setProps({ rowTemplate: "New template" });
await flushPromises();
expect(wrapper.vm.localRowTemplate).toBe("New template");
});
it("should handle rowTemplateType prop updates", async () => {
await wrapper.setProps({ rowTemplateType: "Json" });
await flushPromises();
expect(wrapper.vm.localRowTemplateType).toBe("Json");
});
expect(parentForm.state.values.context_attributes.length).toBe(1);
// descendant mode is form-owned → no emit
expect(host.findComponent(Advanced).emitted("update:contextAttributes"))
.toBeFalsy();
});
describe("Variables Management", () => {
it("should add a new variable", async () => {
await wrapper.vm.addVariable();
expect(wrapper.vm.localVariables.length).toBe(1);
expect(wrapper.vm.localVariables[0]).toHaveProperty("id");
expect(wrapper.vm.localVariables[0]).toHaveProperty("key", "");
expect(wrapper.vm.localVariables[0]).toHaveProperty("value", "");
});
it("typing description / row template writes into the PARENT form", async () => {
const host = mountDescendant();
const parentForm = hostForm(host);
it("should remove a variable", async () => {
await wrapper.vm.addVariable();
const variable = wrapper.vm.localVariables[0];
await wrapper.vm.removeVariable(variable);
expect(wrapper.vm.localVariables.length).toBe(0);
});
const textareas = host.findAll("textarea");
// first textarea = description, second = row template
await textareas[0].setValue("a description");
await flushPromises();
expect(parentForm.state.values.description).toBe("a description");
it("should add multiple variables", async () => {
await wrapper.vm.addVariable();
await wrapper.vm.addVariable();
await wrapper.vm.addVariable();
expect(wrapper.vm.localVariables.length).toBe(3);
});
it("should remove specific variable by id", async () => {
await wrapper.vm.addVariable();
await wrapper.vm.addVariable();
const firstVariable = wrapper.vm.localVariables[0];
await wrapper.vm.removeVariable(firstVariable);
expect(wrapper.vm.localVariables.length).toBe(1);
expect(wrapper.vm.localVariables[0].id).not.toBe(firstVariable.id);
});
it("should generate unique ids for variables", async () => {
await wrapper.vm.addVariable();
await wrapper.vm.addVariable();
const ids = wrapper.vm.localVariables.map((v: Variable) => v.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});
await host
.find('[data-test="add-alert-row-input-textarea"] textarea')
.setValue("row tmpl");
await flushPromises();
expect(parentForm.state.values.row_template).toBe("row tmpl");
});
describe("User Interactions - Variables", () => {
it("should show add variable button when no variables exist", () => {
const addBtn = wrapper.find('[data-test="alert-variables-add-btn"]');
expect(addBtn.exists()).toBe(true);
});
it("template select + row-template-type toggle write into the PARENT form", async () => {
const host = mountDescendant();
const parentForm = hostForm(host);
it("should hide initial add button when variables exist", async () => {
await wrapper.setProps({
contextAttributes: [{ id: "1", key: "test", value: "value" }],
});
await flushPromises();
const addBtn = wrapper.find('[data-test="alert-variables-add-btn"]');
expect(addBtn.exists()).toBe(false);
});
// The OForm* wrappers bind the inner control's update:model-value to
// field.handleChange — drive that path directly.
host
.findComponent('[data-test="advanced-template-override-select"]')
.vm.$emit("update:model-value", "tmpl-a");
await flushPromises();
expect(parentForm.state.values.template).toBe("tmpl-a");
it("should render variable inputs when variables exist", async () => {
await wrapper.setProps({
contextAttributes: [{ id: "1", key: "env", value: "prod" }],
});
await flushPromises();
const keyInput = wrapper.find('[data-test="alert-variables-key-input"]');
const valueInput = wrapper.find('[data-test="alert-variables-value-input"]');
expect(keyInput.exists()).toBe(true);
expect(valueInput.exists()).toBe(true);
});
it("should show delete button for each variable", async () => {
await wrapper.setProps({
contextAttributes: [
{ id: "1", key: "env", value: "prod" },
{ id: "2", key: "region", value: "us-west" },
],
});
await flushPromises();
const deleteBtns = wrapper.findAll('[data-test="alert-variables-delete-variable-btn"]');
expect(deleteBtns.length).toBe(2);
});
it("should show add button only for last variable", async () => {
await wrapper.setProps({
contextAttributes: [
{ id: "1", key: "env", value: "prod" },
{ id: "2", key: "region", value: "us-west" },
],
});
await flushPromises();
const addBtns = wrapper.findAll('[data-test="alert-variables-add-variable-btn"]');
expect(addBtns.length).toBe(1);
});
host
.findComponent('[data-test="add-alert-row-template-type-toggle"]')
.vm.$emit("update:model-value", "Json");
await flushPromises();
expect(parentForm.state.values.row_template_type).toBe("Json");
});
describe("User Interactions - Description", () => {
it("should update description on input", async () => {
const description = "This is a test alert description";
wrapper.vm.localDescription = description;
await wrapper.vm.$nextTick();
expect(wrapper.vm.localDescription).toBe(description);
});
it("applying a template from the help drawer writes into the PARENT form", async () => {
const host = mountDescendant();
const parentForm = hostForm(host);
it("should render description textarea", () => {
const textarea = wrapper.find('textarea');
expect(textarea.exists()).toBe(true);
});
(host.findComponent(Advanced).vm as any).onApplyTemplate("tmpl-b");
await flushPromises();
expect(parentForm.state.values.template).toBe("tmpl-b");
expect(host.findComponent(Advanced).emitted("update:template")).toBeFalsy();
});
describe("User Interactions - Row Template", () => {
it("should update row template on input", async () => {
const template = "Alert triggered at {timestamp}";
wrapper.vm.localRowTemplate = template;
await wrapper.vm.$nextTick();
expect(wrapper.vm.localRowTemplate).toBe(template);
});
it("should render row template textarea", () => {
const textarea = wrapper.find('[data-test="add-alert-row-input-textarea"]');
expect(textarea.exists()).toBe(true);
});
it("should render row template type toggle", () => {
const toggle = wrapper.find('[data-test="add-alert-row-template-type-toggle"]');
expect(toggle.exists()).toBe(true);
});
it("should have String and JSON options for row template type", () => {
expect(wrapper.vm.rowTemplateTypeOptions).toEqual([
{ label: "String", value: "String" },
{ label: "JSON", value: "Json" },
]);
});
it("should change row template type", async () => {
wrapper.vm.localRowTemplateType = "Json";
await wrapper.vm.$nextTick();
expect(wrapper.vm.localRowTemplateType).toBe("Json");
});
it("preserves every data-test", () => {
const host = mountDescendant([{ id: "1", key: "env", value: "prod" }]);
for (const dt of [
"advanced-template-info-btn",
"advanced-variables-info-btn",
"advanced-template-override-select",
"alert-variables-key-input",
"alert-variables-value-input",
"alert-variables-delete-variable-btn",
"alert-variables-add-variable-btn",
"add-alert-row-input-info-btn",
"add-alert-row-template-type-toggle",
"add-alert-row-input-textarea",
]) {
expect(host.find(`[data-test="${dt}"]`).exists()).toBe(true);
}
});
describe("Computed - Row Template Placeholder", () => {
it("should show String placeholder by default", () => {
expect(wrapper.vm.rowTemplatePlaceholder).toContain("Alert was triggered");
});
// ── MANDATORY Rule-① field-array delete test ───────────────────────────────
it("deleting a NON-last row leaves the RENDERED inputs correct (:key=index)", async () => {
const host = mountDescendant([
{ id: "a", key: "env", value: "prod" },
{ id: "b", key: "region", value: "us" },
{ id: "c", key: "tier", value: "gold" },
]);
const parentForm = hostForm(host);
it("should show JSON placeholder when type is Json", async () => {
wrapper.vm.localRowTemplateType = "Json";
await wrapper.vm.$nextTick();
expect(wrapper.vm.rowTemplatePlaceholder).toContain("user");
expect(wrapper.vm.rowTemplatePlaceholder).toContain("timestamp");
});
const renderedKeys = () =>
host
.findAllComponents(OFormInput)
.filter((c: any) =>
/^context_attributes\[\d+\]\.key$/.test(c.props("name")),
)
.map((c: any) => c.findComponent(OInput).props("modelValue"));
const renderedValues = () =>
host
.findAllComponents(OFormInput)
.filter((c: any) =>
/^context_attributes\[\d+\]\.value$/.test(c.props("name")),
)
.map((c: any) => c.findComponent(OInput).props("modelValue"));
it("should update placeholder when type changes", async () => {
expect(wrapper.vm.rowTemplatePlaceholder).toContain("Alert was triggered");
wrapper.vm.localRowTemplateType = "Json";
await wrapper.vm.$nextTick();
expect(wrapper.vm.rowTemplatePlaceholder).toContain("user");
});
});
expect(renderedKeys()).toEqual(["env", "region", "tier"]);
describe("Emit Events", () => {
it("should emit all updates when emitUpdate is called", () => {
wrapper.vm.localVariables = [{ id: "1", key: "test", value: "val" }];
wrapper.vm.localDescription = "Test desc";
wrapper.vm.localRowTemplate = "Template";
wrapper.vm.localRowTemplateType = "Json";
wrapper.vm.emitUpdate();
// delete the MIDDLE row (index 1 = "region")
await host
.findAll('[data-test="alert-variables-delete-variable-btn"]')[1]
.trigger("click");
await flushPromises();
await nextTick();
expect(wrapper.emitted("update:contextAttributes")).toBeTruthy();
expect(wrapper.emitted("update:description")).toBeTruthy();
expect(wrapper.emitted("update:rowTemplate")).toBeTruthy();
expect(wrapper.emitted("update:rowTemplateType")).toBeTruthy();
});
it("should emit correct contextAttributes data", () => {
const variables: Variable[] = [{ id: "1", key: "env", value: "prod" }];
wrapper.vm.localVariables = variables;
wrapper.vm.emitUpdate();
const emitted = wrapper.emitted("update:contextAttributes");
expect(emitted).toBeTruthy();
expect(emitted![0]).toEqual([variables]);
});
it("should emit correct description data", () => {
wrapper.vm.localDescription = "Test description";
wrapper.vm.emitUpdate();
const emitted = wrapper.emitted("update:description");
expect(emitted).toBeTruthy();
expect(emitted![0]).toEqual(["Test description"]);
});
it("should emit correct rowTemplate data", () => {
wrapper.vm.localRowTemplate = "Alert: {msg}";
wrapper.vm.emitUpdate();
const emitted = wrapper.emitted("update:rowTemplate");
expect(emitted).toBeTruthy();
expect(emitted![0]).toEqual(["Alert: {msg}"]);
});
it("should emit correct rowTemplateType data", () => {
wrapper.vm.localRowTemplateType = "Json";
wrapper.vm.emitUpdate();
const emitted = wrapper.emitted("update:rowTemplateType");
expect(emitted).toBeTruthy();
expect(emitted![0]).toEqual(["Json"]);
});
it("should emit when adding a variable", async () => {
await wrapper.vm.addVariable();
expect(wrapper.emitted("update:contextAttributes")).toBeTruthy();
});
it("should emit when removing a variable", async () => {
await wrapper.vm.addVariable();
const variable = wrapper.vm.localVariables[0];
await wrapper.vm.removeVariable(variable);
expect(wrapper.emitted("update:contextAttributes")).toBeTruthy();
});
});
describe("Watcher Behavior", () => {
it("should update localVariables when contextAttributes prop changes", async () => {
const variables: Variable[] = [{ id: "1", key: "test", value: "val" }];
await wrapper.setProps({ contextAttributes: variables });
await flushPromises();
expect(wrapper.vm.localVariables).toEqual(variables);
});
it("should update localDescription when description prop changes", async () => {
await wrapper.setProps({ description: "New description" });
await flushPromises();
expect(wrapper.vm.localDescription).toBe("New description");
});
it("should update localRowTemplate when rowTemplate prop changes", async () => {
await wrapper.setProps({ rowTemplate: "New template" });
await flushPromises();
expect(wrapper.vm.localRowTemplate).toBe("New template");
});
it("should update localRowTemplateType when rowTemplateType prop changes", async () => {
await wrapper.setProps({ rowTemplateType: "Json" });
await flushPromises();
expect(wrapper.vm.localRowTemplateType).toBe("Json");
});
it("should handle deep changes to contextAttributes", async () => {
const variables: Variable[] = [{ id: "1", key: "env", value: "dev" }];
await wrapper.setProps({ contextAttributes: variables });
await flushPromises();
const updatedVariables: Variable[] = [{ id: "1", key: "env", value: "prod" }];
await wrapper.setProps({ contextAttributes: updatedVariables });
await flushPromises();
expect(wrapper.vm.localVariables).toEqual(updatedVariables);
});
});
describe("Edge Cases", () => {
it("should handle empty variables array", () => {
expect(wrapper.vm.localVariables).toEqual([]);
const addBtn = wrapper.find('[data-test="alert-variables-add-btn"]');
expect(addBtn.exists()).toBe(true);
});
it("should handle very long description text", async () => {
const longText = "A".repeat(10000);
wrapper.vm.localDescription = longText;
await wrapper.vm.$nextTick();
expect(wrapper.vm.localDescription).toBe(longText);
});
it("should handle very long row template", async () => {
const longTemplate = "Template: " + "X".repeat(5000);
wrapper.vm.localRowTemplate = longTemplate;
await wrapper.vm.$nextTick();
expect(wrapper.vm.localRowTemplate).toBe(longTemplate);
});
it("should handle special characters in variable keys", async () => {
await wrapper.vm.addVariable();
wrapper.vm.localVariables[0].key = "special-key_123.test@value";
await wrapper.vm.$nextTick();
expect(wrapper.vm.localVariables[0].key).toBe("special-key_123.test@value");
});
it("should handle special characters in variable values", async () => {
await wrapper.vm.addVariable();
wrapper.vm.localVariables[0].value = '{"nested": "json", "value": 123}';
await wrapper.vm.$nextTick();
expect(wrapper.vm.localVariables[0].value).toContain("nested");
});
it("should handle removing all variables", async () => {
await wrapper.vm.addVariable();
await wrapper.vm.addVariable();
const var1 = wrapper.vm.localVariables[0];
const var2 = wrapper.vm.localVariables[1];
await wrapper.vm.removeVariable(var1);
await wrapper.vm.removeVariable(var2);
expect(wrapper.vm.localVariables.length).toBe(0);
});
it("should handle rapid variable additions", async () => {
for (let i = 0; i < 10; i++) {
await wrapper.vm.addVariable();
}
expect(wrapper.vm.localVariables.length).toBe(10);
});
it("should handle empty string in description", async () => {
wrapper.vm.localDescription = "";
await wrapper.vm.$nextTick();
expect(wrapper.vm.localDescription).toBe("");
});
});
describe("UI Rendering", () => {
it("should render Additional Variables label", () => {
const html = wrapper.html();
expect(html).toContain("Additional Variables");
});
it("should render Description label", () => {
const html = wrapper.html();
expect(html).toContain("Description");
});
it("should render Row Template label", () => {
const html = wrapper.html();
expect(html).toContain("Row Template");
});
it("should render info tooltips", () => {
const infoBtns = wrapper.findAll('[data-test="add-alert-row-input-info-btn"]');
expect(infoBtns.length).toBeGreaterThan(0);
});
it("should render template type toggle", () => {
const toggle = wrapper.find('[data-test="add-alert-row-template-type-toggle"]');
expect(toggle.exists()).toBe(true);
});
});
describe("Accessibility", () => {
it("should have proper structure for form inputs", () => {
expect(wrapper.exists()).toBe(true);
});
it("should expose 'Learn more' help triggers that open the help drawer", () => {
// The ambiguous dual-behavior (i) icons were replaced with clearly
// clickable, labeled 'Learn more' links (help icon). Verify the help
// affordance is present for each documented control.
const icons = wrapper.findAllComponents({ name: "OIcon" });
expect(icons.some((i) => i.props("name") === "help")).toBe(true);
expect(
wrapper.find('[data-test="advanced-template-info-btn"]').exists(),
).toBe(true);
expect(
wrapper.find('[data-test="advanced-variables-info-btn"]').exists(),
).toBe(true);
expect(
wrapper.find('[data-test="add-alert-row-input-info-btn"]').exists(),
).toBe(true);
});
});
describe("Boundary Conditions", () => {
it("should handle zero variables", () => {
expect(wrapper.vm.localVariables.length).toBe(0);
});
it("should handle large number of variables", async () => {
const variables: Variable[] = [];
for (let i = 0; i < 50; i++) {
variables.push({ id: `id-${i}`, key: `key-${i}`, value: `value-${i}` });
}
await wrapper.setProps({ contextAttributes: variables });
await flushPromises();
expect(wrapper.vm.localVariables.length).toBe(50);
});
it("should handle empty strings for all fields", () => {
expect(wrapper.vm.localDescription).toBe("");
expect(wrapper.vm.localRowTemplate).toBe("");
});
it("should handle fully populated state", async () => {
await wrapper.setProps({
contextAttributes: [
{ id: "1", key: "env", value: "prod" },
{ id: "2", key: "region", value: "us-west" },
],
description: "Full description",
rowTemplate: "Alert: {message}",
rowTemplateType: "Json",
});
await flushPromises();
expect(wrapper.vm.localVariables.length).toBe(2);
expect(wrapper.vm.localDescription).toBe("Full description");
expect(wrapper.vm.localRowTemplate).toBe("Alert: {message}");
expect(wrapper.vm.localRowTemplateType).toBe("Json");
});
});
describe("Negative Cases", () => {
it("should not crash when removing non-existent variable", async () => {
const fakeVariable: Variable = { id: "non-existent", key: "", value: "" };
await wrapper.vm.removeVariable(fakeVariable);
expect(wrapper.vm.localVariables.length).toBe(0);
});
it("should handle undefined values gracefully", async () => {
wrapper.vm.localDescription = undefined as any;
wrapper.vm.localRowTemplate = undefined as any;
await wrapper.vm.$nextTick();
// Component should not crash
expect(wrapper.exists()).toBe(true);
});
});
describe("Theme Switching", () => {
it("should apply light mode theme", () => {
expect(wrapper.classes()).toContain("light-mode");
});
it("should apply dark mode theme", async () => {
mockStore = createMockStore({ theme: "dark" });
const darkWrapper = mount(Advanced, {
global: {
mocks: { $store: mockStore },
provide: { store: mockStore },
plugins: [i18n],
},
props: {
contextAttributes: [],
description: "",
rowTemplate: "",
rowTemplateType: "String",
},
});
expect(darkWrapper.classes()).toContain("dark-mode");
});
it("should toggle between themes", async () => {
// Light mode
expect(wrapper.classes()).toContain("light-mode");
// Dark mode
mockStore = createMockStore({ theme: "dark" });
const darkWrapper = mount(Advanced, {
global: {
mocks: { $store: mockStore },
provide: { store: mockStore },
plugins: [i18n],
},
props: {
contextAttributes: [],
description: "",
rowTemplate: "",
rowTemplateType: "String",
},
});
expect(darkWrapper.classes()).toContain("dark-mode");
// Back to light mode
mockStore = createMockStore({ theme: "light" });
const lightWrapper = mount(Advanced, {
global: {
mocks: { $store: mockStore },
provide: { store: mockStore },
plugins: [i18n],
},
props: {
contextAttributes: [],
description: "",
rowTemplate: "",
rowTemplateType: "String",
},
});
expect(lightWrapper.classes()).toContain("light-mode");
});
});
describe("Variable ID Generation", () => {
it("should generate unique IDs when adding variables", async () => {
await wrapper.vm.addVariable();
await wrapper.vm.addVariable();
await wrapper.vm.addVariable();
const ids = wrapper.vm.localVariables.map((v: Variable) => v.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(3);
});
it("should preserve IDs from props", async () => {
const variables: Variable[] = [
{ id: "custom-id-1", key: "test1", value: "val1" },
{ id: "custom-id-2", key: "test2", value: "val2" },
];
await wrapper.setProps({ contextAttributes: variables });
await flushPromises();
expect(wrapper.vm.localVariables[0].id).toBe("custom-id-1");
expect(wrapper.vm.localVariables[1].id).toBe("custom-id-2");
});
// form data is correct...
expect(
parentForm.state.values.context_attributes.map((r: any) => r.key),
).toEqual(["env", "tier"]);
// ...AND the RENDERED inputs shifted correctly (would be ["env",""] with a
// stable-id :key — the exact bug this test guards against).
expect(renderedKeys()).toEqual(["env", "tier"]);
expect(renderedValues()).toEqual(["prod", "gold"]);
});
});
+99 -125
View File
@@ -19,6 +19,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="step-advanced w-full"
:class="store.state.theme === 'dark' ? 'dark-mode' : 'light-mode'"
>
<!-- DESCENDANT step (Rule ③): the AddAlert orchestrator owns the ONE <OForm>
and provides FORM_CONTEXT_KEY. The OForm* fields below inject that form
and bind by nested `name=` (template, context_attributes[i].*,
description, row_template, row_template_type); the composed schema in
AddAlert.schema.ts validates on save. -->
<div>
<div
class="step-content rounded-lg bg-[var(--color-surface-overlay)] border border-[var(--color-border-default)]"
>
@@ -31,7 +37,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<span
class="section-header-title text-[13px] font-semibold text-[var(--color-text-primary)]"
>{{
t("alerts.additional_settings") || "Additional Settings"
t("alerts.additional_settings")
}}</span>
</div>
@@ -55,17 +61,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OButton>
</div>
<div class="flex items-center gap-2">
<OSelect
v-model="localTemplate"
<OFormSelect
name="template"
:options="formattedTemplates"
clearable
:placeholder="t('alerts.advanced.selectTemplate')"
class="min-w-[240px] max-w-[300px]"
data-test="advanced-template-override-select"
@update:model-value="emitTemplateUpdate"
>
<template #empty>{{ t("alerts.advanced.noTemplatesAvailable") }}</template>
</OSelect>
</OFormSelect>
<OButton
variant="ghost"
size="icon-circle-sm"
@@ -95,7 +100,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<span>{{ t("alerts.alertSettings.helpLearnMore") }}</span>
</OButton>
</div>
<template v-if="!localVariables.length">
<template v-if="!variableRows.length">
<OButton
data-test="alert-variables-add-btn"
variant="outline"
@@ -106,37 +111,41 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OButton>
</template>
<template v-else>
<!-- Rule ①: repeatable key/value rows are FORM-OWNED. Each row's
fields bind by INDEX-based `name=` (`context_attributes[i].*`),
so the v-for `:key` MUST be the array INDEX — a stable-id/uuid
`:key` would leave surviving rows bound to their OLD index after a
mid-list delete (inputs shift/blank while data stays correct).
Proven by the delete test in Advanced.spec.ts. -->
<div
v-for="(variable, index) in localVariables"
:key="variable.id"
v-for="(variable, index) in variableRows"
:key="index"
class="flex items-center gap-2 mb-2"
:data-test="`alert-variables-${index + 1}`"
>
<OInput
<OFormInput
data-test="alert-variables-key-input"
v-model="variable.key"
:name="`context_attributes[${index}].key`"
:placeholder="t('common.name')"
class="min-w-[140px]"
@update:model-value="emitUpdate"
/>
<OInput
<OFormInput
data-test="alert-variables-value-input"
v-model="variable.value"
:name="`context_attributes[${index}].value`"
:placeholder="t('common.value')"
class="min-w-[200px]"
@update:model-value="emitUpdate"
/>
<OButton
data-test="alert-variables-delete-variable-btn"
variant="ghost"
size="icon-circle-sm"
@click="removeVariable(variable)"
@click="removeVariable(index)"
>
<OIcon name="delete-outline" size="sm" />
</OButton>
<OButton
data-test="alert-variables-add-variable-btn"
v-if="index === localVariables.length - 1"
v-if="index === variableRows.length - 1"
variant="ghost"
size="icon-circle-sm"
@click="addVariable"
@@ -155,11 +164,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<span>{{ t("alerts.description") }}</span>
</div>
<OTextarea
v-model="localDescription"
<OFormTextarea
name="description"
:placeholder="t('alerts.placeholders.typeSomething')"
:rows="4"
@update:model-value="emitUpdate"
/>
</div>
@@ -186,42 +194,41 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<span class="text-xs opacity-60">{{
t("alerts.advanced.templateType")
}}</span>
<OToggleGroup
<OFormToggleGroup
data-test="add-alert-row-template-type-toggle"
v-model="localRowTemplateType"
@update:model-value="emitUpdate"
name="row_template_type"
>
<OToggleGroupItem value="String" size="sm">
<template #icon-left><OIcon name="title" size="sm" /></template>
String
{{ t("alerts.advanced.templateTypeString") }}
</OToggleGroupItem>
<OToggleGroupItem value="Json" size="sm">
<template #icon-left><OIcon name="data-object" size="sm" /></template>
JSON
{{ t("alerts.advanced.templateTypeJson") }}
</OToggleGroupItem>
</OToggleGroup>
</OFormToggleGroup>
</div>
</div>
<OTextarea
<OFormTextarea
data-test="add-alert-row-input-textarea"
v-model="localRowTemplate"
name="row_template"
:placeholder="rowTemplatePlaceholder"
:rows="4"
@update:model-value="emitUpdate"
/>
</div>
</div>
</div>
</div>
<AlertSettingsHelpDrawer
v-model:open="helpDrawerOpen"
:topic="helpTopic"
:templates="templates"
:current-template="localTemplate || ''"
:current-template="templateValue || ''"
:selected-destinations="selectedDestinations"
:destinations="destinations"
:context-attributes="localVariables"
:row-template="localRowTemplate"
:row-template-type="localRowTemplateType"
:context-attributes="variableRows"
:row-template="rowTemplateValue"
:row-template-type="rowTemplateTypeValue"
:facts="previewFacts"
:extra="previewExtra"
@apply:template="onApplyTemplate"
@@ -234,19 +241,20 @@ import {
defineComponent,
ref,
computed,
watch,
inject,
type PropType,
} from "vue";
import { useI18n } from "vue-i18n";
import { useStore } from "vuex";
import { getUUID } from "@/utils/zincutils";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import OFormToggleGroup from "@/lib/core/ToggleGroup/OFormToggleGroup.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import OTextarea from "@/lib/forms/Input/OTextarea.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import OFormTextarea from "@/lib/forms/Input/OFormTextarea.vue";
import OFormSelect from "@/lib/forms/Select/OFormSelect.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import { FORM_CONTEXT_KEY } from "@/lib/forms/Form/OForm.types";
import AlertSettingsHelpDrawer from "@/components/alerts/AlertSettingsHelpDrawer.vue";
export interface Variable {
@@ -257,7 +265,16 @@ export interface Variable {
export default defineComponent({
name: "Step6Advanced",
components: { OToggleGroup, OToggleGroupItem, OButton, OIcon, OInput, OTextarea, OSelect, AlertSettingsHelpDrawer },
components: {
OFormToggleGroup,
OToggleGroupItem,
OButton,
OIcon,
OFormInput,
OFormTextarea,
OFormSelect,
AlertSettingsHelpDrawer,
},
props: {
template: {
type: String,
@@ -320,98 +337,59 @@ export default defineComponent({
"update:rowTemplate",
"update:rowTemplateType",
],
setup(props, { emit }) {
setup(props) {
const { t } = useI18n();
const store = useStore();
// Template override
const localTemplate = ref<string | undefined>(props.template || undefined);
// DESCENDANT step (Rule ③): the AddAlert orchestrator provides
// FORM_CONTEXT_KEY — this is the ONE form all reads/writes go through.
const form: any = inject(FORM_CONTEXT_KEY, null);
// ── Reactive form reads (single source of truth for preview + help
// drawer). ────────────────────────────────────────────────────────────
const variableRows = form.useStore(
(s: any) => (s.values?.context_attributes ?? []) as Variable[],
);
const templateValue = form.useStore(
(s: any) => (s.values?.template ?? "") as string,
);
const descriptionValue = form.useStore(
(s: any) => (s.values?.description ?? "") as string,
);
const rowTemplateValue = form.useStore(
(s: any) => (s.values?.row_template ?? "") as string,
);
const rowTemplateTypeValue = form.useStore(
(s: any) => (s.values?.row_template_type ?? "String") as string,
);
const formattedTemplates = computed(() =>
props.templates.map((t: any) => t.name),
props.templates.map((tpl: any) => tpl.name),
);
const emitTemplateUpdate = () => {
emit("update:template", localTemplate.value || "");
};
watch(
() => props.template,
(newVal) => {
localTemplate.value = newVal || undefined;
},
);
const localVariables = ref<Variable[]>([...props.contextAttributes]);
const localDescription = ref(props.description);
const localRowTemplate = ref(props.rowTemplate);
const localRowTemplateType = ref(props.rowTemplateType);
const rowTemplateTypeOptions = [
{
label: "String",
value: "String",
},
{
label: "JSON",
value: "Json",
},
];
// NOTE: these two messages contain `{name}` / `{timestamp}` — END-USER row
// template syntax, NOT i18n params. Their braces are escaped in en.json
// (`{'{'}` / `{'}'}`) so vue-i18n emits them verbatim instead of trying to
// interpolate them. Advanced.spec.ts asserts the rendered strings.
const rowTemplatePlaceholder = computed(() => {
return localRowTemplateType.value === "Json"
? 'e.g - {"user": "{name}", "timestamp": "{timestamp}"}'
: "e.g - Alert was triggered at {timestamp}";
return rowTemplateTypeValue.value === "Json"
? t("alerts.advanced.rowTemplatePlaceholderJson")
: t("alerts.advanced.rowTemplatePlaceholderString");
});
// Watch for prop changes
watch(
() => props.contextAttributes,
(newVal) => {
localVariables.value = [...newVal];
},
{ deep: true },
);
watch(
() => props.description,
(newVal) => {
localDescription.value = newVal;
},
);
watch(
() => props.rowTemplate,
(newVal) => {
localRowTemplate.value = newVal;
},
);
watch(
() => props.rowTemplateType,
(newVal) => {
localRowTemplateType.value = newVal;
},
);
// ── Field-array add/remove — mutate the form (Rule ①), never a local ref.
// The OForm* fields write their own values; no emit mirror is needed —
// the ONE form is the single source of truth. ───────────────────────────
const addVariable = () => {
localVariables.value.push({
form.pushFieldValue("context_attributes", {
id: getUUID(),
key: "",
value: "",
});
emitUpdate();
};
const removeVariable = (variable: Variable) => {
localVariables.value = localVariables.value.filter(
(v: Variable) => v.id !== variable.id,
);
emitUpdate();
};
const emitUpdate = () => {
emit("update:contextAttributes", localVariables.value);
emit("update:description", localDescription.value);
emit("update:rowTemplate", localRowTemplate.value);
emit("update:rowTemplateType", localRowTemplateType.value);
const removeVariable = (index: number) => {
form.removeFieldValue("context_attributes", index);
};
const helpDrawerOpen = ref(false);
@@ -437,7 +415,7 @@ export default defineComponent({
// Stream field NAMES are known but their runtime values are not, so they
// are passed through and the composable keeps them opaque (not faked).
const previewExtra = computed(() => ({
contextVariables: localVariables.value.reduce(
contextVariables: (variableRows.value || []).reduce(
(acc: Record<string, string>, v: Variable) => {
if (v.key) acc[v.key] = v.value;
return acc;
@@ -450,25 +428,21 @@ export default defineComponent({
}));
const onApplyTemplate = (name: string) => {
localTemplate.value = name;
emitTemplateUpdate();
form.setFieldValue("template", name);
};
return {
t,
store,
localTemplate,
variableRows,
templateValue,
descriptionValue,
rowTemplateValue,
rowTemplateTypeValue,
formattedTemplates,
emitTemplateUpdate,
localVariables,
localDescription,
localRowTemplate,
localRowTemplateType,
rowTemplateTypeOptions,
rowTemplatePlaceholder,
addVariable,
removeVariable,
emitUpdate,
helpDrawerOpen,
helpTopic,
openHelp,
@@ -0,0 +1,142 @@
// 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/>.
// Validation rules for the AlertSettings step of AddAlert. The step is a
// DESCENDANT of the ONE AddAlert <OForm>; these fragments are composed into
// the orchestrator schema (AddAlert.schema.ts). SCOPED STRICTLY to this step's
// OWN fields:
// - trigger_condition.silence (cooldown, minutes) — required, ≥ 0
// - trigger_condition.period (evaluation window) — required, ≥ 1
// (SCHEDULED-only, see below)
// - destinations (multi-select) — at least one required
// - creates_incident (switch) — optional boolean
//
// The cross-step threshold / promql / aggregation / group_by / frequency / cron
// checks that used to live in the component's hand-rolled validate() are NOT
// here — they belong to QueryConfig / the AddAlert orchestrator (see the
// "CROSS-STEP CHECKS TO REHOME" note in the migration report). Do not add them.
//
// Number inputs emit STRINGS out of OFormInput → `z.coerce.number()`
// (playbook §2). TanStack does NOT write the coerced value back onto the field,
// so the component re-coerces (`Number(...)`) when it emits the payload up so the
// parent's trigger_condition keeps number types (Rule ④ payload parity).
import { z } from "zod";
import type { Translator } from "./QueryConfig.schema";
// Validation messages are i18n-driven — `alerts.validation.*` locale keys,
// resolved via the injected `t`. English values are byte-for-byte the
// pre-migration strings (parity). Parity map:
// silence ≥ 0 → alerts.validation.silenceNonNegative
// period ≥ 1 → alerts.validation.periodPositive
// destinations ≥ 1 → alerts.validation.destinationRequired
// ── Composable field FRAGMENTS (factories over the injected `t`) ─────────────
// Exposed so the AddAlert orchestrator schema can compose the SAME rules into
// its (bigger) alert schema — call these instead of re-declaring the rules, so
// the step and the orchestrator can never drift.
//
// import {
// makeSilenceSchema,
// makePeriodSchema,
// makeDestinationsSchema,
// alertSettingsCreatesIncidentSchema,
// } from "./steps/AlertSettings.schema";
//
// The orchestrator applies the period rule MODE-CONDITIONALLY (scheduled only)
// via `createAlertSettingsSchema(t, isRealTime)`.
/** True when the RAW input value is empty (unset). Mirrors the `isBlank` helper
* in QueryConfig.schema.ts (kept local — that one is module-private there). */
const isBlank = (v: unknown): boolean =>
v === undefined || v === null || v === "";
/** silence ≥ 0 (required). Pre-migration: `silence < 0 || undefined/null/''`
* → invalid, in BOTH realtime and scheduled modes.
*
* ⚠️ Checked on the RAW value — NOT `z.coerce.number()`. Because
* `Number("") === 0`, a coerced blank would PASS `.min(0)` (0 is a legal
* silence!), save, and then `parseInt("")` in getAlertPayload → `NaN` →
* serialised to `null`. Zero-safety is exactly why this rule cannot coerce:
* it must tell "" apart from 0. Same trap documented in
* QueryConfig.schema.ts (see its `isBlank` / `isBelowOne` note). */
export const makeSilenceSchema = (t: Translator) =>
z
.unknown()
.refine(
(v) => !isBlank(v) && !Number.isNaN(Number(v)) && Number(v) >= 0,
t("alerts.validation.silenceNonNegative"),
)
// Output stays `number` (identical to the `z.coerce.number()` this replaces)
// so composed z.infer types are unchanged. Inert at runtime — TanStack does
// not write a validator's parsed output back onto the field.
.transform((v) => Number(v));
/** period ≥ 1 (required). Pre-migration this rule applied in the SCHEDULED
* branch only (the period input only renders for scheduled alerts). Compose it
* conditionally in the orchestrator; standalone AlertSettings applies it via
* `createAlertSettingsSchema(t, isRealTime)` below. */
export const makePeriodSchema = (t: Translator) =>
z.coerce.number().min(1, t("alerts.validation.periodPositive"));
/** destinations: at least one required. Elements are destination NAME strings
* (the OSelect options are `formattedDestinations` = array of names). */
export const makeDestinationsSchema = (t: Translator) =>
z.array(z.string()).min(1, t("alerts.validation.destinationRequired"));
/** creates_incident: optional boolean (no pre-migration rule). */
export const alertSettingsCreatesIncidentSchema = z.boolean().optional();
/**
* Shape object for composition — spread into a bigger `z.object({ ... })` so the
* alert schema reuses the EXACT same field rules. The period rule is
* unconditional here; the orchestrator overrides it with a mode-conditional
* (scheduled-only) refinement.
*/
export const makeAlertSettingsShape = (t: Translator) =>
({
trigger_condition: z.object({
silence: makeSilenceSchema(t),
period: makePeriodSchema(t),
}),
destinations: makeDestinationsSchema(t),
creates_incident: alertSettingsCreatesIncidentSchema,
}) as const;
// ── Mode-conditional composite ──────────────────────────────────────────────
/**
* The AlertSettings rule set, composed VERBATIM into the AddAlert orchestrator
* schema (AddAlert.schema.ts superRefine) — the step itself is a DESCENDANT of
* the ONE AddAlert form and no longer owns an <OForm>.
*
* `isRealTime` gates the period rule EXACTLY like the pre-migration code: the
* period input renders (and is validated ≥ 1) only for scheduled alerts. In
* realtime mode period is not rendered and keeps its default value, so it is a
* plain `z.coerce.number()` with no min (no newly-tightened rule — Rule ④).
*/
export const createAlertSettingsSchema = (t: Translator, isRealTime: boolean) =>
z.object({
trigger_condition: z.object({
silence: makeSilenceSchema(t),
period: isRealTime ? z.coerce.number() : makePeriodSchema(t),
}),
destinations: makeDestinationsSchema(t),
creates_incident: alertSettingsCreatesIncidentSchema,
});
export type AlertSettingsForm = z.infer<
ReturnType<typeof createAlertSettingsSchema>
>;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -15,7 +15,11 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
import { defineComponent } from "vue";
import { z } from "zod";
import CompareWithPast from "./CompareWithPast.vue";
import OForm from "@/lib/forms/Form/OForm.vue";
import { useOForm } from "@/lib/forms/Form/useOForm";
import i18n from "@/locales";
@@ -590,3 +594,106 @@ describe("CompareWithPast.vue", () => {
});
});
});
// ── DESCENDANT mode: bridged into an ancestor AddAlert-like <OForm> ──────────
// CustomDateTimePicker is a genuine NON-form widget (kept bare), so the
// multi_time_range array is BRIDGED into the ONE form via setFieldValue (no
// emit). An owner host mirrors AddAlert: it owns the form and feeds the row
// array back down as the prop so the round-trip closes.
describe("CompareWithPast — descendant (bridged into ancestor OForm) mode", () => {
const dtStore = () => ({
state: { theme: "light" },
dispatch: vi.fn(),
commit: vi.fn(),
});
function makeHost(initialRows: any[] = []) {
return defineComponent({
components: { OForm, CompareWithPast },
setup() {
const form = useOForm({
defaultValues: {
query_condition: { multi_time_range: initialRows },
},
schema: z.looseObject({
query_condition: z.looseObject({}).optional(),
}),
onSubmit: () => {},
});
// Feed the form's array back down as the prop (mirrors AddAlert's
// :multiTimeRange="formData.query_condition.multi_time_range").
const rows = form.useStore(
(s: any) => s.values.query_condition.multi_time_range,
);
return { form, rows };
},
template: `
<OForm :form="form">
<CompareWithPast
:multi-time-range="rows"
:selected-tab="'sql'"
:period="10"
:frequency="10"
/>
</OForm>
`,
});
}
function mountHost(initialRows: any[] = []) {
return mount(makeHost(initialRows), {
global: {
mocks: { $store: dtStore() },
provide: { store: dtStore() },
plugins: [i18n],
},
});
}
it("does NOT render its own <OForm> and writes adds into the PARENT form (no emit)", async () => {
const host = mountHost();
expect(host.findAllComponents({ name: "OForm" }).length).toBe(1);
const parentForm = (host.vm as any).form;
host.findComponent(CompareWithPast).vm.addTimeShift();
await flushPromises();
expect(parentForm.state.values.query_condition.multi_time_range.length).toBe(
1,
);
expect(
parentForm.state.values.query_condition.multi_time_range[0].offSet,
).toBe("15m");
// form-owned in descendant mode → the bare-mode emit does NOT fire
expect(
host.findComponent(CompareWithPast).emitted("update:multiTimeRange"),
).toBeFalsy();
});
it("deleting a NON-last window leaves the PARENT form + rendered pickers correct", async () => {
const host = mountHost([
{ offSet: "15m", uuid: "u1" },
{ offSet: "30m", uuid: "u2" },
{ offSet: "45m", uuid: "u3" },
]);
const parentForm = (host.vm as any).form;
const renderedOffsets = () =>
host
.findAllComponents({ name: "CustomDateTimePicker" })
.map((c: any) => c.props("picker").offSet);
expect(renderedOffsets()).toEqual(["15m", "30m", "45m"]);
// delete the MIDDLE window
host.findComponent(CompareWithPast).vm.removeTimeShift(1);
await flushPromises();
expect(
parentForm.state.values.query_condition.multi_time_range.map(
(r: any) => r.offSet,
),
).toEqual(["15m", "45m"]);
expect(renderedOffsets()).toEqual(["15m", "45m"]);
});
});
@@ -76,6 +76,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<!-- Reference Windows List -->
<!-- Rule ① note: `:key` stays the row UUID (NOT the array index) BECAUSE
the only per-row control is CustomDateTimePicker — a genuine non-form
widget bound by OBJECT reference (`v-model="picker.offSet"`), not by an
index-based OForm* `name=`. The mid-list-delete index bug that forces
`:key="index"` on OForm* field-arrays therefore does not apply here.
The multi_time_range array is bridged into the ONE form via
setFieldValue (descendant) / emit (bare) — see commit(). -->
<div
v-for="(picker, index) in localMultiTimeRange"
:key="picker.uuid"
@@ -170,7 +177,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<script lang="ts">
import { defineComponent, ref, watch, computed, type PropType } from "vue";
import {
defineComponent,
ref,
watch,
computed,
inject,
type PropType,
} from "vue";
import { useI18n } from "vue-i18n";
import { useStore } from "vuex";
import { getUUID } from "@/utils/zincutils";
@@ -178,6 +192,7 @@ import CustomDateTimePicker from "@/components/CustomDateTimePicker.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { FORM_CONTEXT_KEY } from "@/lib/forms/Form/OForm.types";
interface TimeShiftPicker {
offSet: string;
@@ -223,18 +238,47 @@ export default defineComponent({
const { t } = useI18n();
const store = useStore();
const multiWindowContainerRef = ref<HTMLElement | null>(null);
const localMultiTimeRange = ref<TimeShiftPicker[]>([...(props.multiTimeRange || [])]);
// ── inject-or-own (Rule ③) ─────────────────────────────────────────────
// CompareWithPast has NO OForm* fields — its only per-row control is the
// CustomDateTimePicker, a genuine NON-form widget (a relative/absolute
// date-time dropdown, NOT a plain input). Per Rule ② it stays BARE and its
// value is BRIDGED into the form. When a parent OForm exists (DESCENDANT
// mode) we write the multi_time_range array straight into that ONE form via
// setFieldValue; otherwise we keep the pre-migration emit (bare) so the
// parent's @update:multiTimeRange→setF bridge stays the write path.
const injectedForm = inject(FORM_CONTEXT_KEY, null);
const hasParentForm = !!injectedForm;
// Watch for prop changes
const multiWindowContainerRef = ref<HTMLElement | null>(null);
// Deep-clone rows into a LOCAL working copy so the bare CustomDateTimePicker
// mutates real local reactive objects, never the form's internal state.
const cloneRows = (rows: TimeShiftPicker[] | undefined): TimeShiftPicker[] =>
(rows || []).map((p) => ({ offSet: p.offSet, uuid: p.uuid }));
const localMultiTimeRange = ref<TimeShiftPicker[]>(
cloneRows(props.multiTimeRange),
);
// Watch for prop changes (props.multiTimeRange is fed FROM the form in
// descendant mode, so this keeps the local working copy in sync both ways).
watch(
() => props.multiTimeRange,
(newVal) => {
localMultiTimeRange.value = [...(newVal || [])];
localMultiTimeRange.value = cloneRows(newVal);
},
{ deep: true }
);
// Single write path: form (descendant) or emit (bare). Fresh clones so the
// form never shares mutable refs with the local working copy.
const commit = () => {
const rows = cloneRows(localMultiTimeRange.value);
if (hasParentForm) {
injectedForm!.setFieldValue("query_condition.multi_time_range", rows);
} else {
emit("update:multiTimeRange", rows);
}
};
// Check if comparison window should be disabled (only SQL mode supports comparison)
const isComparisonDisabled = computed(() => {
return props.selectedTab !== "sql";
@@ -253,35 +297,39 @@ export default defineComponent({
uuid: getUUID(),
};
localMultiTimeRange.value.push(newTimeShift);
emit("update:multiTimeRange", localMultiTimeRange.value);
commit();
};
const removeTimeShift = (index: number) => {
localMultiTimeRange.value.splice(index, 1);
emit("update:multiTimeRange", localMultiTimeRange.value);
commit();
};
const updateDateTimePicker = () => {
emit("update:multiTimeRange", localMultiTimeRange.value);
commit();
};
const getDisplayValue = (value: string) => {
const relativePeriods = [
{ label: "Second(s)", value: "s" },
{ label: "Minute(s)", value: "m" },
{ label: "Hour(s)", value: "h" },
{ label: "Day(s)", value: "d" },
{ label: "Week(s)", value: "w" },
{ label: "Month(s)", value: "M" },
];
// Static unit labels for the offset display ("15m" → "15 Minute(s)"). These
// are NOT counted nouns — the literal "(s)" is part of the label — so they
// stay plain keys and are deliberately NOT pluralized (unlike the *Count
// keys used by convertMinutesToDisplayValue below).
const relativePeriods = computed(() => [
{ label: t("alerts.compareWithPast.periodSeconds"), value: "s" },
{ label: t("alerts.compareWithPast.periodMinutes"), value: "m" },
{ label: t("alerts.compareWithPast.periodHours"), value: "h" },
{ label: t("alerts.compareWithPast.periodDays"), value: "d" },
{ label: t("alerts.compareWithPast.periodWeeks"), value: "w" },
{ label: t("alerts.compareWithPast.periodMonths"), value: "M" },
]);
const getDisplayValue = (value: string) => {
if (typeof value !== 'string') return value;
const match = value.match(/^(\d+)([smhdwM])$/);
if (!match) return value;
const [, numberPart, unitPart] = match;
const period = relativePeriods.find((p) => p.value === unitPart);
const period = relativePeriods.value.find((p) => p.value === unitPart);
if (period) {
return `${numberPart} ${period.label}`;
@@ -290,21 +338,22 @@ export default defineComponent({
return value;
};
// Counted nouns → real vue-i18n plural forms ("{n} Minute | {n} Minutes"),
// called as t(key, n). This replaces the hand-rolled `+ 's'` English
// pluralization; output is byte-identical for every n the UI can produce
// (vue-i18n picks the singular form only for n === 1, matching the old
// `n !== 1 ? 's' : ''`, including n === 0 → "0 Minutes").
const convertMinutesToDisplayValue = (minutes: number) => {
if (minutes < 60) {
return `${minutes} Minute${minutes !== 1 ? 's' : ''}`;
return t("alerts.compareWithPast.minuteCount", minutes);
} else if (minutes < 1440) {
const hours = Math.floor(minutes / 60);
return `${hours} Hour${hours !== 1 ? 's' : ''}`;
return t("alerts.compareWithPast.hourCount", Math.floor(minutes / 60));
} else if (minutes < 10080) {
const days = Math.floor(minutes / 1440);
return `${days} Day${days !== 1 ? 's' : ''}`;
return t("alerts.compareWithPast.dayCount", Math.floor(minutes / 1440));
} else if (minutes < 43200) {
const weeks = Math.floor(minutes / 10080);
return `${weeks} Week${weeks !== 1 ? 's' : ''}`;
return t("alerts.compareWithPast.weekCount", Math.floor(minutes / 10080));
} else {
const months = Math.floor(minutes / 43200);
return `${months} Month${months !== 1 ? 's' : ''}`;
return t("alerts.compareWithPast.monthCount", Math.floor(minutes / 43200));
}
};
@@ -12,582 +12,170 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mount, VueWrapper } from "@vue/test-utils";
import { nextTick } from "vue";
// Behavior spec for the Deduplication step. The step is a DESCENDANT of the
// ONE AddAlert <OForm>: an ancestor form provides FORM_CONTEXT_KEY, the fields
// bind `deduplication.*` by nested `name=` into it, and flushDedup writes the
// DERIVED `enabled` + SANITIZED `time_window_minutes` back into the parent
// form (Rule ④ payload parity — getAlertPayload reads the raw form values).
import { describe, it, expect } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { defineComponent, nextTick } from "vue";
import { z } from "zod";
import Deduplication from "./Deduplication.vue";
import OForm from "@/lib/forms/Form/OForm.vue";
import { deduplicationSchema } from "@/components/alerts/AddAlert.schema";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
const globalCfg = { global: { plugins: [i18n, store] } };
// Mock store
const createMockStore = (overrides = {}) => ({
state: {
theme: "light",
zoConfig: {
build_type: "opensource",
// ── DESCENDANT host (binds into an ancestor OForm — the app wiring) ─────────
function makeDescendantHost(dedupDefaults: Record<string, any> = {}) {
return defineComponent({
components: { OForm, Deduplication },
setup() {
const schema = z.looseObject({
deduplication: deduplicationSchema.optional(),
});
const defaultValues = {
deduplication: {
enabled: false,
fingerprint_fields: [] as string[],
time_window_minutes: undefined as number | undefined,
...dedupDefaults,
},
};
return { schema, defaultValues };
},
...overrides,
},
dispatch: vi.fn(),
commit: vi.fn(),
});
template: `
<OForm :schema="schema" :default-values="defaultValues" @submit="() => {}">
<Deduplication :deduplication="defaultValues.deduplication" :columns="['field1','field2','field3']" />
</OForm>
`,
});
}
describe("Deduplication.vue", () => {
let wrapper: VueWrapper<any>;
let mockStore: any;
function mountDescendant(dedupDefaults: Record<string, any> = {}) {
return mount(makeDescendantHost(dedupDefaults), globalCfg);
}
beforeEach(() => {
mockStore = createMockStore();
const hostForm = (host: any) =>
(host.findComponent({ name: "OForm" }).vm as any).form;
wrapper = mount(Deduplication, {
global: {
mocks: {
$store: mockStore,
},
provide: {
store: mockStore,
},
plugins: [i18n],
},
props: {
deduplication: {
enabled: true,
fingerprint_fields: [],
time_window_minutes: undefined,
},
columns: ["field1", "field2", "field3"],
},
});
/** flushDedup runs on a microtask after field.handleChange — settle both. */
const settle = async () => {
await flushPromises();
await nextTick();
await flushPromises();
};
describe("Deduplication — descendant (binds into ancestor OForm) mode", () => {
it("does NOT render its own <OForm>", () => {
const host = mountDescendant();
expect(host.findAllComponents({ name: "OForm" }).length).toBe(1);
expect(host.findComponent(Deduplication).exists()).toBe(true);
});
afterEach(() => {
if (wrapper) {
wrapper.unmount();
}
it("preserves data-tests", () => {
const host = mountDescendant();
expect(
host.find('[data-test="alert-dedup-fingerprint-fields"]').exists(),
).toBe(true);
expect(host.find('[data-test="alert-dedup-time-window"]').exists()).toBe(
true,
);
});
describe("Initialization", () => {
it("should mount component successfully", () => {
expect(wrapper.exists()).toBe(true);
});
it("selecting fields writes fingerprint_fields + derived enabled into the PARENT form", async () => {
const host = mountDescendant();
const parentForm = hostForm(host);
it("should render with correct theme class (light mode)", () => {
expect(wrapper.find(".step-deduplication.light-mode").exists()).toBe(
true
);
});
host
.findComponent('[data-test="alert-dedup-fingerprint-fields"]')
.vm.$emit("update:model-value", ["field1"]);
await settle();
it("should render with correct theme class (dark mode)", async () => {
const darkStore = createMockStore({ theme: "dark" });
const darkWrapper = mount(Deduplication, {
global: {
mocks: { $store: darkStore },
provide: { store: darkStore },
plugins: [i18n],
},
props: {
deduplication: {
enabled: true,
fingerprint_fields: [],
time_window_minutes: undefined,
},
columns: [],
},
});
expect(darkWrapper.find(".step-deduplication.dark-mode").exists()).toBe(
true
);
darkWrapper.unmount();
});
it("should initialize with empty fingerprint fields", () => {
expect(wrapper.vm.localDeduplication.fingerprint_fields).toEqual([]);
});
it("should initialize with undefined time window", () => {
expect(wrapper.vm.localDeduplication.time_window_minutes).toBeUndefined();
});
it("should initialize enabled as false when no fingerprint fields", () => {
expect(wrapper.vm.localDeduplication.enabled).toBe(false);
});
it("should initialize with provided fingerprint fields", async () => {
const wrapperWithFields = mount(Deduplication, {
global: {
mocks: { $store: mockStore },
provide: { store: mockStore },
plugins: [i18n],
},
props: {
deduplication: {
enabled: true,
fingerprint_fields: ["field1", "field2"],
time_window_minutes: 10,
},
columns: ["field1", "field2"],
},
});
expect(
wrapperWithFields.vm.localDeduplication.fingerprint_fields
).toEqual(["field1", "field2"]);
expect(
wrapperWithFields.vm.localDeduplication.time_window_minutes
).toBe(10);
wrapperWithFields.unmount();
});
expect(parentForm.state.values.deduplication.fingerprint_fields).toEqual([
"field1",
]);
expect(parentForm.state.values.deduplication.enabled).toBe(true);
// descendant mode is form-owned → no emit
expect(
host.findComponent(Deduplication).emitted("update:deduplication"),
).toBeFalsy();
});
describe("Props", () => {
it("should accept deduplication prop", () => {
expect(wrapper.props().deduplication).toBeDefined();
it("clearing all fields derives enabled=false in the PARENT form", async () => {
const host = mountDescendant({
enabled: true,
fingerprint_fields: ["field1"],
});
const parentForm = hostForm(host);
it("should accept columns prop", () => {
expect(wrapper.props().columns).toEqual(["field1", "field2", "field3"]);
});
host
.findComponent('[data-test="alert-dedup-fingerprint-fields"]')
.vm.$emit("update:model-value", []);
await settle();
it("should use default deduplication values", () => {
const defaultWrapper = mount(Deduplication, {
global: {
mocks: { $store: mockStore },
provide: { store: mockStore },
plugins: [i18n],
},
props: {
columns: [],
},
});
expect(defaultWrapper.props().deduplication).toBeDefined();
expect(defaultWrapper.props().deduplication.enabled).toBe(true);
expect(defaultWrapper.props().deduplication.fingerprint_fields).toEqual(
[]
);
defaultWrapper.unmount();
});
it("should use default empty columns array", () => {
const emptyWrapper = mount(Deduplication, {
global: {
mocks: { $store: mockStore },
provide: { store: mockStore },
plugins: [i18n],
},
props: {
deduplication: {
enabled: true,
fingerprint_fields: [],
time_window_minutes: undefined,
},
},
});
expect(emptyWrapper.props().columns).toEqual([]);
emptyWrapper.unmount();
});
it("should handle deduplication prop updates", async () => {
await wrapper.setProps({
deduplication: {
enabled: true,
fingerprint_fields: ["newField"],
time_window_minutes: 20,
},
});
await nextTick();
expect(wrapper.vm.localDeduplication.fingerprint_fields).toEqual([
"newField",
]);
expect(wrapper.vm.localDeduplication.time_window_minutes).toBe(20);
});
it("should handle columns prop updates", async () => {
await wrapper.setProps({
columns: ["new1", "new2"],
});
expect(wrapper.props().columns).toEqual(["new1", "new2"]);
});
expect(parentForm.state.values.deduplication.fingerprint_fields).toEqual(
[],
);
expect(parentForm.state.values.deduplication.enabled).toBe(false);
});
describe("Fingerprint Fields Selection", () => {
it("should update fingerprint fields on selection", async () => {
wrapper.vm.localDeduplication.fingerprint_fields = ["field1", "field2"];
await wrapper.vm.emitUpdate();
it("@create adds the custom value to the PARENT form (duplicate ignored)", async () => {
const host = mountDescendant();
const parentForm = hostForm(host);
expect(wrapper.emitted("update:deduplication")).toBeTruthy();
expect(wrapper.emitted("update:deduplication")![0][0]).toEqual({
enabled: true,
fingerprint_fields: ["field1", "field2"],
time_window_minutes: undefined,
});
});
host
.findComponent('[data-test="alert-dedup-fingerprint-fields"]')
.vm.$emit("create", "customField");
await settle();
it("should handle single fingerprint field", async () => {
wrapper.vm.localDeduplication.fingerprint_fields = ["field1"];
await wrapper.vm.emitUpdate();
expect(parentForm.state.values.deduplication.fingerprint_fields).toEqual([
"customField",
]);
expect(parentForm.state.values.deduplication.enabled).toBe(true);
expect(
wrapper.emitted("update:deduplication")![0][0].fingerprint_fields
).toEqual(["field1"]);
});
it("should handle multiple fingerprint fields", async () => {
wrapper.vm.localDeduplication.fingerprint_fields = [
"field1",
"field2",
"field3",
];
await wrapper.vm.emitUpdate();
expect(
wrapper.emitted("update:deduplication")![0][0].fingerprint_fields
).toEqual(["field1", "field2", "field3"]);
});
it("should handle empty fingerprint fields", async () => {
wrapper.vm.localDeduplication.fingerprint_fields = [];
await wrapper.vm.emitUpdate();
expect(
wrapper.emitted("update:deduplication")![0][0].fingerprint_fields
).toEqual([]);
});
it("should allow adding custom field values", async () => {
wrapper.vm.localDeduplication.fingerprint_fields = ["customField"];
await wrapper.vm.emitUpdate();
expect(
wrapper.emitted("update:deduplication")![0][0].fingerprint_fields
).toEqual(["customField"]);
});
// duplicate create is ignored
host
.findComponent('[data-test="alert-dedup-fingerprint-fields"]')
.vm.$emit("create", "customField");
await settle();
expect(parentForm.state.values.deduplication.fingerprint_fields).toEqual([
"customField",
]);
});
describe("Time Window Management", () => {
it("should update time window minutes", async () => {
wrapper.vm.localDeduplication.time_window_minutes = 15;
await wrapper.vm.emitUpdate();
it("time_window is sanitized to a number in the PARENT form (payload parity)", async () => {
const host = mountDescendant();
const parentForm = hostForm(host);
expect(wrapper.emitted("update:deduplication")).toBeTruthy();
expect(
wrapper.emitted("update:deduplication")![0][0].time_window_minutes
).toBe(15);
});
await host
.find('[data-test="alert-dedup-time-window"] input')
.setValue("20");
// sanitize runs on a microtask after field.handleChange
await settle();
it("should handle zero time window", async () => {
wrapper.vm.localDeduplication.time_window_minutes = 0;
await wrapper.vm.emitUpdate();
expect(
wrapper.emitted("update:deduplication")![0][0].time_window_minutes
).toBe(0);
});
it("should handle undefined time window", async () => {
wrapper.vm.localDeduplication.time_window_minutes = undefined;
await wrapper.vm.emitUpdate();
expect(
wrapper.emitted("update:deduplication")![0][0].time_window_minutes
).toBeUndefined();
});
it("should handle very large time window values", async () => {
wrapper.vm.localDeduplication.time_window_minutes = 99999;
await wrapper.vm.emitUpdate();
expect(
wrapper.emitted("update:deduplication")![0][0].time_window_minutes
).toBe(99999);
});
it("should handle minimum time window value", async () => {
wrapper.vm.localDeduplication.time_window_minutes = 1;
await wrapper.vm.emitUpdate();
expect(
wrapper.emitted("update:deduplication")![0][0].time_window_minutes
).toBe(1);
});
expect(parentForm.state.values.deduplication.time_window_minutes).toBe(20);
expect(
typeof parentForm.state.values.deduplication.time_window_minutes,
).toBe("number");
});
describe("Emit Events", () => {
it("should emit update:deduplication event", async () => {
await wrapper.vm.emitUpdate();
it("an empty time_window is sanitized to undefined in the PARENT form", async () => {
const host = mountDescendant({ time_window_minutes: 10 });
const parentForm = hostForm(host);
expect(wrapper.emitted("update:deduplication")).toBeTruthy();
});
await host
.find('[data-test="alert-dedup-time-window"] input')
.setValue("");
await settle();
it("should emit correct data structure", async () => {
wrapper.vm.localDeduplication = {
enabled: true,
fingerprint_fields: ["field1"],
time_window_minutes: 10,
};
await wrapper.vm.emitUpdate();
const emitted = wrapper.emitted("update:deduplication")![0][0];
expect(emitted).toHaveProperty("enabled");
expect(emitted).toHaveProperty("fingerprint_fields");
expect(emitted).toHaveProperty("time_window_minutes");
});
it("should emit enabled as false when no fingerprint fields", async () => {
wrapper.vm.localDeduplication.fingerprint_fields = [];
await wrapper.vm.emitUpdate();
expect(wrapper.emitted("update:deduplication")![0][0].enabled).toBe(false);
});
it("should emit enabled as true when fingerprint fields are set", async () => {
wrapper.vm.localDeduplication.fingerprint_fields = ["field1"];
await wrapper.vm.emitUpdate();
expect(wrapper.emitted("update:deduplication")![0][0].enabled).toBe(true);
});
it("should emit multiple times on multiple updates", async () => {
await wrapper.vm.emitUpdate();
await wrapper.vm.emitUpdate();
await wrapper.vm.emitUpdate();
expect(wrapper.emitted("update:deduplication")!.length).toBe(3);
});
});
describe("Watcher Behavior", () => {
it("should update local state when deduplication prop changes", async () => {
await wrapper.setProps({
deduplication: {
enabled: true,
fingerprint_fields: ["watchedField"],
time_window_minutes: 25,
},
});
await nextTick();
expect(wrapper.vm.localDeduplication.fingerprint_fields).toEqual([
"watchedField",
]);
expect(wrapper.vm.localDeduplication.time_window_minutes).toBe(25);
});
it("should handle null deduplication prop", async () => {
await wrapper.setProps({
deduplication: null,
});
await nextTick();
// Should not crash
expect(wrapper.exists()).toBe(true);
});
it("should update when fingerprint_fields change", async () => {
await wrapper.setProps({
deduplication: {
enabled: true,
fingerprint_fields: ["field1", "field2"],
time_window_minutes: 10,
},
});
await nextTick();
expect(wrapper.vm.localDeduplication.fingerprint_fields).toEqual([
"field1",
"field2",
]);
});
it("should update when time_window_minutes changes", async () => {
await wrapper.setProps({
deduplication: {
enabled: true,
fingerprint_fields: [],
time_window_minutes: 30,
},
});
await nextTick();
expect(wrapper.vm.localDeduplication.time_window_minutes).toBe(30);
});
});
describe("Edge Cases", () => {
it("should handle empty columns array", async () => {
await wrapper.setProps({ columns: [] });
expect(wrapper.props().columns).toEqual([]);
});
it("should handle very long column names", async () => {
const longColumnName = "a".repeat(500);
await wrapper.setProps({ columns: [longColumnName] });
expect(wrapper.props().columns[0]).toBe(longColumnName);
});
it("should handle special characters in column names", async () => {
const specialColumns = ["field-1", "field_2", "field.3", "field@4"];
await wrapper.setProps({ columns: specialColumns });
expect(wrapper.props().columns).toEqual(specialColumns);
});
it("should handle numeric string as time window", async () => {
wrapper.vm.localDeduplication.time_window_minutes = 123;
await wrapper.vm.emitUpdate();
expect(
wrapper.emitted("update:deduplication")![0][0].time_window_minutes
).toBe(123);
});
it("should handle duplicate fingerprint fields", async () => {
wrapper.vm.localDeduplication.fingerprint_fields = [
"field1",
"field1",
"field2",
];
await wrapper.vm.emitUpdate();
expect(
wrapper.emitted("update:deduplication")![0][0].fingerprint_fields
).toEqual(["field1", "field1", "field2"]);
});
});
describe("UI Rendering", () => {
it("should render fingerprint fields label", () => {
const label = wrapper.find(".font-semibold");
expect(label.exists()).toBe(true);
});
it("should render time window label", () => {
const labels = wrapper.findAll(".font-semibold");
expect(labels.length).toBeGreaterThan(1);
});
it("should render info icons", () => {
// OIcon components render SVGs — check via component lookup
const icons = wrapper.findAllComponents({ name: "OIcon" });
expect(icons.some((i) => /^info/.test(i.props("name") ?? ""))).toBe(true);
});
it("should render hint text for fingerprint fields", () => {
// Hint text is in a slot template, check HTML contains the hint text
const html = wrapper.html();
expect(html).toContain("auto-detect");
});
it("should render minutes label for time window", () => {
const minutesLabel = wrapper.find(".flex.justify-center.items-center");
expect(minutesLabel.exists()).toBe(true);
});
});
describe("Accessibility", () => {
it("should have tooltips for info icons", () => {
const tooltips = wrapper.findAllComponents({ name: "OTooltip" });
expect(tooltips.length).toBeGreaterThanOrEqual(1);
});
it("should have proper structure for form inputs", () => {
const select = wrapper.findComponent({ name: "OSelect" });
expect(select.exists()).toBe(true);
const input = wrapper.findComponent({ name: "OInput" });
expect(input.exists()).toBe(true);
});
});
describe("Boundary Conditions", () => {
it("should handle minimum valid time window (1 minute)", async () => {
wrapper.vm.localDeduplication.time_window_minutes = 1;
await wrapper.vm.emitUpdate();
expect(
wrapper.emitted("update:deduplication")![0][0].time_window_minutes
).toBe(1);
});
it("should handle maximum reasonable time window", async () => {
wrapper.vm.localDeduplication.time_window_minutes = 525600; // 1 year in minutes
await wrapper.vm.emitUpdate();
expect(
wrapper.emitted("update:deduplication")![0][0].time_window_minutes
).toBe(525600);
});
it("should handle empty state", async () => {
wrapper.vm.localDeduplication = {
enabled: true,
fingerprint_fields: [],
time_window_minutes: undefined,
};
await wrapper.vm.emitUpdate();
const emitted = wrapper.emitted("update:deduplication")![0][0];
expect(emitted.fingerprint_fields).toEqual([]);
expect(emitted.time_window_minutes).toBeUndefined();
});
it("should handle fully populated state", async () => {
wrapper.vm.localDeduplication = {
enabled: true,
fingerprint_fields: ["f1", "f2", "f3", "f4", "f5"],
time_window_minutes: 60,
};
await wrapper.vm.emitUpdate();
const emitted = wrapper.emitted("update:deduplication")![0][0];
expect(emitted.fingerprint_fields.length).toBe(5);
expect(emitted.time_window_minutes).toBe(60);
});
});
describe("Negative Cases", () => {
it("should not emit when emitUpdate is not called", () => {
expect(wrapper.emitted("update:deduplication")).toBeFalsy();
});
it("should handle negative time window gracefully", async () => {
wrapper.vm.localDeduplication.time_window_minutes = -5;
await wrapper.vm.emitUpdate();
expect(
wrapper.emitted("update:deduplication")![0][0].time_window_minutes
).toBe(-5);
});
});
describe("Theme Switching", () => {
it("should apply light mode theme", () => {
expect(wrapper.find(".light-mode").exists()).toBe(true);
});
it("should apply dark mode theme", async () => {
const darkStore = createMockStore({ theme: "dark" });
const darkWrapper = mount(Deduplication, {
global: {
mocks: { $store: darkStore },
provide: { store: darkStore },
plugins: [i18n],
},
props: {
deduplication: {
enabled: true,
fingerprint_fields: [],
time_window_minutes: undefined,
},
columns: [],
},
});
expect(darkWrapper.find(".dark-mode").exists()).toBe(true);
darkWrapper.unmount();
});
expect(
parentForm.state.values.deduplication.time_window_minutes,
).toBeUndefined();
});
});
@@ -19,6 +19,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="step-deduplication w-full h-full overflow-auto mx-auto"
:class="store.state.theme === 'dark' ? 'dark-mode' : 'light-mode'"
>
<!-- DESCENDANT step (Rule ③): the AddAlert orchestrator owns the ONE <OForm>
and provides FORM_CONTEXT_KEY. The fields below bind by nested `name=`
(deduplication.fingerprint_fields / .time_window_minutes) into that
form; flushDedup writes the derived `enabled` + sanitized time window
back into it (payload parity). -->
<div>
<div
class="step-content rounded-lg min-h-full bg-[var(--color-surface-overlay)] border border-[var(--color-border-default)]"
>
@@ -60,20 +66,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
{{ t("alerts.deduplication.fingerprintFieldsHint") }}
</div>
<div class="relative">
<OSelect
v-model="localDeduplication.fingerprint_fields"
<OFormSelect
name="deduplication.fingerprint_fields"
:options="props.columns || []"
multiple
creatable
data-test="alert-dedup-fingerprint-fields"
class="max-w-[600px] min-w-[300px]"
helpText="Leave empty to auto-detect based on query (SQL: GROUP BY columns, PromQL: labels, Custom: condition fields)"
@update:model-value="emitUpdate"
:helpText="t('alerts.deduplication.fingerprintFieldsHelp')"
@update:model-value="onFingerprintChange"
@create="addFingerprintField"
/>
<OTooltip
v-if="localDeduplication.fingerprint_fields?.length > 0"
:content="localDeduplication.fingerprint_fields.join(', ')"
v-if="fingerprintFields?.length > 0"
:content="fingerprintFields.join(', ')"
max-width="400px"
/>
</div>
@@ -102,13 +108,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<div class="flex items-center">
<div class="w-[210px] ml-0">
<OInput
v-model="localDeduplication.time_window_minutes"
<OFormInput
name="deduplication.time_window_minutes"
type="number"
min="1"
data-test="alert-dedup-time-window"
:placeholder="t('alerts.placeholders.autoUsesCheckInterval')"
@update:model-value="emitUpdate"
@update:model-value="onTimeWindowChange"
/>
</div>
<div
@@ -128,21 +134,30 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<!-- end px-3 py-2 -->
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, watch, type PropType } from "vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import { defineComponent, inject, type PropType } from "vue";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import OFormSelect from "@/lib/forms/Select/OFormSelect.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { useI18n } from "vue-i18n";
import { useStore } from "vuex";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import { FORM_CONTEXT_KEY } from "@/lib/forms/Form/OForm.types";
/** number | undefined — "" and non-numeric become undefined (payload parity). */
const sanitizeTimeWindow = (val: any): number | undefined => {
if (val == null || val === "") return undefined;
const num = Number(val);
return isNaN(num) ? undefined : num;
};
export default defineComponent({
name: "Step5Deduplication",
components: { OIcon, OInput, OSelect, OTooltip },
components: { OIcon, OFormInput, OFormSelect, OTooltip },
props: {
deduplication: {
type: Object as PropType<any>,
@@ -158,57 +173,60 @@ export default defineComponent({
},
},
emits: ["update:deduplication"],
setup(props, { emit }) {
setup(props) {
const { t } = useI18n();
const store = useStore();
const localDeduplication = ref({
enabled: (props.deduplication?.fingerprint_fields?.length ?? 0) > 0,
fingerprint_fields: props.deduplication?.fingerprint_fields || [],
time_window_minutes:
props.deduplication?.time_window_minutes || undefined,
});
// DESCENDANT step (Rule ③): the AddAlert orchestrator provides
// FORM_CONTEXT_KEY — the ONE form the fields bind into by nested `name=`.
const form: any = inject(FORM_CONTEXT_KEY, null);
// Watch for prop changes
watch(
() => props.deduplication,
(newVal) => {
if (newVal) {
const fields = newVal.fingerprint_fields || [];
localDeduplication.value = {
enabled: fields.length > 0,
fingerprint_fields: fields,
time_window_minutes: newVal.time_window_minutes || undefined,
};
}
},
{ deep: true },
// Reactive view of the selected fingerprint fields (tooltip + enabled derive).
const fingerprintFields = form.useStore(
(s: any) =>
(s.values?.deduplication?.fingerprint_fields ?? []) as string[],
);
const sanitizeTimeWindow = (val: any): number | undefined => {
if (val == null || val === "") return undefined;
const num = Number(val);
return isNaN(num) ? undefined : num;
};
const emitUpdate = () => {
const hasFields = localDeduplication.value.fingerprint_fields?.length > 0;
emit("update:deduplication", {
enabled: hasFields,
fingerprint_fields: localDeduplication.value.fingerprint_fields,
time_window_minutes: sanitizeTimeWindow(
localDeduplication.value.time_window_minutes,
),
// ── flushDedup — the single derive/sanitize step. Deferred to a microtask
// so it runs AFTER the OForm* field.handleChange has stored the raw value
// (their handler order is not guaranteed), making it fully
// order-independent. It derives `enabled` (fingerprint_fields.length > 0)
// and sanitizes `time_window_minutes` to number|undefined, writing both
// back into the ONE form so getAlertPayload (which reads raw form
// values) keeps payload parity. ─────────────────────────────────────────
const flushDedup = () => {
Promise.resolve().then(() => {
const d = (form.getFieldValue("deduplication") as any) ?? {};
const fields: string[] = Array.isArray(d.fingerprint_fields)
? d.fingerprint_fields
: [];
const enabled = fields.length > 0;
const cleanWindow = sanitizeTimeWindow(d.time_window_minutes);
if (d.enabled !== enabled)
form.setFieldValue("deduplication.enabled", enabled);
if (cleanWindow !== d.time_window_minutes)
form.setFieldValue(
"deduplication.time_window_minutes",
cleanWindow,
);
});
};
const onFingerprintChange = () => flushDedup();
const onTimeWindowChange = () => flushDedup();
// @create only NOTIFIES (OSelect does not add the created term to the model),
// so add it here — matching the pre-migration addFingerprintField.
const addFingerprintField = (value: string) => {
if (!localDeduplication.value.fingerprint_fields) {
localDeduplication.value.fingerprint_fields = [];
}
if (!localDeduplication.value.fingerprint_fields.includes(value)) {
localDeduplication.value.fingerprint_fields.push(value);
emitUpdate();
const current =
(form.getFieldValue("deduplication.fingerprint_fields") as string[]) ??
[];
if (!current.includes(value)) {
form.setFieldValue("deduplication.fingerprint_fields", [
...current,
value,
]);
flushDedup();
}
};
@@ -216,8 +234,9 @@ export default defineComponent({
t,
store,
props,
localDeduplication,
emitUpdate,
fingerprintFields,
onFingerprintChange,
onTimeWindowChange,
addFingerprintField,
};
},
@@ -0,0 +1,708 @@
// Copyright 2026 OpenObserve Inc.
//
// Schema-level tests for the conditions-tree pieces of QueryConfig.schema.ts
// (alerts-migration.md §A item 4): the recursive refineConditionsTree walker +
// the loose node schemas. Component-level (real <OForm>) coverage lives in
// FilterCondition.spec.ts / FilterGroup.spec.ts.
import { describe, it, expect } from "vitest";
import { z } from "zod";
import i18n from "@/locales";
import {
conditionGroupNodeSchema,
isConditionGroupNode,
refineConditionsTree,
makeConditionsTreeSchema,
makeQueryConfigSchema,
queryConfigDefaults,
type QueryConfigMeta,
} from "./QueryConfig.schema";
// Messages are i18n-driven now — resolve them through the real locale so the
// assertions verify the exact English (parity) AND that the keys exist.
const t = (key: string, named?: Record<string, unknown>): string =>
(i18n.global.t as any)(key, named);
const conditionsTreeSchema = makeConditionsTreeSchema(t);
const queryConfigSchema = makeQueryConfigSchema(t);
const CONDITION_REQUIRED_MESSAGE = t("alerts.validation.fieldRequired");
const THRESHOLD_REQUIRED_MESSAGE = t("alerts.validation.thresholdPositive");
const FREQUENCY_REQUIRED_MESSAGE = t("alerts.validation.frequencyPositive");
const CONDITION_VALUE_REQUIRED_MESSAGE = t("alerts.validation.fieldRequired");
const AGGREGATION_COLUMN_REQUIRED_MESSAGE = t(
"alerts.validation.aggregationColumnRequired",
);
const PROMQL_OPERATOR_REQUIRED_MESSAGE = t("alerts.validation.fieldRequired");
const PROMQL_VALUE_REQUIRED_MESSAGE = t("alerts.validation.fieldRequired");
// Builders matching the LIVE shapes FilterGroup.addCondition / addGroup seed.
const leaf = (overrides: Record<string, unknown> = {}) => ({
filterType: "condition",
column: "field1",
operator: "=",
value: "x",
values: [],
logicalOperator: "AND",
id: "leaf-id",
...overrides,
});
const group = (
conditions: unknown[],
overrides: Record<string, unknown> = {},
) => ({
filterType: "group",
logicalOperator: "AND",
groupId: "group-id",
conditions,
...overrides,
});
const issuesOf = (result: { success: boolean; error?: any }) =>
result.success ? [] : result.error.issues;
describe("QueryConfig.schema — conditions tree", () => {
describe("conditionsTreeSchema (root walker, basePath = [])", () => {
it("passes an EMPTY tree — no rows adds no issues (alerts save with zero conditions)", () => {
expect(conditionsTreeSchema.safeParse(group([])).success).toBe(true);
// A root with no conditions key at all is also clean.
expect(
conditionsTreeSchema.safeParse({ filterType: "group" }).success,
).toBe(true);
});
it("passes a complete condition row", () => {
expect(conditionsTreeSchema.safeParse(group([leaf()])).success).toBe(
true,
);
});
it("fails a partial row at the RIGHT nested paths (column + value empty on row 1)", () => {
const result = conditionsTreeSchema.safeParse(
group([leaf(), leaf({ column: "", value: "" })]),
);
expect(result.success).toBe(false);
const issues = issuesOf(result);
expect(issues).toHaveLength(2);
expect(issues[0]).toMatchObject({
path: ["conditions", 1, "column"],
message: CONDITION_REQUIRED_MESSAGE,
});
expect(issues[1]).toMatchObject({
path: ["conditions", 1, "value"],
message: CONDITION_REQUIRED_MESSAGE,
});
});
it("fails a missing operator at its exact path", () => {
const result = conditionsTreeSchema.safeParse(
group([leaf({ operator: "" })]),
);
expect(result.success).toBe(false);
const issues = issuesOf(result);
expect(issues).toHaveLength(1);
expect(issues[0]).toMatchObject({
path: ["conditions", 0, "operator"],
message: CONDITION_REQUIRED_MESSAGE,
});
});
it("value is ZERO-SAFE: numeric 0 passes", () => {
expect(
conditionsTreeSchema.safeParse(group([leaf({ value: 0 })])).success,
).toBe(true);
});
it('value fails ONLY on undefined / null / ""', () => {
for (const bad of ["", null, undefined]) {
const result = conditionsTreeSchema.safeParse(
group([leaf({ value: bad })]),
);
expect(result.success).toBe(false);
const issues = issuesOf(result);
expect(issues).toHaveLength(1);
expect(issues[0]).toMatchObject({
path: ["conditions", 0, "value"],
message: CONDITION_REQUIRED_MESSAGE,
});
}
// Other falsy-but-present values pass (zero-safe, not truthy-required).
expect(
conditionsTreeSchema.safeParse(group([leaf({ value: false })])).success,
).toBe(true);
});
it("recurses into nested groups — issue paths carry the exact nested indices", () => {
const tree = group([
leaf(),
group([leaf(), leaf({ column: "" })], { groupId: "nested-group" }),
]);
const result = conditionsTreeSchema.safeParse(tree);
expect(result.success).toBe(false);
const issues = issuesOf(result);
expect(issues).toHaveLength(1);
expect(issues[0]).toMatchObject({
path: ["conditions", 1, "conditions", 1, "column"],
message: CONDITION_REQUIRED_MESSAGE,
});
});
});
describe("refineConditionsTree with a real basePath (the future query_condition wiring)", () => {
const hostSchema = z
.object({
query_condition: z.object({ conditions: conditionGroupNodeSchema }),
})
.superRefine((val, ctx) =>
refineConditionsTree(
val.query_condition.conditions,
ctx,
["query_condition", "conditions"],
CONDITION_REQUIRED_MESSAGE,
),
);
it("prefixes issue paths with the basePath", () => {
const result = hostSchema.safeParse({
query_condition: { conditions: group([leaf({ operator: "" })]) },
});
expect(result.success).toBe(false);
expect(issuesOf(result)[0]).toMatchObject({
path: ["query_condition", "conditions", "conditions", 0, "operator"],
message: CONDITION_REQUIRED_MESSAGE,
});
});
it("adds no issues for an empty tree under the basePath", () => {
expect(
hostSchema.safeParse({ query_condition: { conditions: group([]) } })
.success,
).toBe(true);
});
});
// ════════════════════════════════════════════════════════════════════════
// Full form schema (scalars, §4 PromQL restores, REHOMED cross-step checks)
// ════════════════════════════════════════════════════════════════════════
describe("queryConfigSchema — mode-discriminated form rules", () => {
const meta = (overrides: Partial<QueryConfigMeta> = {}): QueryConfigMeta => ({
tab: "custom",
isRealTime: "false",
isEventBased: true,
selectedFunction: "total_events",
frequencyMode: "minutes",
hasConditions: false,
hasGroupBy: false,
aggregationEnabled: false,
minAutoRefreshInterval: 0,
...overrides,
});
const form = (overrides: Record<string, any> = {}) => ({
trigger_condition: {
operator: ">=",
threshold: 3,
// STORED minutes. The frequency RULES key off `_ui.checkEvery` (the
// display value bound to the visible input) — see below.
frequency: 10,
...(overrides.trigger_condition || {}),
},
query_condition: {
conditions: group([]),
aggregation: null,
promql_condition: null,
...(overrides.query_condition || {}),
},
logGroupBy: overrides.logGroupBy ?? [],
// DISPLAY value: equals the stored minutes in minutes mode, stored/60 in
// hours mode.
_ui: { checkEvery: 10, ...(overrides._ui || {}) },
_meta: meta(overrides._meta),
});
const parse = (overrides: Record<string, any> = {}) =>
queryConfigSchema.safeParse(form(overrides));
const paths = (r: any) =>
r.success ? [] : r.error.issues.map((i: any) => i.path.join("."));
it("a valid scheduled count alert passes", () => {
expect(parse().success).toBe(true);
});
// ── Threshold (REHOMED, scheduled) ──────────────────────────────────────
it("blocks empty threshold (scheduled)", () => {
const r = parse({ trigger_condition: { threshold: "" } });
expect(r.success).toBe(false);
expect(paths(r)).toContain("trigger_condition.threshold");
expect(r.error!.issues[0].message).toBe(THRESHOLD_REQUIRED_MESSAGE);
});
it("blocks threshold < 1 and threshold 0 (scheduled)", () => {
expect(parse({ trigger_condition: { threshold: 0 } }).success).toBe(false);
expect(parse({ trigger_condition: { threshold: "0" } }).success).toBe(false);
});
it("coerces a numeric STRING threshold — '5' passes", () => {
expect(parse({ trigger_condition: { threshold: "5" } }).success).toBe(true);
});
it("does NOT require threshold in realtime mode", () => {
const r = parse({
trigger_condition: { threshold: "" },
_meta: { isRealTime: "true" },
});
expect(paths(r)).not.toContain("trigger_condition.threshold");
});
// ── Frequency (REHOMED, scheduled, non-cron) ────────────────────────────
// The rule keys off the DISPLAY value (`_ui.checkEvery`) because that is the
// path the visible "Check every" input binds, so the message renders inline.
it("blocks empty frequency when not cron", () => {
const r = parse({ _ui: { checkEvery: "" } });
expect(paths(r)).toContain("_ui.checkEvery");
expect(
r.error!.issues.find((i: any) => i.path.join(".") === "_ui.checkEvery")!
.message,
).toBe(FREQUENCY_REQUIRED_MESSAGE);
});
it("does NOT require frequency in cron mode (cronError owns it)", () => {
const r = parse({
_ui: { checkEvery: "" },
_meta: { frequencyMode: "cron" },
});
expect(paths(r)).not.toContain("_ui.checkEvery");
});
// ── Org min-frequency floor (R5 RESTORE) ────────────────────────────────
// Pre-migration AlertSettings.validateFrequency compared the STORED minutes
// against ceil(min_auto_refresh_interval / 60) and blocked save.
const FLOOR_MSG = (mins: number) =>
"Minimum frequency should be " + mins + " minutes";
it("blocks a frequency below the org floor (minutes mode)", () => {
// 300s → floor 5 min. Display 3 min < 5.
const r = parse({
_ui: { checkEvery: 3 },
trigger_condition: { frequency: 3 },
_meta: { minAutoRefreshInterval: 300 },
});
expect(paths(r)).toContain("_ui.checkEvery");
expect(
r.error!.issues.find((i: any) => i.path.join(".") === "_ui.checkEvery")!
.message,
).toBe(FLOOR_MSG(5));
});
it("uses ceil() for a non-whole-minute org floor (90s → 2 minutes)", () => {
const r = parse({
_ui: { checkEvery: 1 },
trigger_condition: { frequency: 1 },
_meta: { minAutoRefreshInterval: 90 },
});
expect(
r.error!.issues.find((i: any) => i.path.join(".") === "_ui.checkEvery")!
.message,
).toBe(FLOOR_MSG(2));
});
it("allows a frequency exactly AT the org floor", () => {
const r = parse({
_ui: { checkEvery: 5 },
trigger_condition: { frequency: 5 },
_meta: { minAutoRefreshInterval: 300 },
});
expect(paths(r)).not.toContain("_ui.checkEvery");
});
it("compares the floor in MINUTES — hours mode converts display*60", () => {
// Display 1 hour = 60 min, floor 5 min → passes even though 1 < 5.
const r = parse({
_ui: { checkEvery: 1 },
trigger_condition: { frequency: 60 },
_meta: { frequencyMode: "hours", minAutoRefreshInterval: 300 },
});
expect(paths(r)).not.toContain("_ui.checkEvery");
});
it("does NOT apply the org floor in cron mode", () => {
const r = parse({
_ui: { checkEvery: 1 },
trigger_condition: { frequency: 1 },
_meta: { frequencyMode: "cron", minAutoRefreshInterval: 300 },
});
expect(paths(r)).not.toContain("_ui.checkEvery");
});
it("does NOT invent a floor when minAutoRefreshInterval is absent/0", () => {
const r = parse({
_ui: { checkEvery: 1 },
trigger_condition: { frequency: 1 },
_meta: { minAutoRefreshInterval: 0 },
});
expect(paths(r)).not.toContain("_ui.checkEvery");
});
// ── §4 PromQL restores ──────────────────────────────────────────────────
it("PromQL: blocks empty operator", () => {
const r = parse({
_meta: { tab: "promql", isEventBased: false, selectedFunction: "total_events" },
query_condition: { promql_condition: { operator: "", value: 5 } },
});
expect(paths(r)).toContain("query_condition.promql_condition.operator");
expect(
r.error!.issues.find((i: any) =>
i.path.join(".").endsWith("operator"),
)!.message,
).toBe(PROMQL_OPERATOR_REQUIRED_MESSAGE);
});
it("PromQL: value is ZERO-SAFE — 0 PASSES", () => {
const r = parse({
_meta: { tab: "promql", isEventBased: false },
query_condition: { promql_condition: { operator: ">=", value: 0 } },
});
expect(paths(r)).not.toContain("query_condition.promql_condition.value");
expect(r.success).toBe(true);
});
it('PromQL: value "" / null / undefined are BLOCKED', () => {
for (const bad of ["", null, undefined]) {
const r = parse({
_meta: { tab: "promql", isEventBased: false },
query_condition: { promql_condition: { operator: ">=", value: bad } },
});
expect(paths(r)).toContain("query_condition.promql_condition.value");
expect(
r.error!.issues.find((i: any) => i.path.join(".").endsWith("value"))!
.message,
).toBe(PROMQL_VALUE_REQUIRED_MESSAGE);
}
});
it("PromQL restores DO NOT fire in custom mode", () => {
const r = parse({
query_condition: { promql_condition: { operator: "", value: "" } },
});
expect(paths(r)).not.toContain(
"query_condition.promql_condition.operator",
);
expect(paths(r)).not.toContain("query_condition.promql_condition.value");
});
it("PromQL restores DO NOT fire in sql mode", () => {
const r = parse({
_meta: { tab: "sql" },
query_condition: { promql_condition: { operator: "", value: "" } },
});
expect(paths(r)).not.toContain(
"query_condition.promql_condition.operator",
);
});
// ── Measure column (RESTORE of the red highlight main drew via
// QueryConfig's `columnSelectError` ref). The rule is SCHEMA-owned so the
// name-bound <OFormSelect> renders it; the issue path is what paints the
// field, so assert the path, not just the failure. ──────────────────────
const measureAgg = (overrides: Record<string, any> = {}) => ({
_meta: { selectedFunction: "avg", aggregationEnabled: true },
query_condition: {
aggregation: {
function: "avg",
group_by: [],
having: { column: "field2", operator: ">=", value: 5 },
...(overrides.aggregation || {}),
},
},
...(overrides.rest || {}),
});
const withColumn = (column: unknown) =>
measureAgg({
aggregation: { having: { column, operator: ">=", value: 5 } },
});
it("custom measure: blocks an empty aggregation column", () => {
const r = parse(withColumn(""));
expect(paths(r)).toContain("query_condition.aggregation.having.column");
expect(
r.error!.issues.find((i: any) => i.path.join(".").endsWith("column"))!
.message,
).toBe(AGGREGATION_COLUMN_REQUIRED_MESSAGE);
});
// Parity with the old `!col || col.trim() === ''` predicate.
it("custom measure: blocks a whitespace-only aggregation column", () => {
expect(paths(parse(withColumn(" ")))).toContain(
"query_condition.aggregation.having.column",
);
});
it("custom measure: blocks a missing aggregation column", () => {
expect(paths(parse(withColumn(undefined)))).toContain(
"query_condition.aggregation.having.column",
);
});
it("custom measure: a set aggregation column is VALID", () => {
expect(paths(parse(withColumn("field2")))).not.toContain(
"query_condition.aggregation.having.column",
);
});
// ── Gate parity (Rule ④ — the rule must fire in EXACTLY the states the old
// imperative check did: aggregation ON + an aggregation object carrying a
// non-count function). Each of these used to save and must still save. ───
it("count mode (total_events): an empty column is VALID", () => {
const r = parse({
_meta: { selectedFunction: "total_events", aggregationEnabled: true },
query_condition: {
aggregation: {
function: "total_events",
group_by: [],
having: { column: "", operator: ">=", value: 5 },
},
},
});
expect(paths(r)).not.toContain("query_condition.aggregation.having.column");
});
it("aggregation OFF: an empty column is VALID", () => {
const r = parse({
_meta: { selectedFunction: "avg", aggregationEnabled: false },
query_condition: {
aggregation: {
function: "avg",
group_by: [],
having: { column: "", operator: ">=", value: 5 },
},
},
});
expect(paths(r)).not.toContain("query_condition.aggregation.having.column");
});
it("no aggregation object: an empty column is VALID", () => {
const r = parse({
_meta: { selectedFunction: "avg", aggregationEnabled: true },
query_condition: { aggregation: null },
});
expect(paths(r)).not.toContain("query_condition.aggregation.having.column");
});
it("sql tab: the measure column rule does not apply", () => {
const r = parse({
_meta: { tab: "sql", selectedFunction: "avg", aggregationEnabled: true },
query_condition: {
aggregation: {
function: "avg",
group_by: [],
having: { column: "", operator: ">=", value: 5 },
},
},
});
expect(paths(r)).not.toContain("query_condition.aggregation.having.column");
});
// ── Measure aggregation (REHOMED + conditionValue) ──────────────────────
it("custom measure: blocks empty having.value", () => {
const r = parse({
_meta: { selectedFunction: "avg", aggregationEnabled: true },
logGroupBy: ["field1"],
query_condition: {
aggregation: {
group_by: ["field1"],
having: { column: "field2", operator: ">=", value: "" },
},
},
});
expect(paths(r)).toContain("query_condition.aggregation.having.value");
expect(
r.error!.issues.find((i: any) => i.path.join(".").endsWith("value"))!
.message,
).toBe(CONDITION_VALUE_REQUIRED_MESSAGE);
});
// PARITY (pre-migration AlertSettings.validate(): "if any are added, they
// must not be empty"). An EMPTY group_by is VALID — a measure alert with no
// group-by saves, and alertPayload.ts (~:88-96) is purpose-built for that
// state (forces threshold=1 / operator=">="). Only BLANK rows are rejected.
it("custom measure (logs): an EMPTY group-by is VALID (no group-by saves)", () => {
const r = parse({
_meta: { selectedFunction: "avg", aggregationEnabled: true, isEventBased: true },
logGroupBy: [],
query_condition: {
aggregation: {
group_by: [],
having: { column: "field2", operator: ">=", value: 5 },
},
},
});
expect(paths(r)).not.toContain("logGroupBy");
expect(paths(r)).not.toContain("logGroupBy.0");
});
it("custom measure (metrics): an EMPTY group-by is VALID (no group-by saves)", () => {
const r = parse({
_meta: {
selectedFunction: "avg",
aggregationEnabled: true,
isEventBased: false,
},
query_condition: {
aggregation: {
group_by: [],
having: { column: "value", operator: ">=", value: 5 },
},
},
});
expect(paths(r)).not.toContain("query_condition.aggregation.group_by");
expect(paths(r)).not.toContain("query_condition.aggregation.group_by.0");
});
it("custom measure (logs): a BLANK group-by row blocks, flagged on that row", () => {
const r = parse({
_meta: { selectedFunction: "avg", aggregationEnabled: true, isEventBased: true },
logGroupBy: ["field1", ""],
query_condition: {
aggregation: {
group_by: ["field1", ""],
having: { column: "field2", operator: ">=", value: 5 },
},
},
});
// Flagged at the OFFENDING index (name="logGroupBy[1]"), not the array.
expect(paths(r)).toContain("logGroupBy.1");
expect(paths(r)).not.toContain("logGroupBy.0");
});
it("custom measure (metrics): a BLANK group-by row blocks, flagged on that row", () => {
const r = parse({
_meta: {
selectedFunction: "avg",
aggregationEnabled: true,
isEventBased: false,
},
query_condition: {
aggregation: {
group_by: ["", "field2"],
having: { column: "value", operator: ">=", value: 5 },
},
},
});
expect(paths(r)).toContain("query_condition.aggregation.group_by.0");
expect(paths(r)).not.toContain("query_condition.aggregation.group_by.1");
});
it("custom measure with a group-by + value passes", () => {
const r = parse({
_meta: { selectedFunction: "avg", aggregationEnabled: true, isEventBased: true, hasGroupBy: true },
logGroupBy: ["field1"],
query_condition: {
aggregation: {
group_by: ["field1"],
having: { column: "field2", operator: ">=", value: 5 },
},
},
});
expect(r.success).toBe(true);
});
it("measure rules DO NOT fire for total_events (count)", () => {
const r = parse({
_meta: { selectedFunction: "total_events" },
query_condition: {
aggregation: {
group_by: [],
having: { column: "", operator: ">=", value: "" },
},
},
});
expect(paths(r)).not.toContain("query_condition.aggregation.having.value");
expect(paths(r)).not.toContain("logGroupBy");
});
// ── Conditions tree (custom, both modes) ────────────────────────────────
it("blocks a partial condition row in custom mode (RESTORE)", () => {
const r = parse({
query_condition: {
conditions: group([leaf({ column: "" })]),
},
});
expect(paths(r)).toContain(
"query_condition.conditions.conditions.0.column",
);
});
it("conditions tree is validated even in realtime custom mode", () => {
const r = parse({
_meta: { isRealTime: "true" },
query_condition: { conditions: group([leaf({ value: "" })]) },
});
expect(paths(r)).toContain(
"query_condition.conditions.conditions.0.value",
);
});
});
describe("queryConfigDefaults", () => {
it("projects trigger_condition / conditions / group-by / meta from props", () => {
const defaults = queryConfigDefaults({
triggerCondition: { operator: ">", threshold: 7, frequency: 15 },
inputData: { conditions: group([leaf()]), aggregation: null },
logGroupBy: ["a", "b"],
meta: {
tab: "custom",
isRealTime: "false",
isEventBased: true,
selectedFunction: "total_events",
frequencyMode: "minutes",
hasConditions: true,
hasGroupBy: false,
aggregationEnabled: false,
},
});
expect(defaults.trigger_condition!.threshold).toBe(7);
expect(defaults.trigger_condition!.operator).toBe(">");
expect(defaults.logGroupBy).toEqual(["a", "b"]);
expect((defaults._meta as any).tab).toBe("custom");
// A fresh copy of logGroupBy (not the same array reference).
expect(defaults.logGroupBy).not.toBe(undefined);
});
it("falls back to sane defaults when props are empty", () => {
const defaults = queryConfigDefaults({
triggerCondition: null,
inputData: null,
logGroupBy: [],
meta: {
tab: "custom",
isRealTime: "false",
isEventBased: true,
selectedFunction: "total_events",
frequencyMode: "minutes",
hasConditions: false,
hasGroupBy: false,
aggregationEnabled: false,
},
});
expect(defaults.trigger_condition!.threshold).toBe(1);
expect(defaults.trigger_condition!.operator).toBe(">=");
expect(defaults.trigger_condition!.frequency).toBe(10);
});
});
describe("isConditionGroupNode (mirrors FilterGroup.isGroup)", () => {
it("recognises V2 groups and rejects condition leaves", () => {
expect(isConditionGroupNode(group([]))).toBe(true);
expect(isConditionGroupNode(leaf())).toBe(false);
expect(isConditionGroupNode(null)).toBe(false);
expect(isConditionGroupNode(undefined)).toBe(false);
});
it("recognises V1-legacy groups (items[] + groupId) so they are never validated as leaves", () => {
const v1 = { groupId: "legacy", items: [] };
expect(isConditionGroupNode(v1)).toBe(true);
// The walker recurses into it, finds no V2 conditions[] rows → no issues.
expect(conditionsTreeSchema.safeParse(group([v1])).success).toBe(true);
});
});
});
@@ -0,0 +1,531 @@
// Copyright 2026 OpenObserve Inc.
//
// Schema pieces for QueryConfig.vue.
//
// ⚠️ SCOPE NOTE (conditions-tree phase): this file currently exports ONLY the
// alert-conditions-tree pieces that back FilterGroup.vue / FilterCondition.vue
// form mode (alerts-migration.md §A). The FULL QueryConfig form schema — the
// mode-discriminated scalars (threshold / operator / period …), `group_by[]`,
// the promql_condition restores, aggregation, etc. — will be ADDED to this file
// by a later phase (alerts-migration.md §C), which also wires
// `name-prefix="query_condition.conditions"` onto QueryConfig's two
// <FilterGroup> usages and calls `refineConditionsTree` from BOTH the
// QueryConfig and AddAlert superRefines under `query.type === 'custom'`.
//
// ── The live tree shape (read from FilterGroup.vue / FilterCondition.vue) ──
// The alert-conditions tree saved at `query_condition.conditions` is a single
// ROOT GROUP node whose `conditions[]` mixes two node kinds:
// • condition leaf: { filterType: "condition", column, operator, value,
// values: [], logicalOperator, id } (FilterGroup.addCondition)
// • nested group: { filterType: "group", logicalOperator, groupId,
// conditions: [...] } (FilterGroup.addGroup)
// FilterGroup.isGroup() additionally recognises a V1-legacy group shape
// (`items[]` + `groupId`); form mode renders only V2 `conditions[]` children,
// and the walker below mirrors that exactly.
//
// Validation TIMING is owned by OForm (submit-then-change via revalidateLogic);
// this file only describes WHAT is valid. Requiredness is enforced by the
// `refineConditionsTree` walker — NOT by the structural node schemas — so the
// issue paths land on the exact nested field the OForm* wrapper is bound to
// (e.g. ["tree", "conditions", 1, "conditions", 0, "column"] →
// `tree.conditions[1].conditions[0].column`).
import { z } from "zod";
/** i18n translator injected by the component: `(key, namedParams?) => string`.
* Every user-facing validation message resolves through this against the
* `alerts.validation.*` locale keys (parity: the English values are byte-for-byte
* the pre-migration strings). */
export type Translator = (key: string, named?: Record<string, unknown>) => string;
// ── Structural node schemas (deliberately loose) ────────────────────────────
// These describe SHAPE only. All per-field requiredness lives in
// `refineConditionsTree` so rules stay path-addressed and zero-safe; keeping
// the node schemas permissive guarantees no accidental rule-tightening
// (Rule ④) and that an empty/absent tree parses clean.
/** One condition leaf (a FilterCondition row). */
export const conditionNodeSchema = z.looseObject({
id: z.string().optional(),
filterType: z.literal("condition").optional(),
column: z.string().optional(),
operator: z.string().optional(),
// `value` may be a string, a number (0 is VALID — zero-safe), etc. Typed
// `unknown` on purpose: requiredness is the walker's job.
value: z.unknown().optional(),
values: z.array(z.unknown()).optional(),
logicalOperator: z.string().optional(),
});
export type ConditionNode = z.infer<typeof conditionNodeSchema>;
/** A group node — recursive: `conditions[]` holds leaves and/or sub-groups. */
export const conditionGroupNodeSchema = z.looseObject({
filterType: z.literal("group").optional(),
groupId: z.string().optional(),
logicalOperator: z.string().optional(),
get conditions() {
return z
.array(z.union([conditionGroupNodeSchema, conditionNodeSchema]))
.optional();
},
});
export type ConditionGroupNode = z.infer<typeof conditionGroupNodeSchema>;
// ── Node-kind detection (mirrors FilterGroup.isGroup exactly) ───────────────
export function isConditionGroupNode(item: unknown): boolean {
const node = item as Record<string, unknown> | null | undefined;
// V2: filterType === "group" with a conditions array.
if (node && node.filterType === "group" && Array.isArray(node.conditions)) {
return true;
}
// V1 compatibility (legacy `items[]` + groupId) — recognised as a group so
// it is never mis-validated as a condition leaf. It carries no V2
// `conditions[]` rows, so the walker recurses into it and finds nothing —
// exactly what FilterGroup's form mode renders for it.
if (node && Array.isArray(node.items) && node.groupId) {
return true;
}
return false;
}
// ── The recursive superRefine walker ────────────────────────────────────────
/**
* Walk a conditions tree from `root` (a group node) and add one issue per
* missing field of every condition leaf, at the EXACT nested path derived
* from `basePath` (the path of `root` inside the form values, e.g.
* `["query_condition", "conditions"]` — or `["tree"]` in specs).
*
* Per condition leaf:
* • `column` — truthy-required (pre-migration blur rule: `!column`)
* • `operator` — truthy-required (pre-migration blur rule: `!operator`)
* • `value` — required but ZERO-SAFE: fails ONLY on undefined/null/""
* (numeric 0 PASSES — never `.min(1)` on a number)
* Message is the caller-supplied `requiredMessage` (i18n
* `alerts.validation.fieldRequired` → "Field is required!", the pre-migration hint).
*
* An EMPTY tree (no root / no conditions / empty array) adds NO issues —
* alerts save with zero conditions today and must keep doing so.
*/
export function refineConditionsTree(
root: unknown,
ctx: z.RefinementCtx,
basePath: (string | number)[] = [],
requiredMessage = "",
): void {
if (!root || typeof root !== "object") return;
const children = (root as { conditions?: unknown }).conditions;
if (!Array.isArray(children)) return;
children.forEach((item, index) => {
const itemPath = [...basePath, "conditions", index];
if (isConditionGroupNode(item)) {
refineConditionsTree(item, ctx, itemPath, requiredMessage);
return;
}
if (!item || typeof item !== "object") return;
const row = item as { column?: unknown; operator?: unknown; value?: unknown };
if (!row.column) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [...itemPath, "column"],
message: requiredMessage,
});
}
if (!row.operator) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [...itemPath, "operator"],
message: requiredMessage,
});
}
if (row.value === undefined || row.value === null || row.value === "") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [...itemPath, "value"],
message: requiredMessage,
});
}
});
}
// ── Standalone tree schema ──────────────────────────────────────────────────
/**
* A root group node validated by the walker (basePath = [] — issue paths are
* relative to the tree root, e.g. ["conditions", 0, "column"]). Used for
* schema-level tests and anywhere the tree is validated on its own; the
* QueryConfig/AddAlert form schemas call `refineConditionsTree` from their own
* superRefine with the real basePath instead.
*/
export const makeConditionsTreeSchema = (t: Translator) =>
conditionGroupNodeSchema.superRefine((root, ctx) =>
refineConditionsTree(root, ctx, [], t("alerts.validation.fieldRequired")),
);
export type ConditionsTree = z.infer<typeof conditionGroupNodeSchema>;
// ════════════════════════════════════════════════════════════════════════════
// FULL QueryConfig form schema (alerts-migration.md §C — QueryConfig scalars,
// group_by[], PromQL §4 restores, and the AlertSettings CROSS-STEP checks
// REHOMED here). Everything above (the conditions-tree pieces) is unchanged and
// still exported for FilterGroup/FilterCondition + AddAlert.
//
// DESIGN — a SINGLE schema with a `_meta` discriminator object. QueryConfig's
// mode (query tab, event-based vs metrics, realtime vs scheduled, the selected
// function, the frequency unit, whether group-by / conditions exist,
// aggregation on/off) is NOT itself form data — those are bare mode-selectors
// (tabs, function dropdown, …). They are BRIDGED into the form as `_meta.*` via
// the sanctioned `watch → setFieldValue` for a non-input discriminator, and the
// superRefine reads `_meta` to fire each rule ONLY in the branch where the
// pre-migration form enforced it. This lets ONE static schema cover every mode
// (OForm reads `:schema` once at creation — a per-mode schema swap would not
// re-wire the validators).
//
// ⚠️ Numbers come out of OFormInput as STRINGS. Requiredness / min checks are
// done in the superRefine on the RAW value so we control empty-vs-0 exactly
// (z.coerce.number() would turn "" into 0 and silently pass a required check).
// `promql_condition.value` is ZERO-SAFE (0 is valid; only undefined/null/"" fail).
// ════════════════════════════════════════════════════════════════════════════
// ── Validation messages are i18n-driven (`alerts.validation.*` locale keys),
// resolved via the injected `t` inside `makeQueryConfigSchema`. Parity map:
// threshold ≥ 1 → alerts.validation.thresholdPositive
// frequency ≥ 1 → alerts.validation.frequencyPositive
// org min-freq → alerts.validation.minimumFrequency ({minutes} = the
// ceil(min_auto_refresh_interval / 60) floor)
// "value is" → alerts.validation.fieldRequired
// promql operator → alerts.validation.fieldRequired
// promql value → alerts.validation.fieldRequired
// blank group-by → alerts.validation.fieldRequired (per offending row;
// an EMPTY group_by is VALID — see the parity note in
// the custom+measure branch below)
// (English values are byte-for-byte the pre-migration strings.)
/** The mode discriminators bridged into the form (NOT part of the alert
* payload — they gate the superRefine only). */
export interface QueryConfigMeta {
tab: "custom" | "sql" | "promql";
/** "true" | "false" | "anomaly" — string, mirrors the prop. */
isRealTime: string;
isEventBased: boolean;
/** "total_events" (COUNT, no aggregation) or a measure function. */
selectedFunction: string;
frequencyMode: "minutes" | "hours" | "cron";
hasConditions: boolean;
hasGroupBy: boolean;
aggregationEnabled: boolean;
/** Org floor for the evaluation frequency, in SECONDS — raw
* `store.state.zoConfig.min_auto_refresh_interval`. Drives the
* "Minimum frequency should be N minutes" rule below. 0/absent → rule
* skipped (never invent a floor). */
minAutoRefreshInterval: number;
}
/** True when the RAW input value is empty (unset). Shared by the number checks
* so "" / null / undefined always count as "not provided". */
const isBlank = (v: unknown): boolean =>
v === undefined || v === null || v === "";
/** A number field that must be present AND ≥ 1. Empty OR NaN OR < 1 → invalid.
* Operates on the raw (possibly string) value so 0 fails but "" is caught as
* "required" rather than silently coerced to 0. */
const isBelowOne = (v: unknown): boolean => {
if (isBlank(v)) return true;
const n = Number(v);
return Number.isNaN(n) || n < 1;
};
/**
* The full QueryConfig form schema. `looseObject` throughout so the many bridged
* runtime keys (aggregation.function, conditions ids, values[], etc.) pass
* through untouched — requiredness lives entirely in the superRefine, keyed on
* `_meta`, so nothing tightens by accident (Rule ④) and an untouched form parses
* clean.
*/
export const makeQueryConfigSchema = (t: Translator) =>
z
.looseObject({
trigger_condition: z
.looseObject({
operator: z.string().optional(),
threshold: z.unknown().optional(),
frequency: z.unknown().optional(),
})
.optional(),
query_condition: z
.looseObject({
conditions: conditionGroupNodeSchema.optional(),
aggregation: z
.looseObject({
group_by: z.array(z.string()).optional(),
function: z.string().optional(),
having: z
.looseObject({
column: z.string().optional(),
operator: z.string().optional(),
value: z.unknown().optional(),
})
.optional(),
})
.nullable()
.optional(),
promql_condition: z
.looseObject({
operator: z.string().optional(),
value: z.unknown().optional(),
})
.nullable()
.optional(),
})
.optional(),
/** Logs/traces group-by lives on its own field (metrics group-by lives on
* query_condition.aggregation.group_by). Both are Rule-① field arrays. */
logGroupBy: z.array(z.string()).optional(),
/** DISPLAY-ONLY form state — never sent to the backend (stripped by
* getAlertPayload alongside `_meta`/`logGroupBy`). `checkEvery` holds the
* number the user actually sees in the "Check every" input: a HOURS-count
* in hours mode, a MINUTES-count otherwise. The stored
* `trigger_condition.frequency` is ALWAYS minutes — QueryConfig's
* onCheckEveryChange bridges display → minutes. Both frequency rules live
* on this path because it is the one bound to the visible input. */
_ui: z
.looseObject({
checkEvery: z.unknown().optional(),
})
.optional(),
_meta: z.looseObject({
tab: z.string(),
isRealTime: z.string(),
isEventBased: z.boolean(),
selectedFunction: z.string(),
frequencyMode: z.string(),
hasConditions: z.boolean(),
hasGroupBy: z.boolean(),
aggregationEnabled: z.boolean(),
minAutoRefreshInterval: z.number().optional(),
}),
})
.superRefine((val, ctx) => {
const meta = val._meta as QueryConfigMeta;
const scheduled = meta.isRealTime === "false";
const isCustom = meta.tab === "custom";
const isSql = meta.tab === "sql";
const isPromql = meta.tab === "promql";
const isMeasure = meta.selectedFunction !== "total_events";
const tc = (val.trigger_condition ?? {}) as Record<string, unknown>;
const qc = (val.query_condition ?? {}) as Record<string, any>;
// ── Conditions tree (custom mode, realtime + scheduled) ──────────────────
// Blocks save on partially-filled rows — a §4-style RESTORE (pre-migration
// the "Field is required!" hints were blur-only and never gated save).
if (isCustom && qc.conditions) {
refineConditionsTree(
qc.conditions,
ctx,
["query_condition", "conditions"],
t("alerts.validation.fieldRequired"),
);
}
// Everything below is SCHEDULED-only (realtime alerts show filters only —
// no threshold sentence / "Check every" — so none of these rules applied).
if (!scheduled) return;
// ── Threshold ≥ 1 (REHOMED) ──────────────────────────────────────────────
// Shown+required in every scheduled branch: custom (count OR the measure
// "having groups" — forced to 1 when no group-by, so ≥1 always holds), SQL
// ("no. of events"), PromQL ("having series"). One rule covers them all.
if (isBelowOne(tc.threshold)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["trigger_condition", "threshold"],
message: t("alerts.validation.thresholdPositive"),
});
}
// ── Frequency ≥ 1 (REHOMED + QueryConfig's own checkEveryFrequencyError) ──
// Only when NOT in cron mode (cron is validated separately — the component
// renders `cronError`, useAlertForm.runImperativeQueryChecks gates save).
// Keyed on the DISPLAY value (`_ui.checkEvery`) because that is the path the
// visible "Check every" input binds, so the message renders inline. Display
// ≥ 1 ⇔ stored ≥ 1 in both modes (hours multiplies by 60), so this is the
// same rule pre-migration applied to its local display ref.
const ui = (val._ui ?? {}) as Record<string, unknown>;
const display = ui.checkEvery;
if (meta.frequencyMode !== "cron") {
if (isBelowOne(display)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["_ui", "checkEvery"],
message: t("alerts.validation.frequencyPositive"),
});
} else {
// ── Org min-frequency floor (R5 RESTORE) ────────────────────────────
// Pre-migration: AlertSettings.validateFrequency compared the STORED
// minutes against ceil(min_auto_refresh_interval / 60) and blocked save.
// Compare in MINUTES: convert the display value the same way
// onCheckEveryChange does. Skipped entirely when the org sets no floor.
const floorSecs = Number(meta.minAutoRefreshInterval) || 0;
if (floorSecs > 0) {
const freqMins =
meta.frequencyMode === "hours"
? Number(display) * 60
: Number(display);
const floorMins = Math.ceil(floorSecs / 60);
if (freqMins < floorMins) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["_ui", "checkEvery"],
message: t("alerts.validation.minimumFrequency", {
minutes: floorMins,
}),
});
}
}
}
}
// ── PromQL branch (§4 RESTORES — dead bindings pre-migration) ─────────────
if (isPromql) {
const pc = (qc.promql_condition ?? {}) as Record<string, unknown>;
if (!pc.operator) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["query_condition", "promql_condition", "operator"],
message: t("alerts.validation.fieldRequired"),
});
}
// ZERO-SAFE: 0 is a valid PromQL threshold; only unset fails.
if (isBlank(pc.value)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["query_condition", "promql_condition", "value"],
message: t("alerts.validation.fieldRequired"),
});
}
}
// ── Custom + measure aggregation (REHOMED aggregation checks + QueryConfig
// conditionValueError) ────────────────────────────────────────────────────
if (isCustom && isMeasure) {
const having = (qc.aggregation?.having ?? {}) as Record<string, unknown>;
// ── Measure column — required (RESTORE of main's red highlight) ─────────
// Pre-migration BOTH halves of this rule lived in QueryConfig.validate():
// it set a local `columnSelectError` ref (which fed `:error` on a bare
// <OSelect> and drew the red border) AND toasted. The migration re-homed
// only the toast, into useAlertForm.runImperativeQueryChecks, and dropped
// the ref — so save was still blocked but the field stayed grey.
//
// It has to live HERE, not in an imperative gate: the two selects are now
// name-bound <OFormSelect>, which derives `:error` from
// `field.state.meta.errors` and OMITS `error` from its props (a
// parent-passed `:error` is clobbered by the field-driven binding). Form
// state is the ONLY thing that can paint the field.
//
// ONE path covers BOTH branches — the logs select and the metrics select
// bind the same `query_condition.aggregation.having.column` name.
//
// The predicate mirrors the imperative gate verbatim (aggregation ON + an
// aggregation object carrying a non-count function) so nothing tightens
// (Rule ④). `aggregation.function` is re-checked against `total_events`
// even though `isMeasure` already read `_meta.selectedFunction`: they are
// two views of the same choice, and requiring both means a stale bridge
// can only ever under-fire, never block a save that used to pass.
const aggregation = qc.aggregation;
const fn = aggregation?.function;
if (meta.aggregationEnabled && aggregation && fn && fn !== "total_events") {
// Trim-aware (the old check was `!col || col.trim() === ''`) — a
// whitespace-only column is not a column.
const column = having.column;
if (column == null || String(column).trim() === "") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["query_condition", "aggregation", "having", "column"],
message: t("alerts.validation.aggregationColumnRequired"),
});
}
}
// "value is" field — required (non-empty).
if (isBlank(having.value)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["query_condition", "aggregation", "having", "value"],
message: t("alerts.validation.fieldRequired"),
});
}
// Group-by rows — PARITY with pre-migration AlertSettings.validate()
// (HEAD ~828-837): "if any are added, they must not be empty".
// • EMPTY group_by array → VALID. A measure alert with NO group-by
// saves, exactly as before. alertPayload.ts (~:88-96) is purpose-built
// for that state: it forces threshold=1 / operator=">=" because the
// "Having groups" row is hidden. Requiring a group-by here would make
// that pre-existing branch dead code.
// • A BLANK row → INVALID (the old loop rejected any empty
// entry). The issue is attached to THAT row's index so the row's own
// <OFormSelect> (name="…group_by[i]" / "logGroupBy[i]") surfaces it,
// matching the old "show inline error only" behavior.
// Predicate `!g` mirrors the old `!field || field === ""` exactly.
if (meta.aggregationEnabled) {
const groupByPath = meta.isEventBased
? ["logGroupBy"]
: ["query_condition", "aggregation", "group_by"];
const groupBy = (
meta.isEventBased
? (val.logGroupBy as unknown[])
: (qc.aggregation?.group_by as unknown[])
) ?? [];
if (Array.isArray(groupBy)) {
groupBy.forEach((g, i) => {
if (!g) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [...groupByPath, i],
message: t("alerts.validation.fieldRequired"),
});
}
});
}
}
}
});
export type QueryConfigForm = z.infer<ReturnType<typeof makeQueryConfigSchema>>;
/**
* Typed defaults projected from the parent-owned runtime model (props). A
* factory over the live records, NOT an inline literal (playbook Step-1). The
* `_meta` block seeds the discriminators from the initial props; the component
* keeps them fresh via `watch → setFieldValue`.
*/
export const queryConfigDefaults = (args: {
triggerCondition: Record<string, any> | null | undefined;
inputData: Record<string, any> | null | undefined;
logGroupBy: string[];
meta: QueryConfigMeta;
}): QueryConfigForm => {
const tc = args.triggerCondition ?? {};
const inputData = args.inputData ?? {};
return {
trigger_condition: {
operator: tc.operator ?? ">=",
threshold: tc.threshold ?? 1,
frequency: tc.frequency ?? 10,
},
query_condition: {
conditions: inputData.conditions ?? {
filterType: "group",
logicalOperator: "AND",
groupId: "",
conditions: [],
},
aggregation: inputData.aggregation ?? null,
promql_condition: inputData.promql_condition ?? null,
},
logGroupBy: [...args.logGroupBy],
_meta: { ...args.meta },
} as QueryConfigForm;
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -80,7 +80,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template #trailing>
<button
type="button"
aria-label="Remove"
:aria-label="t('common.remove')"
class="inline-flex items-center justify-center cursor-pointer hover:opacity-70"
@click="removeAtIndex(index)"
>
@@ -123,8 +123,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-if="
config.alert_enabled && config.alert_destination_ids.length === 0
"
class="text-red-8 pt-1"
style="font-size: 11px; line-height: 12px"
class="text-xs text-input-error-text pt-1"
data-test="anomaly-destination-error"
>
{{ t('alerts.anomaly.destinationRequired') }}
@@ -0,0 +1,210 @@
// 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/>.
// Validation schema for AnomalyDetectionConfig.vue (the "Detection Config" step
// of the anomaly-detection wizard inside AddAlert).
//
// Mode-conditional rules live in the `superRefine` keyed on `query_mode`
// (START-HERE Rule ④ / brief §4):
// - custom_sql mode: `custom_sql` required (bare-Monaco value bridged into the
// form via form.setFieldValue — the sanctioned Rule-② bridge) + the
// timestamp-column alias ban (kept from the live hand-rolled validate()).
// - filters mode: `detection_function` required (**§4 RESTORE** — the original
// Quasar rule `!!v || 'Detection function is required'` was silently dropped)
// and `detection_function_field` required when the function is not `count`
// (kept from the live validate()).
//
// Base (both modes — these controls render in BOTH query modes, so their rules
// apply in both; that matches the pre-O-migration Quasar rules which sat on the
// always-rendered inputs):
// - histogram/schedule/detection-window interval values ≥ 1 (kept from the live
// validate() + inline "Field is required!" divs).
// - `training_window_days` ≥ 1 (**§4 RESTORE** — original Quasar rule
// `v >= 1 || 'Minimum 1 day'` was dropped; only the HTML min=1 remained).
// - `retrain_interval_days` / units / filter rows carry NO value rules — the
// live form never validated them (Rule ④: no added/tightened rules).
//
// Number inputs come out of OFormInput as STRINGS → `z.coerce.number()`
// (per the playbook §2 recipe; the component's write-back to props.config
// re-coerces so the parent payload keeps number types).
import { z } from "zod";
/** One row of the filters[] field-array (Rule ① — indexed names, `:key` = index).
* No per-field rules: the pre-migration form never validated filter rows
* (incomplete rows are simply skipped when the SQL is built). */
export const anomalyFilterRowSchema = z.object({
field: z.string(),
operator: z.string(),
value: z.string(),
});
export type AnomalyFilterRow = z.infer<typeof anomalyFilterRowSchema>;
/** Factory for a blank filter row (same shape the old addFilter() pushed). */
export const makeAnomalyFilterRow = (): AnomalyFilterRow => ({
field: "",
operator: "=",
value: "",
});
/** i18n translator injected by the component: `(key, namedParams?) => string`.
* Validation messages resolve through this against the `alerts.validation.*`
* (and `alerts.anomaly.sqlRequired`) locale keys — English values are
* byte-for-byte the pre-migration strings (parity). */
type Translator = (key: string, named?: Record<string, unknown>) => string;
const makeAnomalyDetectionConfigBase = (t: Translator) =>
z.object({
query_mode: z.enum(["filters", "custom_sql"]),
filters: z.array(anomalyFilterRowSchema),
// Bare-Monaco value, bridged in via form.setFieldValue (Rule ② sanctioned
// bridge). Required-ness is mode-conditional → superRefine below.
custom_sql: z.string(),
// Required-ness is mode-conditional (filters mode only) → superRefine below.
detection_function: z.string(),
detection_function_field: z.string(),
histogram_interval_value: z.coerce
.number()
.min(1, t("alerts.validation.fieldRequired")),
histogram_interval_unit: z.string(),
schedule_interval_value: z.coerce
.number()
.min(1, t("alerts.validation.fieldRequired")),
schedule_interval_unit: z.string(),
detection_window_value: z.coerce
.number()
.min(1, t("alerts.validation.fieldRequired")),
detection_window_unit: z.string(),
// §4 RESTORE — original Quasar rule: v >= 1 || 'Minimum 1 day'.
training_window_days: z.coerce
.number()
.min(1, t("alerts.validation.minimumOneDay")),
// Never validated pre-migration (fixed OSelect options) — type-only.
retrain_interval_days: z.coerce.number(),
// ORange dual-handle value; written back to config.threshold_min/threshold.
// Never validated pre-migration — type-only.
threshold_range: z.object({ min: z.coerce.number(), max: z.coerce.number() }),
});
export type AnomalyDetectionConfigForm = z.infer<
ReturnType<typeof makeAnomalyDetectionConfigBase>
>;
/** True when the SQL aliases a column as the timestamp column (banned — the
* anomaly query must alias its time column as `time_bucket`). Same regex the
* live component used. */
export const hasTimestampAliasInSql = (
sql: string,
timestampColumn: string,
): boolean => {
if (!sql) return false;
const escaped = timestampColumn.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(
`\\bAS\\s+["'\`]?${escaped}["'\`]?\\s*(?:,|\\s|$)`,
"i",
).test(sql);
};
/**
* Schema factory — takes a getter for the org's timestamp column
* (store.state.zoConfig.timestamp_column) so the alias rule stays live without
* the schema file importing the store.
*/
export const createAnomalyDetectionConfigSchema = (
t: Translator,
getTimestampColumn: () => string = () => "_timestamp",
) =>
makeAnomalyDetectionConfigBase(t).superRefine((value, ctx) => {
if (value.query_mode === "custom_sql") {
// Kept from the live validate(): SQL required in custom SQL mode.
if (!value.custom_sql || !value.custom_sql.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["custom_sql"],
message: t("alerts.anomaly.sqlRequired"),
});
} else if (
hasTimestampAliasInSql(value.custom_sql, getTimestampColumn())
) {
// Kept from the live validate(): timestamp column can't be an alias.
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["custom_sql"],
message: t("alerts.validation.timestampAliasBanned", {
column: getTimestampColumn(),
}),
});
}
}
if (value.query_mode === "filters") {
// §4 RESTORE — required, only where the control renders (filters mode).
if (!value.detection_function) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["detection_function"],
message: t("alerts.validation.detectionFunctionRequired"),
});
} else if (
value.detection_function !== "count" &&
!value.detection_function_field
) {
// Kept from the live validate() (which blocked silently); message from
// the pre-O-migration Quasar rule `!!v || 'Field is required'`.
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["detection_function_field"],
message: t("alerts.validation.fieldRequiredPlain"),
});
}
}
});
/**
* Typed defaults, projected from the parent-owned config object
* (useAlertForm's `anomalyConfig` — passed in as props.config). Dynamic/edit
* prefill → typed factory over the runtime record, NOT an inline literal.
* The parent replaces the whole config object on async edit-load; the
* component re-seeds via form.reset(anomalyDetectionConfigDefaults(cfg)).
* Fallbacks mirror useAlertForm's defaultAnomalyConfig().
*/
export const anomalyDetectionConfigDefaults = (
cfg: Record<string, any> | null | undefined,
): AnomalyDetectionConfigForm => ({
query_mode: cfg?.query_mode === "custom_sql" ? "custom_sql" : "filters",
filters: Array.isArray(cfg?.filters)
? cfg.filters.map((f: any) => ({
field: f?.field ?? "",
operator: f?.operator ?? "=",
value: f?.value ?? "",
}))
: [],
custom_sql: cfg?.custom_sql ?? "",
detection_function: cfg?.detection_function ?? "count",
detection_function_field: cfg?.detection_function_field ?? "",
histogram_interval_value: cfg?.histogram_interval_value ?? 5,
histogram_interval_unit: cfg?.histogram_interval_unit ?? "m",
schedule_interval_value: cfg?.schedule_interval_value ?? 1,
schedule_interval_unit: cfg?.schedule_interval_unit ?? "h",
detection_window_value: cfg?.detection_window_value ?? 1,
detection_window_unit: cfg?.detection_window_unit ?? "h",
training_window_days: cfg?.training_window_days ?? 14,
retrain_interval_days: cfg?.retrain_interval_days ?? 7,
threshold_range: {
min: cfg?.threshold_min ?? 0,
max: cfg?.threshold ?? 100,
},
});
@@ -13,10 +13,32 @@
// 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, expect, it, beforeEach, afterEach, vi } from "vitest";
// Spec for AnomalyDetectionConfig (the "Detection Config" step of the anomaly
// wizard). Two layers:
// 1. SURVIVING BEHAVIOR — the buildPreviewSql / loadPreview logic that the
// migration kept unchanged (kept verbatim from the pre-rewrite spec).
// 2. OFORM BEHAVIOR — the real <OForm> the migration introduced: per-mode
// required validation, the two §4-restored rules (training_window_days ≥1,
// detection_function required — each only where its control renders),
// z.coerce.number typing + write-back egress, the custom_sql bare-Monaco
// bridge with submission-gated errors (R3), the filters[] field-array
// keying (rendered-inputs delete test), and the exposed validate() surface
// the parent (useAlertForm) still calls to gate Next/Save.
//
// The step OWNS its <OForm> (Rule ③ useOForm owner) and returns `form` from
// setup(), so the TanStack form is reachable as `(wrapper.vm as any).form`.
// Submits are awaited deterministically via `await form.handleSubmit()`.
import { describe, expect, it, afterEach, vi } from "vitest";
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
import { nextTick } from "vue";
import store from "@/test/unit/helpers/store";
import i18n from "@/locales";
import OFormSelect from "@/lib/forms/Select/OFormSelect.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import { firstFieldError } from "@/lib/forms/Form/fieldError";
// vi.mock must be hoisted — declared before component import
vi.mock("@/services/stream", () => ({
@@ -45,12 +67,11 @@ vi.mock("@/components/QueryEditor.vue", () => ({
import AnomalyDetectionConfig from "./AnomalyDetectionConfig.vue";
// ---------------------------------------------------------------------------
// Mount factory — keeps stubs and global plugins in one place
// ---------------------------------------------------------------------------
function mountConfig(configOverrides: Record<string, unknown> = {}) {
const config = {
function buildConfig(configOverrides: Record<string, unknown> = {}) {
return {
query_mode: "filters",
stream_name: "my_stream",
stream_type: "logs",
@@ -67,18 +88,50 @@ function mountConfig(configOverrides: Record<string, unknown> = {}) {
threshold_min: 0,
...configOverrides,
};
return mount(AnomalyDetectionConfig, {
global: {
plugins: [store, i18n],
stubs: {
PanelSchemaRenderer: true,
QueryEditor: true,
},
}
const mountOptions = {
global: {
plugins: [store, i18n],
stubs: {
PanelSchemaRenderer: true,
QueryEditor: true,
},
props: { config },
},
};
function mountConfig(configOverrides: Record<string, unknown> = {}) {
return mount(AnomalyDetectionConfig, {
...mountOptions,
props: { config: buildConfig(configOverrides) },
});
}
/** Like mountConfig but also returns the config object (the write-back egress
* target — mutated in place by the form → props.config watch). */
function mountReturning(configOverrides: Record<string, unknown> = {}) {
const config = buildConfig(configOverrides);
const wrapper = mount(AnomalyDetectionConfig, {
...mountOptions,
props: { config },
});
return { wrapper, config };
}
// The TanStack form the step owns (returned from setup()).
const getForm = (w: VueWrapper): any => (w.vm as any).form;
// First error message routed to a field (schema issue objects → .message).
const fieldError = (w: VueWrapper, name: string): string | undefined =>
firstFieldError(getForm(w).getFieldMeta(name)?.errors);
// Rendered values of the filters[i].field selects, in render order.
const renderedFilterFields = (w: VueWrapper): unknown[] =>
w
.findAllComponents(OFormSelect)
.filter((c: any) => /^filters\[\d+\]\.field$/.test(c.props("name") || ""))
.map((c: any) => c.findComponent(OSelect).props("modelValue"));
// ---------------------------------------------------------------------------
// Helper: call loadPreview() and return the normalised SQL string
// ---------------------------------------------------------------------------
@@ -88,6 +141,10 @@ async function getSqlFromPreview(wrapper: VueWrapper): Promise<string> {
return (wrapper.vm as any).previewPanelSchema?.queries?.[0]?.query ?? "";
}
// A valid custom SQL query (aliases time_bucket, NOT the timestamp column).
const VALID_CUSTOM_SQL =
"SELECT histogram(_timestamp, '5m') AS time_bucket, count(*) AS value FROM \"events\" GROUP BY time_bucket ORDER BY time_bucket";
// ===========================================================================
describe("AnomalyDetectionConfig", () => {
let wrapper: VueWrapper;
@@ -462,4 +519,418 @@ describe("AnomalyDetectionConfig", () => {
expect((wrapper.vm as any).previewPanelSchema).toBeNull();
});
});
// =========================================================================
// OForm behavior — the real <OForm> the migration introduced
// =========================================================================
describe("OForm — a fully valid form passes and does not block", () => {
it("filters mode: valid config → isValid true, validate() true", async () => {
wrapper = mountConfig();
await flushPromises();
const form = getForm(wrapper);
await form.handleSubmit();
await nextTick();
expect(form.state.isValid).toBe(true);
await expect((wrapper.vm as any).validate()).resolves.toBe(true);
});
it("custom_sql mode: valid SQL → isValid true", async () => {
wrapper = mountConfig({
query_mode: "custom_sql",
stream_name: "events",
custom_sql: VALID_CUSTOM_SQL,
});
await flushPromises();
const form = getForm(wrapper);
await form.handleSubmit();
await nextTick();
expect(form.state.isValid).toBe(true);
});
});
describe("OForm — per-mode empty-required blocks submit (errors only post-submit)", () => {
it("filters mode: empty numeric interval → coerced to 0, blocked, error post-submit", async () => {
wrapper = mountConfig();
await flushPromises();
const form = getForm(wrapper);
// pre-submit: submit-then-change timing → no errors surfaced yet
expect(form.state.submissionAttempts).toBe(0);
expect((form.getFieldMeta("detection_window_value")?.errors ?? [])
.length).toBe(0);
form.setFieldValue("detection_window_value", ""); // OFormInput emits ""
await form.handleSubmit();
await nextTick();
expect(form.state.isValid).toBe(false); // "" → coerce 0 → min(1) fails
expect(fieldError(wrapper, "detection_window_value")).toBe(
"Field is required!",
);
// validate() (the exposed surface) blocks Next/Save
await expect((wrapper.vm as any).validate()).resolves.toBe(false);
});
it("custom_sql mode: empty SQL → blocked, and NOTHING renders pre-submit (R3)", async () => {
wrapper = mountConfig({
query_mode: "custom_sql",
stream_name: "",
custom_sql: "",
});
await flushPromises();
const form = getForm(wrapper);
// R3: before the first submit the bare-Monaco error gate is closed
expect((wrapper.vm as any).showSqlErrors).toBe(false);
await form.handleSubmit();
await nextTick();
expect(form.state.isValid).toBe(false);
expect((wrapper.vm as any).showSqlErrors).toBe(true);
});
});
// =========================================================================
// §4 RESTORE 1 — training_window_days ≥ 1 ("Minimum 1 day").
// Always-rendered control → the rule applies in BOTH query modes (its
// applicable scope), so it is an unconditional base-field rule, NOT
// mode-gated. Prove it fires in both modes.
// =========================================================================
describe("§4 restore — training_window_days ≥ 1 (Minimum 1 day)", () => {
it("filters mode: value < 1 → 'Minimum 1 day' + blocked", async () => {
wrapper = mountConfig();
await flushPromises();
const form = getForm(wrapper);
form.setFieldValue("training_window_days", 0);
await form.handleSubmit();
await nextTick();
expect(form.state.isValid).toBe(false);
expect(fieldError(wrapper, "training_window_days")).toBe("Minimum 1 day");
await expect((wrapper.vm as any).validate()).resolves.toBe(false);
});
it("custom_sql mode: value < 1 → still blocked (rule is unconditional)", async () => {
wrapper = mountConfig({
query_mode: "custom_sql",
stream_name: "events",
custom_sql: VALID_CUSTOM_SQL,
});
await flushPromises();
const form = getForm(wrapper);
// sanity: everything else valid → the ONLY failing rule is training window
form.setFieldValue("training_window_days", 0);
await form.handleSubmit();
await nextTick();
expect(form.state.isValid).toBe(false);
expect(fieldError(wrapper, "training_window_days")).toBe("Minimum 1 day");
});
it("string '0' from the number input is coerced (z.coerce.number) and rejected", async () => {
wrapper = mountConfig();
await flushPromises();
const form = getForm(wrapper);
form.setFieldValue("training_window_days", "0"); // input emits a string
await form.handleSubmit();
await nextTick();
expect(form.state.isValid).toBe(false);
expect(fieldError(wrapper, "training_window_days")).toBe("Minimum 1 day");
});
it("a valid training window (≥1) does not block", async () => {
wrapper = mountConfig({ training_window_days: 14 });
await flushPromises();
const form = getForm(wrapper);
await form.handleSubmit();
await nextTick();
expect(form.state.isValid).toBe(true);
});
});
// =========================================================================
// §4 RESTORE 2 — detection_function required, ONLY in filters mode (the
// control does not render in custom_sql mode). Prove it fires in filters
// mode and does NOT fire in custom_sql mode.
// =========================================================================
describe("§4 restore — detection_function required (filters mode only)", () => {
it("filters mode: empty detection_function → 'Detection function is required' + blocked", async () => {
wrapper = mountConfig();
await flushPromises();
const form = getForm(wrapper);
form.setFieldValue("detection_function", "");
await form.handleSubmit();
await nextTick();
expect(form.state.isValid).toBe(false);
expect(fieldError(wrapper, "detection_function")).toBe(
"Detection function is required",
);
await expect((wrapper.vm as any).validate()).resolves.toBe(false);
});
it("custom_sql mode: empty detection_function does NOT block (rule is mode-gated)", async () => {
wrapper = mountConfig({
query_mode: "custom_sql",
stream_name: "events",
custom_sql: VALID_CUSTOM_SQL,
});
await flushPromises();
const form = getForm(wrapper);
form.setFieldValue("detection_function", "");
await form.handleSubmit();
await nextTick();
// detection_function is not a control in custom_sql mode → its required
// rule must not fire; the form is otherwise valid.
expect(form.state.isValid).toBe(true);
});
it("filters mode: non-count function requires detection_function_field", async () => {
wrapper = mountConfig({
detection_function: "avg",
detection_function_field: "",
});
await flushPromises();
const form = getForm(wrapper);
await form.handleSubmit();
await nextTick();
expect(form.state.isValid).toBe(false);
expect(fieldError(wrapper, "detection_function_field")).toBe(
"Field is required",
);
});
it("filters mode: count function does NOT require detection_function_field", async () => {
wrapper = mountConfig({
detection_function: "count",
detection_function_field: "",
});
await flushPromises();
const form = getForm(wrapper);
await form.handleSubmit();
await nextTick();
expect(form.state.isValid).toBe(true);
});
});
// =========================================================================
// custom_sql bare-Monaco bridge — value bridged into the schema via
// setFieldValue; timestamp-alias ban; submission-gated bare error divs (R3).
// =========================================================================
describe("custom_sql — bridged value, timestamp-alias ban, R3-gated error divs", () => {
it("bridges the editor value into the form (onCustomSqlChange → setFieldValue)", async () => {
wrapper = mountConfig({
query_mode: "custom_sql",
stream_name: "events",
custom_sql: "",
});
await flushPromises();
const form = getForm(wrapper);
(wrapper.vm as any).onCustomSqlChange(VALID_CUSTOM_SQL);
await nextTick();
expect(form.state.values.custom_sql).toBe(VALID_CUSTOM_SQL);
await form.handleSubmit();
await nextTick();
expect(form.state.isValid).toBe(true);
});
it("timestamp-column used as an alias is blocked, and its error div is R3-gated", async () => {
const badSql =
"SELECT histogram(_timestamp, '5m') AS _timestamp, count(*) AS value FROM \"events\" GROUP BY 1 ORDER BY 1";
wrapper = mountConfig({
query_mode: "custom_sql",
stream_name: "events",
custom_sql: badSql,
});
await flushPromises();
const form = getForm(wrapper);
// R3: pre-submit the alias error div (a bare, data-test-selectable div)
// must NOT render even though the SQL is already invalid.
expect(
wrapper
.find('[data-test="anomaly-custom-sql-timestamp-alias-error"]')
.exists(),
).toBe(false);
await form.handleSubmit();
await nextTick();
expect(form.state.isValid).toBe(false);
// post-submit the gate opens and the div renders
expect(
wrapper
.find('[data-test="anomaly-custom-sql-timestamp-alias-error"]')
.exists(),
).toBe(true);
});
it("detection-window error div (kept data-test) is submission-gated", async () => {
wrapper = mountConfig();
await flushPromises();
const form = getForm(wrapper);
form.setFieldValue("detection_window_value", 0);
await nextTick();
// pre-submit: no error div
expect(
wrapper.find('[data-test="anomaly-detection-window-error"]').exists(),
).toBe(false);
await form.handleSubmit();
await nextTick();
expect(
wrapper.find('[data-test="anomaly-detection-window-error"]').exists(),
).toBe(true);
});
});
// =========================================================================
// Payload / egress typing (Rule ④ b) — numeric fields come out of OFormInput
// as strings → z.coerce.number(); the write-back watch re-coerces so
// props.config (which the parent's saveAnomalyDetection payload reads) keeps
// NUMBER types.
// =========================================================================
describe("egress — write-back to props.config keeps number types", () => {
it("string numeric input is written back to props.config as a number", async () => {
const { wrapper: w, config } = mountReturning();
wrapper = w;
await flushPromises();
const form = getForm(wrapper);
form.setFieldValue("training_window_days", "10"); // string, as OFormInput emits
form.setFieldValue("histogram_interval_value", "20");
await flushPromises();
await nextTick();
expect(config.training_window_days).toBe(10);
expect(typeof config.training_window_days).toBe("number");
expect(config.histogram_interval_value).toBe(20);
expect(typeof config.histogram_interval_value).toBe("number");
});
it("threshold range max/min are written back to threshold / threshold_min", async () => {
const { wrapper: w, config } = mountReturning();
wrapper = w;
await flushPromises();
const form = getForm(wrapper);
form.setFieldValue("threshold_range", { min: 10, max: 80 });
await flushPromises();
await nextTick();
expect(config.threshold).toBe(80);
expect(config.threshold_min).toBe(10);
});
it("query_mode is mirrored to props.config (egress, not into-form mirror)", async () => {
const { wrapper: w, config } = mountReturning();
wrapper = w;
await flushPromises();
const form = getForm(wrapper);
form.setFieldValue("query_mode", "custom_sql");
await flushPromises();
await nextTick();
expect(config.query_mode).toBe("custom_sql");
});
});
// =========================================================================
// filters[] field-array (Rule ① — indexed names + :key=index). The
// non-negotiable gate: delete a NON-last row and assert the RENDERED inputs
// (each OFormSelect→OSelect model-value), not just form.state.values.
// =========================================================================
describe("filters[] field-array — rendered inputs stay in sync on non-last delete", () => {
it("removing the MIDDLE of three filter rows leaves the rendered field selects correct", async () => {
wrapper = mountConfig({
filters: [
{ field: "alpha", operator: "=", value: "1" },
{ field: "beta", operator: "=", value: "2" },
{ field: "gamma", operator: "=", value: "3" },
],
});
await flushPromises();
await nextTick();
// sanity — all three rendered in order
expect(renderedFilterFields(wrapper)).toEqual(["alpha", "beta", "gamma"]);
(wrapper.vm as any).removeFilter(1); // delete "beta" (non-last)
await nextTick();
await flushPromises();
// form DATA is correct
expect(
getForm(wrapper).state.values.filters.map((f: any) => f.field),
).toEqual(["alpha", "gamma"]);
// the RENDERED inputs must match — not shifted, not blank (:key=index)
expect(renderedFilterFields(wrapper)).toEqual(["alpha", "gamma"]);
});
it("addFilter / removeFilter mutate the form array (single source of truth)", async () => {
wrapper = mountConfig({ filters: [] });
await flushPromises();
const form = getForm(wrapper);
(wrapper.vm as any).addFilter();
(wrapper.vm as any).addFilter();
await nextTick();
expect(form.state.values.filters.length).toBe(2);
(wrapper.vm as any).removeFilter(0);
await nextTick();
expect(form.state.values.filters.length).toBe(1);
});
});
// =========================================================================
// Exposed validate() — the surface the parent (useAlertForm) still calls to
// gate Next/Save. Driven by form.handleSubmit() (flips submissionAttempts),
// NEVER TanStack formRef.validate().
// =========================================================================
describe("exposed validate() — parent hand-off surface", () => {
it("returns false for an invalid form and flips the submission gate", async () => {
wrapper = mountConfig({ detection_function: "" });
await flushPromises();
expect((wrapper.vm as any).showSqlErrors).toBe(false);
const ok = await (wrapper.vm as any).validate();
await nextTick();
expect(ok).toBe(false);
expect((wrapper.vm as any).showSqlErrors).toBe(true); // submissionAttempts > 0
});
it("returns true for a valid form", async () => {
wrapper = mountConfig();
await flushPromises();
const ok = await (wrapper.vm as any).validate();
expect(ok).toBe(true);
});
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -23,11 +23,24 @@ vi.mock("@/lib/feedback/Toast/useToast", () => ({
toast: vi.fn(),
}));
vi.mock("vue-i18n", () => ({
useI18n: vi.fn(() => ({
t: (key: string) => key,
})),
}));
// Resolve against the real en.json rather than echoing the key, so these tests
// also prove the keys exist and interpolate.
vi.mock("vue-i18n", async () => {
const en: any = (await import("@/locales/languages/en.json")).default;
return {
useI18n: vi.fn(() => ({
t: (key: string, named?: Record<string, unknown>) => {
const msg = key.split(".").reduce((a: any, k) => (a == null ? a : a[k]), en);
if (typeof msg !== "string") return key;
return named
? msg.replace(/\{(\w+)\}/g, (_: string, p: string) =>
named[p] === undefined ? `{${p}}` : String(named[p])
)
: msg;
},
})),
};
});
vi.mock("vuex", () => ({
useStore: vi.fn(() => ({
+18 -11
View File
@@ -159,7 +159,7 @@ export function usePrebuiltDestinations() {
if (!config) {
return {
isValid: false,
errors: { type: 'Unknown destination type' }
errors: { type: t('alerts.prebuilt.unknownDestinationType') }
};
}
@@ -171,7 +171,9 @@ export function usePrebuiltDestinations() {
// Check required fields
if (field.required && (!value || value.toString().trim() === '')) {
errors[field.key] = `${field.label} is required`;
errors[field.key] = t('alerts.validation.credentialFieldRequired', {
field: t(field.labelKey)
});
continue;
}
@@ -180,11 +182,12 @@ export function usePrebuiltDestinations() {
continue;
}
// Apply custom validator if present
// Apply custom validator if present. Configs are Vue-less and carry i18n
// keys, so resolve the returned `{ key, params }` here (see CredentialField).
if (field.validator && value) {
const validationResult = field.validator(value.toString());
if (validationResult !== true) {
errors[field.key] = validationResult as string;
errors[field.key] = t(validationResult.key, validationResult.params);
}
}
}
@@ -295,7 +298,7 @@ export function usePrebuiltDestinations() {
const firstError = Object.values(validation.errors)[0];
const result = {
success: false,
error: `Validation error: ${firstError}`,
error: t('alerts.prebuilt.validationError', { error: firstError }),
timestamp: Date.now()
};
lastTestResult.value = result;
@@ -306,7 +309,7 @@ export function usePrebuiltDestinations() {
if (!config) {
const result = {
success: false,
error: 'Invalid destination type',
error: t('alerts.prebuilt.invalidDestinationType'),
timestamp: Date.now()
};
lastTestResult.value = result;
@@ -431,7 +434,7 @@ export function usePrebuiltDestinations() {
} catch (error: any) {
const result: TestResult = {
success: false,
error: error.message || 'Test failed with unknown error',
error: error.message || t('alerts.prebuilt.testFailedUnknownError'),
timestamp: Date.now()
};
@@ -466,12 +469,16 @@ export function usePrebuiltDestinations() {
// Validate inputs
const validation = validateCredentials(type, credentials);
if (!validation.isValid) {
throw new Error(`Validation error: ${Object.values(validation.errors).join(', ')}`);
throw new Error(
t('alerts.prebuilt.validationError', {
error: Object.values(validation.errors).join(', '),
})
);
}
const config = getPrebuiltConfig(type);
if (!config) {
throw new Error('Invalid destination type');
throw new Error(t('alerts.prebuilt.invalidDestinationType'));
}
// Generate destination data
@@ -598,7 +605,7 @@ export function usePrebuiltDestinations() {
const config = getPrebuiltConfig(type);
if (!config) {
throw new Error('Invalid destination type');
throw new Error(t('alerts.prebuilt.invalidDestinationType'));
}
// Generate destination data
@@ -752,7 +759,7 @@ export function usePrebuiltDestinations() {
const config = getPrebuiltConfig(targetType);
if (!config) {
throw new Error('Invalid target type');
throw new Error(t('alerts.prebuilt.invalidTargetType'));
}
// Ensure system templates exist
+7 -5
View File
@@ -15,11 +15,13 @@ export interface FormInputProps extends Omit<
* OFormInput slots. In addition to the OInput passthrough slots
* (icon-left/icon-right/prefix/suffix/tooltip/append), OFormInput adds:
*
* - `error` — when provided, the consumer OWNS the validation message and
* OInput's built-in error (border + inline text) is suppressed. Use it to
* render the message where the default can't reach — e.g. a full-width sibling
* outside a composite "input + unit" border group. May be left empty purely to
* suppress the built-in error.
* - `error` — when provided, the consumer OWNS the validation message: OInput's
* inline error TEXT is suppressed, but the field still renders its error
* border (a field whose message moved elsewhere must still look invalid). Use
* it to render the message where the default can't reach — e.g. a full-width
* sibling outside a composite "input + unit" border group, or below a narrow
* numeric field where the message would otherwise wrap into a ragged column.
* May be left empty purely to suppress the text.
*/
export interface FormInputSlots {
error?: () => unknown;
+7 -7
View File
@@ -47,9 +47,7 @@ if (import.meta.env.DEV && !form) {
:size="props.size"
:width="props.width"
:model-value="field.state.value"
:error="
!$slots.error && field.state.meta.errors.length > 0
"
:error="field.state.meta.errors.length > 0"
:error-message="
!$slots.error && field.state.meta.errors.length > 0
? firstFieldError(field.state.meta.errors)
@@ -79,10 +77,12 @@ if (import.meta.env.DEV && !form) {
<slot name="append" />
</template>
</OInput>
<!-- #error slot: when provided the consumer OWNS the message and OInput's
built-in error (border + inline text) is suppressed above. Rendered
after OInput so it can escape a composite border; may be left empty
purely to suppress. -->
<!-- #error slot: when provided the consumer OWNS the message — only the
inline TEXT is suppressed above (error-message is left undefined). The
invalid STATE still reaches OInput, so the field keeps its red border:
a narrow field that re-homes its message must still look invalid.
Rendered after OInput so it can escape a composite border; may be left
empty purely to suppress the text. -->
<template v-if="$slots.error">
<slot name="error" />
</template>
+6 -2
View File
@@ -404,9 +404,13 @@ const wrapperClasses = computed(() => [
</span>
</div>
<!-- Bottom row: helpText / error / counter -->
<!-- Bottom row: helpText / error / counter.
`effectiveError` is " " when the field is invalid but its message is
rendered elsewhere (OFormInput's #error slot). That carries no text, so
it must not open this row — an empty row still costs the parent's gap-1
and would grow the field on error, nudging anything aligned beside it. -->
<div
v-if="effectiveError || helpText || maxlength"
v-if="(effectiveError && effectiveError.trim()) || helpText || maxlength"
class="flex items-center justify-between gap-2"
>
<span
@@ -46,6 +46,43 @@ describe("OFormRange", () => {
expect(Number((inputs[1].element as HTMLInputElement).value)).toBe(65);
});
// FormRangeProps extends RangeProps, so these are declared props and never
// reach $attrs — OFormRange must forward each one by hand. When it didn't,
// ORange fell back to its defaults and rendered a bare horizontal track with
// no markers where the anomaly sensitivity slider should have been.
it("forwards the layout props through to ORange", () => {
wrapper = mount(OForm, {
props: { defaultValues: { threshold: { min: 0, max: 100 } } },
slots: {
default: () =>
h(OFormRange, {
name: "threshold",
min: 0,
max: 100,
vertical: true,
reverse: true,
labelAlways: true,
markers: true,
markerLabels: [
{ value: 0, label: "0" },
{ value: 50, label: "50" },
{ value: 100, label: "100" },
],
}),
},
global: { components: { OFormRange } },
});
const range = wrapper.findComponent({ name: "ORange" });
expect(range.props("vertical")).toBe(true);
expect(range.props("reverse")).toBe(true);
expect(range.props("labelAlways")).toBe(true);
expect(range.props("markers")).toBe(true);
expect(range.props("markerLabels")).toHaveLength(3);
// ...and the markers actually render, not just arrive as props.
expect(wrapper.text()).toContain("50");
});
it("shows schema error after submit when the range is too narrow", async () => {
wrapper = mount(OForm, {
props: {
+11
View File
@@ -10,6 +10,12 @@ import type { RangeValue } from "./ORange.types";
defineOptions({ inheritAttrs: false });
// FormRangeProps extends RangeProps, so EVERY ORange prop is declared here and is
// therefore absent from $attrs — `v-bind="$attrs"` cannot carry it through. Each
// one must be forwarded explicitly below or it is silently dropped and ORange
// falls back to its own default (this is how `vertical`/`markers` once collapsed
// a vertical slider into a bare 16px horizontal one). Add a binding below when
// adding a prop to RangeProps.
const props = defineProps<FormRangeProps>();
const form = inject(FORM_CONTEXT_KEY, null);
@@ -43,6 +49,11 @@ if (import.meta.env.DEV && !form) {
:size="props.size"
:id="props.id"
:name="props.name"
:vertical="props.vertical"
:reverse="props.reverse"
:label-always="props.labelAlways"
:markers="props.markers"
:marker-labels="props.markerLabels"
:model-value="field.state.value"
:error="
field.state.meta.errors.length > 0
+51 -1
View File
@@ -1,7 +1,8 @@
// Copyright 2026 OpenObserve Inc.
import { describe, it, expect, afterEach } from "vitest";
import { mount, VueWrapper } from "@vue/test-utils";
import { flushPromises, mount, VueWrapper } from "@vue/test-utils";
import { z } from "zod";
import OFormSelect from "./OFormSelect.vue";
import OSelect from "./OSelect.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
@@ -90,6 +91,55 @@ describe("OFormSelect", () => {
);
});
// Regression (the alerts "Select column" highlight): OFormSelect is the ONLY
// thing that can paint a name-bound select — it derives `:error` from the
// field's own errors and deliberately OMITS `error` from its props, so a
// parent-passed `:error` is swallowed. Any rule that needs to highlight a
// field must therefore live in the schema. Prove the chain end-to-end for a
// DEEPLY NESTED path (the alert column is
// `query_condition.aggregation.having.column`): a schema issue there must
// reach the underlying OSelect and render, and must stay hidden until submit.
it("paints the select from a schema issue at a nested path (submit-gated)", async () => {
const schema = z
.looseObject({
query_condition: z.looseObject({}).optional(),
})
.superRefine((val: any, ctx) => {
if (!val.query_condition?.aggregation?.having?.column) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["query_condition", "aggregation", "having", "column"],
message: "Column is required when using an aggregate function.",
});
}
});
wrapper = mount(OForm, {
props: {
schema,
defaultValues: {
query_condition: { aggregation: { having: { column: "" } } },
},
},
slots: {
default:
"<OFormSelect name=\"query_condition.aggregation.having.column\" :options=\"[{label:'f2',value:'field2'}]\" />",
},
global: { components: { OFormSelect } },
});
// Pre-submit: silent (revalidateLogic mode "submit").
expect(wrapper.findComponent(OSelect).props("error")).toBeFalsy();
await (wrapper.vm as any).submit();
await flushPromises();
expect(wrapper.findComponent(OSelect).props("error")).toBe(true);
expect(wrapper.text()).toContain(
"Column is required when using an aggregate function.",
);
});
it("forwards other re-declared props (optionTooltip, labelPosition) through to OSelect", () => {
wrapper = mount(OForm, {
props: { defaultValues: { kind: "" } },
+19
View File
@@ -3134,6 +3134,25 @@
"SearchSQLCancelled": "Fehler: Die Suche in SQL wurde abgebrochen."
},
"alerts": {
"validation": {
"streamTypeRequired": "Stream-Typ ist erforderlich.",
"streamNameRequired": "Stream-Name ist erforderlich.",
"fieldRequired": "Feld ist erforderlich!",
"fieldRequiredPlain": "Feld ist erforderlich",
"nameInvalidChars": "Zeichen wie :, ?, /, # und Leerzeichen sind nicht erlaubt.",
"templateRequired": "Vorlage ist erforderlich!",
"validEmails": "Bitte gültige E-Mail-Adressen hinzufügen!",
"silenceNonNegative": "Benachrichtigungsstummschaltung muss größer als oder gleich 0 sein",
"periodPositive": "Zeitraum muss größer als 0 sein",
"destinationRequired": "Mindestens ein Ziel ist erforderlich.",
"thresholdPositive": "Schwellenwert muss größer als 0 sein",
"frequencyPositive": "Häufigkeit muss größer als 0 sein",
"groupByRequired": "Mindestens ein Gruppierungsfeld ist erforderlich",
"minimumOneDay": "Mindestens 1 Tag",
"detectionFunctionRequired": "Erkennungsfunktion ist erforderlich",
"timestampAliasBanned": "{column} kann nicht als Spaltenalias verwendet werden. Verwenden Sie stattdessen time_bucket.",
"credentialFieldRequired": "{field} ist erforderlich"
},
"header": "Warnmeldungen",
"subtitle": "Alarmregeln und Benachrichtigungskanäle",
"searchThisFolder": "Dieser Ordner",
+313 -9
View File
@@ -494,7 +494,12 @@
"error": "Error",
"db": "Database",
"clearFilter": "Clear filter",
"noMatches": "No matches"
"noMatches": "No matches",
"pleaseWait": "Please wait...",
"fillRequiredFields": "Please fill required fields",
"test": "Test",
"username": "Username",
"password": "Password"
},
"validation": {
"required": "This field is required",
@@ -3158,6 +3163,74 @@
"SearchSQLCancelled": "Error: Search SQL was cancelled."
},
"alerts": {
"validation": {
"streamTypeRequired": "Stream type is required.",
"streamNameRequired": "Stream name is required.",
"fieldRequired": "Field is required!",
"fieldRequiredPlain": "Field is required",
"nameInvalidChars": "Characters like :, ?, /, #, and spaces are not allowed.",
"templateRequired": "Template is required!",
"validEmails": "Add valid emails!",
"silenceNonNegative": "Silence notification should be greater than or equal to 0",
"periodPositive": "Period should be greater than 0",
"destinationRequired": "At least one destination is required.",
"thresholdPositive": "Threshold should be greater than 0",
"frequencyPositive": "Frequency should be greater than 0",
"aggregationColumnRequired": "Column is required when using an aggregate function.",
"groupByRequired": "At least one group by field is required",
"minimumOneDay": "Minimum 1 day",
"detectionFunctionRequired": "Detection function is required",
"timestampAliasBanned": "{column} cannot be used as a column alias. Use time_bucket instead.",
"credentialFieldRequired": "{field} is required",
"minimumFrequency": "Minimum frequency should be {minutes} minutes",
"nameMandatoryString": "Name is mandatory and should be a valid string",
"orgIdMismatch": "Organization Id should be equal to {orgId}",
"streamTypeMandatory": "Stream Type is mandatory and should be one of: logs, metrics, traces",
"streamNameMandatoryString": "Stream Name is mandatory and should be a valid string",
"streamNotInList": "Stream \"{streamName}\" does not exist in the stream list",
"isRealTimeMandatoryBoolean": "Is Real-Time is mandatory and should be a boolean value",
"conditionItemsArray": "Query conditions items should be an array",
"conditionItemIncomplete": "Query condition item {index} must have column, operator, and value",
"invalidOperatorInConditionItem": "Invalid operator \"{operator}\" in query condition item {index}",
"conditionsOrAndArray": "Query conditions or/and should be an array",
"conditionIncomplete": "Query condition {index} must have column, operator, and value",
"invalidOperatorInCondition": "Invalid operator \"{operator}\" in condition {index}",
"ignoreCaseBoolean": "ignore_case must be a boolean in condition {index}",
"invalidConditionsFormat": "Invalid conditions format. Must use either items array or or/and array",
"multiTimeRangeEmpty": "Multi Time Range should be an empty array or null",
"sqlQueryRequired": "SQL query is required",
"selectAllNotAllowed": "Selecting all columns is not allowed. Please specify the columns explicitly",
"sqlQueryMustStartWithSelect": "SQL query must start with SELECT",
"promqlQueryRequired": "PromQL query is required",
"invalidQueryConditionType": "Invalid query condition type",
"vrlFunctionString": "VRL function should be a string when provided",
"aggregationGroupByArray": "Aggregation group_by should be an array",
"aggregationFunctionRequired": "Aggregation function is required and should be a non-empty string",
"aggregationHavingOperator": "Aggregation having clause operator must be a valid comparison operator",
"aggregationHavingValue": "Aggregation having clause value must be a number when provided",
"periodPositiveNumber": "Period should be a positive number greater than 0",
"invalidOperatorInTrigger": "Invalid operator \"{operator}\" in trigger condition",
"frequencyPositiveNumber": "Frequency should be a positive number greater than 0",
"thresholdPositiveNumber": "Threshold should be a positive number greater than 0",
"silencePositiveNumber": "Silence should be a positive number greater than 0",
"frequencyTypeMinutesOrCron": "Frequency Type must be either minutes or cron",
"cronRequiredForCronType": "Cron expression is required when frequency type is cron",
"timezoneRequiredForCronType": "Timezone is required when frequency type is cron",
"triggerConditionRequired": "Trigger condition is required",
"destinationsArray": "Destinations must be an array",
"destinationRequiredPlain": "At least one destination is required",
"noDestinationsInSystem": "No available destinations found in system",
"invalidDestinations": "Invalid destinations: {destinations} - must be from available destinations list",
"enabledFlagBoolean": "Enabled flag must be a boolean",
"silenceNotificationRequired": "Silence Notification should not be empty",
"thresholdNotEmpty": "Threshold should not be empty",
"invalidCronExpressionBang": "Invalid cron expression!",
"timezoneRequiredForCron": "Timezone is required for cron schedule",
"invalidVrlFunction": "Invalid VRL Function",
"invalidSqlQuery": "Invalid SQL Query",
"errorFetchingStreams": "Error fetching streams",
"sqlValidationError": "SQL validation error: {error}"
},
"header": "Alerts",
"subtitle": "Alert rules and notification channels",
"searchThisFolder": "This folder",
@@ -3384,7 +3457,8 @@
"sendStrategySummary": "Summary",
"sendStrategyAll": "All",
"groupWaitTime": "Group Wait Time (seconds)",
"groupWaitTimeTooltip": "Time to wait and collect similar alerts before sending a grouped notification. Allows batching of alerts that occur in quick succession."
"groupWaitTimeTooltip": "Time to wait and collect similar alerts before sending a grouped notification. Allows batching of alerts that occur in quick succession.",
"fingerprintFieldsHelp": "Leave empty to auto-detect based on query (SQL: GROUP BY columns, PromQL: labels, Custom: condition fields)"
},
"summary": {
"title": "Summary",
@@ -3450,7 +3524,9 @@
"defaultWindowDescription": "In absence of semantic field match, alerts and data from given duration is considered related",
"defaultWindowPlaceholder": "Use alert period by default",
"cancelButton": "Cancel",
"saveButton": "Save"
"saveButton": "Save",
"settingsSaved": "Organization deduplication settings saved successfully",
"settingsSaveError": "Failed to save settings"
},
"placeholders": {
"typeSomething": "Type something",
@@ -3658,7 +3734,18 @@
"addComparisonWindow": "Add Comparison Window",
"goToConditions": "Go to Conditions",
"goToConditionsTooltip": "Click on \"Go to Conditions\" to verify the events for selected comparison windows",
"comparisonDisabledTooltip": "Comparison windows are only supported in SQL mode. Switch to SQL in the Alert Rules tab."
"comparisonDisabledTooltip": "Comparison windows are only supported in SQL mode. Switch to SQL in the Alert Rules tab.",
"periodSeconds": "Second(s)",
"periodMinutes": "Minute(s)",
"periodHours": "Hour(s)",
"periodDays": "Day(s)",
"periodWeeks": "Week(s)",
"periodMonths": "Month(s)",
"minuteCount": "{n} Minute | {n} Minutes",
"hourCount": "{n} Hour | {n} Hours",
"dayCount": "{n} Day | {n} Days",
"weekCount": "{n} Week | {n} Weeks",
"monthCount": "{n} Month | {n} Months"
},
"alertSettings": {
"sectionTitle": "Settings",
@@ -3740,7 +3827,11 @@
"templateType": "Template Type:",
"noTemplatesAvailable": "No templates available",
"refreshTemplates": "Refresh latest Templates",
"selectTemplate": "Select Template"
"selectTemplate": "Select Template",
"templateTypeString": "String",
"templateTypeJson": "JSON",
"rowTemplatePlaceholderJson": "e.g - {'{'}\"user\": \"{'{'}name{'}'}\", \"timestamp\": \"{'{'}timestamp{'}'}\"{'}'}",
"rowTemplatePlaceholderString": "e.g - Alert was triggered at {'{'}timestamp{'}'}"
},
"editJson": "Edit Alert JSON",
"additional_settings": "Additional Settings",
@@ -3749,6 +3840,7 @@
"nameNoSpecialChars": "Alert name cannot contain ':', '#', '?', '&', '%', quotes or space characters. Use underscores (_) instead.",
"anomalyNamePlaceholder": "Anomaly name",
"anomalyName": "Anomaly Name",
"anomalyNameRequired": "Anomaly name is required.",
"folder": "Folder",
"selectStreamTypeFirst": "Select a stream type first",
"previewEmptyState": "Select a stream type and stream name to see a preview",
@@ -3761,7 +3853,52 @@
"addGroupByField": "Add group by field",
"noFunctions": "No functions",
"writeQueryHint": "Write a query to get started",
"sqlEditorHint": "Use open full editor to run and validate"
"sqlEditorHint": "Use open full editor to run and validate",
"checkEvery": "Check every",
"cronExpressionPlaceholder": "0 */10 * * * *",
"timezonePlaceholder": "timezone",
"onThese": "on these",
"filters": "filters",
"viewAlertQuery": "view the alert query",
"vrlEditor": "VRL Editor",
"showVrlEditor": "Show VRL editor",
"hideVrlEditor": "Hide VRL editor",
"alertIfValueIsTooltip": "Alert when the PromQL expression evaluates to this condition for a time series. Example: >= 100 triggers when the result is 100 or more.",
"havingSeriesTooltip": "Minimum number of time series that must satisfy the condition above to trigger the alert.",
"clearMultiWindowsMessage": "Multi-windows are configured. To enable {mode} mode, we need to clear them. Do you want to proceed?",
"cron": "Cron",
"cronExpressionTimezoneRequired": "Cron expression and timezone are required",
"frequencyGreaterThanSeconds": "Frequency should be greater than {seconds} seconds.",
"invalidCronExpression": "Invalid cron expression",
"functions": {
"totalEvents": "total events",
"count": "count",
"avg": "avg",
"min": "min",
"max": "max",
"sum": "sum",
"median": "median",
"p50": "p50",
"p75": "p75",
"p90": "p90",
"p95": "p95",
"p99": "p99"
},
"functionTooltips": {
"totalEvents": "Count the total number of log events matching your filters (COUNT(*))",
"count": "Count non-null values of a specific field (COUNT(field))",
"avg": "Average value of a numeric field",
"min": "Minimum value of a numeric field",
"max": "Maximum value of a numeric field",
"sum": "Sum of values of a numeric field",
"median": "Median value of a numeric field",
"p50": "50th percentile of a numeric field",
"p75": "75th percentile of a numeric field",
"p90": "90th percentile of a numeric field",
"p95": "95th percentile of a numeric field",
"p99": "99th percentile of a numeric field"
},
"customMode": "Custom"
},
"anomaly": {
"filters": "Filters",
@@ -3793,7 +3930,128 @@
"noDestinationsFound": "No destinations found",
"addNewDestination": "Add New Destination",
"destinationRequired": "At least one destination is required!",
"disabledNotificationsInfo": "Anomaly detection will still run and write results to the _anomalies stream. Enable notifications to receive alerts when anomalies are detected."
"disabledNotificationsInfo": "Anomaly detection will still run and write results to the _anomalies stream. Enable notifications to receive alerts when anomalies are detected.",
"timestampAliasBanned": "{column} cannot be used as a column alias. Use {timeBucket} instead.",
"sqlColumnsHint": "Query must return two columns: {timeBucket} and {valueColumn}.",
"trainingWindowTooltip": "How many days of historical data to use for training. Min 1 day. Seasonality is auto-detected: <7 days → hour-of-day; ≥7 days → hour-of-day + day-of-week.",
"trainingWindowSeasonality": "days (seasonality: {seasonality})",
"retrainNever": "Never",
"retrainOneDay": "1 day",
"retrainSevenDays": "7 days",
"retrainFourteenDays": "14 days",
"maxThresholdMarkLine": "max threshold",
"minThresholdMarkLine": "min threshold"
},
"variables": {
"label": "Variable"
},
"prebuiltDestinations": {
"slackWebhookUrl": "Slack Webhook URL",
"slackWebhookUrlHelp": "Get your webhook URL from Slack App settings",
"slackChannel": "Channel (optional)",
"slackChannelHelp": "e.g., #alerts",
"invalidSlackWebhookUrl": "Invalid Slack webhook URL",
"discordWebhookUrl": "Discord Webhook URL",
"discordWebhookUrlHelp": "Get your webhook URL from Discord channel settings",
"discordUsername": "Bot Username (optional)",
"discordUsernameHelp": "Custom username for the webhook bot",
"invalidDiscordWebhookUrl": "Invalid Discord webhook URL",
"msteamsWebhookUrl": "Microsoft Teams Webhook URL",
"msteamsWebhookUrlHelp": "Get your webhook URL from Teams channel connectors",
"invalidMsteamsWebhookUrl": "Invalid Microsoft Teams webhook URL",
"pagerdutyIntegrationKey": "Integration Key",
"pagerdutyIntegrationKeyHelp": "Get your integration key from PagerDuty service settings",
"pagerdutyIntegrationKeyLength": "PagerDuty integration key should be 32 characters",
"pagerdutySeverity": "Default Severity",
"pagerdutySeverityHelp": "Select the default severity for PagerDuty incidents",
"severityCritical": "Critical",
"severityError": "Error",
"severityWarning": "Warning",
"severityInfo": "Info",
"servicenowInstanceUrl": "ServiceNow Instance URL",
"servicenowInstanceUrlHelp": "https://your-instance.service-now.com/api/now/table/incident",
"servicenowInstanceUrlFormat": "URL should be like https://instance.service-now.com/api/now/table/incident",
"servicenowUsernameHelp": "ServiceNow username with incident creation permissions",
"servicenowPasswordHelp": "ServiceNow password or API token",
"servicenowAssignmentGroup": "Assignment Group (optional)",
"servicenowAssignmentGroupHelp": "Group to assign incidents to (e.g., IT Operations)",
"usernameRequired": "Username is required",
"passwordRequired": "Password is required",
"emailRecipients": "Recipient Email Addresses",
"emailRecipientsHelp": "Comma-separated email addresses",
"invalidEmailAddresses": "Invalid email addresses: {emails}",
"opsgenieApiKey": "Opsgenie API Key",
"opsgenieApiKeyHelp": "Get your API key from Opsgenie integration settings",
"opsgenieApiKeyLength": "Opsgenie API key should be longer than 30 characters",
"opsgeniePriority": "Default Priority",
"opsgeniePriorityHelp": "Select the default priority for Opsgenie alerts",
"opsgenieEuRegion": "EU Region",
"opsgenieEuRegionHelp": "Enable for EU-based Opsgenie instances",
"priorityP1": "P1 (Critical)",
"priorityP2": "P2 (High)",
"priorityP3": "P3 (Moderate)",
"priorityP4": "P4 (Low)",
"priorityP5": "P5 (Informational)"
},
"lastRun": "Last run: {time}",
"queryEditor": {
"noStream": "none",
"showVrlEditor": "show VRL editor",
"hideVrlEditor": "hide VRL editor",
"fetchingResults": "Fetching results...",
"writeQueryHint": "Write a query to get started",
"runQueryHint": "Press Run Query to see results",
"noMatchingEvents": "Query ran successfully — no matching events",
"eventsFound": "{n} event found | {n} events found",
"queryResult": "Query Result",
"resultsAcrossWindows": "results across all time windows",
"combinedOutput": "Combined Output",
"running": "Running"
},
"filters": {
"conditions": "Conditions",
"conditionsHint": "(AND operator is used by default to evaluate multiple conditions)",
"if": "if",
"toggleOperatorTooltip": "Toggle between and/or",
"previewLabel": "Preview:",
"reorder": "Reorder",
"reorderTooltip": "Reorder items: Conditions first, then Groups",
"deleteConditionTitle": "Delete Condition",
"deleteConditionMessage": "Deleting this condition will remove the entire condition group.",
"deleteConditionSubGroupWarning": "This will also delete {count} sub-group nested under this group. This action cannot be undone. | This will also delete {count} sub-groups nested under this group. This action cannot be undone."
},
"messages": {
"trainingTriggered": "Training triggered.",
"trainingTriggerFailed": "Failed to trigger training.",
"sqlPreviewPlaceholder": "-- Enter your SQL in Detection Config step",
"thresholdMarkLine": "Threshold",
"anomalyDetectionNameRequired": "Anomaly detection name is required.",
"anomalyNameRequired": "Anomaly name is required.",
"failedToLoadPanelData": "Failed to load panel data",
"anomalyConfigLoadFailed": "Failed to load anomaly detection config.",
"fixHighlightedFields": "Please fix the highlighted fields before saving.",
"customSqlRequired": "Custom SQL is required in custom SQL mode.",
"invalidSqlQueryLower": "Invalid SQL query",
"anomalyConfigUpdated": "Anomaly detection config updated.",
"anomalyConfigSaveFailed": "Failed to save anomaly config.",
"sqlQueryEmpty": "SQL query cannot be empty.",
"fixSqlErrorBeforeSaving": "Please fix the SQL error before saving.",
"promqlQueryEmpty": "PromQL query cannot be empty.",
"selectAllColumnsNotAllowed": "Selecting all Columns in SQL query is not allowed.",
"udsFieldNotAvailable": "Field \"{field}\" is not available. Please use only the available fields in your conditions.",
"udsFieldsNotAvailable": "Fields {fields} are not available. Please use only the available fields in your conditions.",
"udsFieldsNotAvailableTruncated": "{count} fields are not available ({fields} and {remaining} more). Please use only the available fields in your conditions.",
"sqlValidationRequestFailed": "Error while validating sql query. Please check the query and try again.",
"alertUpdated": "Alert updated successfully.",
"alertSaved": "Alert saved successfully."
},
"prebuilt": {
"conversionSuccess": "Destination converted successfully",
"validationError": "Validation error: {error}",
"invalidDestinationType": "Invalid destination type",
"invalidTargetType": "Invalid target type",
"unknownDestinationType": "Unknown destination type",
"testFailedUnknownError": "Test failed with unknown error"
}
},
"alert_templates": {
@@ -3820,7 +4078,12 @@
"customBadge": "Custom",
"filterAll": "All",
"filterPrebuilt": "Prebuilt",
"filterCustom": "Custom"
"filterCustom": "Custom",
"variableRowsDescription": "multiple lines of row template",
"variableStreamFields": "All of the stream fields are variables.",
"variableLimits": "{'{'}rows:N{'}'} {'{'}var:N{'}'} used to limit rows or string length.",
"savedSuccessfully": "Template Saved Successfully.",
"contentCopied": "Content Copied Successfully!"
},
"alert_destinations": {
"header": "Notification Destinations",
@@ -3863,7 +4126,48 @@
"separator_placeholder": "Enter separator (e.g., |, \\n, \\t, space)",
"separator_hint": "Separator string to use between records (spaces allowed)",
"templateDefaultOption": "Default ({name})",
"templateHelp": "Leave as default to use the standard {type} template ({name}), or pick a custom one."
"templateHelp": "Leave as default to use the standard {type} template ({name}), or pick a custom one.",
"loadingData": "Loading destination data...",
"noEmailTemplates": "It looks like you haven't created any Email Templates yet.",
"createEmailTemplate": "Create Email Template",
"headers": "Headers",
"previewError": "Failed to generate preview",
"import": {
"title": "Import Destination",
"errorValidations": "Error Validations",
"outputMessages": "Output Messages",
"destinationName": "Destination Name",
"destinationUrl": "Destination URL",
"destinationMethod": "Destination Method",
"templates": "Templates",
"emails": "Emails (comma separated)",
"actions": "Actions",
"destinationCreation": "Destination Creation",
"jsonStringEmpty": "JSON string is empty",
"invalidJsonFormat": "Invalid JSON format",
"importSuccess": "Successfully imported destination(s)",
"importError": "Error importing Destination please check the JSON",
"unknownError": "Unknown Error",
"nameRequired": "Destination - {index}: The \"name\" field is required and should be a valid string.",
"nameExists": "Destination - {index}: \"{name}\" already exists",
"typeInvalid": "Destination - {index}: The \"type\" field must be either \"email\", \"http\", or \"action\"",
"actionTypeNotSupported": "Destination - {index}: 'action' type is not supported.",
"actionNotFound": "Destination - {index}: action \"{actionId}\" does not exist for type \"{type}\"",
"urlRequired": "Destination - {index}: The \"url\" field is required for http type destinations.",
"methodInvalid": "Destination - {index}: The \"method\" field must be one of \"post\", \"get\", or \"put\"",
"templateRequiredHttp": "Destination - {index}: The \"template\" field is required for http type destinations.",
"skipTlsVerifyRequired": "Destination - {index}: The \"skip_tls_verify\" field is required and should be a boolean value",
"headersMustBeObject": "Destination - {index}: 'headers' should be an object for http type",
"urlNotAllowedEmail": "Destination - {index}: 'url' should not be provided for email type",
"headersNotAllowedEmail": "Destination - {index}: 'headers' should not be provided for email type",
"emailsRequired": "Destination - {index}: The \"emails\" field is required and should be an array for email type destinations.",
"emailsMustBeStrings": "Destination - {index}: 'emails' should be an array of strings for email type",
"templateRequiredEmail": "Destination - {index}: The \"template\" field is required for email type destinations.",
"templateNotFound": "Destination - {index}: template \"{template}\" does not exist for type \"{type}\"",
"actionIdInvalid": "Destination - {index}: The \"action_id\" field must be a valid string.",
"createSuccess": "Destination - {index}: \"{name}\" created successfully \nNote: please remove the created destination object {name} from the json file ",
"createFailed": "Destination - {index}: \"{name}\" creation failed --> \n Reason: {reason}"
}
},
"pipeline_destinations": {
"header": "Pipeline Destinations",
+19
View File
@@ -3134,6 +3134,25 @@
"SearchSQLCancelled": "Error: se canceló Search SQL."
},
"alerts": {
"validation": {
"streamTypeRequired": "El tipo de Stream es obligatorio.",
"streamNameRequired": "El nombre del Stream es obligatorio.",
"fieldRequired": "El campo es obligatorio.",
"fieldRequiredPlain": "El campo es obligatorio",
"nameInvalidChars": "No se permiten caracteres como :, ?, /, # ni espacios.",
"templateRequired": "La plantilla es obligatoria.",
"validEmails": "¡Agrega correos electrónicos válidos!",
"silenceNonNegative": "El silencio de notificaciones debe ser mayor o igual que 0",
"periodPositive": "El período debe ser mayor que 0",
"destinationRequired": "Se requiere al menos un destino.",
"thresholdPositive": "El umbral debe ser mayor que 0",
"frequencyPositive": "La frecuencia debe ser mayor que 0",
"groupByRequired": "Se requiere al menos un campo de agrupación",
"minimumOneDay": "Mínimo 1 día",
"detectionFunctionRequired": "La función de detección es obligatoria",
"timestampAliasBanned": "{column} no se puede usar como alias de columna. Usa time_bucket en su lugar.",
"credentialFieldRequired": "{field} es obligatorio"
},
"header": "Alertas",
"subtitle": "Reglas de alertas y canales de notificación",
"searchThisFolder": "Esta carpeta",
+19
View File
@@ -3134,6 +3134,25 @@
"SearchSQLCancelled": "Erreur : Search SQL a été annulé."
},
"alerts": {
"validation": {
"streamTypeRequired": "Le type de Stream est requis.",
"streamNameRequired": "Le nom du Stream est requis.",
"fieldRequired": "Le champ est requis !",
"fieldRequiredPlain": "Le champ est requis",
"nameInvalidChars": "Les caractères comme :, ?, /, # et les espaces ne sont pas autorisés.",
"templateRequired": "Le modèle est requis !",
"validEmails": "Ajoutez des e-mails valides !",
"silenceNonNegative": "La mise en sourdine des notifications doit être supérieure ou égale à 0",
"periodPositive": "La période doit être supérieure à 0",
"destinationRequired": "Au moins une destination est requise.",
"thresholdPositive": "Le seuil doit être supérieur à 0",
"frequencyPositive": "La fréquence doit être supérieure à 0",
"groupByRequired": "Au moins un champ de regroupement est requis",
"minimumOneDay": "Minimum 1 jour",
"detectionFunctionRequired": "La fonction de détection est requise",
"timestampAliasBanned": "{column} ne peut pas être utilisé comme alias de colonne. Utilisez time_bucket à la place.",
"credentialFieldRequired": "{field} est requis"
},
"header": "Alertes",
"subtitle": "Règles d'alerte et canaux de notification",
"searchThisFolder": "Ce dossier",
+19
View File
@@ -3134,6 +3134,25 @@
"SearchSQLCancelled": "Errore: Search SQL è stato annullato."
},
"alerts": {
"validation": {
"streamTypeRequired": "Il tipo di Stream è obbligatorio.",
"streamNameRequired": "Il nome dello Stream è obbligatorio.",
"fieldRequired": "Il campo è obbligatorio!",
"fieldRequiredPlain": "Il campo è obbligatorio",
"nameInvalidChars": "Caratteri come :, ?, /, # e gli spazi non sono consentiti.",
"templateRequired": "Il modello è obbligatorio!",
"validEmails": "Aggiungi email valide!",
"silenceNonNegative": "Il silenziamento delle notifiche deve essere maggiore o uguale a 0",
"periodPositive": "Il periodo deve essere maggiore di 0",
"destinationRequired": "È richiesta almeno una destinazione.",
"thresholdPositive": "La soglia deve essere maggiore di 0",
"frequencyPositive": "La frequenza deve essere maggiore di 0",
"groupByRequired": "È richiesto almeno un campo di raggruppamento",
"minimumOneDay": "Minimo 1 giorno",
"detectionFunctionRequired": "La funzione di rilevamento è obbligatoria",
"timestampAliasBanned": "{column} non può essere usato come alias di colonna. Usa invece time_bucket.",
"credentialFieldRequired": "{field} è obbligatorio"
},
"header": "Avvisi",
"subtitle": "Regole di avviso e canali di notifica",
"searchThisFolder": "Questa cartella",
+19
View File
@@ -3134,6 +3134,25 @@
"SearchSQLCancelled": "エラー:検索 SQL はキャンセルされました。"
},
"alerts": {
"validation": {
"streamTypeRequired": "Streamタイプは必須です。",
"streamNameRequired": "Stream名は必須です。",
"fieldRequired": "フィールドは必須です!",
"fieldRequiredPlain": "フィールドは必須です",
"nameInvalidChars": ":, ?, /, # やスペースなどの文字は使用できません。",
"templateRequired": "テンプレートは必須です!",
"validEmails": "有効なメールアドレスを追加してください!",
"silenceNonNegative": "通知のサイレンスは0以上である必要があります",
"periodPositive": "期間は0より大きい必要があります",
"destinationRequired": "少なくとも1つの宛先が必要です。",
"thresholdPositive": "しきい値は0より大きい必要があります",
"frequencyPositive": "頻度は0より大きい必要があります",
"groupByRequired": "少なくとも1つのグループ化フィールドが必要です",
"minimumOneDay": "最低1日",
"detectionFunctionRequired": "検出関数は必須です",
"timestampAliasBanned": "{column} は列のエイリアスとして使用できません。代わりに time_bucket を使用してください。",
"credentialFieldRequired": "{field} は必須です"
},
"header": "アラート",
"subtitle": "アラートルールと通知チャネル",
"searchThisFolder": "このフォルダー",
+19
View File
@@ -3134,6 +3134,25 @@
"SearchSQLCancelled": "오류: 검색 SQL이 취소되었습니다."
},
"alerts": {
"validation": {
"streamTypeRequired": "Stream 유형은 필수입니다.",
"streamNameRequired": "Stream 이름은 필수입니다.",
"fieldRequired": "필드는 필수입니다!",
"fieldRequiredPlain": "필드는 필수입니다",
"nameInvalidChars": ":, ?, /, # 및 공백과 같은 문자는 허용되지 않습니다.",
"templateRequired": "템플릿은 필수입니다!",
"validEmails": "유효한 이메일을 추가하세요!",
"silenceNonNegative": "알림 침묵은 0 이상이어야 합니다",
"periodPositive": "기간은 0보다 커야 합니다",
"destinationRequired": "하나 이상의 대상이 필요합니다.",
"thresholdPositive": "임계값은 0보다 커야 합니다",
"frequencyPositive": "빈도는 0보다 커야 합니다",
"groupByRequired": "하나 이상의 그룹화 필드가 필요합니다",
"minimumOneDay": "최소 1일",
"detectionFunctionRequired": "탐지 함수는 필수입니다",
"timestampAliasBanned": "{column}은(는) 열 별칭으로 사용할 수 없습니다. 대신 time_bucket을 사용하세요.",
"credentialFieldRequired": "{field}은(는) 필수입니다"
},
"header": "알림",
"subtitle": "경고 규칙 및 알림 채널",
"searchThisFolder": "이 폴더",
+19
View File
@@ -3134,6 +3134,25 @@
"SearchSQLCancelled": "Fout: Search SQL is geannuleerd."
},
"alerts": {
"validation": {
"streamTypeRequired": "Stream-type is vereist.",
"streamNameRequired": "Stream-naam is vereist.",
"fieldRequired": "Veld is vereist!",
"fieldRequiredPlain": "Veld is vereist",
"nameInvalidChars": "Tekens zoals :, ?, /, # en spaties zijn niet toegestaan.",
"templateRequired": "Sjabloon is vereist!",
"validEmails": "Voeg geldige e-mailadressen toe!",
"silenceNonNegative": "Meldingsdemping moet groter dan of gelijk aan 0 zijn",
"periodPositive": "Periode moet groter dan 0 zijn",
"destinationRequired": "Ten minste één bestemming is vereist.",
"thresholdPositive": "Drempelwaarde moet groter dan 0 zijn",
"frequencyPositive": "Frequentie moet groter dan 0 zijn",
"groupByRequired": "Ten minste één groeperingsveld is vereist",
"minimumOneDay": "Minimaal 1 dag",
"detectionFunctionRequired": "Detectiefunctie is vereist",
"timestampAliasBanned": "{column} kan niet als kolomalias worden gebruikt. Gebruik in plaats daarvan time_bucket.",
"credentialFieldRequired": "{field} is vereist"
},
"header": "Alarmen",
"subtitle": "Waarschuwingsregels en meldingskanalen",
"searchThisFolder": "Deze map",
+19
View File
@@ -3134,6 +3134,25 @@
"SearchSQLCancelled": "Erro: a pesquisa SQL foi cancelada."
},
"alerts": {
"validation": {
"streamTypeRequired": "O tipo de Stream é obrigatório.",
"streamNameRequired": "O nome do Stream é obrigatório.",
"fieldRequired": "O campo é obrigatório!",
"fieldRequiredPlain": "O campo é obrigatório",
"nameInvalidChars": "Caracteres como :, ?, /, # e espaços não são permitidos.",
"templateRequired": "O modelo é obrigatório!",
"validEmails": "Adicione e-mails válidos!",
"silenceNonNegative": "O silenciamento de notificações deve ser maior ou igual a 0",
"periodPositive": "O período deve ser maior que 0",
"destinationRequired": "Pelo menos um destino é obrigatório.",
"thresholdPositive": "O limite deve ser maior que 0",
"frequencyPositive": "A frequência deve ser maior que 0",
"groupByRequired": "Pelo menos um campo de agrupamento é obrigatório",
"minimumOneDay": "Mínimo de 1 dia",
"detectionFunctionRequired": "A função de detecção é obrigatória",
"timestampAliasBanned": "{column} não pode ser usado como alias de coluna. Use time_bucket em vez disso.",
"credentialFieldRequired": "{field} é obrigatório"
},
"header": "Alertas",
"subtitle": "Regras de alerta e canais de notificação",
"searchThisFolder": "Esta pasta",
+19
View File
@@ -3134,6 +3134,25 @@
"SearchSQLCancelled": "Hata: Search SQL iptal edildi."
},
"alerts": {
"validation": {
"streamTypeRequired": "Stream türü gereklidir.",
"streamNameRequired": "Stream adı gereklidir.",
"fieldRequired": "Alan gereklidir!",
"fieldRequiredPlain": "Alan gereklidir",
"nameInvalidChars": ":, ?, /, # ve boşluk gibi karakterlere izin verilmez.",
"templateRequired": "Şablon gereklidir!",
"validEmails": "Geçerli e-postalar ekleyin!",
"silenceNonNegative": "Bildirim susturması 0'a eşit veya 0'dan büyük olmalıdır",
"periodPositive": "Süre 0'dan büyük olmalıdır",
"destinationRequired": "En az bir hedef gereklidir.",
"thresholdPositive": "Eşik 0'dan büyük olmalıdır",
"frequencyPositive": "Sıklık 0'dan büyük olmalıdır",
"groupByRequired": "En az bir gruplama alanı gereklidir",
"minimumOneDay": "En az 1 gün",
"detectionFunctionRequired": "Algılama işlevi gereklidir",
"timestampAliasBanned": "{column} sütun takma adı olarak kullanılamaz. Bunun yerine time_bucket kullanın.",
"credentialFieldRequired": "{field} gereklidir"
},
"header": "Uyarılar",
"subtitle": "Uyarı kuralları ve bildirim kanalları",
"searchThisFolder": "Bu klasör",
+19
View File
@@ -3134,6 +3134,25 @@
"SearchSQLCancelled": "错误:搜索 SQL 已取消。"
},
"alerts": {
"validation": {
"streamTypeRequired": "Stream 类型为必填项。",
"streamNameRequired": "Stream 名称为必填项。",
"fieldRequired": "此字段为必填项!",
"fieldRequiredPlain": "此字段为必填项",
"nameInvalidChars": "不允许使用 :, ?, /, # 和空格等字符。",
"templateRequired": "模板为必填项!",
"validEmails": "请添加有效的电子邮件!",
"silenceNonNegative": "通知静默应大于或等于 0",
"periodPositive": "周期应大于 0",
"destinationRequired": "至少需要一个目标。",
"thresholdPositive": "阈值应大于 0",
"frequencyPositive": "频率应大于 0",
"groupByRequired": "至少需要一个分组字段",
"minimumOneDay": "最少 1 天",
"detectionFunctionRequired": "检测函数为必填项",
"timestampAliasBanned": "{column} 不能用作列别名。请改用 time_bucket。",
"credentialFieldRequired": "{field} 为必填项"
},
"header": "告警",
"subtitle": "警报规则和通知渠道",
"searchThisFolder": "这个文件夹",
+19
View File
@@ -2571,6 +2571,25 @@
"SearchSQLCancelled": "錯誤:搜尋 SQL 已取消。"
},
"alerts": {
"validation": {
"streamTypeRequired": "Stream 類型為必填項。",
"streamNameRequired": "Stream 名稱為必填項。",
"fieldRequired": "此欄位為必填項!",
"fieldRequiredPlain": "此欄位為必填項",
"nameInvalidChars": "不允許使用 :, ?, /, # 和空格等字元。",
"templateRequired": "範本為必填項!",
"validEmails": "請新增有效的電子郵件!",
"silenceNonNegative": "通知靜音應大於或等於 0",
"periodPositive": "週期應大於 0",
"destinationRequired": "至少需要一個目標。",
"thresholdPositive": "閾值應大於 0",
"frequencyPositive": "頻率應大於 0",
"groupByRequired": "至少需要一個分組欄位",
"minimumOneDay": "最少 1 天",
"detectionFunctionRequired": "偵測函式為必填項",
"timestampAliasBanned": "{column} 不能用作資料行別名。請改用 time_bucket。",
"credentialFieldRequired": "{field} 為必填項"
},
"header": "警示",
"add": "新增警示",
"edit": "編輯",
@@ -666,6 +666,70 @@ describe('alertDataTransforms - OR Operator Pipeline Tests', () => {
});
});
describe('removeConditionGroup - root group', () => {
// FilterGroup at depth 0 is handed the root conditions object itself, so
// deleting its last condition emits remove-group with the ROOT's own id.
// The target is never an ITEM of the array being filtered, so this used to
// no-op: the confirm dialog's OK appeared to do nothing.
it('empties the root group when the root itself is the target', () => {
const context: TransformContext = {
formData: {
query_condition: {
conditions: {
filterType: 'group',
groupId: 'root-uuid',
logicalOperator: 'AND',
conditions: [
{
filterType: 'group',
groupId: 'sub-a',
logicalOperator: 'OR',
conditions: [
{ id: '2', column: 'f2', operator: '=', value: 'v2', ignore_case: false },
],
},
{
filterType: 'group',
groupId: 'sub-b',
logicalOperator: 'OR',
conditions: [
{ id: '3', column: 'f3', operator: '=', value: 'v3', ignore_case: false },
],
},
],
},
},
},
};
removeConditionGroup('root-uuid', null, context);
// The dialog promises the whole group and its sub-groups go.
expect(context.formData.query_condition.conditions.conditions).toEqual([]);
});
it('does not touch the tree when the target id is empty', () => {
const context: TransformContext = {
formData: {
query_condition: {
conditions: {
filterType: 'group',
groupId: '',
logicalOperator: 'AND',
conditions: [
{ id: '1', column: 'f1', operator: '=', value: 'v1', ignore_case: false },
],
},
},
},
};
removeConditionGroup('', null, context);
expect(context.formData.query_condition.conditions.conditions).toHaveLength(1);
});
});
describe('removeConditionGroup - OR Operator Context', () => {
it('should remove OR group from root', () => {
const context: TransformContext = {
@@ -143,6 +143,21 @@ export const removeConditionGroup = (
const itemsArray = groupToProcess?.conditions || groupToProcess?.items;
if (!itemsArray || !Array.isArray(itemsArray)) return;
// If the target IS this group, it can't be spliced out of a parent — at the
// root there is no parent, and FilterGroup at depth 0 emits remove-group with
// the root's own id. Empty it instead: that's what the confirm dialog promises
// ("will remove the entire condition group"). Mirrors the root check in
// updateConditionGroup above. Guarded on a truthy id so an id-less group can
// never match and wipe the whole tree.
if (targetGroupId && groupToProcess.groupId === targetGroupId) {
if (groupToProcess.conditions) {
groupToProcess.conditions = [];
} else {
groupToProcess.items = [];
}
return;
}
const filterEmptyGroups = (items: any[]): any[] => {
return items.filter((item: any) => {
if (item.groupId === targetGroupId) {
+129
View File
@@ -71,6 +71,60 @@ describe('alertPayload', () => {
expect(payload.context_attributes.env).toBe('production');
});
// ── Form-only keys must NOT reach the backend ───────────────────────────
// getAlertPayload cloneDeep()s the WHOLE form value set, so every key the
// OForm migration seeded into the form leaks unless explicitly dropped.
// Pre-migration formData had none of these (Rule ④ payload parity).
it('strips the form-only keys (_ui / _meta / logGroupBy)', () => {
const formData: any = {
...createBaseFormData(),
// Display-only: the "Check every" value the user sees (2 = 2 hours).
// The real value is trigger_condition.frequency (120 minutes).
_ui: { checkEvery: 2 },
_meta: {
tab: 'sql',
isRealTime: 'false',
isEventBased: true,
selectedFunction: 'total_events',
frequencyMode: 'hours',
hasConditions: false,
hasGroupBy: false,
aggregationEnabled: false,
minAutoRefreshInterval: 300,
},
logGroupBy: ['field1'],
};
const context = createBaseContext();
const payload = getAlertPayload(formData, context);
expect(payload._ui).toBeUndefined();
expect(payload._meta).toBeUndefined();
expect(payload.logGroupBy).toBeUndefined();
expect(Object.keys(payload)).not.toContain('_ui');
expect(Object.keys(payload)).not.toContain('_meta');
expect(Object.keys(payload)).not.toContain('logGroupBy');
// The real (stored, MINUTES) frequency still ships.
expect(payload.trigger_condition.frequency).toBe(60);
});
it('does not mutate the caller form data when stripping form-only keys', () => {
const formData: any = {
...createBaseFormData(),
_ui: { checkEvery: 2 },
_meta: { frequencyMode: 'hours' },
logGroupBy: ['field1'],
};
getAlertPayload(formData, createBaseContext());
// The form is the source of truth — the payload build must not strip the
// live form's own display state.
expect(formData._ui).toEqual({ checkEvery: 2 });
expect(formData._meta).toEqual({ frequencyMode: 'hours' });
expect(formData.logGroupBy).toEqual(['field1']);
});
it('should convert string trigger conditions to integers', () => {
const formData = createBaseFormData();
const context = createBaseContext();
@@ -137,6 +191,81 @@ describe('alertPayload', () => {
expect(payload.query_condition.aggregation).toBeNull();
});
// having.value / promql_condition.value reach getAlertPayload as the RAW
// STRING the input produced (OFormInput's field.handleChange commits after
// QueryConfig's Number()-coercing handler and overwrites it). Pre-migration
// both went out as numbers, and nothing else rescues them — so these assert
// the TYPE, not just the value. Verified live: before the fix the POST body
// carried "value":"5".
describe('numeric value types (string-vs-number parity with main)', () => {
const aggFormData = (havingValue: any) => {
const f = createBaseFormData();
f.query_condition.type = 'custom';
f.query_condition.aggregation = {
function: 'avg',
group_by: ['cloud_provider'],
having: { column: 'cpu_usage_percent', operator: '>=', value: havingValue },
} as any;
return f;
};
const aggContext = () =>
createBaseContext({
isAggregationEnabled: { value: true },
getSelectedTab: { value: 'custom' },
});
it('coerces aggregation.having.value from the input string to a number', () => {
const payload = getAlertPayload(aggFormData('5'), aggContext());
expect(payload.query_condition.aggregation.having.value).toBe(5);
expect(typeof payload.query_condition.aggregation.having.value).toBe('number');
});
it('keeps having.value zero-safe (0 must not become null/omitted)', () => {
const payload = getAlertPayload(aggFormData('0'), aggContext());
expect(payload.query_condition.aggregation.having.value).toBe(0);
});
it('passes an empty having.value through untouched (no silent 0)', () => {
const payload = getAlertPayload(aggFormData(''), aggContext());
expect(payload.query_condition.aggregation.having.value).toBe('');
});
it('coerces promql_condition.value from the input string to a number', () => {
const formData = createBaseFormData();
formData.query_condition.promql_condition = {
column: 'value',
operator: '>=',
value: '5',
} as any;
const context = createBaseContext({ getSelectedTab: { value: 'promql' } });
const payload = getAlertPayload(formData, context);
expect(payload.query_condition.promql_condition.value).toBe(5);
expect(typeof payload.query_condition.promql_condition.value).toBe('number');
});
it('keeps promql_condition.value zero-safe', () => {
const formData = createBaseFormData();
formData.query_condition.promql_condition = {
column: 'value',
operator: '>=',
value: '0',
} as any;
const context = createBaseContext({ getSelectedTab: { value: 'promql' } });
const payload = getAlertPayload(formData, context);
expect(payload.query_condition.promql_condition.value).toBe(0);
});
// NaN would JSON-serialize to null and the backend would reject it.
it('passes a non-numeric value through rather than shipping NaN', () => {
const payload = getAlertPayload(aggFormData('abc'), aggContext());
expect(payload.query_condition.aggregation.having.value).toBe('abc');
});
});
it('should clear conditions for sql tab', () => {
const formData = createBaseFormData();
formData.query_condition.conditions = [{ field: 'level', operator: '=', value: 'error' }];
+65 -1
View File
@@ -59,6 +59,28 @@ export interface SaveAlertContext {
handleAlertError: (err: any) => void;
}
/**
* Drop the FORM-ONLY keys added by the OForm migration. These are seeded into
* the form by `withFormExtras` and are not part of the alert resource:
* _ui → display-only state (the "Check every" hours/minutes value the
* user sees; the real value is trigger_condition.frequency)
* _meta → schema discriminators (tab / mode / org floor)
* logGroupBy → the logs group-by field array (mirrored into
* query_condition.aggregation.group_by)
* Pre-migration formData had none of these, so dropping them restores the
* pre-migration shape exactly (Rule ④). Mutates and returns `obj`.
*
* BOTH save paths must use this: the normal one (getAlertPayload) and the JSON
* editor one (prepareAndSaveAlert). It is also applied to the JSON editor's
* displayed data, so users never see or edit these internal keys.
*/
export const stripFormExtras = <T>(obj: T): T => {
delete (obj as any)._ui;
delete (obj as any)._meta;
delete (obj as any).logGroupBy;
return obj;
};
export const getAlertPayload = (
formData: PayloadFormData,
context: PayloadContext,
@@ -69,6 +91,10 @@ export const getAlertPayload = (
// Deleting uuid from payload as it was added for reference of frontend
if (payload.uuid) delete payload.uuid;
// Same reason: `payload` is a cloneDeep of the whole form value set, so
// anything seeded into the form leaks to the backend unless dropped here.
stripFormExtras(payload);
payload.is_real_time = payload.is_real_time === "true";
payload.context_attributes = {} as any;
@@ -118,6 +144,40 @@ export const getAlertPayload = (
payload.query_condition.sql = "";
}
// `having.value` and `promql_condition.value` arrive here as the RAW STRING the
// input produced. Both are name=-owned OFormInputs, and OFormInput registers
// `v-bind="$attrs"` BEFORE its own @update:model-value="field.handleChange" — so
// QueryConfig's Number()-coercing consumer handler runs FIRST and handleChange
// commits the raw string LAST, overwriting it. Pre-migration both went out as
// numbers (promql via `v-model.number`; having via a direct Number() write onto
// inputData), so without this the saved type silently drifts string-vs-number.
//
// Coerced HERE rather than in the form, for two reasons:
// • it is the same last-mile rescue threshold/period/frequency/silence already
// get above — one place owns payload numerics;
// • form state must stay the raw string while typing. Coercing on each
// keystroke would fight the user: "5." (mid-way to "5.5") is Number-ed to 5,
// snapping the field back and eating the decimal point.
const toNumericValue = (v: unknown) => {
if (v === "" || v === null || v === undefined) return v;
const n = Number(v);
// Zero-safe (Number("0") === 0). A non-numeric value is passed through
// untouched rather than shipped as NaN (which JSON-serializes to null).
return Number.isNaN(n) ? v : n;
};
if (payload.query_condition.aggregation?.having) {
payload.query_condition.aggregation.having.value = toNumericValue(
payload.query_condition.aggregation.having.value,
);
}
if (payload.query_condition.promql_condition) {
payload.query_condition.promql_condition.value = toNumericValue(
payload.query_condition.promql_condition.value,
);
}
if (formData.query_condition.vrl_function) {
payload.query_condition.vrl_function = b64EncodeUnicode(
formData.query_condition.vrl_function.trim(),
@@ -153,7 +213,11 @@ export const prepareAndSaveAlert = async (
} = context;
const payload = cloneDeep(data);
// The JSON editor's data comes from the form value set, so it can carry the
// form-only keys. Pre-migration formData had none of them (Rule ④).
stripFormExtras(payload);
if (!isAggregationEnabled.value) {
payload.query_condition.aggregation = null;
}
+94 -56
View File
@@ -9,6 +9,21 @@ import CronExpressionParser from "cron-parser";
import { b64EncodeUnicode } from "@/utils/zincutils";
import { toast } from "@/lib/feedback/Toast/useToast";
/**
* Injected translator. Same shape as the one the alerts `*.schema.ts` factories
* take (see `Translator` in components/alerts/steps/QueryConfig.schema.ts) —
* re-declared here rather than imported so this pure util keeps NO dependency on
* the component layer. Callers pass vue-i18n's `t` (from `useI18n()`), which
* this module cannot obtain itself: it has no Vue context.
*/
export type Translator = (key: string, named?: Record<string, unknown>) => string;
/** Last-resort translator for `validateAlert(alert)` — the context (and hence
* `t`) is optional there. Echoes the key back. The ONLY production caller
* (saveAlertJson) always supplies `t`; this exists so the no-context overload
* stays callable (it is exercised by validateAlerts.spec.ts, which asserts
* validity rather than message text on that path). */
const echoKeyTranslator: Translator = (key) => key;
interface QueryCondition {
conditions?: {
@@ -86,6 +101,9 @@ interface AlertValidationContext {
streamList: string[];
destinationsList: string[];
selectedOrgId: string;
/** vue-i18n `t`. Optional only because `context` itself is optional; supply it
* from any caller whose errors reach a user. */
t?: Translator;
}
export interface ValidationResult {
@@ -94,6 +112,7 @@ export interface ValidationResult {
}
export function validateAlert(alert: Alert, context?: AlertValidationContext): ValidationResult {
const t = context?.t ?? echoKeyTranslator;
const result: ValidationResult = {
isValid: true,
errors: []
@@ -101,27 +120,27 @@ export function validateAlert(alert: Alert, context?: AlertValidationContext): V
// Validate name
if (!alert.name || typeof alert.name !== 'string' || alert.name.trim() === '') {
result.errors.push('Name is mandatory and should be a valid string');
result.errors.push(t('alerts.validation.nameMandatoryString'));
}
// Validate org_id if provided
if (alert.org_id) {
if (alert.org_id !== context?.selectedOrgId) {
result.errors.push(`Organization Id should be equal to ${context?.selectedOrgId}`);
result.errors.push(t('alerts.validation.orgIdMismatch', { orgId: context?.selectedOrgId }));
}
}
// Validate stream_type
const validStreamTypes = ['logs', 'metrics', 'traces'];
if (!alert.stream_type || !validStreamTypes.includes(alert.stream_type)) {
result.errors.push('Stream Type is mandatory and should be one of: logs, metrics, traces');
result.errors.push(t('alerts.validation.streamTypeMandatory'));
}
// Validate stream_name
if (!alert.stream_name || typeof alert.stream_name !== 'string') {
result.errors.push('Stream Name is mandatory and should be a valid string');
result.errors.push(t('alerts.validation.streamNameMandatoryString'));
} else if (context?.streamList && !context.streamList.includes(alert.stream_name)) {
result.errors.push(`Stream "${alert.stream_name}" does not exist in the stream list`);
result.errors.push(t('alerts.validation.streamNotInList', { streamName: alert.stream_name }));
}
// Validate is_real_time
@@ -130,7 +149,7 @@ export function validateAlert(alert: Alert, context?: AlertValidationContext): V
alert.is_real_time = alert.is_real_time.toLowerCase() === 'true';
}
if (typeof alert.is_real_time !== 'boolean') {
result.errors.push('Is Real-Time is mandatory and should be a boolean value');
result.errors.push(t('alerts.validation.isRealTimeMandatoryBoolean'));
}
// Validate query_condition
@@ -143,17 +162,17 @@ export function validateAlert(alert: Alert, context?: AlertValidationContext): V
// Format 1: Using groupId and items
if (alert.query_condition.conditions.items) {
if (!Array.isArray(alert.query_condition.conditions.items)) {
result.errors.push('Query conditions items should be an array');
result.errors.push(t('alerts.validation.conditionItemsArray'));
} else {
// Validate each condition item
alert.query_condition.conditions.items.forEach((item, index) => {
if (!item.column || !item.operator || item.value === undefined) {
result.errors.push(`Query condition item ${index + 1} must have column, operator, and value`);
result.errors.push(t('alerts.validation.conditionItemIncomplete', { index: index + 1 }));
}
const validOperators = ['=', '>', '<', '>=', '<=', 'Contains', 'NotContains'];
if (!validOperators.includes(item.operator)) {
result.errors.push(`Invalid operator "${item.operator}" in query condition item ${index + 1}`);
result.errors.push(t('alerts.validation.invalidOperatorInConditionItem', { operator: item.operator, index: index + 1 }));
}
});
}
@@ -162,33 +181,33 @@ export function validateAlert(alert: Alert, context?: AlertValidationContext): V
else if (alert.query_condition.conditions.or || alert.query_condition.conditions.and) {
const conditions = alert.query_condition.conditions.or || alert.query_condition.conditions.and;
if (!Array.isArray(conditions)) {
result.errors.push('Query conditions or/and should be an array');
result.errors.push(t('alerts.validation.conditionsOrAndArray'));
} else {
conditions.forEach((condition, index) => {
if (!condition.column || !condition.operator || condition.value === undefined) {
result.errors.push(`Query condition ${index + 1} must have column, operator, and value`);
result.errors.push(t('alerts.validation.conditionIncomplete', { index: index + 1 }));
}
const validOperators = ['=', '>', '<', '>=', '<=', 'Contains', 'NotContains'];
if (!validOperators.includes(condition.operator)) {
result.errors.push(`Invalid operator "${condition.operator}" in condition ${index + 1}`);
result.errors.push(t('alerts.validation.invalidOperatorInCondition', { operator: condition.operator, index: index + 1 }));
}
// Validate ignore_case if present
if (condition.ignore_case !== undefined && typeof condition.ignore_case !== 'boolean') {
result.errors.push(`ignore_case must be a boolean in condition ${index + 1}`);
result.errors.push(t('alerts.validation.ignoreCaseBoolean', { index: index + 1 }));
}
});
}
} else {
result.errors.push('Invalid conditions format. Must use either items array or or/and array');
result.errors.push(t('alerts.validation.invalidConditionsFormat'));
}
}
// Validate multi_time_range for custom type
if (alert.query_condition.multi_time_range !== null &&
(!Array.isArray(alert.query_condition.multi_time_range) || alert.query_condition.multi_time_range.length !== 0)) {
result.errors.push('Multi Time Range should be an empty array or null');
result.errors.push(t('alerts.validation.multiTimeRangeEmpty'));
}
// Remove the SQL/PromQL validation for custom type since it's not needed
@@ -196,33 +215,33 @@ export function validateAlert(alert: Alert, context?: AlertValidationContext): V
case 'sql':
if (!alert.query_condition.sql || typeof alert.query_condition.sql !== 'string' || alert.query_condition.sql.trim() === '') {
result.errors.push('SQL query is required');
result.errors.push(t('alerts.validation.sqlQueryRequired'));
} else {
const sqlQuery = alert.query_condition.sql.trim().toLowerCase();
// Check for select * pattern
if (sqlQuery.includes('select *') || sqlQuery.includes('select\n*') || sqlQuery.includes('select\t*')) {
result.errors.push('Selecting all columns is not allowed. Please specify the columns explicitly');
result.errors.push(t('alerts.validation.selectAllNotAllowed'));
}
// Check if it's a valid SELECT query
if (!sqlQuery.startsWith('select ')) {
result.errors.push('SQL query must start with SELECT');
result.errors.push(t('alerts.validation.sqlQueryMustStartWithSelect'));
}
}
break;
case 'promql':
if (!alert.query_condition.promql || typeof alert.query_condition.promql !== 'string' || alert.query_condition.promql.trim() === '') {
result.errors.push('PromQL query is required');
result.errors.push(t('alerts.validation.promqlQueryRequired'));
}
break;
default:
result.errors.push('Invalid query condition type');
result.errors.push(t('alerts.validation.invalidQueryConditionType'));
}
// Validate VRL function if present
if (alert.query_condition.vrl_function && typeof alert.query_condition.vrl_function !== 'string') {
result.errors.push('VRL function should be a string when provided');
result.errors.push(t('alerts.validation.vrlFunctionString'));
}
// Validate aggregation if present
@@ -231,22 +250,22 @@ export function validateAlert(alert: Alert, context?: AlertValidationContext): V
// Validate group_by array
if (!Array.isArray(agg.group_by)) {
result.errors.push('Aggregation group_by should be an array');
result.errors.push(t('alerts.validation.aggregationGroupByArray'));
}
// Validate function
if (!agg.function || typeof agg.function !== 'string' || agg.function.trim() === '') {
result.errors.push('Aggregation function is required and should be a non-empty string');
result.errors.push(t('alerts.validation.aggregationFunctionRequired'));
}
// Validate having clause structure
if (agg.having && typeof agg.having === 'object') {
// Only validate operator and value if they are provided
if (agg.having.operator && (typeof agg.having.operator !== 'string' || !['>=', '<=', '>', '<', '=', '!='].includes(agg.having.operator))) {
result.errors.push('Aggregation having clause operator must be a valid comparison operator');
result.errors.push(t('alerts.validation.aggregationHavingOperator'));
}
if (agg.having.value !== undefined && typeof agg.having.value !== 'number') {
result.errors.push('Aggregation having clause value must be a number when provided');
result.errors.push(t('alerts.validation.aggregationHavingValue'));
}
}
}
@@ -258,66 +277,71 @@ export function validateAlert(alert: Alert, context?: AlertValidationContext): V
// Validate period
if (typeof trigger.period !== 'number' || trigger.period < 1) {
result.errors.push('Period should be a positive number greater than 0');
result.errors.push(t('alerts.validation.periodPositiveNumber'));
}
// Validate operator
const validOperators = ['=', '!=', '>=', '<=', '>', '<', 'Contains', 'NotContains'];
if (!validOperators.includes(trigger.operator)) {
result.errors.push(`Invalid operator "${trigger.operator}" in trigger condition`);
result.errors.push(t('alerts.validation.invalidOperatorInTrigger', { operator: trigger.operator }));
}
// Validate frequency
if (typeof trigger.frequency !== 'number' || trigger.frequency < 1) {
result.errors.push('Frequency should be a positive number greater than 0');
result.errors.push(t('alerts.validation.frequencyPositiveNumber'));
}
// Validate threshold
if (typeof trigger.threshold !== 'number' || trigger.threshold < 1) {
result.errors.push('Threshold should be a positive number greater than 0');
result.errors.push(t('alerts.validation.thresholdPositiveNumber'));
}
// Validate silence
if (typeof trigger.silence !== 'number' || trigger.silence < 1) {
result.errors.push('Silence should be a positive number greater than 0');
result.errors.push(t('alerts.validation.silencePositiveNumber'));
}
// Validate frequency_type and related fields
if (!['minutes', 'cron'].includes(trigger.frequency_type)) {
result.errors.push('Frequency Type must be either minutes or cron');
result.errors.push(t('alerts.validation.frequencyTypeMinutesOrCron'));
}
if (trigger.frequency_type === 'cron') {
if (!trigger.cron || trigger.cron.trim() === '') {
result.errors.push('Cron expression is required when frequency type is cron');
result.errors.push(t('alerts.validation.cronRequiredForCronType'));
}
if (!trigger.timezone || trigger.timezone.trim() === '') {
result.errors.push('Timezone is required when frequency type is cron');
result.errors.push(t('alerts.validation.timezoneRequiredForCronType'));
}
}
} else {
result.errors.push('Trigger condition is required');
result.errors.push(t('alerts.validation.triggerConditionRequired'));
}
// Validate destinations
if (!Array.isArray(alert.destinations)) {
result.errors.push('Destinations must be an array');
result.errors.push(t('alerts.validation.destinationsArray'));
} else if (alert.destinations.length === 0) {
result.errors.push('At least one destination is required');
// NOTE: no trailing period — deliberately NOT the same string as
// `alerts.validation.destinationRequired` ("At least one destination is
// required."), which the AlertSettings schema owns.
result.errors.push(t('alerts.validation.destinationRequiredPlain'));
} else if (context?.destinationsList) {
// Debug check for destinations list
if (!Array.isArray(context.destinationsList) || context.destinationsList.length === 0) {
result.errors.push('No available destinations found in system');
result.errors.push(t('alerts.validation.noDestinationsInSystem'));
} else {
const invalidDestinations = alert.destinations.filter(dest => !context.destinationsList.includes(dest));
if (invalidDestinations.length > 0) {
result.errors.push(`Invalid destinations: ${invalidDestinations.map(dest => `"${dest}"`).join(', ')} - must be from available destinations list`);
result.errors.push(t('alerts.validation.invalidDestinations', {
destinations: invalidDestinations.map(dest => `"${dest}"`).join(', '),
}));
}
}
}
// Validate enabled flag
if (typeof alert.enabled !== 'boolean') {
result.errors.push('Enabled flag must be a boolean');
result.errors.push(t('alerts.validation.enabledFlagBoolean'));
}
result.isValid = result.errors.length === 0;
@@ -326,6 +350,8 @@ export function validateAlert(alert: Alert, context?: AlertValidationContext): V
export interface ValidationContext {
store: any;
/** vue-i18n `t`, injected by the composable (this module has no Vue context). */
t: Translator;
validateSqlQueryPromise: any;
sqlQueryErrorMsg: any;
/** Ref<SqlErrorRange[]> — editor squiggle ranges (optional). */
@@ -363,6 +389,8 @@ export interface AlertFormData {
export interface JsonValidationContext {
store: any;
/** vue-i18n `t`, injected by the composable (this module has no Vue context). */
t: Translator;
streams: any;
getStreams: (streamType: string, schema: boolean) => Promise<any>;
getParser: (sql: string) => boolean;
@@ -375,11 +403,13 @@ export const validateInputs = (
context: ValidationContext,
notify: boolean = true,
): boolean => {
const { t } = context;
if (isNaN(Number(input.trigger_condition.silence))) {
notify &&
toast({
variant: "error",
message: "Silence Notification should not be empty",
message: t("alerts.validation.silenceNotificationRequired"),
timeout: 1500,
});
return false;
@@ -394,7 +424,7 @@ export const validateInputs = (
notify &&
toast({
variant: "error",
message: "Period should be greater than 0",
message: t("alerts.validation.periodPositive"),
timeout: 1500,
});
return false;
@@ -411,7 +441,7 @@ export const validateInputs = (
notify &&
toast({
variant: "error",
message: "Threshold should not be empty",
message: t("alerts.validation.thresholdNotEmpty"),
timeout: 1500,
});
return false;
@@ -428,7 +458,7 @@ export const validateInputs = (
notify &&
toast({
variant: "error",
message: "Threshold should not be empty",
message: t("alerts.validation.thresholdNotEmpty"),
timeout: 1500,
});
return false;
@@ -446,7 +476,7 @@ export const validateInputs = (
notify &&
toast({
variant: "error",
message: "Invalid cron expression!",
message: t("alerts.validation.invalidCronExpressionBang"),
timeout: 1500,
});
return false;
@@ -457,7 +487,7 @@ export const validateInputs = (
notify &&
toast({
variant: "error",
message: "Timezone is required for cron schedule",
message: t("alerts.validation.timezoneRequiredForCron"),
timeout: 1500,
});
return false;
@@ -469,7 +499,7 @@ export const validateInputs = (
notify &&
toast({
variant: "error",
message: "Frequency should be greater than 0",
message: t("alerts.validation.frequencyPositive"),
timeout: 1500,
});
return false;
@@ -485,6 +515,7 @@ export const validateSqlQuery = async (
): Promise<void> => {
const {
store,
t,
validateSqlQueryPromise,
sqlQueryErrorMsg,
sqlErrorRanges,
@@ -541,7 +572,7 @@ export const validateSqlQuery = async (
vrlFunctionError.value = res.data.function_error;
toast({
variant: "error",
message: "Invalid VRL Function",
message: t("alerts.validation.invalidVrlFunction"),
});
reject("function_error");
} else vrlFunctionError.value = "";
@@ -551,7 +582,7 @@ export const validateSqlQuery = async (
.catch((err: any) => {
sqlQueryErrorMsg.value = err.response?.data?.message
? err.response?.data?.message
: "Invalid SQL Query";
: t("alerts.validation.invalidSqlQuery");
// Error message is displayed inline below the editor
// No need for toast notification as it's redundant
@@ -580,11 +611,12 @@ export const saveAlertJson = async (
props: any,
validationErrors: any,
showJsonEditorDialog: any,
formData: any,
applyFormData: (obj: any) => void,
context: JsonValidationContext,
): Promise<void> => {
const {
store,
t,
streams,
getParser,
buildQueryPayload,
@@ -607,7 +639,9 @@ export const saveAlertJson = async (
if (err.response.status !== 403) {
toast({
variant: "error",
message: err.response?.data?.message || "Error fetching streams",
message:
err.response?.data?.message ||
t("alerts.validation.errorFetchingStreams"),
});
}
}
@@ -620,6 +654,7 @@ export const saveAlertJson = async (
streamList: streamList,
selectedOrgId: store.state.selectedOrganization.identifier,
destinationsList: destinationsList,
t,
});
if (!validationResult.isValid) {
@@ -636,7 +671,7 @@ export const saveAlertJson = async (
// First check if query has SELECT *
if (!getParser(jsonPayload.query_condition.sql)) {
validationErrors.value = [
"Selecting all columns is not allowed. Please specify the columns explicitly",
t("alerts.validation.selectAllNotAllowed"),
];
return;
}
@@ -666,18 +701,21 @@ export const saveAlertJson = async (
// If we get here, SQL is valid
showJsonEditorDialog.value = false;
formData.value = jsonPayload;
applyFormData(jsonPayload);
await prepareAndSaveAlert(jsonPayload);
} catch (err: any) {
// Handle SQL validation errors
const errorMessage = err.response?.data?.message || "Invalid SQL Query";
validationErrors.value = [`SQL validation error: ${errorMessage}`];
const errorMessage =
err.response?.data?.message || t("alerts.validation.invalidSqlQuery");
validationErrors.value = [
t("alerts.validation.sqlValidationError", { error: errorMessage }),
];
return;
}
} else {
// If not SQL or is real-time, just close and update
showJsonEditorDialog.value = false;
formData.value = jsonPayload;
applyFormData(jsonPayload);
await prepareAndSaveAlert(jsonPayload);
}
};
+5 -4
View File
@@ -92,25 +92,26 @@ export const discordConfig: PrebuiltConfig = {
credentialFields: [
{
key: 'webhookUrl',
label: 'Discord Webhook URL',
labelKey: 'alerts.prebuiltDestinations.discordWebhookUrl',
type: 'text',
required: true,
hint: 'Get your webhook URL from Discord channel settings',
validator: (url: string) => {
const invalid = { key: 'alerts.prebuiltDestinations.invalidDiscordWebhookUrl' };
try {
const parsed = new URL(url);
const hostname = parsed.hostname.toLowerCase();
return ((hostname === 'discord.com' || hostname.endsWith('.discord.com')) &&
parsed.pathname.startsWith('/api/webhooks/')) ||
'Invalid Discord webhook URL';
invalid;
} catch {
return 'Invalid Discord webhook URL';
return invalid;
}
}
},
{
key: 'username',
label: 'Bot Username (optional)',
labelKey: 'alerts.prebuiltDestinations.discordUsername',
type: 'text',
required: false,
hint: 'Custom username for the webhook bot'
@@ -58,7 +58,7 @@ describe('email template', () => {
const recipientsField = emailConfig.credentialFields.find(f => f.key === 'recipients');
expect(recipientsField).toBeDefined();
expect(recipientsField?.required).toBe(true);
expect(recipientsField?.label).toBeTruthy();
expect(recipientsField?.labelKey).toBeTruthy();
});
it('recipients field has validator', () => {
+5 -2
View File
@@ -105,7 +105,7 @@ export const emailConfig: PrebuiltConfig = {
credentialFields: [
{
key: 'recipients',
label: 'Recipient Email Addresses',
labelKey: 'alerts.prebuiltDestinations.emailRecipients',
type: 'email',
required: true,
hint: 'Comma-separated email addresses',
@@ -113,7 +113,10 @@ export const emailConfig: PrebuiltConfig = {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const emailList = emails.split(',').map(e => e.trim());
const invalidEmails = emailList.filter(email => !emailRegex.test(email));
return invalidEmails.length === 0 || `Invalid email addresses: ${invalidEmails.join(', ')}`;
return invalidEmails.length === 0 || {
key: 'alerts.prebuiltDestinations.invalidEmailAddresses',
params: { emails: invalidEmails.join(', ') }
};
}
}
// CC and Subject fields removed - not supported by backend Email struct
@@ -378,12 +378,12 @@ describe('Prebuilt Templates Index', () => {
Object.values(PREBUILT_CONFIGS).forEach(config => {
config.credentialFields.forEach(field => {
expect(field).toHaveProperty('key');
expect(field).toHaveProperty('label');
expect(field).toHaveProperty('labelKey');
expect(field).toHaveProperty('type');
expect(field).toHaveProperty('required');
expect(typeof field.key).toBe('string');
expect(typeof field.label).toBe('string');
expect(typeof field.labelKey).toBe('string');
expect(['text', 'password', 'email', 'select', 'toggle']).toContain(field.type);
expect(typeof field.required).toBe('boolean');
});
+4 -2
View File
@@ -116,12 +116,14 @@ export const msteamsConfig: PrebuiltConfig = {
credentialFields: [
{
key: 'webhookUrl',
label: 'Microsoft Teams Webhook URL',
labelKey: 'alerts.prebuiltDestinations.msteamsWebhookUrl',
type: 'text',
required: true,
hint: 'Get your webhook URL from Teams channel connectors or Workflows',
validator: (url: string) =>
isValidTeamsWebhookUrl(url) || 'Invalid Microsoft Teams webhook URL'
isValidTeamsWebhookUrl(url) || {
key: 'alerts.prebuiltDestinations.invalidMsteamsWebhookUrl'
}
}
]
};
+6 -4
View File
@@ -57,23 +57,25 @@ export const opsgenieConfig: PrebuiltConfig = {
credentialFields: [
{
key: 'apiKey',
label: 'Opsgenie API Key',
labelKey: 'alerts.prebuiltDestinations.opsgenieApiKey',
type: 'password',
required: true,
hint: 'Get your API key from Opsgenie integration settings',
validator: (key: string) =>
key.length > 30 || 'Opsgenie API key should be longer than 30 characters'
key.length > 30 || {
key: 'alerts.prebuiltDestinations.opsgenieApiKeyLength'
}
},
{
key: 'euRegion',
label: 'EU Region',
labelKey: 'alerts.prebuiltDestinations.opsgenieEuRegion',
type: 'toggle',
required: false,
hint: 'Enable for EU-based Opsgenie instances'
},
{
key: 'priority',
label: 'Default Priority',
labelKey: 'alerts.prebuiltDestinations.opsgeniePriority',
type: 'select',
required: false,
options: [
@@ -63,16 +63,18 @@ export const pagerdutyConfig: PrebuiltConfig = {
credentialFields: [
{
key: 'integrationKey',
label: 'Integration Key',
labelKey: 'alerts.prebuiltDestinations.pagerdutyIntegrationKey',
type: 'password',
required: true,
hint: 'Get your integration key from PagerDuty service settings',
validator: (key: string) =>
key.length === 32 || 'PagerDuty integration key should be 32 characters'
key.length === 32 || {
key: 'alerts.prebuiltDestinations.pagerdutyIntegrationKeyLength'
}
},
{
key: 'severity',
label: 'Default Severity',
labelKey: 'alerts.prebuiltDestinations.pagerdutySeverity',
type: 'select',
required: true,
options: [
+15 -8
View File
@@ -80,33 +80,40 @@ export const servicenowConfig: PrebuiltConfig = {
credentialFields: [
{
key: 'instanceUrl',
label: 'ServiceNow Instance URL',
labelKey: 'alerts.prebuiltDestinations.servicenowInstanceUrl',
type: 'text',
required: true,
hint: 'https://your-instance.service-now.com/api/now/table/incident',
validator: (url: string) =>
isValidServiceNowIncidentUrl(url) ||
'URL should be like https://instance.service-now.com/api/now/table/incident'
isValidServiceNowIncidentUrl(url) || {
key: 'alerts.prebuiltDestinations.servicenowInstanceUrlFormat'
}
},
{
key: 'username',
label: 'Username',
labelKey: 'common.username',
type: 'text',
required: true,
hint: 'ServiceNow username with incident creation permissions',
validator: (value: string) => value.trim().length > 0 || 'Username is required'
validator: (value: string) =>
value.trim().length > 0 || {
key: 'alerts.prebuiltDestinations.usernameRequired'
}
},
{
key: 'password',
label: 'Password',
labelKey: 'common.password',
type: 'password',
required: true,
hint: 'ServiceNow password or API token',
validator: (value: string) => value.trim().length > 0 || 'Password is required'
validator: (value: string) =>
value.trim().length > 0 || {
key: 'alerts.prebuiltDestinations.passwordRequired'
}
},
{
key: 'assignmentGroup',
label: 'Assignment Group (optional)',
labelKey: 'alerts.prebuiltDestinations.servicenowAssignmentGroup',
type: 'text',
required: false,
hint: 'Group to assign incidents to (e.g., IT Operations)'
+5 -4
View File
@@ -85,25 +85,26 @@ export const slackConfig: PrebuiltConfig = {
credentialFields: [
{
key: 'webhookUrl',
label: 'Slack Webhook URL',
labelKey: 'alerts.prebuiltDestinations.slackWebhookUrl',
type: 'text',
required: true,
hint: 'Get your webhook URL from Slack App settings',
validator: (url: string) => {
const invalid = { key: 'alerts.prebuiltDestinations.invalidSlackWebhookUrl' };
try {
const parsed = new URL(url);
const hostname = parsed.hostname.toLowerCase();
return (parsed.protocol === 'https:' &&
(hostname === 'hooks.slack.com' || hostname.endsWith('.hooks.slack.com'))) ||
'Invalid Slack webhook URL';
invalid;
} catch {
return 'Invalid Slack webhook URL';
return invalid;
}
}
},
{
key: 'channel',
label: 'Channel (optional)',
labelKey: 'alerts.prebuiltDestinations.slackChannel',
type: 'text',
required: false,
hint: 'e.g., #alerts'
@@ -206,7 +206,7 @@ describe('Template consistency', () => {
templates.forEach(({ name, config }) => {
config.credentialFields?.forEach((field: any) => {
expect(field).toHaveProperty('key');
expect(field).toHaveProperty('label');
expect(field).toHaveProperty('labelKey');
expect(field).toHaveProperty('type');
expect(field).toHaveProperty('required');
});
+12 -9
View File
@@ -48,13 +48,13 @@ describe('Prebuilt Templates Types', () => {
it('should have required properties', () => {
const field: CredentialField = {
key: 'apiKey',
label: 'API Key',
labelKey: 'alerts.prebuiltDestinations.opsgenieApiKey',
type: 'password',
required: true
};
expect(field).toHaveProperty('key');
expect(field).toHaveProperty('label');
expect(field).toHaveProperty('labelKey');
expect(field).toHaveProperty('type');
expect(field).toHaveProperty('required');
});
@@ -62,7 +62,7 @@ describe('Prebuilt Templates Types', () => {
it('should support optional hint property', () => {
const field: CredentialField = {
key: 'apiKey',
label: 'API Key',
labelKey: 'alerts.prebuiltDestinations.opsgenieApiKey',
type: 'password',
required: true,
hint: 'Enter your API key here'
@@ -71,11 +71,12 @@ describe('Prebuilt Templates Types', () => {
expect(field.hint).toBe('Enter your API key here');
});
it('should support optional validator function', () => {
const validator = (value: string) => value.length > 0 || 'Required';
it('should support optional validator function returning an i18n message', () => {
const validator = (value: string) =>
value.length > 0 || { key: 'alerts.validation.credentialFieldRequired' };
const field: CredentialField = {
key: 'apiKey',
label: 'API Key',
labelKey: 'alerts.prebuiltDestinations.opsgenieApiKey',
type: 'password',
required: true,
validator
@@ -83,13 +84,15 @@ describe('Prebuilt Templates Types', () => {
expect(typeof field.validator).toBe('function');
expect(field.validator?.('test')).toBe(true);
expect(field.validator?.('')).toBe('Required');
expect(field.validator?.('')).toEqual({
key: 'alerts.validation.credentialFieldRequired'
});
});
it('should support select type with options', () => {
const field: CredentialField = {
key: 'priority',
label: 'Priority',
labelKey: 'alerts.prebuiltDestinations.opsgeniePriority',
type: 'select',
required: true,
options: [
@@ -123,7 +126,7 @@ describe('Prebuilt Templates Types', () => {
credentialFields: [
{
key: 'webhookUrl',
label: 'Webhook URL',
labelKey: 'alerts.prebuiltDestinations.slackWebhookUrl',
type: 'text',
required: true
}
+24 -2
View File
@@ -19,17 +19,39 @@ import { Component } from 'vue';
*/
export type PrebuiltTypeId = 'slack' | 'discord' | 'msteams' | 'pagerduty' | 'servicenow' | 'email' | 'opsgenie';
/**
* A user-facing validation message, expressed as an i18n KEY (+ optional
* interpolation params) rather than English copy.
*
* These config modules are plain, Vue-less objects: they have no `useI18n()` and
* therefore cannot translate anything themselves. So they describe WHAT is wrong
* and let the two consumers — both of which do have a `t` — render it:
* • `PrebuiltDestinationForm.schema.ts` (`makePrebuiltDestinationSchema`)
* • `usePrebuiltDestinations.ts` (`validateCredentials`)
*/
export interface ValidationMessage {
key: string;
params?: Record<string, unknown>;
}
/** `true` when valid; otherwise the i18n message describing the failure. */
export type CredentialValidatorResult = true | ValidationMessage;
/**
* Credential field configuration
*
* `labelKey` is an i18n KEY (not English): it is interpolated into
* `alerts.validation.credentialFieldRequired` ("{field} is required") by the
* consumers above, so it must be resolved with `t()` before display.
*/
export interface CredentialField {
key: string;
label: string;
labelKey: string;
type: 'text' | 'password' | 'email' | 'select' | 'toggle';
required: boolean;
hint?: string;
options?: Array<{ label: string; value: string; description?: string }>;
validator?: (value: string) => boolean | string;
validator?: (value: string) => CredentialValidatorResult;
}
/**
+10 -1
View File
@@ -14,8 +14,16 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, it, expect } from "vitest";
import i18n from "@/locales";
import { validateAlert, ValidationResult } from "./alerts/alertValidation";
// validateAlert's messages are i18n-driven now — the pure util has no Vue
// context, so callers inject `t` through the validation context. Resolve through
// the REAL locale (same convention as the alerts *.schema.spec.ts files) so the
// assertions below keep verifying the exact English AND prove the keys exist.
const t = (key: string, named?: Record<string, unknown>): string =>
(i18n.global.t as any)(key, named);
describe("Alert Validation", () => {
const validAlert = {
name: "Test Alert",
@@ -57,7 +65,8 @@ describe("Alert Validation", () => {
const validContext = {
streamList: ["test_stream", "other_stream"],
destinationsList: ["webhook1", "email1"],
selectedOrgId: "org123"
selectedOrgId: "org123",
t
};
describe("Valid alert validation", () => {