mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 00:08:55 +00:00
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:
@@ -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();
|
||||
|
||||
+21
-17
@@ -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
@@ -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: {},
|
||||
});
|
||||
@@ -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: "",
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
+697
-425
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(() => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "" } },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "このフォルダー",
|
||||
|
||||
@@ -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": "이 폴더",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "这个文件夹",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' }];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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)'
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user