mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 00:08:55 +00:00
fix: form migration with zod for management module (#12877)
- form migration with zod schema for management module --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Abhay Padamani <141122205+ktx-abhay@users.noreply.github.com>
This commit is contained in:
co-authored by
github-actions[bot]
Claude Fable 5
Abhay Padamani
parent
be8e0adda3
commit
232642bef2
@@ -305,6 +305,24 @@ export class ModelPricingPage {
|
||||
}, { timeout: 5000 }).toBe(true);
|
||||
}
|
||||
|
||||
/** Fill a tier's "add price" draft row WITHOUT committing it (no Add click).
|
||||
* Used to exercise the save-time draft-key guard: an uncommitted key with
|
||||
* spaces or a pure-integer key produces a toast at submit. (Committing the
|
||||
* row instead surfaces an inline per-row error via the schema.)
|
||||
* @param {string} key
|
||||
* @param {number|string} valuePricePerMillion
|
||||
* @param {number} [tierIndex=0]
|
||||
*/
|
||||
async fillDraftPriceRow(key, valuePricePerMillion, tierIndex = 0) {
|
||||
const keyInput = this.page.locator(`[data-test="model-pricing-add-price-key-input-${tierIndex}-field"]`);
|
||||
await keyInput.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await keyInput.fill(key);
|
||||
|
||||
const valueInput = this.page.locator(`[data-test="model-pricing-add-price-value-input-${tierIndex}-field"]`);
|
||||
await valueInput.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await valueInput.fill(String(valuePricePerMillion));
|
||||
}
|
||||
|
||||
async clickSave() {
|
||||
await this.saveBtn.click();
|
||||
await Promise.race([
|
||||
|
||||
@@ -176,20 +176,27 @@ test.describe("Model Pricing — Create & Form Validation", () => {
|
||||
const pm = new PageManager(page);
|
||||
await pm.modelPricingPage.gotoEditorCreate();
|
||||
|
||||
// Both fields empty → save disabled
|
||||
await expect(pm.modelPricingPage.saveBtn).toBeDisabled({ timeout: 5000 });
|
||||
// Save is a plain submit button — the OForm has no disabled-gate and
|
||||
// validates submit-then-change, so field errors surface only after the
|
||||
// first submit attempt (not on load / first keystroke).
|
||||
await expect(pm.modelPricingPage.saveBtn).toBeEnabled();
|
||||
|
||||
// Name too long → inline error; fix → error clears
|
||||
// Both fields empty → the first submit arms validation: required errors
|
||||
// appear and the editor stays open (submit is blocked).
|
||||
await pm.modelPricingPage.saveBtn.click();
|
||||
await expect(pm.modelPricingPage.nameInputError).toBeVisible({ timeout: 5000 });
|
||||
await expect(pm.modelPricingPage.patternInputError).toBeVisible({ timeout: 5000 });
|
||||
await pm.modelPricingPage.editorTitle.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Name too long → inline error; fix → error clears (revalidates on change)
|
||||
await pm.modelPricingPage.fillName('a'.repeat(257));
|
||||
await expect(pm.modelPricingPage.nameInputError).toBeVisible({ timeout: 5000 });
|
||||
await expect(pm.modelPricingPage.saveBtn).toBeDisabled();
|
||||
await pm.modelPricingPage.fillName(validName);
|
||||
await expect(pm.modelPricingPage.nameInputError).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Invalid regex → inline error; fix → error clears
|
||||
await pm.modelPricingPage.fillPattern('[invalid(');
|
||||
await expect(pm.modelPricingPage.patternInputError).toBeVisible({ timeout: 5000 });
|
||||
await expect(pm.modelPricingPage.saveBtn).toBeDisabled();
|
||||
await pm.modelPricingPage.fillPattern(`^${validName}.*`);
|
||||
await expect(pm.modelPricingPage.patternInputError).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
@@ -197,13 +204,19 @@ test.describe("Model Pricing — Create & Form Validation", () => {
|
||||
await pm.modelPricingPage.saveBtn.click();
|
||||
await pm.modelPricingPage.editorTitle.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Key with spaces → toast error (filter by text — earlier toasts may still be visible)
|
||||
await pm.modelPricingPage.addPriceRow('input tokens', '0.000002');
|
||||
// Draft key with spaces → save-time toast. The "Add price" button now
|
||||
// commits any non-empty key (bad keys get an inline row error instead),
|
||||
// so key validation as a TOAST fires via the save() draft-row guard on an
|
||||
// uncommitted draft. Leaving it uncommitted also keeps the form clean for
|
||||
// the successful save below. (Filter by text — earlier toasts may linger.)
|
||||
await pm.modelPricingPage.fillDraftPriceRow('input tokens', '0.000002');
|
||||
await pm.modelPricingPage.saveBtn.click();
|
||||
await expect(pm.modelPricingPage.toastMessage.filter({ hasText: /must not contain spaces/i }))
|
||||
.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Pure integer key → toast error
|
||||
await pm.modelPricingPage.addPriceRow('123', '0.000002');
|
||||
// Draft key that is a pure integer → save-time toast
|
||||
await pm.modelPricingPage.fillDraftPriceRow('123', '0.000002');
|
||||
await pm.modelPricingPage.saveBtn.click();
|
||||
await expect(pm.modelPricingPage.toastMessage.filter({ hasText: /cannot be a pure integer/i }))
|
||||
.toBeVisible({ timeout: 5000 });
|
||||
|
||||
|
||||
@@ -295,24 +295,25 @@ test.describe("Settings ModelPricingEditor form validation", () => {
|
||||
testLogger.info('Navigated to Settings > Model Pricing');
|
||||
});
|
||||
|
||||
test("should disable save or show error when model pricing name is empty", {
|
||||
test("should show error when model pricing name is empty", {
|
||||
tag: ['@domainFormValidation', '@P0', '@smoke']
|
||||
}, async ({ page }) => {
|
||||
testLogger.info('Verifying save is disabled or error shown when model pricing name is empty');
|
||||
testLogger.info('Verifying name error shown when model pricing name is empty');
|
||||
|
||||
// Clear the name field to trigger validation
|
||||
// Clear the name field
|
||||
await pm.settingsFormValidation.clearModelPricingName();
|
||||
|
||||
const saveBtn = pm.settingsFormValidation.getModelPricingSaveBtnLocator();
|
||||
// Attempt save to trigger validation. The OForm validates submit-then-change,
|
||||
// so the required-field error surfaces only after the first submit attempt
|
||||
// (the Save button itself is never disabled by validity).
|
||||
await pm.settingsFormValidation.clickModelPricingSave();
|
||||
|
||||
const nameError = pm.settingsFormValidation.getModelPricingNameErrorLocator();
|
||||
await nameError.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await expect(nameError).toBeVisible();
|
||||
await expect(nameError).toContainText('Model name is required');
|
||||
|
||||
// Either the save button is disabled OR an error message is shown
|
||||
const saveDisabled = await saveBtn.isDisabled().catch(() => false);
|
||||
const errorVisible = await nameError.isVisible().catch(() => false);
|
||||
|
||||
expect(saveDisabled || errorVisible).toBe(true);
|
||||
|
||||
testLogger.info('Save button disabled or name error shown for empty model pricing name');
|
||||
testLogger.info('Name error correctly shown when empty model pricing name');
|
||||
});
|
||||
|
||||
test("should show pattern error when pattern is empty", {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from "vitest";
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import DomainManagement from "./DomainManagement.vue";
|
||||
import { isValidDomain, isValidEmail } from "./DomainManagement.schema";
|
||||
import i18n from "@/locales";
|
||||
import { nextTick } from "vue";
|
||||
import {
|
||||
@@ -207,8 +208,7 @@ describe("DomainManagement Integration Tests", () => {
|
||||
expect(vm.domains.length).toBe(3); // From mockDomainData.complex
|
||||
|
||||
// 2. Add a new domain
|
||||
vm.newDomain = "newcompany.com";
|
||||
await vm.addDomain();
|
||||
await vm.addDomain({ newDomain: "newcompany.com" });
|
||||
expect(vm.domains.length).toBe(4);
|
||||
expect(vm.domains[3].name).toBe("newcompany.com");
|
||||
expect(vm.domains[3].allowAllUsers).toBe(true);
|
||||
@@ -217,12 +217,10 @@ describe("DomainManagement Integration Tests", () => {
|
||||
vm.domains[3].allowAllUsers = false;
|
||||
|
||||
// 4. Add emails to restricted domain
|
||||
vm.domains[3].newEmail = "admin@newcompany.com";
|
||||
await vm.addEmail(vm.domains[3]);
|
||||
await vm.addEmail(vm.domains[3], "admin@newcompany.com");
|
||||
expect(vm.domains[3].allowedEmails).toContain("admin@newcompany.com");
|
||||
|
||||
vm.domains[3].newEmail = "user@newcompany.com";
|
||||
await vm.addEmail(vm.domains[3]);
|
||||
await vm.addEmail(vm.domains[3], "user@newcompany.com");
|
||||
expect(vm.domains[3].allowedEmails).toContain("user@newcompany.com");
|
||||
expect(vm.domains[3].allowedEmails.length).toBe(2);
|
||||
|
||||
@@ -288,7 +286,7 @@ describe("DomainManagement Integration Tests", () => {
|
||||
domainValidationTestCases.forEach(({ input, expected, description }) => {
|
||||
it(`should ${expected ? 'accept' : 'reject'} ${description}: "${input}"`, () => {
|
||||
const vm = wrapper.vm;
|
||||
expect(vm.isValidDomain(input)).toBe(expected);
|
||||
expect(isValidDomain(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -297,7 +295,7 @@ describe("DomainManagement Integration Tests", () => {
|
||||
emailValidationTestCases.forEach(({ email, domain, expected, description }) => {
|
||||
it(`should ${expected ? 'accept' : 'reject'} ${description}`, () => {
|
||||
const vm = wrapper.vm;
|
||||
expect(vm.isValidEmail(email, domain)).toBe(expected);
|
||||
expect(isValidEmail(email, domain)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -339,8 +337,7 @@ describe("DomainManagement Integration Tests", () => {
|
||||
const initialState = JSON.parse(JSON.stringify(vm.domains));
|
||||
|
||||
// Add domain
|
||||
vm.newDomain = "test.com";
|
||||
await vm.addDomain();
|
||||
await vm.addDomain({ newDomain: "test.com" });
|
||||
|
||||
// Verify state change
|
||||
expect(vm.domains.length).toBe(initialState.length + 1);
|
||||
@@ -380,44 +377,39 @@ describe("DomainManagement Integration Tests", () => {
|
||||
expect(wrapper.text()).toContain(vm.domains.length.toString());
|
||||
|
||||
// Add a domain and check count update
|
||||
vm.newDomain = "test.com";
|
||||
await vm.addDomain();
|
||||
await vm.addDomain({ newDomain: "test.com" });
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toContain(vm.domains.length.toString());
|
||||
});
|
||||
|
||||
it("should enable/disable buttons based on validation", async () => {
|
||||
it("keeps the add-domain button always enabled (R3 — validation is schema-driven)", async () => {
|
||||
wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
|
||||
const vm = wrapper.vm;
|
||||
|
||||
// Test add domain button state
|
||||
vm.newDomain = "";
|
||||
await nextTick();
|
||||
|
||||
// Try to find the add domain button with a simpler selector
|
||||
const addDomainButton = wrapper.find('button');
|
||||
const buttons = wrapper.findAll('button');
|
||||
|
||||
// Look for button with "Add Domain" text
|
||||
let addButton = null;
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
if (buttons[i].text().includes('Add Domain')) {
|
||||
addButton = buttons[i];
|
||||
break;
|
||||
const findAddButton = () => {
|
||||
const buttons = wrapper.findAll("button");
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
if (buttons[i].text().includes("Add Domain")) return buttons[i];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Empty input → button stays enabled (no :disabled gate); the schema
|
||||
// blocks the actual submit instead.
|
||||
vm.addDomainForm.form.setFieldValue("newDomain", "");
|
||||
await nextTick();
|
||||
let addButton = findAddButton();
|
||||
if (addButton) {
|
||||
expect(addButton.element.disabled).toBe(true);
|
||||
expect(addButton.element.disabled).toBe(false);
|
||||
}
|
||||
|
||||
// Set valid domain
|
||||
vm.newDomain = "valid.com";
|
||||
// Valid domain → still enabled.
|
||||
vm.addDomainForm.form.setFieldValue("newDomain", "valid.com");
|
||||
await nextTick();
|
||||
|
||||
addButton = findAddButton();
|
||||
if (addButton) {
|
||||
expect(addButton.element.disabled).toBe(false);
|
||||
}
|
||||
@@ -473,7 +465,7 @@ describe("DomainManagement Integration Tests", () => {
|
||||
];
|
||||
|
||||
for (const maliciousDomain of maliciousDomains) {
|
||||
expect(vm.isValidDomain(maliciousDomain)).toBe(false);
|
||||
expect(isValidDomain(maliciousDomain)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -488,7 +480,7 @@ describe("DomainManagement Integration Tests", () => {
|
||||
];
|
||||
|
||||
for (const maliciousEmail of maliciousEmails) {
|
||||
expect(vm.isValidEmail(maliciousEmail, "example.com")).toBe(false);
|
||||
expect(isValidEmail(maliciousEmail, "example.com")).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -497,15 +489,15 @@ describe("DomainManagement Integration Tests", () => {
|
||||
const longDomain = "a".repeat(250) + ".com"; // Domain names should be max 253 chars
|
||||
const veryLongDomain = "a".repeat(300) + ".com";
|
||||
|
||||
expect(vm.isValidDomain(longDomain)).toBe(false);
|
||||
expect(vm.isValidDomain(veryLongDomain)).toBe(false);
|
||||
expect(isValidDomain(longDomain)).toBe(false);
|
||||
expect(isValidDomain(veryLongDomain)).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject extremely long email addresses", async () => {
|
||||
const vm = wrapper.vm;
|
||||
const longEmail = "a".repeat(300) + "@example.com"; // Email addresses have practical limits
|
||||
|
||||
expect(vm.isValidEmail(longEmail, "example.com")).toBe(false);
|
||||
expect(isValidEmail(longEmail, "example.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject Unicode and special character exploits", async () => {
|
||||
@@ -519,7 +511,7 @@ describe("DomainManagement Integration Tests", () => {
|
||||
];
|
||||
|
||||
for (const unicodeDomain of unicodeExploits) {
|
||||
expect(vm.isValidDomain(unicodeDomain)).toBe(false);
|
||||
expect(isValidDomain(unicodeDomain)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -553,15 +545,13 @@ describe("DomainManagement Integration Tests", () => {
|
||||
name: "test.com",
|
||||
allowAllUsers: false,
|
||||
allowedEmails: [],
|
||||
newEmail: ""
|
||||
};
|
||||
|
||||
vm.domains.push(testDomain);
|
||||
|
||||
// Add many emails
|
||||
for (let i = 0; i < 50; i++) {
|
||||
testDomain.newEmail = `user${i}@test.com`;
|
||||
await vm.addEmail(testDomain);
|
||||
await vm.addEmail(testDomain, `user${i}@test.com`);
|
||||
}
|
||||
|
||||
expect(testDomain.allowedEmails.length).toBe(50);
|
||||
@@ -571,16 +561,14 @@ describe("DomainManagement Integration Tests", () => {
|
||||
const vm = wrapper.vm;
|
||||
|
||||
// Test empty domain
|
||||
vm.newDomain = "";
|
||||
expect(vm.isValidDomain("")).toBe(true); // Empty is considered valid (optional)
|
||||
expect(isValidDomain("")).toBe(true); // Empty is considered valid (optional)
|
||||
|
||||
// Test whitespace-only domain
|
||||
vm.newDomain = " ";
|
||||
expect(vm.isValidDomain(" ")).toBe(false);
|
||||
expect(isValidDomain(" ")).toBe(false);
|
||||
|
||||
// Test tabs and newlines
|
||||
expect(vm.isValidDomain("\t\n")).toBe(false);
|
||||
expect(vm.isValidEmail(" ", "example.com")).toBe(false);
|
||||
expect(isValidDomain("\t\n")).toBe(false);
|
||||
expect(isValidEmail(" ", "example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -591,8 +579,7 @@ describe("DomainManagement Integration Tests", () => {
|
||||
// Rapid domain additions
|
||||
const addPromises = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
vm.newDomain = `rapid${i}.com`;
|
||||
addPromises.push(vm.addDomain());
|
||||
addPromises.push(vm.addDomain({ newDomain: `rapid${i}.com` }));
|
||||
}
|
||||
|
||||
await Promise.all(addPromises);
|
||||
@@ -608,8 +595,7 @@ describe("DomainManagement Integration Tests", () => {
|
||||
const savePromise = vm.saveChanges();
|
||||
|
||||
// Modify data while saving
|
||||
vm.newDomain = "concurrent.com";
|
||||
vm.addDomain();
|
||||
vm.addDomain({ newDomain: "concurrent.com" });
|
||||
|
||||
// Should complete without errors
|
||||
await expect(savePromise).resolves.not.toThrow();
|
||||
@@ -687,8 +673,7 @@ describe("DomainManagement Integration Tests", () => {
|
||||
const vm = wrapper.vm;
|
||||
|
||||
// Start operations
|
||||
vm.newDomain = "test.com";
|
||||
const addPromise = vm.addDomain();
|
||||
const addPromise = vm.addDomain({ newDomain: "test.com" });
|
||||
const savePromise = vm.saveChanges();
|
||||
|
||||
// Unmount component immediately
|
||||
@@ -705,8 +690,7 @@ describe("DomainManagement Integration Tests", () => {
|
||||
const vm = wrapper.vm;
|
||||
|
||||
// Trigger various operations to create potential listeners
|
||||
vm.newDomain = "test.com";
|
||||
await vm.addDomain();
|
||||
await vm.addDomain({ newDomain: "test.com" });
|
||||
await vm.saveChanges();
|
||||
|
||||
// Store a reference to test that the component exists
|
||||
@@ -738,16 +722,21 @@ describe("DomainManagement Integration Tests", () => {
|
||||
|
||||
it("should maintain form state during validation errors", async () => {
|
||||
const vm = wrapper.vm;
|
||||
|
||||
// Set up form data
|
||||
vm.newDomain = "invalid domain with spaces";
|
||||
|
||||
// Try to add invalid domain
|
||||
await vm.addDomain();
|
||||
|
||||
// Form should maintain state and not be corrupted
|
||||
expect(vm.newDomain).toBe("invalid domain with spaces");
|
||||
expect(vm.domains.length).toBe(3); // Should not have been added
|
||||
const initialCount = vm.domains.length;
|
||||
|
||||
// Set up form data — an invalid domain.
|
||||
vm.addDomainForm.form.setFieldValue("newDomain", "invalid domain with spaces");
|
||||
await nextTick();
|
||||
|
||||
// Submit through the real form: the schema blocks the invalid domain,
|
||||
// so @submit (addDomain) never runs — nothing is added and the field
|
||||
// keeps its value.
|
||||
await vm.addDomainForm.form.handleSubmit();
|
||||
await flushPromises();
|
||||
|
||||
expect(vm.addDomainForm.form.state.isValid).toBe(false);
|
||||
expect(vm.addDomainForm.form.state.values.newDomain).toBe("invalid domain with spaces");
|
||||
expect(vm.domains.length).toBe(initialCount); // Should not have been added
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// Validation schemas for the two add-rows in DomainManagement.vue (Settings >
|
||||
// Domain Management). This is an add-to-list / dynamic-array shape (partial
|
||||
// fit): only the two add-rows are OForm-validated; the rendered domain/email
|
||||
// lists + radios are display, and the save-time "domain needs emails" check
|
||||
// stays a submit-level guard in saveChanges().
|
||||
//
|
||||
// Restores the Quasar BEFORE rules (truthy→Zod inversion):
|
||||
// • newDomain: `isValidDomain(v) || invalidDomain` → required + domain regex
|
||||
// (the audit adds ≤253 + reject-malicious, already enforced by isValidDomain).
|
||||
// • domain.newEmail: REQUIRED for the Add-Email action — the email must be
|
||||
// present, valid, and belong to the domain (clicking "Add Email" with an
|
||||
// empty box shows "Email is required" on first submit). The add-email form is
|
||||
// its OWN OForm, so requiring it does NOT affect the domain-level
|
||||
// "Save Changes" button. (Deliberate UX choice over the old empty-passes rule.)
|
||||
//
|
||||
// The pure validators live here so they are shared by the schema AND re-exposed
|
||||
// from the component (the spec exercises them directly). Validation TIMING is
|
||||
// owned by OForm (submit-then-change); these schemas only describe what is valid.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export const isValidDomain = (domain: any): boolean => {
|
||||
// Handle null, undefined, and non-string inputs
|
||||
if (domain === null || domain === undefined) return true; // Empty is valid
|
||||
if (typeof domain !== "string") return false; // Non-strings are invalid
|
||||
|
||||
// Handle empty strings - empty is valid, but whitespace-only is not
|
||||
const trimmed = domain.trim();
|
||||
if (!trimmed) return domain.length === 0; // Empty string is valid, whitespace-only is not
|
||||
|
||||
// Security: Check for potentially malicious content (more targeted patterns)
|
||||
const maliciousPatterns = [
|
||||
"<script", "</script", "javascript:", "DROP TABLE", "SELECT FROM",
|
||||
"INSERT INTO", "UPDATE SET", "DELETE FROM", "UNION SELECT", "--", "/*",
|
||||
"*/", "\0", "\n", "\r",
|
||||
];
|
||||
|
||||
const upperDomain = trimmed.toUpperCase();
|
||||
if (
|
||||
maliciousPatterns.some((pattern) =>
|
||||
upperDomain.includes(pattern.toUpperCase()),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Length validation (DNS limit is 253 characters)
|
||||
if (trimmed.length > 253) return false;
|
||||
|
||||
// Remove trailing dot if present (valid in DNS)
|
||||
const cleanDomain = trimmed.endsWith(".") ? trimmed.slice(0, -1) : trimmed;
|
||||
|
||||
// Each label can be 1-63 characters, the domain must have at least one dot.
|
||||
const domainRegex =
|
||||
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
|
||||
|
||||
try {
|
||||
return domainRegex.test(cleanDomain);
|
||||
} catch (error) {
|
||||
return false; // Any regex error means invalid
|
||||
}
|
||||
};
|
||||
|
||||
export const isValidEmail = (email: any, domain: any): boolean => {
|
||||
// Handle null, undefined, and non-string inputs
|
||||
if (email === null || email === undefined || typeof email !== "string")
|
||||
return false;
|
||||
if (domain === null || domain === undefined || typeof domain !== "string")
|
||||
return false;
|
||||
|
||||
// Handle empty strings
|
||||
if (!email.trim() || !domain.trim()) return false;
|
||||
|
||||
// Security: Check for potentially malicious content
|
||||
const maliciousPatterns = [
|
||||
"<", ">", "script", "javascript:", "DROP", "SELECT", "INSERT", "UPDATE",
|
||||
"DELETE", "UNION", "CREATE", "ALTER", "TABLE", "FROM", "--", "/*", "*/",
|
||||
"'", '"', "\0", "\n", "\r", "\t",
|
||||
];
|
||||
|
||||
const upperEmail = email.toUpperCase();
|
||||
if (
|
||||
maliciousPatterns.some((pattern) =>
|
||||
upperEmail.includes(pattern.toUpperCase()),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Length validation (practical email limit)
|
||||
if (email.length > 254 || domain.length > 253) return false;
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
try {
|
||||
if (!emailRegex.test(email)) return false;
|
||||
// Check if email belongs to the domain
|
||||
return email.toLowerCase().endsWith(`@${domain.toLowerCase()}`);
|
||||
} catch (error) {
|
||||
return false; // Any error means invalid
|
||||
}
|
||||
};
|
||||
|
||||
// ── Add-domain row: required + valid domain ──────────────────────────────────
|
||||
export const makeAddDomainSchema = (t: (_key: string) => string) =>
|
||||
z.object({
|
||||
newDomain: z
|
||||
.string()
|
||||
.min(1, t("settings.domainRequired") || "Domain is required")
|
||||
.refine((v) => isValidDomain(v), {
|
||||
message:
|
||||
t("settings.invalidDomain") ||
|
||||
"Please enter a valid domain (e.g. example.com)",
|
||||
}),
|
||||
});
|
||||
|
||||
export type AddDomainForm = z.infer<ReturnType<typeof makeAddDomainSchema>>;
|
||||
|
||||
// Typed default-values factory (matches the pattern used by every other
|
||||
// settings form: licenseDefaults / extendTrialDefaults / generalSettingsDefaults
|
||||
// …) instead of an inline `{ newDomain: '' }` literal in the template.
|
||||
export const addDomainDefaults = (): AddDomainForm => ({ newDomain: "" });
|
||||
|
||||
// ── Add-email row (per-domain): REQUIRED + valid + belongs to the domain.
|
||||
// Empty → "Email is required" (first submit); non-empty invalid → email error.
|
||||
// Its own OForm, so requiring it does not gate the domain-level save. ────────
|
||||
export const makeAddEmailSchema = (
|
||||
domainName: string,
|
||||
t: (_key: string) => string,
|
||||
) =>
|
||||
z.object({
|
||||
newEmail: z
|
||||
.string()
|
||||
.min(1, t("settings.emailRequired") || "Email is required")
|
||||
.refine((v) => isValidEmail(v, domainName), {
|
||||
message: t("settings.invalidEmail") || "Please enter a valid email",
|
||||
}),
|
||||
});
|
||||
|
||||
export type AddEmailForm = z.infer<ReturnType<typeof makeAddEmailSchema>>;
|
||||
|
||||
// Typed default-values factory for the per-domain add-email row. The default is
|
||||
// the same blank value for every domain, so a plain factory (not keyed by
|
||||
// domain) is enough.
|
||||
export const addEmailDefaults = (): AddEmailForm => ({ newEmail: "" });
|
||||
@@ -16,6 +16,11 @@
|
||||
import { DOMWrapper, flushPromises, mount } from "@vue/test-utils";
|
||||
import { describe, expect, it, beforeEach, vi, afterEach, afterAll } from "vitest";
|
||||
import DomainManagement from "./DomainManagement.vue";
|
||||
import {
|
||||
isValidDomain,
|
||||
isValidEmail,
|
||||
makeAddEmailSchema,
|
||||
} from "./DomainManagement.schema";
|
||||
import i18n from "@/locales";
|
||||
import { nextTick } from "vue";
|
||||
|
||||
@@ -245,29 +250,29 @@ describe("DomainManagement Component", () => {
|
||||
const vm = wrapper.vm;
|
||||
|
||||
// Valid domains
|
||||
expect(vm.isValidDomain("example.com")).toBe(true);
|
||||
expect(vm.isValidDomain("sub.example.com")).toBe(true);
|
||||
expect(vm.isValidDomain("my-company.org")).toBe(true);
|
||||
expect(isValidDomain("example.com")).toBe(true);
|
||||
expect(isValidDomain("sub.example.com")).toBe(true);
|
||||
expect(isValidDomain("my-company.org")).toBe(true);
|
||||
|
||||
// Invalid domains
|
||||
expect(vm.isValidDomain("")).toBe(true); // Empty is considered valid for optional validation
|
||||
expect(vm.isValidDomain("invalid")).toBe(false);
|
||||
expect(vm.isValidDomain("spaces in domain.com")).toBe(false);
|
||||
expect(vm.isValidDomain(".example.com")).toBe(false);
|
||||
expect(isValidDomain("")).toBe(true); // Empty is considered valid for optional validation
|
||||
expect(isValidDomain("invalid")).toBe(false);
|
||||
expect(isValidDomain("spaces in domain.com")).toBe(false);
|
||||
expect(isValidDomain(".example.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("should validate email addresses for specific domains", async () => {
|
||||
const vm = wrapper.vm;
|
||||
|
||||
// Valid emails for domain
|
||||
expect(vm.isValidEmail("user@example.com", "example.com")).toBe(true);
|
||||
expect(vm.isValidEmail("admin@company.org", "company.org")).toBe(true);
|
||||
expect(isValidEmail("user@example.com", "example.com")).toBe(true);
|
||||
expect(isValidEmail("admin@company.org", "company.org")).toBe(true);
|
||||
|
||||
// Invalid emails
|
||||
expect(vm.isValidEmail("", "example.com")).toBe(false);
|
||||
expect(vm.isValidEmail("invalid-email", "example.com")).toBe(false);
|
||||
expect(vm.isValidEmail("user@different.com", "example.com")).toBe(false);
|
||||
expect(vm.isValidEmail("user@example.com", "different.org")).toBe(false);
|
||||
expect(isValidEmail("", "example.com")).toBe(false);
|
||||
expect(isValidEmail("invalid-email", "example.com")).toBe(false);
|
||||
expect(isValidEmail("user@different.com", "example.com")).toBe(false);
|
||||
expect(isValidEmail("user@example.com", "different.org")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -280,10 +285,9 @@ describe("DomainManagement Component", () => {
|
||||
it("should add a new domain successfully", async () => {
|
||||
const vm = wrapper.vm;
|
||||
|
||||
vm.newDomain = "newdomain.com";
|
||||
await vm.addDomain();
|
||||
await vm.addDomain({ newDomain: "newdomain.com" });
|
||||
await nextTick();
|
||||
|
||||
|
||||
expect(vm.domains).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "newdomain.com",
|
||||
@@ -296,9 +300,7 @@ describe("DomainManagement Component", () => {
|
||||
it("should not add invalid domain", async () => {
|
||||
const vm = wrapper.vm;
|
||||
|
||||
vm.newDomain = "invalid-domain";
|
||||
|
||||
expect(vm.isValidDomain("invalid-domain")).toBe(false);
|
||||
expect(isValidDomain("invalid-domain")).toBe(false);
|
||||
});
|
||||
|
||||
it("should not add duplicate domain", async () => {
|
||||
@@ -309,8 +311,7 @@ describe("DomainManagement Component", () => {
|
||||
const initialDomainsCount = vm.domains.length;
|
||||
|
||||
// Try to add a domain that already exists
|
||||
vm.newDomain = "example.com";
|
||||
await vm.addDomain();
|
||||
await vm.addDomain({ newDomain: "example.com" });
|
||||
|
||||
expect(vm.domains.length).toBe(initialDomainsCount);
|
||||
});
|
||||
@@ -318,10 +319,56 @@ describe("DomainManagement Component", () => {
|
||||
it("should clear input after adding domain", async () => {
|
||||
const vm = wrapper.vm;
|
||||
|
||||
vm.newDomain = "newdomain.com";
|
||||
await vm.addDomain();
|
||||
|
||||
expect(vm.newDomain).toBe("");
|
||||
vm.addDomainForm.form.setFieldValue("newDomain", "newdomain.com");
|
||||
await vm.addDomain({ newDomain: "newdomain.com" });
|
||||
|
||||
expect(vm.addDomainForm.form.state.values.newDomain).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Add-domain schema validation (real OForm)", () => {
|
||||
beforeEach(async () => {
|
||||
wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("blocks submit and does NOT add a domain when the input is empty", async () => {
|
||||
const vm = wrapper.vm;
|
||||
const initialCount = vm.domains.length;
|
||||
|
||||
await vm.addDomainForm.form.handleSubmit();
|
||||
await flushPromises();
|
||||
|
||||
expect(vm.addDomainForm.form.state.isValid).toBe(false);
|
||||
expect(vm.domains.length).toBe(initialCount);
|
||||
});
|
||||
|
||||
it("blocks submit when the domain is invalid (restored domain regex rule)", async () => {
|
||||
const vm = wrapper.vm;
|
||||
const initialCount = vm.domains.length;
|
||||
|
||||
vm.addDomainForm.form.setFieldValue("newDomain", "not-a-domain");
|
||||
await nextTick();
|
||||
await vm.addDomainForm.form.handleSubmit();
|
||||
await flushPromises();
|
||||
|
||||
expect(vm.addDomainForm.form.state.isValid).toBe(false);
|
||||
expect(vm.domains.length).toBe(initialCount);
|
||||
});
|
||||
|
||||
it("adds the domain when a valid value is submitted through the form", async () => {
|
||||
const vm = wrapper.vm;
|
||||
|
||||
vm.addDomainForm.form.setFieldValue("newDomain", "valid-domain.com");
|
||||
await nextTick();
|
||||
await vm.addDomainForm.form.handleSubmit();
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
vm.domains.some((d: any) => d.name === "valid-domain.com"),
|
||||
).toBe(true);
|
||||
// Inline add-row cleared after a successful add.
|
||||
expect(vm.addDomainForm.form.state.values.newDomain).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -371,12 +418,10 @@ describe("DomainManagement Component", () => {
|
||||
if (restrictedDomain) {
|
||||
const initialEmailCount = restrictedDomain.allowedEmails.length;
|
||||
|
||||
restrictedDomain.newEmail = `newuser@${restrictedDomain.name}`;
|
||||
await vm.addEmail(restrictedDomain);
|
||||
|
||||
await vm.addEmail(restrictedDomain, `newuser@${restrictedDomain.name}`);
|
||||
|
||||
expect(restrictedDomain.allowedEmails.length).toBe(initialEmailCount + 1);
|
||||
expect(restrictedDomain.allowedEmails).toContain(`newuser@${restrictedDomain.name}`);
|
||||
expect(restrictedDomain.newEmail).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -387,8 +432,7 @@ describe("DomainManagement Component", () => {
|
||||
if (restrictedDomain) {
|
||||
const initialEmailCount = restrictedDomain.allowedEmails.length;
|
||||
|
||||
restrictedDomain.newEmail = "invalid-email";
|
||||
await vm.addEmail(restrictedDomain);
|
||||
await vm.addEmail(restrictedDomain, "invalid-email");
|
||||
|
||||
expect(restrictedDomain.allowedEmails.length).toBe(initialEmailCount);
|
||||
}
|
||||
@@ -402,8 +446,7 @@ describe("DomainManagement Component", () => {
|
||||
const initialEmailCount = restrictedDomain.allowedEmails.length;
|
||||
const existingEmail = restrictedDomain.allowedEmails[0];
|
||||
|
||||
restrictedDomain.newEmail = existingEmail;
|
||||
await vm.addEmail(restrictedDomain);
|
||||
await vm.addEmail(restrictedDomain, existingEmail);
|
||||
|
||||
expect(restrictedDomain.allowedEmails.length).toBe(initialEmailCount);
|
||||
}
|
||||
@@ -450,7 +493,6 @@ describe("DomainManagement Component", () => {
|
||||
name: "test.com",
|
||||
allowAllUsers: false,
|
||||
allowedEmails: [],
|
||||
newEmail: "",
|
||||
});
|
||||
|
||||
await vm.saveChanges();
|
||||
@@ -489,14 +531,14 @@ describe("DomainManagement Component", () => {
|
||||
const vm = wrapper.vm;
|
||||
|
||||
// Modify some data
|
||||
vm.newDomain = "test-input";
|
||||
|
||||
vm.addDomainForm.form.setFieldValue("newDomain", "test-input");
|
||||
|
||||
// Clear the mock call count from beforeEach/mount
|
||||
mockDomainManagement.getDomainRestrictions.mockClear();
|
||||
|
||||
|
||||
await vm.resetForm();
|
||||
|
||||
expect(vm.newDomain).toBe("");
|
||||
|
||||
expect(vm.addDomainForm.form.state.values.newDomain).toBe("");
|
||||
expect(mockDomainManagement.getDomainRestrictions).toHaveBeenCalledTimes(1); // Called once by resetForm
|
||||
});
|
||||
});
|
||||
@@ -601,8 +643,7 @@ describe("DomainManagement Component", () => {
|
||||
];
|
||||
|
||||
for (const xss of xssAttempts) {
|
||||
vm.newDomain = xss;
|
||||
expect(vm.isValidDomain(xss)).toBe(false);
|
||||
expect(isValidDomain(xss)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -618,8 +659,8 @@ describe("DomainManagement Component", () => {
|
||||
];
|
||||
|
||||
for (const sql of sqlInjections) {
|
||||
expect(vm.isValidDomain(sql)).toBe(false);
|
||||
expect(vm.isValidEmail(sql + "@test.com", "test.com")).toBe(false);
|
||||
expect(isValidDomain(sql)).toBe(false);
|
||||
expect(isValidEmail(sql + "@test.com", "test.com")).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -629,14 +670,14 @@ describe("DomainManagement Component", () => {
|
||||
const vm = wrapper.vm;
|
||||
|
||||
// Test null/undefined domain validation
|
||||
expect(vm.isValidDomain(null)).toBe(true); // null treated as empty
|
||||
expect(vm.isValidDomain(undefined)).toBe(true); // undefined treated as empty
|
||||
expect(isValidDomain(null)).toBe(true); // null treated as empty
|
||||
expect(isValidDomain(undefined)).toBe(true); // undefined treated as empty
|
||||
|
||||
// Test null/undefined email validation
|
||||
expect(vm.isValidEmail(null, "test.com")).toBe(false);
|
||||
expect(vm.isValidEmail(undefined, "test.com")).toBe(false);
|
||||
expect(vm.isValidEmail("test@test.com", null)).toBe(false);
|
||||
expect(vm.isValidEmail("test@test.com", undefined)).toBe(false);
|
||||
expect(isValidEmail(null, "test.com")).toBe(false);
|
||||
expect(isValidEmail(undefined, "test.com")).toBe(false);
|
||||
expect(isValidEmail("test@test.com", null)).toBe(false);
|
||||
expect(isValidEmail("test@test.com", undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject non-string inputs", async () => {
|
||||
@@ -652,8 +693,8 @@ describe("DomainManagement Component", () => {
|
||||
];
|
||||
|
||||
for (const input of nonStringInputs) {
|
||||
expect(vm.isValidDomain(input)).toBe(false);
|
||||
expect(vm.isValidEmail(input, "test.com")).toBe(false);
|
||||
expect(isValidDomain(input)).toBe(false);
|
||||
expect(isValidEmail(input, "test.com")).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -663,8 +704,8 @@ describe("DomainManagement Component", () => {
|
||||
const hugeDomain = "a".repeat(10000) + ".com";
|
||||
const hugeEmail = "a".repeat(10000) + "@test.com";
|
||||
|
||||
expect(vm.isValidDomain(hugeDomain)).toBe(false);
|
||||
expect(vm.isValidEmail(hugeEmail, "test.com")).toBe(false);
|
||||
expect(isValidDomain(hugeDomain)).toBe(false);
|
||||
expect(isValidEmail(hugeEmail, "test.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -709,8 +750,7 @@ describe("DomainManagement Component", () => {
|
||||
|
||||
// Rapid form submissions
|
||||
for (let i = 0; i < 10; i++) {
|
||||
vm.newDomain = `rapid${i}.com`;
|
||||
vm.addDomain(); // Don't await to simulate rapid clicking
|
||||
vm.addDomain({ newDomain: `rapid${i}.com` }); // Don't await to simulate rapid clicking
|
||||
}
|
||||
|
||||
// Wait for all operations to complete
|
||||
@@ -721,8 +761,10 @@ describe("DomainManagement Component", () => {
|
||||
|
||||
it("should maintain performance with large datasets", async () => {
|
||||
const vm = wrapper.vm;
|
||||
|
||||
// Add many domains (reduced count to prevent timeout)
|
||||
|
||||
// Add many domains (reduced count to prevent timeout). Each restricted
|
||||
// domain now renders its own add-email <OForm>, so this stress scenario
|
||||
// is a little heavier — give it more headroom under parallel CPU load.
|
||||
const largeDomainSet = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
largeDomainSet.push({
|
||||
@@ -735,11 +777,10 @@ describe("DomainManagement Component", () => {
|
||||
vm.domains.splice(0, vm.domains.length, ...largeDomainSet);
|
||||
|
||||
// Should still function normally
|
||||
vm.newDomain = "newdomain.com";
|
||||
await vm.addDomain();
|
||||
await vm.addDomain({ newDomain: "newdomain.com" });
|
||||
|
||||
expect(vm.domains.length).toBe(51);
|
||||
}, 10000);
|
||||
}, 20000);
|
||||
});
|
||||
|
||||
describe("State Consistency Testing", () => {
|
||||
@@ -751,7 +792,6 @@ describe("DomainManagement Component", () => {
|
||||
name: "test.com",
|
||||
allowAllUsers: false,
|
||||
allowedEmails: ["user1@test.com"],
|
||||
newEmail: ""
|
||||
});
|
||||
|
||||
const domain = vm.domains[vm.domains.length - 1];
|
||||
@@ -774,10 +814,10 @@ describe("DomainManagement Component", () => {
|
||||
|
||||
// Rapid validation calls
|
||||
const validationPromises = [
|
||||
Promise.resolve(vm.isValidDomain("test1.com")),
|
||||
Promise.resolve(vm.isValidDomain("test2.com")),
|
||||
Promise.resolve(vm.isValidEmail("user@test.com", "test.com")),
|
||||
Promise.resolve(vm.isValidEmail("admin@test.com", "test.com"))
|
||||
Promise.resolve(isValidDomain("test1.com")),
|
||||
Promise.resolve(isValidDomain("test2.com")),
|
||||
Promise.resolve(isValidEmail("user@test.com", "test.com")),
|
||||
Promise.resolve(isValidEmail("admin@test.com", "test.com"))
|
||||
];
|
||||
|
||||
const results = await Promise.all(validationPromises);
|
||||
@@ -795,8 +835,7 @@ describe("DomainManagement Component", () => {
|
||||
const vm = wrapper.vm;
|
||||
|
||||
// Start long-running operations
|
||||
vm.newDomain = "test.com";
|
||||
const addPromise = vm.addDomain();
|
||||
const addPromise = vm.addDomain({ newDomain: "test.com" });
|
||||
const savePromise = vm.saveChanges();
|
||||
|
||||
// Destroy component immediately
|
||||
@@ -819,13 +858,11 @@ describe("DomainManagement Component", () => {
|
||||
name: `stress-test-${i}.com`,
|
||||
allowAllUsers: false,
|
||||
allowedEmails: [`user@stress-test-${i}.com`],
|
||||
newEmail: ""
|
||||
});
|
||||
}
|
||||
|
||||
// Component operations should still work under load
|
||||
vm.newDomain = "memory-test.com";
|
||||
await vm.addDomain();
|
||||
await vm.addDomain({ newDomain: "memory-test.com" });
|
||||
|
||||
expect(vm.domains.some(d => d.name === "memory-test.com")).toBe(true);
|
||||
expect(vm.domains.length).toBe(initialCount + 30 + 1); // Original + stress domains + new domain
|
||||
@@ -836,3 +873,82 @@ describe("DomainManagement Component", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeAddEmailSchema (required: empty fails, must be valid + belong to domain)", () => {
|
||||
const schema = makeAddEmailSchema("example.com", (k: string) => k);
|
||||
|
||||
it("FAILS when the email is empty/omitted (required for the Add-Email action)", () => {
|
||||
// Option A: the Add-Email field is required, so empty → "Email is required".
|
||||
// (No .trim() — matches pre-migration; whitespace-only is caught as invalid.)
|
||||
expect(schema.safeParse({ newEmail: "" }).success).toBe(false);
|
||||
expect(schema.safeParse({ newEmail: undefined }).success).toBe(false);
|
||||
expect(schema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
|
||||
it("passes a valid email that belongs to the domain", () => {
|
||||
expect(schema.safeParse({ newEmail: "user@example.com" }).success).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a malformed email or one from another domain", () => {
|
||||
expect(schema.safeParse({ newEmail: "invalid-email" }).success).toBe(false);
|
||||
expect(schema.safeParse({ newEmail: "user@other.com" }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
describe("Add-email form — first-submit validation (repro)", () => {
|
||||
const createWrapper = () =>
|
||||
mount(DomainManagement, { global: { plugins: [i18n] } });
|
||||
const emailFormFor = async (wrapper: any, name: string) => {
|
||||
const vm = wrapper.vm as any;
|
||||
vm.domains.push({ name, allowAllUsers: false, allowedEmails: [] });
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
return vm.emailFormRefs[name]?.form;
|
||||
};
|
||||
|
||||
it("INVALID email on the FIRST submit reveals the error and does not add", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const form = await emailFormFor(wrapper, "openobaseve.ai");
|
||||
expect(form).toBeTruthy();
|
||||
|
||||
form.setFieldValue("newEmail", "abc"); // invalid
|
||||
await nextTick();
|
||||
await form.handleSubmit(); // FIRST submit
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
const domain = (wrapper.vm as any).domains.find(
|
||||
(d: any) => d.name === "openobaseve.ai",
|
||||
);
|
||||
expect(domain.allowedEmails).not.toContain("abc");
|
||||
// The bug would be: isValid true / no error on first submit.
|
||||
expect(form.state.isValid).toBe(false);
|
||||
expect((form.getFieldMeta("newEmail")?.errors ?? []).length).toBeGreaterThan(0);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("EMPTY email on the FIRST submit shows 'required' and does not add (Option A)", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const form = await emailFormFor(wrapper, "openobaseve.ai");
|
||||
expect(form).toBeTruthy();
|
||||
|
||||
await form.handleSubmit(); // FIRST submit, empty
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
const domain = (wrapper.vm as any).domains.find(
|
||||
(d: any) => d.name === "openobaseve.ai",
|
||||
);
|
||||
expect(domain.allowedEmails).toEqual([]);
|
||||
expect(form.state.isValid).toBe(false); // required now → empty blocked
|
||||
expect((form.getFieldMeta("newEmail")?.errors ?? []).length).toBeGreaterThan(0);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,31 +168,32 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<div class="text-base font-bold mb-3">
|
||||
{{ t("settings.domainAndAllowedUsers") }}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-x-2 items-center">
|
||||
<!-- Fixed-width wrapper: OInput fills its parent (w-full), so
|
||||
constraining the parent sizes the input without overriding it. -->
|
||||
|
||||
<OForm
|
||||
ref="addDomainForm"
|
||||
:schema="addDomainSchema"
|
||||
:default-values="addDomainDefaults()"
|
||||
@submit="addDomain"
|
||||
v-slot="{ isSubmitting }"
|
||||
class="flex gap-x-2 items-start"
|
||||
>
|
||||
<div class="w-[18.75rem] shrink-0">
|
||||
<OInput
|
||||
<OFormInput
|
||||
data-test="domain-management-new-domain-input"
|
||||
v-model="newDomain"
|
||||
name="newDomain"
|
||||
class="domain-input"
|
||||
@keydown.enter="addDomain"
|
||||
:placeholder="t('settings.domainPlaceholder')"
|
||||
:error="!!domainError"
|
||||
:error-message="domainError"
|
||||
@update:model-value="domainError = ''"
|
||||
/>
|
||||
</div>
|
||||
<OButton
|
||||
data-test="domain-management-add-domain-btn"
|
||||
variant="primary"
|
||||
size="sm-action"
|
||||
@click="addDomain"
|
||||
:disabled="!newDomain || !isValidDomain(newDomain)"
|
||||
type="submit"
|
||||
:loading="isSubmitting"
|
||||
>{{ t('settings.addDomain') }}
|
||||
</OButton>
|
||||
</div>
|
||||
</OForm>
|
||||
<div class="text-xs text-gray-400 mt-1">
|
||||
{{ t('settings.domainHint', { at_sign: '@' }) }}
|
||||
</div>
|
||||
@@ -211,7 +212,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:class="store.state.theme === 'dark' ? 'border-[#444] bg-[#1e1e1e]' : 'bg-white'"
|
||||
>
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-b-(--o2-border) rounded-t-lg" :class="store.state.theme === 'dark' ? 'bg-[#2a2a2a] border-b-[#444]' : 'bg-[#f5f5f5]'">
|
||||
<div class="text-base font-bold" :data-test="`domain-management-domain-name-${domain.name}`">{{ domain.name }}</div>
|
||||
<div
|
||||
:data-test="`domain-management-domain-name-${domain.name}`"
|
||||
class="text-base font-bold"
|
||||
>{{ domain.name }}</div>
|
||||
<OButton
|
||||
icon-left="close"
|
||||
variant="ghost-destructive"
|
||||
@@ -249,20 +253,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<!-- Specific users section -->
|
||||
<div v-if="!domain.allowAllUsers" class="specific-users-section ml-6">
|
||||
<div class="flex gap-x-2 items-end">
|
||||
<OInput
|
||||
v-model="domain.newEmail"
|
||||
:label="t('settings.emailPlaceholder', { domain: '@' + domain.name })"
|
||||
class="email-input min-w-62.5"
|
||||
@keydown.enter="addEmail(domain)"
|
||||
/>
|
||||
<OButton
|
||||
variant="primary"
|
||||
size="sm-action"
|
||||
@click="addEmail(domain)"
|
||||
:disabled="!domain.newEmail || !isValidEmail(domain.newEmail, domain.name)"
|
||||
>{{ t('settings.addEmail') }}</OButton>
|
||||
</div>
|
||||
<OForm
|
||||
:ref="(el) => setEmailFormRef(domain.name, el)"
|
||||
:schema="getEmailSchema(domain.name)"
|
||||
:default-values="addEmailDefaults()"
|
||||
@submit="(v) => addEmail(domain, v.newEmail)"
|
||||
v-slot="{ isSubmitting }"
|
||||
>
|
||||
<!-- Hint label above the row, so the error can grow below the
|
||||
input without shoving the Add button out of alignment. -->
|
||||
<div class="o-input-label text-sm font-semibold leading-tight mb-1">
|
||||
{{ t('settings.emailPlaceholder', { domain: '@' + domain.name }) }}
|
||||
</div>
|
||||
<div class="flex gap-x-2 items-start">
|
||||
<OFormInput
|
||||
name="newEmail"
|
||||
class="email-input min-w-62.5"
|
||||
/>
|
||||
<OButton
|
||||
variant="primary"
|
||||
size="sm-action"
|
||||
type="submit"
|
||||
:loading="isSubmitting"
|
||||
>{{ t('settings.addEmail') }}</OButton>
|
||||
</div>
|
||||
</OForm>
|
||||
|
||||
<!-- Email List -->
|
||||
<div v-if="domain.allowedEmails && domain.allowedEmails.length > 0">
|
||||
@@ -346,7 +361,8 @@ import { ref, reactive, onMounted, onActivated, watch, computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useStore } from "vuex";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OInput from "@/lib/forms/Input/OInput.vue";
|
||||
import OForm from "@/lib/forms/Form/OForm.vue";
|
||||
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
|
||||
import OSelect from "@/lib/forms/Select/OSelect.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
@@ -364,12 +380,19 @@ import ORadio from "@/lib/forms/Radio/ORadio.vue";
|
||||
import ORadioGroup from "@/lib/forms/Radio/ORadioGroup.vue";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import OSeparator from '@/lib/core/Separator/OSeparator.vue';
|
||||
import {
|
||||
isValidEmail,
|
||||
makeAddDomainSchema,
|
||||
makeAddEmailSchema,
|
||||
addDomainDefaults,
|
||||
addEmailDefaults,
|
||||
type AddDomainForm,
|
||||
} from "./DomainManagement.schema";
|
||||
|
||||
interface Domain {
|
||||
name: string;
|
||||
allowAllUsers: boolean;
|
||||
allowedEmails: string[];
|
||||
newEmail: string;
|
||||
}
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -382,11 +405,33 @@ const pendingRemoveEmail = ref<{ domain: any; emailIndex: number; email: string
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
|
||||
const newDomain = ref("");
|
||||
const domainError = ref("");
|
||||
const domains = reactive<Domain[]>([]);
|
||||
const saving = ref(false);
|
||||
|
||||
// Schema-driven validation replaces the manual domainError ref + the
|
||||
// :disabled="!newDomain || !isValidDomain" gate (R3). The pure validators live
|
||||
// in the schema file (used by the Zod refines); the component only needs
|
||||
// `isValidEmail` for the addEmail guard, imported directly.
|
||||
|
||||
const addDomainForm = ref<any>(null);
|
||||
const addDomainSchema = makeAddDomainSchema(t);
|
||||
|
||||
// Per-domain email add-row forms: a schema cache (keyed by domain name, since
|
||||
// the email schema embeds the domain) + a ref map so each row can be reset
|
||||
// after a successful add.
|
||||
const emailSchemaCache = new Map<string, ReturnType<typeof makeAddEmailSchema>>();
|
||||
const getEmailSchema = (domainName: string) => {
|
||||
if (!emailSchemaCache.has(domainName)) {
|
||||
emailSchemaCache.set(domainName, makeAddEmailSchema(domainName, t));
|
||||
}
|
||||
return emailSchemaCache.get(domainName);
|
||||
};
|
||||
const emailFormRefs = ref<Record<string, any>>({});
|
||||
const setEmailFormRef = (domainName: string, el: any) => {
|
||||
if (el) emailFormRefs.value[domainName] = el;
|
||||
else delete emailFormRefs.value[domainName];
|
||||
};
|
||||
|
||||
// Claim parser function state
|
||||
const claimParserFunction = ref("");
|
||||
const originalClaimParserFunction = ref(""); // Track original value to detect changes
|
||||
@@ -445,7 +490,7 @@ const loadDomainSettings = async () => {
|
||||
.map((domain: any) => ({
|
||||
name: domain.domain,
|
||||
allowAllUsers: domain.allow_all_users,
|
||||
allowedEmails: domain.allowed_emails || []
|
||||
allowedEmails: domain.allowed_emails || [],
|
||||
}));
|
||||
domains.splice(0, domains.length, ...loadedDomains);
|
||||
}
|
||||
@@ -466,91 +511,15 @@ const loadDomainSettings = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const isValidDomain = (domain: any): boolean => {
|
||||
// Handle null, undefined, and non-string inputs
|
||||
if (domain === null || domain === undefined) return true; // Empty is valid
|
||||
if (typeof domain !== 'string') return false; // Non-strings are invalid
|
||||
|
||||
// Handle empty strings - empty is valid, but whitespace-only is not
|
||||
const trimmed = domain.trim();
|
||||
if (!trimmed) return domain.length === 0; // Empty string is valid, whitespace-only is not
|
||||
|
||||
// Security: Check for potentially malicious content (more targeted patterns)
|
||||
const maliciousPatterns = [
|
||||
'<script', '</script', 'javascript:', 'DROP TABLE', 'SELECT FROM', 'INSERT INTO',
|
||||
'UPDATE SET', 'DELETE FROM', 'UNION SELECT', '--', '/*', '*/', '\0', '\n', '\r'
|
||||
];
|
||||
|
||||
const upperDomain = trimmed.toUpperCase();
|
||||
if (maliciousPatterns.some(pattern => upperDomain.includes(pattern.toUpperCase()))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Length validation (DNS limit is 253 characters)
|
||||
if (trimmed.length > 253) return false;
|
||||
|
||||
// Remove trailing dot if present (valid in DNS)
|
||||
const cleanDomain = trimmed.endsWith('.') ? trimmed.slice(0, -1) : trimmed;
|
||||
|
||||
// Improved domain validation that properly handles hyphens and edge cases
|
||||
// Domain parts can contain letters, numbers, and hyphens (but not start/end with hyphens)
|
||||
// Each label can be 1-63 characters, and the domain must have at least one dot
|
||||
const domainRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
|
||||
|
||||
try {
|
||||
return domainRegex.test(cleanDomain);
|
||||
} catch (error) {
|
||||
return false; // Any regex error means invalid
|
||||
}
|
||||
};
|
||||
|
||||
const isValidEmail = (email: any, domain: any): boolean => {
|
||||
// Handle null, undefined, and non-string inputs
|
||||
if (email === null || email === undefined || typeof email !== 'string') return false;
|
||||
if (domain === null || domain === undefined || typeof domain !== 'string') return false;
|
||||
|
||||
// Handle empty strings
|
||||
if (!email.trim() || !domain.trim()) return false;
|
||||
|
||||
// Security: Check for potentially malicious content
|
||||
const maliciousPatterns = [
|
||||
'<', '>', 'script', 'javascript:', 'DROP', 'SELECT', 'INSERT', 'UPDATE', 'DELETE',
|
||||
'UNION', 'CREATE', 'ALTER', 'TABLE', 'FROM', '--', '/*', '*/', "'", '"',
|
||||
'\0', '\n', '\r', '\t'
|
||||
];
|
||||
|
||||
const upperEmail = email.toUpperCase();
|
||||
if (maliciousPatterns.some(pattern => upperEmail.includes(pattern.toUpperCase()))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Length validation (practical email limit)
|
||||
if (email.length > 254 || domain.length > 253) return false;
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
try {
|
||||
if (!emailRegex.test(email)) return false;
|
||||
|
||||
// Check if email belongs to the domain
|
||||
return email.toLowerCase().endsWith(`@${domain.toLowerCase()}`);
|
||||
} catch (error) {
|
||||
return false; // Any error means invalid
|
||||
}
|
||||
};
|
||||
|
||||
const addDomain = () => {
|
||||
if (!newDomain.value) {
|
||||
domainError.value = t("settings.domainRequired") || "Domain is required";
|
||||
return;
|
||||
}
|
||||
if (!isValidDomain(newDomain.value)) {
|
||||
domainError.value = t("settings.invalidDomain") || "Please enter a valid domain (e.g. example.com)";
|
||||
return;
|
||||
}
|
||||
// @submit handler — fires only once the schema passes (required + valid
|
||||
// domain), so the old empty/invalid guards are gone. The submitted form value
|
||||
// is the single source of truth; the cross-state duplicate check stays here.
|
||||
const addDomain = (value?: AddDomainForm) => {
|
||||
const candidate = (value?.newDomain ?? "").trim();
|
||||
if (!candidate) return;
|
||||
|
||||
// Check if domain already exists
|
||||
if (domains.some(d => d.name.toLowerCase() === newDomain.value.toLowerCase())) {
|
||||
if (domains.some(d => d.name.toLowerCase() === candidate.toLowerCase())) {
|
||||
toast({
|
||||
variant: "error",
|
||||
message: t("settings.domainAlreadyExists"),
|
||||
@@ -559,19 +528,14 @@ const addDomain = () => {
|
||||
}
|
||||
|
||||
domains.push({
|
||||
name: newDomain.value,
|
||||
name: candidate,
|
||||
allowAllUsers: true,
|
||||
allowedEmails: [],
|
||||
newEmail: ""
|
||||
});
|
||||
|
||||
newDomain.value = "";
|
||||
|
||||
// toast({
|
||||
// variant: "success",
|
||||
// message: t("settings.domainAdded"),
|
||||
// timeout: 3000,
|
||||
// });
|
||||
// Inline add-row cleared after save → reset() clears the field AND submit
|
||||
// state (submissionAttempts → 0), so no post-save "required" flash.
|
||||
addDomainForm.value?.form?.reset();
|
||||
};
|
||||
|
||||
const removeDomain = (index: number) => {
|
||||
@@ -591,11 +555,15 @@ const doRemoveDomain = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const addEmail = (domain: Domain) => {
|
||||
if (!domain.newEmail || !isValidEmail(domain.newEmail, domain.name)) return;
|
||||
// @submit handler for a domain's email add-row. The schema is conditional
|
||||
// (empty passes), so an empty submit is a no-op; otherwise the email must be
|
||||
// valid + belong to the domain. Still callable directly with the row model.
|
||||
const addEmail = (domain: Domain, emailValue?: string) => {
|
||||
const email = (emailValue ?? "").trim();
|
||||
if (!email || !isValidEmail(email, domain.name)) return;
|
||||
|
||||
// Check if email already exists
|
||||
if (domain.allowedEmails.includes(domain.newEmail.toLowerCase())) {
|
||||
if (domain.allowedEmails.includes(email.toLowerCase())) {
|
||||
toast({
|
||||
variant: "error",
|
||||
message: t("settings.emailAlreadyExists"),
|
||||
@@ -603,8 +571,8 @@ const addEmail = (domain: Domain) => {
|
||||
return;
|
||||
}
|
||||
|
||||
domain.allowedEmails.push(domain.newEmail.toLowerCase());
|
||||
domain.newEmail = "";
|
||||
domain.allowedEmails.push(email.toLowerCase());
|
||||
emailFormRefs.value[domain.name]?.form?.reset();
|
||||
|
||||
toast({
|
||||
variant: "success",
|
||||
@@ -801,7 +769,7 @@ const viewAllErrors = () => {
|
||||
|
||||
const saveChanges = async () => {
|
||||
saving.value = true;
|
||||
|
||||
|
||||
try {
|
||||
// Validate all domains have proper configuration
|
||||
for (const domain of domains) {
|
||||
@@ -844,8 +812,9 @@ const saveChanges = async () => {
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
newDomain.value = "";
|
||||
// Reset the add-domain row through the form (clears the field + submit state),
|
||||
// keeping TanStack the single owner of that field — no parallel ref.
|
||||
addDomainForm.value?.form?.reset();
|
||||
loadDomainSettings();
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// Validation schema for the General Settings "platform settings" form (the two
|
||||
// number fields inside the <OForm>). The logo text / logo uploads / theme color
|
||||
// chips are pure-UI controls with their own inline actions and are decoupled
|
||||
// OUTSIDE the form, so they are NOT in this schema.
|
||||
//
|
||||
// Restores the Quasar BEFORE rules (truthy→Zod inversion):
|
||||
// • scrapeInterval: `!!v || scrapeIntervalRequired` (+ HTML min=0)
|
||||
// → required AND ≥ 0 (0 is a valid value).
|
||||
// • maxSeriesPerQuery: empty→pass; else `v>=1000 && v<=1000000`
|
||||
// → OPTIONAL, only range-checked when present (NOT required).
|
||||
//
|
||||
// The <input type="number"> emits a string, so the rules read the raw value via
|
||||
// Number(); the submit handler coerces at use.
|
||||
//
|
||||
// Validation TIMING is owned by OForm (submit-then-change via revalidateLogic);
|
||||
// this file only describes WHAT is valid.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
const isEmpty = (v: unknown) => v === "" || v === null || v === undefined;
|
||||
|
||||
// Factory (takes `t`) so the messages are localized like makeLicenseSchema /
|
||||
// makeAddEmailSchema, rather than hardcoded English. The component owns the
|
||||
// `t` instance and builds the schema once in setup().
|
||||
export const makeGeneralSettingsSchema = (t: (_key: string) => string) =>
|
||||
z.object({
|
||||
// Required (non-empty) and ≥ 0. 0 passes; empty fails as "required".
|
||||
scrape_interval: z
|
||||
.any()
|
||||
.refine((v) => !isEmpty(v), {
|
||||
message:
|
||||
t("settings.scrapeIntervalRequired") || "Scrape interval is required",
|
||||
})
|
||||
.refine(
|
||||
(v) => isEmpty(v) || (!Number.isNaN(Number(v)) && Number(v) >= 0),
|
||||
{
|
||||
message:
|
||||
t("settings.scrapeIntervalPositive") ||
|
||||
"Scrape interval must be a positive number",
|
||||
},
|
||||
),
|
||||
// Optional — only range-checked (1000..1000000) when a value is present.
|
||||
max_series_per_query: z.any().refine(
|
||||
(v) =>
|
||||
isEmpty(v) ||
|
||||
(!Number.isNaN(Number(v)) &&
|
||||
Number(v) >= 1000 &&
|
||||
Number(v) <= 1000000),
|
||||
{
|
||||
message:
|
||||
t("settings.maxSeriesPerQueryValidation") ||
|
||||
"Max series per query must be between 1000 and 1000000",
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
export type GeneralSettingsForm = z.infer<
|
||||
ReturnType<typeof makeGeneralSettingsSchema>
|
||||
>;
|
||||
@@ -144,31 +144,9 @@ const createWrapper = (props = {}, options = {}) => {
|
||||
store: mockStore,
|
||||
},
|
||||
stubs: {
|
||||
OForm: {
|
||||
template:
|
||||
'<form data-test-stub="o-form" @submit.prevent=\'$emit("submit", $event)\'><slot></slot></form>',
|
||||
emits: ["submit"],
|
||||
},
|
||||
OInput: {
|
||||
template: `<input
|
||||
data-test-stub='o-input'
|
||||
:data-test='$attrs["data-test"]'
|
||||
:value='modelValue'
|
||||
@input='$emit("update:modelValue", Number($event.target.value))'
|
||||
:type='type'
|
||||
:min='min'
|
||||
:placeholder='placeholder'
|
||||
/>`,
|
||||
props: [
|
||||
"modelValue",
|
||||
"type",
|
||||
"min",
|
||||
"placeholder",
|
||||
"error",
|
||||
"errorMessage",
|
||||
],
|
||||
emits: ["update:modelValue"],
|
||||
},
|
||||
// OForm / OFormInput / OInput are intentionally NOT stubbed so the real
|
||||
// schema validation runs (per the migration playbook: at least one path
|
||||
// must mount the real <OForm>).
|
||||
OButton: {
|
||||
template: `<button
|
||||
data-test-stub='o-button'
|
||||
@@ -317,33 +295,33 @@ describe("General", () => {
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("should initialize with organization settings values", () => {
|
||||
it("should seed the form defaults from organization settings", () => {
|
||||
const wrapper = createWrapper();
|
||||
|
||||
expect(wrapper.vm.scrapeIntereval).toBe(30);
|
||||
const form = wrapper.findComponent({ name: "OForm" });
|
||||
expect(form.vm.form.state.values.scrape_interval).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form inputs", () => {
|
||||
it("should update scrape interval value", async () => {
|
||||
it("should keep the scrape interval and max series inputs (data-tests preserved)", () => {
|
||||
const wrapper = createWrapper();
|
||||
const scrapeInput = wrapper.find('[data-test-stub="o-input"]');
|
||||
|
||||
await scrapeInput.setValue("45");
|
||||
expect(wrapper.vm.scrapeIntereval).toBe(45);
|
||||
expect(
|
||||
wrapper.find('[data-test="general-settings-scrape-interval"]').exists(),
|
||||
).toBe(true);
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-test="general-settings-max-series-per-query"]')
|
||||
.exists(),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form submission", () => {
|
||||
describe("Form submission (real OForm)", () => {
|
||||
it("should save organization settings successfully", async () => {
|
||||
const wrapper = createWrapper();
|
||||
const form = wrapper.findComponent({ name: "OForm" });
|
||||
|
||||
// Set the scrape interval in the component
|
||||
wrapper.vm.scrapeIntereval = 30;
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const form = wrapper.find('[data-test-stub="o-form"]');
|
||||
await form.trigger("submit");
|
||||
await form.vm.form.handleSubmit();
|
||||
await nextTick();
|
||||
|
||||
// The dispatch is called with the spread of existing organizationSettings plus scrape_interval
|
||||
@@ -371,8 +349,8 @@ describe("General", () => {
|
||||
});
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const form = wrapper.find('[data-test-stub="o-form"]');
|
||||
await form.trigger("submit");
|
||||
const form = wrapper.findComponent({ name: "OForm" });
|
||||
await form.vm.form.handleSubmit();
|
||||
await nextTick();
|
||||
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
@@ -385,8 +363,8 @@ describe("General", () => {
|
||||
mockOrganizations.post_organization_settings.mockRejectedValue({});
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const form = wrapper.find('[data-test-stub="o-form"]');
|
||||
await form.trigger("submit");
|
||||
const form = wrapper.findComponent({ name: "OForm" });
|
||||
await form.vm.form.handleSubmit();
|
||||
await nextTick();
|
||||
|
||||
expect(mockToast).toHaveBeenCalledWith({
|
||||
@@ -831,19 +809,100 @@ describe("General", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form validation", () => {
|
||||
it("should validate scrape interval is required", () => {
|
||||
describe("Form validation (real OForm)", () => {
|
||||
it("blocks submit and does NOT save when scrape interval is empty (restored required rule)", async () => {
|
||||
const wrapper = createWrapper();
|
||||
const scrapeInput = wrapper.findComponent({ name: "OInput" });
|
||||
const form = wrapper.findComponent({ name: "OForm" });
|
||||
form.vm.form.setFieldValue("scrape_interval", "");
|
||||
|
||||
if (scrapeInput.exists()) {
|
||||
// OInput uses error/errorMessage props instead of rules
|
||||
// Validation happens in the onSubmit handler, not declaratively
|
||||
expect(scrapeInput.props("error")).toBeDefined();
|
||||
} else {
|
||||
// Fallback: verify component has validation logic
|
||||
expect(wrapper.vm.scrapeIntereval).toBeDefined();
|
||||
}
|
||||
await form.vm.form.handleSubmit();
|
||||
await nextTick();
|
||||
|
||||
expect(form.vm.form.state.isValid).toBe(false);
|
||||
expect(
|
||||
mockOrganizations.post_organization_settings,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks submit when scrape interval is negative", async () => {
|
||||
const wrapper = createWrapper();
|
||||
const form = wrapper.findComponent({ name: "OForm" });
|
||||
form.vm.form.setFieldValue("scrape_interval", -5);
|
||||
|
||||
await form.vm.form.handleSubmit();
|
||||
await nextTick();
|
||||
|
||||
expect(form.vm.form.state.isValid).toBe(false);
|
||||
expect(
|
||||
mockOrganizations.post_organization_settings,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a scrape interval of 0", async () => {
|
||||
const wrapper = createWrapper();
|
||||
const form = wrapper.findComponent({ name: "OForm" });
|
||||
form.vm.form.setFieldValue("scrape_interval", 0);
|
||||
|
||||
await form.vm.form.handleSubmit();
|
||||
await nextTick();
|
||||
|
||||
expect(
|
||||
mockOrganizations.post_organization_settings,
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks submit when max series per query is below 1000 (restored optional range rule)", async () => {
|
||||
const wrapper = createWrapper();
|
||||
const form = wrapper.findComponent({ name: "OForm" });
|
||||
form.vm.form.setFieldValue("max_series_per_query", 500);
|
||||
|
||||
await form.vm.form.handleSubmit();
|
||||
await nextTick();
|
||||
|
||||
expect(form.vm.form.state.isValid).toBe(false);
|
||||
expect(
|
||||
mockOrganizations.post_organization_settings,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks submit when max series per query is above 1000000", async () => {
|
||||
const wrapper = createWrapper();
|
||||
const form = wrapper.findComponent({ name: "OForm" });
|
||||
form.vm.form.setFieldValue("max_series_per_query", 2000000);
|
||||
|
||||
await form.vm.form.handleSubmit();
|
||||
await nextTick();
|
||||
|
||||
expect(form.vm.form.state.isValid).toBe(false);
|
||||
expect(
|
||||
mockOrganizations.post_organization_settings,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps max series per query OPTIONAL — empty value saves", async () => {
|
||||
const wrapper = createWrapper();
|
||||
const form = wrapper.findComponent({ name: "OForm" });
|
||||
// default is null (empty) — should still submit
|
||||
await form.vm.form.handleSubmit();
|
||||
await nextTick();
|
||||
|
||||
expect(
|
||||
mockOrganizations.post_organization_settings,
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits a valid in-range max series per query", async () => {
|
||||
const wrapper = createWrapper();
|
||||
const form = wrapper.findComponent({ name: "OForm" });
|
||||
form.vm.form.setFieldValue("max_series_per_query", 50000);
|
||||
|
||||
await form.vm.form.handleSubmit();
|
||||
await nextTick();
|
||||
|
||||
expect(mockStore.dispatch).toHaveBeenCalledWith(
|
||||
"setOrganizationSettings",
|
||||
expect.objectContaining({ max_series_per_query: 50000 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -878,17 +937,13 @@ describe("General", () => {
|
||||
});
|
||||
|
||||
describe("Accessibility", () => {
|
||||
it("should have proper form labels", () => {
|
||||
it("should render the scrape interval as a number input", () => {
|
||||
const wrapper = createWrapper();
|
||||
const scrapeInput = wrapper.findComponent({ name: "OInput" });
|
||||
|
||||
if (scrapeInput.exists()) {
|
||||
// OInput has proper aria attributes or associated labels
|
||||
expect(scrapeInput.props("type")).toBeDefined();
|
||||
} else {
|
||||
// Fallback: verify component has proper structure
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
}
|
||||
const input = wrapper.find(
|
||||
'[data-test="general-settings-scrape-interval"] input',
|
||||
);
|
||||
expect(input.exists()).toBe(true);
|
||||
expect(input.attributes("type")).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -897,8 +952,9 @@ describe("General", () => {
|
||||
mockStore.state.organizationData.organizationSettings = null;
|
||||
const wrapper = createWrapper();
|
||||
|
||||
// Component uses default values when organizationSettings is null
|
||||
expect(wrapper.vm.scrapeIntereval).toBe(15); // default from component
|
||||
// The form falls back to the default scrape_interval (15) from the schema defaults.
|
||||
const form = wrapper.findComponent({ name: "OForm" });
|
||||
expect(form.vm.form.state.values.scrape_interval).toBe(15);
|
||||
});
|
||||
|
||||
it("should handle upload when not enterprise", async () => {
|
||||
|
||||
@@ -19,24 +19,27 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<div>
|
||||
<!-- Section header (title + description) is provided full-width by the
|
||||
Settings shell; this component renders only the form content. -->
|
||||
<!-- platform settings section -->
|
||||
<div class="">
|
||||
<!-- platform settings section. The page gutter is owned by the Settings
|
||||
shell's ConstrainedPage; this block adds none of its own. -->
|
||||
<div>
|
||||
<GroupHeader :title="t('settings.platformSettings')" :showIcon="false" />
|
||||
<div class="w-full flex flex-col">
|
||||
<OForm @submit.stop="onSubmit.execute">
|
||||
<OForm
|
||||
:schema="generalSettingsSchema"
|
||||
:default-values="generalSettingsDefaults"
|
||||
@submit="saveGeneralSettings"
|
||||
v-slot="{ isSubmitting }"
|
||||
>
|
||||
<!-- scape interval section -->
|
||||
<div class="settings-grid-item grid grid-cols-3 gap-4 items-center py-4 border-b border-(--o2-border-color)">
|
||||
<span class="individual-setting-title text-sm font-medium leading-5">
|
||||
{{ t("settings.scrapintervalLabel") }}
|
||||
</span>
|
||||
<OInput
|
||||
v-model.number="scrapeIntereval"
|
||||
<OFormInput
|
||||
name="scrape_interval"
|
||||
type="number"
|
||||
min="0"
|
||||
class="ml-2"
|
||||
:error="!!scrapeIntervalError"
|
||||
:error-message="scrapeIntervalError"
|
||||
@update:model-value="scrapeIntervalError = ''"
|
||||
data-test="general-settings-scrape-interval"
|
||||
style="width: 120px"
|
||||
/>
|
||||
@@ -50,15 +53,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<span class="individual-setting-title text-sm font-medium leading-5">
|
||||
{{ t("settings.maxSeriesPerQueryLabel") }}
|
||||
</span>
|
||||
<OInput
|
||||
v-model.number="maxSeriesPerQuery"
|
||||
<OFormInput
|
||||
name="max_series_per_query"
|
||||
type="number"
|
||||
:min="1000"
|
||||
:max="1000000"
|
||||
class="ml-2"
|
||||
:error="!!maxSeriesError"
|
||||
:error-message="maxSeriesError"
|
||||
@update:model-value="maxSeriesError = ''"
|
||||
:placeholder="'40000 (' + t('settings.systemDefault') + ')'"
|
||||
data-test="general-settings-max-series-per-query"
|
||||
style="width: 180px"
|
||||
@@ -68,7 +68,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<OTooltip side="top" :content="t('settings.maxSeriesPerQueryTooltip')" />
|
||||
</OIcon>
|
||||
</template>
|
||||
</OInput>
|
||||
</OFormInput>
|
||||
<span class="individual-setting-description text-[13px] opacity-70">
|
||||
{{ t("settings.maxSeriesPerQueryDescription") }}
|
||||
</span>
|
||||
@@ -143,10 +143,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<div class="flex justify-start">
|
||||
<OButton
|
||||
data-test="dashboard-add-submit"
|
||||
:loading="onSubmit.isLoading.value"
|
||||
:loading="isSubmitting"
|
||||
variant="primary"
|
||||
size="sm-action"
|
||||
@click="onSubmit.execute"
|
||||
type="submit"
|
||||
>
|
||||
{{ t("dashboard.save") }}
|
||||
</OButton>
|
||||
@@ -168,7 +168,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:showIcon="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="">
|
||||
<div>
|
||||
<div class="settings-grid-item no-border-bottom grid grid-cols-3 gap-4 items-center py-4 border-b border-(--o2-border-color)">
|
||||
<span class="individual-setting-title text-sm font-medium leading-5">
|
||||
{{ t("settings.customLogoText") }}
|
||||
@@ -192,10 +192,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
/>
|
||||
<OButton
|
||||
data-test="settings_ent_logo_custom_text_save_btn"
|
||||
:loading="onSubmit.isLoading.value"
|
||||
:loading="loadingState"
|
||||
variant="outline"
|
||||
size="icon-xs-sq"
|
||||
type="submit"
|
||||
type="button"
|
||||
@click="updateCustomText"
|
||||
icon-left="check"
|
||||
/>
|
||||
@@ -217,11 +217,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</span>
|
||||
<OButton
|
||||
data-test="settings_ent_logo_custom_text_edit_btn"
|
||||
:loading="onSubmit.isLoading.value"
|
||||
:loading="loadingState"
|
||||
variant="outline"
|
||||
size="icon-xs-sq"
|
||||
class="ml-2"
|
||||
type="submit"
|
||||
type="button"
|
||||
@click="editingText = !editingText"
|
||||
icon-left="edit"
|
||||
/>
|
||||
@@ -286,10 +286,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
/>
|
||||
<OButton
|
||||
data-test="settings_ent_logo_custom_light_save_btn"
|
||||
:loading="onSubmit.isLoading.value"
|
||||
:loading="loadingState"
|
||||
variant="outline"
|
||||
size="icon-xs-sq"
|
||||
type="submit"
|
||||
type="button"
|
||||
@click="uploadImage(filesLight, 'light')"
|
||||
icon-left="check"
|
||||
/>
|
||||
@@ -357,10 +357,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
/>
|
||||
<OButton
|
||||
data-test="settings_ent_logo_custom_dark_save_btn"
|
||||
:loading="onSubmit.isLoading.value"
|
||||
:loading="loadingState"
|
||||
variant="outline"
|
||||
size="icon-xs-sq"
|
||||
type="submit"
|
||||
type="button"
|
||||
@click="uploadImage(filesDark, 'dark')"
|
||||
icon-left="check"
|
||||
/>
|
||||
@@ -405,11 +405,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<script lang="ts">
|
||||
// @ts-ignore
|
||||
import { defineComponent, onActivated, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { computed, defineComponent, onActivated, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useStore } from "vuex";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useLoading } from "@/composables/useLoading";
|
||||
import organizations from "@/services/organizations";
|
||||
import settingsService from "@/services/settings";
|
||||
import config from "@/aws-exports";
|
||||
@@ -426,8 +425,13 @@ import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
|
||||
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
|
||||
import OFile from "@/lib/forms/File/OFile.vue";
|
||||
import OForm from "@/lib/forms/Form/OForm.vue";
|
||||
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
|
||||
import OColor from "@/lib/forms/Color/OColor.vue";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import {
|
||||
makeGeneralSettingsSchema,
|
||||
type GeneralSettingsForm,
|
||||
} from "./General.schema";
|
||||
|
||||
export default defineComponent({
|
||||
name: "PageGeneralSettings",
|
||||
@@ -454,6 +458,7 @@ export default defineComponent({
|
||||
OInput,
|
||||
OFile,
|
||||
OForm,
|
||||
OFormInput,
|
||||
OColor,
|
||||
},
|
||||
setup() {
|
||||
@@ -461,16 +466,20 @@ export default defineComponent({
|
||||
|
||||
const store = useStore();
|
||||
const router: any = useRouter();
|
||||
const scrapeIntervalError = ref("");
|
||||
const maxSeriesError = ref("");
|
||||
const scrapeIntereval = ref(
|
||||
store.state?.organizationData?.organizationSettings?.scrape_interval ??
|
||||
|
||||
// Schema-driven validation replaces the manual scrapeIntervalError /
|
||||
// maxSeriesError refs + the imperative if-checks in onSubmit.
|
||||
// Built once from the component's `t` so the messages are localized.
|
||||
const generalSettingsSchema = makeGeneralSettingsSchema(t);
|
||||
// Dynamic defaults (edit-prefill from the store) → a typed computed.
|
||||
const generalSettingsDefaults = computed((): GeneralSettingsForm => ({
|
||||
scrape_interval:
|
||||
store.state?.organizationData?.organizationSettings?.scrape_interval ??
|
||||
15,
|
||||
);
|
||||
const maxSeriesPerQuery = ref(
|
||||
store.state?.organizationData?.organizationSettings
|
||||
?.max_series_per_query ?? null,
|
||||
);
|
||||
max_series_per_query:
|
||||
store.state?.organizationData?.organizationSettings
|
||||
?.max_series_per_query ?? null,
|
||||
}));
|
||||
|
||||
const loadingState = ref(false);
|
||||
const customText = ref("");
|
||||
@@ -514,10 +523,7 @@ export default defineComponent({
|
||||
* Priority: Vuex store tempThemeColors > organizationSettings > defaults
|
||||
*/
|
||||
const updateFromStore = () => {
|
||||
// Update scrape interval setting
|
||||
scrapeIntereval.value =
|
||||
store.state?.organizationData?.organizationSettings?.scrape_interval ??
|
||||
15;
|
||||
// (scrape_interval is now form-owned via :default-values — no ref to sync.)
|
||||
|
||||
// Get theme colors from store with priority order
|
||||
// 1. Check Vuex store for temporary preview colors (highest priority)
|
||||
@@ -585,44 +591,24 @@ export default defineComponent({
|
||||
},
|
||||
);
|
||||
|
||||
const onSubmit = useLoading(async () => {
|
||||
// Validate scrape interval
|
||||
if (!scrapeIntereval.value && scrapeIntereval.value !== 0) {
|
||||
scrapeIntervalError.value = t("settings.scrapeIntervalRequired") || "Scrape interval is required";
|
||||
return;
|
||||
}
|
||||
if (scrapeIntereval.value < 0) {
|
||||
scrapeIntervalError.value = t("settings.scrapeIntervalPositive") || "Scrape interval must be a positive number";
|
||||
return;
|
||||
}
|
||||
// Validate max series per query (optional field — only validate when provided)
|
||||
if (
|
||||
maxSeriesPerQuery.value !== null &&
|
||||
maxSeriesPerQuery.value !== undefined &&
|
||||
maxSeriesPerQuery.value !== ""
|
||||
) {
|
||||
if (maxSeriesPerQuery.value < 1000) {
|
||||
maxSeriesError.value = t("settings.maxSeriesMinError") || "Minimum value is 1000";
|
||||
return;
|
||||
}
|
||||
if (maxSeriesPerQuery.value > 1000000) {
|
||||
maxSeriesError.value = t("settings.maxSeriesMaxError") || "Maximum value is 1000000";
|
||||
return;
|
||||
}
|
||||
}
|
||||
// @submit handler — fires only once the schema passes (scrape_interval
|
||||
// required + ≥0; max_series_per_query optional range). The <input
|
||||
// type="number"> emits a string, so coerce at use. Awaited by OForm so the
|
||||
// inline Save button's spinner spans the POST (no useLoading wrapper).
|
||||
const saveGeneralSettings = async (value: GeneralSettingsForm) => {
|
||||
const maxSeriesRaw = value.max_series_per_query;
|
||||
const maxSeriesNum =
|
||||
maxSeriesRaw === null || maxSeriesRaw === undefined || maxSeriesRaw === ""
|
||||
? null
|
||||
: Number(maxSeriesRaw);
|
||||
|
||||
try {
|
||||
//set organizations settings in store
|
||||
//scrape interval will be in number
|
||||
store.dispatch("setOrganizationSettings", {
|
||||
...store.state?.organizationData?.organizationSettings,
|
||||
scrape_interval: scrapeIntereval.value,
|
||||
max_series_per_query:
|
||||
maxSeriesPerQuery.value === null ||
|
||||
maxSeriesPerQuery.value === undefined ||
|
||||
maxSeriesPerQuery.value === ""
|
||||
? null
|
||||
: maxSeriesPerQuery.value,
|
||||
scrape_interval: Number(value.scrape_interval),
|
||||
max_series_per_query: maxSeriesNum,
|
||||
light_mode_theme_color: customLightColor.value,
|
||||
dark_mode_theme_color: customDarkColor.value,
|
||||
});
|
||||
@@ -654,7 +640,7 @@ export default defineComponent({
|
||||
message: err?.message || t("settings.somethingWentWrong"),
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const uploadImage = (fileList: any = null, theme: string = "light") => {
|
||||
const selectedFiles = fileList || files.value;
|
||||
@@ -1024,11 +1010,11 @@ export default defineComponent({
|
||||
store,
|
||||
config,
|
||||
router,
|
||||
scrapeIntereval,
|
||||
scrapeIntervalError,
|
||||
maxSeriesPerQuery,
|
||||
maxSeriesError,
|
||||
onSubmit,
|
||||
// Form wiring (Options-API: schema + defaults MUST be returned so :schema
|
||||
// resolves and validation runs).
|
||||
generalSettingsSchema,
|
||||
generalSettingsDefaults,
|
||||
saveGeneralSettings,
|
||||
files,
|
||||
filesLight,
|
||||
filesDark,
|
||||
@@ -1088,4 +1074,3 @@ export default defineComponent({
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// Validation schema for the License key entry (License.vue). The same
|
||||
// `licenseKey` field is entered in two mutually-exclusive cards (the "no
|
||||
// license" card and the "update license" card); both share this schema.
|
||||
//
|
||||
// Replaces the `:disabled="!licenseKey.trim()"` Save gate with a schema rule
|
||||
// (R3 — the Save button stays enabled; the schema blocks an empty submit).
|
||||
//
|
||||
// Validation TIMING is owned by OForm (submit-then-change via revalidateLogic);
|
||||
// this file only describes WHAT is valid.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export const makeLicenseSchema = (t: (_key: string) => string) =>
|
||||
z.object({
|
||||
licenseKey: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, t("about.license_key_required") || "License key is required"),
|
||||
});
|
||||
|
||||
export type LicenseForm = z.infer<ReturnType<typeof makeLicenseSchema>>;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import License from './License.vue';
|
||||
import { makeLicenseSchema } from './License.schema';
|
||||
import licenseServer from '@/services/license_server';
|
||||
import { createStore } from 'vuex';
|
||||
import i18n from '@/locales';
|
||||
@@ -260,14 +261,14 @@ describe('License.vue', () => {
|
||||
expect(getLicenseBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should disable Update License button when license key is empty', () => {
|
||||
it('keeps the Update License button enabled even when empty (R3 — schema-gated)', () => {
|
||||
const updateBtn = wrapper.findAll('button').find((btn: any) =>
|
||||
btn.text().includes('Update License')
|
||||
);
|
||||
expect(updateBtn?.attributes('disabled')).toBeDefined();
|
||||
expect(updateBtn?.attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should enable Update License button when license key is entered', async () => {
|
||||
it('should keep the Update License button enabled when a key is entered', async () => {
|
||||
const textarea = wrapper.find('textarea');
|
||||
await textarea.setValue('new-license-key-value');
|
||||
await wrapper.vm.$nextTick();
|
||||
@@ -277,6 +278,36 @@ describe('License.vue', () => {
|
||||
);
|
||||
expect(updateBtn?.attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('blocks the submit and does NOT call update_license when the key is empty (real OForm)', async () => {
|
||||
vi.mocked(licenseServer.update_license).mockResolvedValue(
|
||||
createAxiosResponse({ success: true }),
|
||||
);
|
||||
const form = wrapper.findComponent({ name: 'OForm' });
|
||||
expect(form.exists()).toBe(true);
|
||||
await (form.vm as any).form.handleSubmit();
|
||||
await flushPromises();
|
||||
|
||||
expect((form.vm as any).form.state.isValid).toBe(false);
|
||||
expect(licenseServer.update_license).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits when a valid key is entered (real OForm)', async () => {
|
||||
vi.mocked(licenseServer.update_license).mockResolvedValue(
|
||||
createAxiosResponse({ success: true }),
|
||||
);
|
||||
const textarea = wrapper.find('textarea');
|
||||
await textarea.setValue('a-valid-license-key');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const form = wrapper.findComponent({ name: 'OForm' });
|
||||
await (form.vm as any).form.handleSubmit();
|
||||
await flushPromises();
|
||||
|
||||
expect(licenseServer.update_license).toHaveBeenCalledWith(
|
||||
'a-valid-license-key',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Active License State', () => {
|
||||
@@ -972,3 +1003,22 @@ describe('License.vue', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeLicenseSchema', () => {
|
||||
const schema = makeLicenseSchema((k: string) => k);
|
||||
|
||||
it('rejects a whitespace-only key (the .trim() rule is load-bearing)', () => {
|
||||
// Without .trim() a single space passes .min(1); this guards that.
|
||||
expect(schema.safeParse({ licenseKey: ' ' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an empty key', () => {
|
||||
expect(schema.safeParse({ licenseKey: '' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a non-empty key and trims surrounding whitespace', () => {
|
||||
const res = schema.safeParse({ licenseKey: ' abc-123 ' });
|
||||
expect(res.success).toBe(true);
|
||||
if (res.success) expect(res.data.licenseKey).toBe('abc-123');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<!-- Page gutter is owned by the Settings shell's ConstrainedPage. -->
|
||||
<div>
|
||||
<LicensePeriod @updateLicense="showUpdateFormAndFocus"></LicensePeriod>
|
||||
@@ -41,36 +41,43 @@
|
||||
<div class="text-base font-medium mb-3">
|
||||
{{ t("about.enter_license_key") }}
|
||||
</div>
|
||||
<OTextarea
|
||||
data-test="no-license-key-input"
|
||||
v-model="licenseKey"
|
||||
:rows="8"
|
||||
:placeholder="t('about.paste_license_placeholder')"
|
||||
style="min-height: 200px"
|
||||
/>
|
||||
<div v-if="isLicenseKeyAutoFilled" class="mt-2 mb-3">
|
||||
<div class="flex items-center py-3 px-4 bg-[rgba(34,197,94,0.08)] border border-solid border-[rgba(34,197,94,0.2)] rounded-lg [backdrop-filter:blur(10px)] transition-all duration-200 dark:bg-[rgba(34,197,94,0.15)] dark:border-[rgba(34,197,94,0.3)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.2)]">
|
||||
<OIcon
|
||||
name="check-circle"
|
||||
class="text-green-600 mr-2"
|
||||
size="md"
|
||||
/>
|
||||
<span class="text-sm">{{
|
||||
t("about.license_auto_filled")
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<OButton
|
||||
data-test="no-license-update-btn"
|
||||
variant="primary"
|
||||
size="sm-action"
|
||||
class="mt-2"
|
||||
:loading="updating"
|
||||
:disabled="!licenseKey.trim()"
|
||||
@click="updateLicense"
|
||||
<OForm
|
||||
ref="noLicenseForm"
|
||||
:schema="licenseSchema"
|
||||
:default-values="licenseDefaults"
|
||||
@submit="updateLicense"
|
||||
v-slot="{ isSubmitting }"
|
||||
>
|
||||
{{ t("about.update_license") }}
|
||||
</OButton>
|
||||
<OFormTextarea
|
||||
data-test="no-license-key-input"
|
||||
name="licenseKey"
|
||||
:rows="8"
|
||||
:placeholder="t('about.paste_license_placeholder')"
|
||||
style="min-height: 200px"
|
||||
/>
|
||||
<div v-if="isLicenseKeyAutoFilled" class="mt-2 mb-3">
|
||||
<div class="flex items-center py-3 px-4 bg-[rgba(34,197,94,0.08)] border border-solid border-[rgba(34,197,94,0.2)] rounded-lg [backdrop-filter:blur(10px)] transition-all duration-200 dark:bg-[rgba(34,197,94,0.15)] dark:border-[rgba(34,197,94,0.3)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.2)]">
|
||||
<OIcon
|
||||
name="check-circle"
|
||||
class="text-green-600 mr-2"
|
||||
size="md"
|
||||
/>
|
||||
<span class="text-sm">{{
|
||||
t("about.license_auto_filled")
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<OButton
|
||||
data-test="no-license-update-btn"
|
||||
variant="primary"
|
||||
size="sm-action"
|
||||
class="mt-2"
|
||||
type="submit"
|
||||
:loading="isSubmitting"
|
||||
>
|
||||
{{ t("about.update_license") }}
|
||||
</OButton>
|
||||
</OForm>
|
||||
</OCardSection>
|
||||
</OCard>
|
||||
</div>
|
||||
@@ -98,10 +105,13 @@
|
||||
{{ t("about.status_lbl") }}
|
||||
</td>
|
||||
<td class="px-3 py-2 leading-[1.2]">
|
||||
<OTag
|
||||
type="licenseStatus"
|
||||
:value="licenseData?.expired ? 'expired' : 'active'"
|
||||
/>
|
||||
<OBadge :variant="licenseData?.expired ? 'error' : 'success'">
|
||||
{{
|
||||
licenseData?.expired
|
||||
? t("about.expired_lbl")
|
||||
: t("about.active_lbl")
|
||||
}}
|
||||
</OBadge>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-solid border-[var(--o2-border-color)]">
|
||||
@@ -194,48 +204,57 @@
|
||||
<div class="text-base font-medium mb-2">
|
||||
{{ t("about.update_license_key") }}
|
||||
</div>
|
||||
<OTextarea
|
||||
data-test="update-license-key-input"
|
||||
v-model="licenseKey"
|
||||
:rows="6"
|
||||
:placeholder="t('about.paste_new_license_placeholder')"
|
||||
style="min-height: 150px"
|
||||
/>
|
||||
<div v-if="isLicenseKeyAutoFilled" class="mt-2 mb-3">
|
||||
<div class="flex items-center py-3 px-4 bg-[rgba(34,197,94,0.08)] border border-solid border-[rgba(34,197,94,0.2)] rounded-lg [backdrop-filter:blur(10px)] transition-all duration-200 dark:bg-[rgba(34,197,94,0.15)] dark:border-[rgba(34,197,94,0.3)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.2)]">
|
||||
<OIcon
|
||||
name="check-circle"
|
||||
class="text-green-600 mr-2"
|
||||
size="md"
|
||||
/>
|
||||
<span class="text-sm">{{
|
||||
t("about.license_auto_filled")
|
||||
}}</span>
|
||||
<OForm
|
||||
ref="updateLicenseForm"
|
||||
:schema="licenseSchema"
|
||||
:default-values="licenseDefaults"
|
||||
@submit="updateLicense"
|
||||
v-slot="{ isSubmitting }"
|
||||
>
|
||||
<OFormTextarea
|
||||
data-test="update-license-key-input"
|
||||
name="licenseKey"
|
||||
:rows="6"
|
||||
:placeholder="t('about.paste_new_license_placeholder')"
|
||||
style="min-height: 150px"
|
||||
/>
|
||||
<div v-if="isLicenseKeyAutoFilled" class="mt-2 mb-3">
|
||||
<div class="flex items-center py-3 px-4 bg-[rgba(34,197,94,0.08)] border border-solid border-[rgba(34,197,94,0.2)] rounded-lg [backdrop-filter:blur(10px)] transition-all duration-200 dark:bg-[rgba(34,197,94,0.15)] dark:border-[rgba(34,197,94,0.3)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.2)]">
|
||||
<OIcon
|
||||
name="check-circle"
|
||||
class="text-green-600 mr-2"
|
||||
size="md"
|
||||
/>
|
||||
<span class="text-sm">{{
|
||||
t("about.license_auto_filled")
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<OButton
|
||||
data-test="cancel-update-license-btn"
|
||||
variant="outline"
|
||||
size="sm-action"
|
||||
@click="
|
||||
showUpdateForm = false;
|
||||
licenseKey = '';
|
||||
"
|
||||
>
|
||||
{{ t("common.cancel") }}
|
||||
</OButton>
|
||||
<OButton
|
||||
data-test="confirm-update-license-btn"
|
||||
variant="primary"
|
||||
size="sm-action"
|
||||
:loading="updating"
|
||||
:disabled="!licenseKey.trim()"
|
||||
@click="updateLicense"
|
||||
>
|
||||
{{ t("about.update_license") }}
|
||||
</OButton>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<OButton
|
||||
data-test="cancel-update-license-btn"
|
||||
variant="outline"
|
||||
size="sm-action"
|
||||
type="button"
|
||||
:disabled="isSubmitting"
|
||||
@click="
|
||||
showUpdateForm = false;
|
||||
licenseKey = '';
|
||||
"
|
||||
>
|
||||
{{ t("common.cancel") }}
|
||||
</OButton>
|
||||
<OButton
|
||||
data-test="confirm-update-license-btn"
|
||||
variant="primary"
|
||||
size="sm-action"
|
||||
type="submit"
|
||||
:loading="isSubmitting"
|
||||
>
|
||||
{{ t("about.update_license") }}
|
||||
</OButton>
|
||||
</div>
|
||||
</OForm>
|
||||
</OCardSection>
|
||||
</OCard>
|
||||
</div>
|
||||
@@ -441,6 +460,7 @@ import {
|
||||
ref,
|
||||
onMounted,
|
||||
computed,
|
||||
watch,
|
||||
defineAsyncComponent,
|
||||
} from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
@@ -452,13 +472,16 @@ import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
|
||||
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
|
||||
import OTag from "@/lib/core/Badge/OTag.vue";
|
||||
import OBadge from "@/lib/core/Badge/OBadge.vue";
|
||||
import OTextarea from "@/lib/forms/Input/OTextarea.vue";
|
||||
import OForm from "@/lib/forms/Form/OForm.vue";
|
||||
import OFormTextarea from "@/lib/forms/Input/OFormTextarea.vue";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import OCard from "@/lib/core/Card/OCard.vue";
|
||||
import OCardSection from "@/lib/core/Card/OCardSection.vue";
|
||||
import { useConfirmDialog } from "@/composables/useConfirmDialog";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
import { makeLicenseSchema, type LicenseForm } from "./License.schema";
|
||||
|
||||
const RenderDashboardCharts = defineAsyncComponent(
|
||||
() => import("@/views/Dashboards/RenderDashboardCharts.vue"),
|
||||
@@ -473,8 +496,10 @@ export default defineComponent({
|
||||
ODialog,
|
||||
OSpinner,
|
||||
OIcon,
|
||||
OTag,
|
||||
OBadge,
|
||||
OTextarea,
|
||||
OForm,
|
||||
OFormTextarea,
|
||||
OCard,
|
||||
OCardSection,
|
||||
},
|
||||
@@ -482,7 +507,6 @@ export default defineComponent({
|
||||
const { t } = useI18n();
|
||||
const { confirm } = useConfirmDialog();
|
||||
const loading = ref(false);
|
||||
const updating = ref(false);
|
||||
const licenseData = ref<any>({});
|
||||
const licenseKey = ref("");
|
||||
const showUpdateForm = ref(false);
|
||||
@@ -492,6 +516,29 @@ export default defineComponent({
|
||||
const usageDashboardData = ref<any>(null);
|
||||
const dashboardRenderKey = ref(0);
|
||||
|
||||
// Schema-driven validation replaces the `:disabled="!licenseKey.trim()"`
|
||||
// gate. The same licenseKey is entered in two mutually-exclusive cards, each
|
||||
// wrapped in its own <OForm> that OWNS the field (by name). `licenseKey` is
|
||||
// only the shared INCOMING working value (auto-filled from URL, cleared on
|
||||
// save/cancel) — bridged one-way into whichever form is mounted via the
|
||||
// watch below; the typed value is read back from the submit payload, never
|
||||
// from this ref. Options-API: schema/defaults MUST be returned from setup().
|
||||
const licenseSchema = makeLicenseSchema(t);
|
||||
const noLicenseForm = ref<any>(null);
|
||||
const updateLicenseForm = ref<any>(null);
|
||||
const licenseDefaults = computed(
|
||||
(): LicenseForm => ({ licenseKey: licenseKey.value }),
|
||||
);
|
||||
|
||||
// Bridge programmatic licenseKey changes (URL auto-fill, cancel/clear) into
|
||||
// whichever card's form is mounted. Use form.reset (NOT setFieldValue) so the
|
||||
// field re-seeds AND submit state resets (submissionAttempts → 0) — no
|
||||
// post-save "required" flash on the v-show'd update card when clearing to "".
|
||||
watch(licenseKey, (v) => {
|
||||
noLicenseForm.value?.form?.reset({ licenseKey: v ?? "" });
|
||||
updateLicenseForm.value?.form?.reset({ licenseKey: v ?? "" });
|
||||
});
|
||||
|
||||
const loadLicenseData = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
@@ -509,10 +556,14 @@ export default defineComponent({
|
||||
}
|
||||
};
|
||||
|
||||
const updateLicense = async () => {
|
||||
// @submit handler — fires only once the schema passes (licenseKey required +
|
||||
// trim), so the `:disabled` gate is gone (R3). Still callable directly with
|
||||
// no args (reads the shared working ref); OForm awaits it so the inline Save
|
||||
// button's spinner spans the request.
|
||||
const updateLicense = async (value?: LicenseForm) => {
|
||||
const key = (value?.licenseKey ?? licenseKey.value ?? "").trim();
|
||||
try {
|
||||
updating.value = true;
|
||||
await licenseServer.update_license(licenseKey.value.trim());
|
||||
await licenseServer.update_license(key);
|
||||
toast({
|
||||
variant: "success",
|
||||
message: t("about.license_updated_success"),
|
||||
@@ -537,8 +588,6 @@ export default defineComponent({
|
||||
" : " +
|
||||
(error?.response?.data?.message || "unexpected error"),
|
||||
});
|
||||
} finally {
|
||||
updating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -945,13 +994,19 @@ export default defineComponent({
|
||||
|
||||
return {
|
||||
t,
|
||||
store,
|
||||
loading,
|
||||
updating,
|
||||
licenseData,
|
||||
licenseKey,
|
||||
showUpdateForm,
|
||||
showLicenseKeyModal,
|
||||
updateLicense,
|
||||
// Form wiring (Options-API: schema/defaults MUST be returned so :schema
|
||||
// resolves and validation runs).
|
||||
licenseSchema,
|
||||
licenseDefaults,
|
||||
noLicenseForm,
|
||||
updateLicenseForm,
|
||||
formatDate,
|
||||
isIngestionUnlimited,
|
||||
ingestionUsagePercent,
|
||||
@@ -973,7 +1028,6 @@ export default defineComponent({
|
||||
generateUsageDashboard,
|
||||
limitBreachAllowedCount,
|
||||
DOMPurify,
|
||||
store,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// Full validation schema for ModelPricingEditor.vue — scalars (name +
|
||||
// match_pattern) AND the dynamic tiers/prices array.
|
||||
//
|
||||
// The API model stores prices as a `{ key: perTokenPrice }` MAP; TanStack
|
||||
// field-arrays operate on ARRAYS, so the form models prices as an array of
|
||||
// `{ key, value }` rows (value held as PER-MILLION for display). The component
|
||||
// converts map↔rows at edit-prefill and submit.
|
||||
//
|
||||
// Restores the manual-`:error` BEFORE rules (inventory §6b) AND the imperative
|
||||
// save() guards as schema rules:
|
||||
// • name: required + max 256
|
||||
// • match_pattern: required + max 512 + valid RegExp (Rust inline flags stripped)
|
||||
// • price-row key: not a pure integer, no spaces (per row)
|
||||
// • condition.usage_key: not a pure integer (non-default tiers)
|
||||
//
|
||||
// Two structural rules with no single field to attach to — "default tier needs
|
||||
// ≥1 non-zero price" and "a draft price has a value but no key" — stay as
|
||||
// submit-handler guards (toasts), since they don't map to one OForm* field.
|
||||
//
|
||||
// Validation TIMING is owned by OForm (submit-then-change via revalidateLogic);
|
||||
// this file only describes WHAT is valid.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
const PURE_INT = /^\d+$/;
|
||||
const HAS_SPACE = /\s/;
|
||||
|
||||
/**
|
||||
* Strip Rust/PCRE inline flag groups that JavaScript RegExp doesn't understand
|
||||
* (e.g. `(?i)`, `(?ims)`). Flag-scoped groups like `(?i:...)` are left intact.
|
||||
*/
|
||||
export function stripInlineFlags(pattern: string): string {
|
||||
return pattern.replace(/\(\?[imsxu]+\)/g, "");
|
||||
}
|
||||
|
||||
// One committed price row. `value` is held as PER-MILLION in the form.
|
||||
export const priceRowSchema = z.object({
|
||||
key: z.string(),
|
||||
value: z.coerce.number(),
|
||||
});
|
||||
|
||||
export const tierConditionSchema = z.object({
|
||||
usage_key: z.string(),
|
||||
operator: z.string(),
|
||||
value: z.coerce.number(),
|
||||
});
|
||||
|
||||
export const tierSchema = z.object({
|
||||
name: z.string(),
|
||||
// null for the default (first) tier; an object for conditional tiers.
|
||||
condition: tierConditionSchema.nullable().optional(),
|
||||
prices: z.array(priceRowSchema).default([]),
|
||||
// Staging "add price" row — non-validated form state, auto-committed at submit.
|
||||
draftKey: z.string().optional().default(""),
|
||||
draftValue: z.coerce.number().optional().default(0),
|
||||
});
|
||||
|
||||
export const makeModelPricingSchema = (
|
||||
t: (_key: string, _named?: Record<string, unknown>) => string,
|
||||
) =>
|
||||
z
|
||||
.object({
|
||||
name: z.string(),
|
||||
match_pattern: z.string(),
|
||||
tiers: z.array(tierSchema).default([]),
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
// ── name ──────────────────────────────────────────────────────────────
|
||||
const name = String(val.name ?? "");
|
||||
if (!name.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["name"],
|
||||
message: t("modelPricing.nameRequired"),
|
||||
});
|
||||
} else if (name.length > 256) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["name"],
|
||||
message: t("modelPricing.nameTooLong"),
|
||||
});
|
||||
}
|
||||
|
||||
// ── match_pattern ─────────────────────────────────────────────────────
|
||||
const pattern = String(val.match_pattern ?? "");
|
||||
if (!pattern.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["match_pattern"],
|
||||
message: t("modelPricing.patternRequired"),
|
||||
});
|
||||
} else if (pattern.length > 512) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["match_pattern"],
|
||||
message: t("modelPricing.patternTooLong"),
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
new RegExp(stripInlineFlags(pattern));
|
||||
} catch (e: any) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["match_pattern"],
|
||||
message: t("modelPricing.invalidRegex", { error: e.message }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── tiers / prices ────────────────────────────────────────────────────
|
||||
(val.tiers ?? []).forEach((tier, i) => {
|
||||
// committed price-row keys: not pure-int, no spaces
|
||||
(tier.prices ?? []).forEach((row, j) => {
|
||||
const k = String(row.key ?? "").trim();
|
||||
if (k && PURE_INT.test(k)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["tiers", i, "prices", j, "key"],
|
||||
message: t("modelPricing.usageKeyPureInteger"),
|
||||
});
|
||||
} else if (k && HAS_SPACE.test(k)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["tiers", i, "prices", j, "key"],
|
||||
message: t("modelPricing.usageKeyContainsSpaces"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// non-default tier condition.usage_key: not a pure integer
|
||||
if (i > 0 && tier.condition) {
|
||||
const ck = String(tier.condition.usage_key ?? "").trim();
|
||||
if (ck && PURE_INT.test(ck)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["tiers", i, "condition", "usage_key"],
|
||||
message: t("modelPricing.tierUsageKeyPlainNumber", {
|
||||
name: tier.name || `#${i + 1}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export type ModelPricingForm = z.infer<ReturnType<typeof makeModelPricingSchema>>;
|
||||
export type ModelPricingTier = z.infer<typeof tierSchema>;
|
||||
export type ModelPricingPriceRow = z.infer<typeof priceRowSchema>;
|
||||
@@ -17,7 +17,10 @@ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { nextTick } from "vue";
|
||||
import ModelPricingEditor from "./ModelPricingEditor.vue";
|
||||
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
|
||||
import OInput from "@/lib/forms/Input/OInput.vue";
|
||||
import i18n from "@/locales";
|
||||
import { makeModelPricingSchema } from "./ModelPricingEditor.schema";
|
||||
|
||||
|
||||
// Service mocks
|
||||
@@ -127,14 +130,17 @@ const ODialogStub = {
|
||||
// OButton stub — keeps clicks working and the slot content rendered
|
||||
const OButtonStub = {
|
||||
name: "OButton",
|
||||
props: ["variant", "size", "active", "disabled", "loading"],
|
||||
props: ["variant", "size", "active", "disabled", "loading", "type"],
|
||||
emits: ["click"],
|
||||
// Mirror the real OButton default (type="button") so stubbed buttons inside
|
||||
// the <OForm> don't accidentally submit it on click.
|
||||
template: `
|
||||
<button
|
||||
data-test-stub="o-button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:data-active="String(active)"
|
||||
:type="type || 'button'"
|
||||
:disabled="disabled || loading"
|
||||
@click="$emit('click', $event)"
|
||||
>
|
||||
@@ -221,6 +227,28 @@ const createWrapper = (overrides: { query?: Record<string, any> } = {}) => {
|
||||
});
|
||||
};
|
||||
|
||||
// ── Field-array test helpers: drive the real form (tiers/prices are now
|
||||
// form-owned; values are PER-MILLION rows). ──────────────────────────────
|
||||
// OWNER pattern: the component creates the form via useOForm and exposes it as
|
||||
// `form` (handed to <OForm :form="form">) — read it directly, not via a ref.
|
||||
const getForm = (wrapper: any) => (wrapper.vm as any).form;
|
||||
const row = (key: string, value: number) => ({ key, value });
|
||||
const tier = (overrides: Record<string, any> = {}) => ({
|
||||
name: "Default",
|
||||
condition: null,
|
||||
prices: [] as any[],
|
||||
draftKey: "",
|
||||
draftValue: 0,
|
||||
...overrides,
|
||||
});
|
||||
// Seed a valid form: name + pattern + a default tier with one non-zero price.
|
||||
const fillValid = (wrapper: any, tiers?: any[]) => {
|
||||
const form = getForm(wrapper);
|
||||
form.setFieldValue("name", "GPT 4o");
|
||||
form.setFieldValue("match_pattern", "gpt-4o");
|
||||
form.setFieldValue("tiers", tiers ?? [tier({ prices: [row("input", 1)] })]);
|
||||
};
|
||||
|
||||
describe("ModelPricingEditor.vue", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -287,14 +315,15 @@ describe("ModelPricingEditor.vue", () => {
|
||||
});
|
||||
|
||||
describe("Initial state", () => {
|
||||
it("initializes the model with a single default tier", async () => {
|
||||
it("initializes the form with a single default tier (no prices, no condition)", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as any;
|
||||
expect(vm.model.tiers).toHaveLength(1);
|
||||
expect(vm.model.tiers[0].name).toBe("Default");
|
||||
expect(vm.model.tiers[0].condition).toBeNull();
|
||||
expect(vm.addState).toHaveLength(1);
|
||||
await nextTick();
|
||||
const tiers = (wrapper.vm as any).formTiers;
|
||||
expect(tiers).toHaveLength(1);
|
||||
expect(tiers[0].name).toBe("Default");
|
||||
expect(tiers[0].condition).toBeNull();
|
||||
expect(tiers[0].prices).toEqual([]);
|
||||
});
|
||||
|
||||
it("loads existing models on mount for shadow-conflict detection", async () => {
|
||||
@@ -319,159 +348,256 @@ describe("ModelPricingEditor.vue", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validation: nameError computed", () => {
|
||||
it("requires a model name", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
expect((wrapper.vm as any).nameError).toBe("Model name is required");
|
||||
// The name/match_pattern rules moved from computed refs into the co-located
|
||||
// Zod schema; test the schema directly (messages resolve via the real i18n).
|
||||
describe("Schema validation: name", () => {
|
||||
const schema = makeModelPricingSchema(i18n.global.t as any);
|
||||
const nameIssue = (name: string) => {
|
||||
const res = schema.safeParse({ name, match_pattern: "gpt" });
|
||||
return res.success
|
||||
? ""
|
||||
: res.error.issues.find((iss: any) => iss.path[0] === "name")?.message ?? "";
|
||||
};
|
||||
|
||||
it("requires a model name", () => {
|
||||
expect(nameIssue("")).toBe("Model name is required");
|
||||
});
|
||||
|
||||
it("rejects names over 256 chars", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
(wrapper.vm as any).model.name = "a".repeat(257);
|
||||
await nextTick();
|
||||
expect((wrapper.vm as any).nameError).toBe("Model name must be 256 characters or fewer");
|
||||
it("rejects names over 256 chars", () => {
|
||||
expect(nameIssue("a".repeat(257))).toBe(
|
||||
"Model name must be 256 characters or fewer",
|
||||
);
|
||||
});
|
||||
|
||||
it("clears name error for a valid name", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
(wrapper.vm as any).model.name = "Valid Name";
|
||||
await nextTick();
|
||||
expect((wrapper.vm as any).nameError).toBe("");
|
||||
it("accepts a valid name", () => {
|
||||
expect(nameIssue("Valid Name")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validation: regexError computed", () => {
|
||||
it("requires a match pattern", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
expect((wrapper.vm as any).regexError).toBe("Match pattern is required");
|
||||
describe("Schema validation: match_pattern", () => {
|
||||
const schema = makeModelPricingSchema(i18n.global.t as any);
|
||||
const patternIssue = (match_pattern: string) => {
|
||||
const res = schema.safeParse({ name: "X", match_pattern });
|
||||
return res.success
|
||||
? ""
|
||||
: res.error.issues.find((iss: any) => iss.path[0] === "match_pattern")
|
||||
?.message ?? "";
|
||||
};
|
||||
|
||||
it("requires a match pattern", () => {
|
||||
expect(patternIssue("")).toBe("Match pattern is required");
|
||||
});
|
||||
|
||||
it("rejects patterns over 512 chars", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
(wrapper.vm as any).model.match_pattern = "a".repeat(513);
|
||||
await nextTick();
|
||||
expect((wrapper.vm as any).regexError).toBe("Match pattern must be 512 characters or fewer");
|
||||
it("rejects patterns over 512 chars", () => {
|
||||
expect(patternIssue("a".repeat(513))).toBe(
|
||||
"Match pattern must be 512 characters or fewer",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an invalid regex with the parser error message", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
(wrapper.vm as any).model.match_pattern = "([";
|
||||
await nextTick();
|
||||
expect((wrapper.vm as any).regexError).toContain("Invalid regex:");
|
||||
it("reports an invalid regex with the parser error message", () => {
|
||||
expect(patternIssue("([")).toContain("Invalid regex:");
|
||||
});
|
||||
|
||||
it("strips Rust-style inline flags before validating", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
// (?i) is not valid in JS RegExp but the component strips it
|
||||
(wrapper.vm as any).model.match_pattern = "(?i)gpt-4";
|
||||
await nextTick();
|
||||
expect((wrapper.vm as any).regexError).toBe("");
|
||||
it("strips Rust-style inline flags before validating", () => {
|
||||
// (?i) is not valid in JS RegExp but the schema strips it
|
||||
expect(patternIssue("(?i)gpt-4")).toBe("");
|
||||
});
|
||||
|
||||
it("clears the error for valid patterns", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
(wrapper.vm as any).model.match_pattern = "gpt-.*";
|
||||
await nextTick();
|
||||
expect((wrapper.vm as any).regexError).toBe("");
|
||||
it("accepts a valid pattern", () => {
|
||||
expect(patternIssue("gpt-.*")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tier management", () => {
|
||||
it("addTier appends a conditional tier with default condition", async () => {
|
||||
describe("Schema validation: price-row key", () => {
|
||||
const schema = makeModelPricingSchema(i18n.global.t as any);
|
||||
// Isolate the committed price-row key rule (path tiers[i].prices[j].key).
|
||||
const keyIssue = (key: string) => {
|
||||
const res = schema.safeParse({
|
||||
name: "X",
|
||||
match_pattern: "gpt",
|
||||
tiers: [
|
||||
{ name: "Default", condition: null, prices: [{ key, value: 1 }] },
|
||||
],
|
||||
});
|
||||
return res.success
|
||||
? ""
|
||||
: res.error.issues.find(
|
||||
(iss: any) =>
|
||||
iss.path[0] === "tiers" &&
|
||||
iss.path[2] === "prices" &&
|
||||
iss.path[4] === "key",
|
||||
)?.message ?? "";
|
||||
};
|
||||
|
||||
it("rejects a pure-integer price key", () => {
|
||||
expect(keyIssue("123")).toBe("Usage key cannot be a pure integer");
|
||||
});
|
||||
|
||||
it("rejects a price key that contains spaces", () => {
|
||||
expect(keyIssue("input tokens")).toBe(
|
||||
"Usage key must not contain spaces",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a valid alphanumeric price key", () => {
|
||||
expect(keyIssue("input")).toBe("");
|
||||
});
|
||||
|
||||
it("ignores an empty price key (blank/draft row)", () => {
|
||||
expect(keyIssue("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Schema validation (real OForm)", () => {
|
||||
it("blocks submit and calls neither create nor update when name/pattern are empty", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
|
||||
const form = (wrapper.vm as any).form;
|
||||
await form.handleSubmit();
|
||||
await flushPromises();
|
||||
|
||||
expect(form.state.isValid).toBe(false);
|
||||
expect(mockService.create).not.toHaveBeenCalled();
|
||||
expect(mockService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tier management (field-array)", () => {
|
||||
it("addTier appends a conditional tier with a default condition", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
(wrapper.vm as any).addTier();
|
||||
const vm = wrapper.vm as any;
|
||||
expect(vm.model.tiers).toHaveLength(2);
|
||||
expect(vm.model.tiers[1].condition).toEqual({
|
||||
await nextTick();
|
||||
const tiers = (wrapper.vm as any).formTiers;
|
||||
expect(tiers).toHaveLength(2);
|
||||
expect(tiers[1].condition).toEqual({
|
||||
usage_key: "input",
|
||||
operator: "gt",
|
||||
value: 200000,
|
||||
});
|
||||
expect(vm.addState).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("removeTier removes the tier at the given index", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
(wrapper.vm as any).addTier();
|
||||
await nextTick();
|
||||
(wrapper.vm as any).removeTier(1);
|
||||
expect((wrapper.vm as any).model.tiers).toHaveLength(1);
|
||||
expect((wrapper.vm as any).addState).toHaveLength(1);
|
||||
await nextTick();
|
||||
expect((wrapper.vm as any).formTiers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("hides the remove button on the only tier (just one tier rendered)", async () => {
|
||||
it("hides the remove button on the only tier", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const outlineDestructive = wrapper
|
||||
.findAll('[data-test-stub="o-button"]')
|
||||
.filter((b) => b.attributes("data-variant") === "outline-destructive");
|
||||
// No tier remove buttons when only the default tier exists (only price-row delete buttons would have this variant — empty prices means no rows)
|
||||
// No tier remove buttons when only the default tier exists (and the default
|
||||
// tier starts with no price rows → no price-delete buttons either).
|
||||
expect(outlineDestructive.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pricing template helpers", () => {
|
||||
it("applyTemplate seeds the tier with template keys and zero defaults", async () => {
|
||||
describe("Pricing template helpers (field-array)", () => {
|
||||
it("applyTemplate seeds the tier rows with template keys and zero defaults", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const tier = (wrapper.vm as any).model.tiers[0];
|
||||
(wrapper.vm as any).applyTemplate(tier, ["input", "output"]);
|
||||
expect(tier.prices).toEqual({ input: 0, output: 0 });
|
||||
(wrapper.vm as any).applyTemplate(0, ["input", "output"]);
|
||||
await nextTick();
|
||||
const prices = (wrapper.vm as any).formTiers[0].prices;
|
||||
expect(prices.map((r: any) => r.key)).toEqual(["input", "output"]);
|
||||
expect(prices.every((r: any) => r.value === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("applyTemplate preserves existing values for matching keys", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const tier = (wrapper.vm as any).model.tiers[0];
|
||||
tier.prices = { input: 0.5, foo: 1 };
|
||||
(wrapper.vm as any).applyTemplate(tier, ["input", "output"]);
|
||||
expect(tier.prices).toEqual({ input: 0.5, output: 0 });
|
||||
getForm(wrapper).setFieldValue("tiers", [
|
||||
tier({ prices: [row("input", 0.5), row("foo", 1)] }),
|
||||
]);
|
||||
await nextTick();
|
||||
(wrapper.vm as any).applyTemplate(0, ["input", "output"]);
|
||||
await nextTick();
|
||||
const prices = (wrapper.vm as any).formTiers[0].prices;
|
||||
expect(prices.map((r: any) => [r.key, r.value])).toEqual([
|
||||
["input", 0.5],
|
||||
["output", 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it("clearTemplate removes the supplied keys from a tier", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const tier = (wrapper.vm as any).model.tiers[0];
|
||||
tier.prices = { input: 0.1, output: 0.2, foo: 0.3 };
|
||||
(wrapper.vm as any).clearTemplate(tier, ["input", "output"]);
|
||||
expect(tier.prices).toEqual({ foo: 0.3 });
|
||||
getForm(wrapper).setFieldValue("tiers", [
|
||||
tier({ prices: [row("input", 0.1), row("output", 0.2), row("foo", 0.3)] }),
|
||||
]);
|
||||
await nextTick();
|
||||
(wrapper.vm as any).clearTemplate(0, ["input", "output"]);
|
||||
await nextTick();
|
||||
const prices = (wrapper.vm as any).formTiers[0].prices;
|
||||
expect(prices.map((r: any) => r.key)).toEqual(["foo"]);
|
||||
});
|
||||
|
||||
it("isTemplateActive returns true when prices match the template keys exactly", async () => {
|
||||
it("isTemplateActive returns true when rows match the template keys exactly", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const tier = { prices: { input: 0, output: 0 } };
|
||||
expect((wrapper.vm as any).isTemplateActive(tier, ["input", "output"])).toBe(true);
|
||||
getForm(wrapper).setFieldValue("tiers", [
|
||||
tier({ prices: [row("input", 0), row("output", 0)] }),
|
||||
]);
|
||||
await nextTick();
|
||||
expect((wrapper.vm as any).isTemplateActive(0, ["input", "output"])).toBe(true);
|
||||
});
|
||||
|
||||
it("isTemplateActive returns false when prices do not match the template", async () => {
|
||||
it("isTemplateActive returns false when rows do not match the template", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const tier = { prices: { input: 0 } };
|
||||
expect((wrapper.vm as any).isTemplateActive(tier, ["input", "output"])).toBe(false);
|
||||
const tier2 = { prices: { input: 0, output: 0, foo: 0 } };
|
||||
expect((wrapper.vm as any).isTemplateActive(tier2, ["input", "output"])).toBe(false);
|
||||
getForm(wrapper).setFieldValue("tiers", [tier({ prices: [row("input", 0)] })]);
|
||||
await nextTick();
|
||||
expect((wrapper.vm as any).isTemplateActive(0, ["input", "output"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Price entry helpers", () => {
|
||||
it("updatePrice and deletePrice mutate tier.prices", async () => {
|
||||
it("addPrice commits the draft row into the tier and clears the draft", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const tier: any = { prices: { input: 1 } };
|
||||
(wrapper.vm as any).updatePrice(tier, "output", 2);
|
||||
expect(tier.prices).toEqual({ input: 1, output: 2 });
|
||||
(wrapper.vm as any).deletePrice(tier, "input");
|
||||
expect(tier.prices).toEqual({ output: 2 });
|
||||
getForm(wrapper).setFieldValue("tiers", [
|
||||
tier({ draftKey: "input", draftValue: 5 }),
|
||||
]);
|
||||
await nextTick();
|
||||
(wrapper.vm as any).addPrice(0);
|
||||
await nextTick();
|
||||
const t = (wrapper.vm as any).formTiers[0];
|
||||
expect(t.prices.map((r: any) => [r.key, r.value])).toEqual([["input", 5]]);
|
||||
expect(t.draftKey).toBe("");
|
||||
expect(t.draftValue).toBe(0);
|
||||
});
|
||||
|
||||
it("addPrice is a no-op when the draft key is empty", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
getForm(wrapper).setFieldValue("tiers", [
|
||||
tier({ draftKey: " ", draftValue: 5 }),
|
||||
]);
|
||||
await nextTick();
|
||||
(wrapper.vm as any).addPrice(0);
|
||||
await nextTick();
|
||||
expect((wrapper.vm as any).formTiers[0].prices).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removePrice removes the row at the given index", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
getForm(wrapper).setFieldValue("tiers", [
|
||||
tier({ prices: [row("a", 1), row("b", 2)] }),
|
||||
]);
|
||||
await nextTick();
|
||||
(wrapper.vm as any).removePrice(0, 0);
|
||||
await nextTick();
|
||||
const prices = (wrapper.vm as any).formTiers[0].prices;
|
||||
expect(prices.map((r: any) => r.key)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("toPerMillion / fromPerMillion are inverses for non-zero", async () => {
|
||||
@@ -505,119 +631,22 @@ describe("ModelPricingEditor.vue", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("addPrice", () => {
|
||||
it("returns true and is a no-op when the key is empty", async () => {
|
||||
describe("previewEntries (field-array)", () => {
|
||||
it("appends a pending row when the draft has a key", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const tier: any = { prices: {} };
|
||||
(wrapper.vm as any).addState[0] = { key: " ", value: 5 };
|
||||
const result = (wrapper.vm as any).addPrice(tier, 0);
|
||||
expect(result).toBe(true);
|
||||
expect(tier.prices).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects keys that are pure integers and notifies", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const tier: any = { prices: {} };
|
||||
(wrapper.vm as any).addState[0] = { key: "123", value: 1 };
|
||||
const result = (wrapper.vm as any).addPrice(tier, 0);
|
||||
expect(result).toBe(false);
|
||||
expect(mockToastFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ variant: "error", message: "Usage key cannot be a pure integer" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects keys containing spaces and notifies", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const tier: any = { prices: {} };
|
||||
(wrapper.vm as any).addState[0] = { key: "input tokens", value: 1 };
|
||||
const result = (wrapper.vm as any).addPrice(tier, 0);
|
||||
expect(result).toBe(false);
|
||||
expect(mockToastFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ variant: "error", message: "Usage key must not contain spaces" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("commits the pending entry and resets addState", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const tier: any = { prices: {} };
|
||||
(wrapper.vm as any).addState[0] = { key: "input", value: 1 }; // 1 per million
|
||||
const result = (wrapper.vm as any).addPrice(tier, 0);
|
||||
expect(result).toBe(true);
|
||||
expect(tier.prices.input).toBeCloseTo(0.000001);
|
||||
expect((wrapper.vm as any).addState[0]).toEqual({ key: "", value: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("renamePriceByIndex", () => {
|
||||
it("renames the key at the same position preserving order and value", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const tier: any = { prices: { a: 1, b: 2, c: 3 } };
|
||||
(wrapper.vm as any).renamePriceByIndex(tier, 1, "B");
|
||||
expect(Object.keys(tier.prices)).toEqual(["a", "B", "c"]);
|
||||
expect(tier.prices.B).toBe(2);
|
||||
});
|
||||
|
||||
it("is a no-op when newKey equals oldKey", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const tier: any = { prices: { a: 1 } };
|
||||
(wrapper.vm as any).renamePriceByIndex(tier, 0, "a");
|
||||
expect(tier.prices).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it("notifies and aborts when the new key fails validation", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const tier: any = { prices: { a: 1 } };
|
||||
(wrapper.vm as any).renamePriceByIndex(tier, 0, "1");
|
||||
expect(tier.prices).toEqual({ a: 1 });
|
||||
expect(mockToastFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores out-of-range indices", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const tier: any = { prices: { a: 1 } };
|
||||
(wrapper.vm as any).renamePriceByIndex(tier, 5, "x");
|
||||
expect(tier.prices).toEqual({ a: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("priceEntries + previewEntries", () => {
|
||||
it("priceEntries returns stable IDs per index", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const tier: any = { prices: { a: 1, b: 2 } };
|
||||
const first = (wrapper.vm as any).priceEntries(tier);
|
||||
const second = (wrapper.vm as any).priceEntries(tier);
|
||||
expect(first[0].stableId).toBe(second[0].stableId);
|
||||
expect(first[1].stableId).toBe(second[1].stableId);
|
||||
});
|
||||
|
||||
it("previewEntries appends a pending row when addState has a key", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const tier = (wrapper.vm as any).model.tiers[0];
|
||||
tier.prices = { input: 0.000001 };
|
||||
(wrapper.vm as any).addState[0] = { key: "output", value: 2 };
|
||||
const preview = (wrapper.vm as any).previewEntries(tier, 0);
|
||||
const t = tier({ prices: [row("input", 1)], draftKey: "output", draftValue: 2 });
|
||||
const preview = (wrapper.vm as any).previewEntries(t);
|
||||
expect(preview).toHaveLength(2);
|
||||
const pending = preview.find((p: any) => p.key === "output");
|
||||
expect(pending.stableId).toBe(-1);
|
||||
expect(pending.pending).toBe(true);
|
||||
});
|
||||
|
||||
it("previewEntries does not duplicate when pending key already exists", async () => {
|
||||
it("does not duplicate when the draft key already exists in the rows", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
const tier = (wrapper.vm as any).model.tiers[0];
|
||||
tier.prices = { input: 0.000001 };
|
||||
(wrapper.vm as any).addState[0] = { key: "input", value: 99 };
|
||||
const preview = (wrapper.vm as any).previewEntries(tier, 0);
|
||||
const t = tier({ prices: [row("input", 1)], draftKey: "input", draftValue: 99 });
|
||||
const preview = (wrapper.vm as any).previewEntries(t);
|
||||
expect(preview).toHaveLength(1);
|
||||
expect(preview[0].key).toBe("input");
|
||||
});
|
||||
@@ -694,24 +723,26 @@ describe("ModelPricingEditor.vue", () => {
|
||||
});
|
||||
|
||||
describe("save flow — validation guards", () => {
|
||||
it("does not call the API when name/regex are invalid", async () => {
|
||||
it("does not call the API when name/pattern are invalid (schema-gated submit)", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
// both fields empty by default — invalid
|
||||
await (wrapper.vm as any).save();
|
||||
// both fields empty by default — invalid; submit through the real form.
|
||||
const form = getForm(wrapper);
|
||||
await form.handleSubmit();
|
||||
await flushPromises();
|
||||
expect(form.state.isValid).toBe(false);
|
||||
expect(mockService.create).not.toHaveBeenCalled();
|
||||
expect(mockService.update).not.toHaveBeenCalled();
|
||||
// touched flags are flipped so errors render
|
||||
expect((wrapper.vm as any).nameTouched).toBe(true);
|
||||
expect((wrapper.vm as any).patternTouched).toBe(true);
|
||||
});
|
||||
|
||||
it("warns when default tier has no prices and aborts", async () => {
|
||||
it("warns when the default tier has no prices and aborts", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
(wrapper.vm as any).model.name = "M";
|
||||
(wrapper.vm as any).model.match_pattern = "gpt";
|
||||
await (wrapper.vm as any).save();
|
||||
// valid name/pattern, but a default tier with NO price rows
|
||||
fillValid(wrapper, [tier({ prices: [] })]);
|
||||
await nextTick();
|
||||
await getForm(wrapper).handleSubmit();
|
||||
await flushPromises();
|
||||
expect(mockToastFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variant: "error",
|
||||
@@ -721,14 +752,15 @@ describe("ModelPricingEditor.vue", () => {
|
||||
expect(mockService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns when a pending row has a value but no key", async () => {
|
||||
it("warns when a draft row has a value but no key", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
(wrapper.vm as any).model.name = "M";
|
||||
(wrapper.vm as any).model.match_pattern = "gpt";
|
||||
(wrapper.vm as any).model.tiers[0].prices = { input: 0.001 };
|
||||
(wrapper.vm as any).addState[0] = { key: "", value: 5 };
|
||||
await (wrapper.vm as any).save();
|
||||
fillValid(wrapper, [
|
||||
tier({ prices: [row("input", 1000)], draftKey: "", draftValue: 5 }),
|
||||
]);
|
||||
await nextTick();
|
||||
await getForm(wrapper).handleSubmit();
|
||||
await flushPromises();
|
||||
expect(mockToastFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variant: "error",
|
||||
@@ -738,21 +770,22 @@ describe("ModelPricingEditor.vue", () => {
|
||||
expect(mockService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a non-default tier whose condition.usage_key is a pure integer", async () => {
|
||||
it("blocks submit when a non-default tier's condition.usage_key is a pure integer (schema)", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
(wrapper.vm as any).model.name = "M";
|
||||
(wrapper.vm as any).model.match_pattern = "gpt";
|
||||
(wrapper.vm as any).model.tiers[0].prices = { input: 0.001 };
|
||||
(wrapper.vm as any).addTier();
|
||||
(wrapper.vm as any).model.tiers[1].condition.usage_key = "42";
|
||||
await (wrapper.vm as any).save();
|
||||
expect(mockToastFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variant: "error",
|
||||
message: expect.stringContaining("Usage key cannot be a plain number"),
|
||||
fillValid(wrapper, [
|
||||
tier({ prices: [row("input", 1000)] }),
|
||||
tier({
|
||||
name: "Tier 2",
|
||||
condition: { usage_key: "42", operator: "gt", value: 1 },
|
||||
prices: [],
|
||||
}),
|
||||
);
|
||||
]);
|
||||
await nextTick();
|
||||
const form = getForm(wrapper);
|
||||
await form.handleSubmit();
|
||||
await flushPromises();
|
||||
expect(form.state.isValid).toBe(false);
|
||||
expect(mockService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -761,10 +794,9 @@ describe("ModelPricingEditor.vue", () => {
|
||||
it("calls create when there is no model id and notifies success", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
(wrapper.vm as any).model.name = "GPT 4o";
|
||||
(wrapper.vm as any).model.match_pattern = "gpt-4o";
|
||||
(wrapper.vm as any).model.tiers[0].prices = { input: 0.001 };
|
||||
await (wrapper.vm as any).save();
|
||||
fillValid(wrapper);
|
||||
await nextTick();
|
||||
await getForm(wrapper).handleSubmit();
|
||||
await flushPromises();
|
||||
expect(mockService.create).toHaveBeenCalledWith(
|
||||
"test-org",
|
||||
@@ -776,6 +808,59 @@ describe("ModelPricingEditor.vue", () => {
|
||||
expect(mockRouterPush).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("converts per-million rows back to a per-token price map", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
// 1000 per-million → 0.001 per-token
|
||||
fillValid(wrapper, [tier({ prices: [row("input", 1000)] })]);
|
||||
await nextTick();
|
||||
await getForm(wrapper).handleSubmit();
|
||||
await flushPromises();
|
||||
const payload = mockService.create.mock.calls[0][1];
|
||||
expect(payload.tiers[0].prices.input).toBeCloseTo(0.001);
|
||||
});
|
||||
|
||||
it("sends the exact API tier shape (keys + types) and leaks no draft/helper fields", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
// A tier with a committed row AND a draft row: the draft must be committed
|
||||
// into `prices`, and the form-only draftKey/draftValue must NOT leak into
|
||||
// the payload (the {...value} regression ④b guards against).
|
||||
fillValid(wrapper, [
|
||||
tier({
|
||||
prices: [row("input", 1000)],
|
||||
draftKey: "output",
|
||||
draftValue: 2000,
|
||||
}),
|
||||
]);
|
||||
await nextTick();
|
||||
await getForm(wrapper).handleSubmit();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockService.create).toHaveBeenCalledTimes(1);
|
||||
const payload = mockService.create.mock.calls[0][1];
|
||||
|
||||
// top-level scalars — exact value + correct type
|
||||
expect(typeof payload.name).toBe("string");
|
||||
expect(payload.name).toBe("GPT 4o");
|
||||
expect(typeof payload.match_pattern).toBe("string");
|
||||
expect(payload.match_pattern).toBe("gpt-4o");
|
||||
expect(Array.isArray(payload.tiers)).toBe(true);
|
||||
|
||||
// EXACT tier shape — only {condition, name, prices}; NO draftKey/draftValue leak
|
||||
const t0 = payload.tiers[0];
|
||||
expect(Object.keys(t0).sort()).toEqual(["condition", "name", "prices"]);
|
||||
expect(t0.condition).toBeNull(); // default (first) tier
|
||||
expect(typeof t0.name).toBe("string");
|
||||
|
||||
// prices is a per-token MAP of numbers (committed row + committed draft)
|
||||
expect(Object.keys(t0.prices).sort()).toEqual(["input", "output"]);
|
||||
expect(typeof t0.prices.input).toBe("number");
|
||||
expect(typeof t0.prices.output).toBe("number");
|
||||
expect(t0.prices.input).toBeCloseTo(0.001); // 1000 per-million → per-token
|
||||
expect(t0.prices.output).toBeCloseTo(0.002); // 2000 per-million → per-token
|
||||
});
|
||||
|
||||
it("calls update when the model has an id", async () => {
|
||||
mockService.get.mockResolvedValue({
|
||||
data: {
|
||||
@@ -788,7 +873,7 @@ describe("ModelPricingEditor.vue", () => {
|
||||
});
|
||||
const wrapper = createWrapper({ query: { id: "abc" } });
|
||||
await flushPromises();
|
||||
await (wrapper.vm as any).save();
|
||||
await getForm(wrapper).handleSubmit();
|
||||
await flushPromises();
|
||||
expect(mockService.update).toHaveBeenCalledWith(
|
||||
"test-org",
|
||||
@@ -800,12 +885,15 @@ describe("ModelPricingEditor.vue", () => {
|
||||
it("clears the condition on the default tier before saving", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
(wrapper.vm as any).model.name = "M";
|
||||
(wrapper.vm as any).model.match_pattern = "gpt";
|
||||
(wrapper.vm as any).model.tiers[0].prices = { input: 0.001 };
|
||||
// simulate a stale condition on the default tier
|
||||
(wrapper.vm as any).model.tiers[0].condition = { usage_key: "x", operator: "gt", value: 0 };
|
||||
await (wrapper.vm as any).save();
|
||||
// a stale condition on the default tier — should be nulled on save
|
||||
fillValid(wrapper, [
|
||||
tier({
|
||||
prices: [row("input", 1000)],
|
||||
condition: { usage_key: "x", operator: "gt", value: 0 },
|
||||
}),
|
||||
]);
|
||||
await nextTick();
|
||||
await getForm(wrapper).handleSubmit();
|
||||
await flushPromises();
|
||||
expect(mockService.create).toHaveBeenCalledWith(
|
||||
"test-org",
|
||||
@@ -817,13 +905,14 @@ describe("ModelPricingEditor.vue", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-commits a pending row before saving", async () => {
|
||||
it("auto-commits a draft row before saving", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
(wrapper.vm as any).model.name = "M";
|
||||
(wrapper.vm as any).model.match_pattern = "gpt";
|
||||
(wrapper.vm as any).addState[0] = { key: "input", value: 1 };
|
||||
await (wrapper.vm as any).save();
|
||||
fillValid(wrapper, [
|
||||
tier({ prices: [], draftKey: "input", draftValue: 1 }),
|
||||
]);
|
||||
await nextTick();
|
||||
await getForm(wrapper).handleSubmit();
|
||||
await flushPromises();
|
||||
expect(mockService.create).toHaveBeenCalled();
|
||||
const payload = mockService.create.mock.calls[0][1];
|
||||
@@ -846,10 +935,9 @@ describe("ModelPricingEditor.vue", () => {
|
||||
});
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
(wrapper.vm as any).model.name = "Mine";
|
||||
(wrapper.vm as any).model.match_pattern = "gpt-4o";
|
||||
(wrapper.vm as any).model.tiers[0].prices = { input: 0.001 };
|
||||
await (wrapper.vm as any).save();
|
||||
fillValid(wrapper);
|
||||
await nextTick();
|
||||
await getForm(wrapper).handleSubmit();
|
||||
await flushPromises();
|
||||
expect(mockToastFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -867,10 +955,9 @@ describe("ModelPricingEditor.vue", () => {
|
||||
});
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
(wrapper.vm as any).model.name = "M";
|
||||
(wrapper.vm as any).model.match_pattern = "gpt";
|
||||
(wrapper.vm as any).model.tiers[0].prices = { input: 0.001 };
|
||||
await (wrapper.vm as any).save();
|
||||
fillValid(wrapper);
|
||||
await nextTick();
|
||||
await getForm(wrapper).handleSubmit();
|
||||
await flushPromises();
|
||||
expect(mockToastFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -878,7 +965,9 @@ describe("ModelPricingEditor.vue", () => {
|
||||
message: expect.stringContaining("server boom"),
|
||||
}),
|
||||
);
|
||||
expect((wrapper.vm as any).saving).toBe(false);
|
||||
// Loading is form-driven: after the awaited submit settles (even on
|
||||
// error), TanStack's isSubmitting resets — no manual `saving` flag.
|
||||
expect(getForm(wrapper).state.isSubmitting).toBe(false);
|
||||
});
|
||||
|
||||
it("suppresses 403 error notification (handled globally)", async () => {
|
||||
@@ -887,10 +976,9 @@ describe("ModelPricingEditor.vue", () => {
|
||||
});
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
(wrapper.vm as any).model.name = "M";
|
||||
(wrapper.vm as any).model.match_pattern = "gpt";
|
||||
(wrapper.vm as any).model.tiers[0].prices = { input: 0.001 };
|
||||
await (wrapper.vm as any).save();
|
||||
fillValid(wrapper);
|
||||
await nextTick();
|
||||
await getForm(wrapper).handleSubmit();
|
||||
await flushPromises();
|
||||
const negativeCalls = mockToastFn.mock.calls.filter(
|
||||
([arg]) => arg && arg.variant === "error",
|
||||
@@ -900,18 +988,21 @@ describe("ModelPricingEditor.vue", () => {
|
||||
});
|
||||
|
||||
describe("Footer save button state", () => {
|
||||
it("is disabled when name or regex error is present", async () => {
|
||||
it("stays enabled even when name/pattern are invalid (R3 — validation is schema-driven)", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
// Both fields empty (invalid) — the Save button is NOT disabled; the
|
||||
// schema blocks the actual submit instead.
|
||||
const saveBtn = wrapper.find('[data-test="model-pricing-editor-save-btn"]');
|
||||
expect(saveBtn.attributes("disabled")).toBeDefined();
|
||||
expect(saveBtn.attributes("disabled")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is enabled when name and pattern are valid", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
(wrapper.vm as any).model.name = "valid";
|
||||
(wrapper.vm as any).model.match_pattern = "gpt-.*";
|
||||
const form = (wrapper.vm as any).form;
|
||||
form.setFieldValue("name", "valid");
|
||||
form.setFieldValue("match_pattern", "gpt-.*");
|
||||
await nextTick();
|
||||
const saveBtn = wrapper.find('[data-test="model-pricing-editor-save-btn"]');
|
||||
expect(saveBtn.attributes("disabled")).toBeUndefined();
|
||||
@@ -919,7 +1010,7 @@ describe("ModelPricingEditor.vue", () => {
|
||||
});
|
||||
|
||||
describe("Edit-mode hydration", () => {
|
||||
it("hydrates the model from the service and seeds tier conditions", async () => {
|
||||
it("hydrates the form from the service, converts prices to rows, and seeds tier conditions", async () => {
|
||||
mockService.get.mockResolvedValue({
|
||||
data: {
|
||||
id: "abc",
|
||||
@@ -935,11 +1026,22 @@ describe("ModelPricingEditor.vue", () => {
|
||||
});
|
||||
const wrapper = createWrapper({ query: { id: "abc" } });
|
||||
await flushPromises();
|
||||
const tiers = (wrapper.vm as any).model.tiers;
|
||||
await nextTick();
|
||||
|
||||
// The loaded scalar values are seeded into the form.
|
||||
const form = getForm(wrapper);
|
||||
expect(form.state.values.name).toBe("Existing");
|
||||
expect(form.state.values.match_pattern).toBe("gpt-.*");
|
||||
|
||||
// tiers converted to per-million ROW shape; condition defaulted on tier 2.
|
||||
const tiers = (wrapper.vm as any).formTiers;
|
||||
expect(tiers).toHaveLength(2);
|
||||
// 0.001 per-token → 1000 per-million
|
||||
expect(tiers[0].prices.map((r: any) => [r.key, r.value])).toEqual([
|
||||
["input", 1000],
|
||||
]);
|
||||
expect(tiers[0].condition).toBeNull();
|
||||
expect(tiers[1].condition).toEqual({ usage_key: "input", operator: "gt", value: 0 });
|
||||
expect((wrapper.vm as any).nameTouched).toBe(true);
|
||||
expect((wrapper.vm as any).patternTouched).toBe(true);
|
||||
});
|
||||
|
||||
it("notifies and continues when get() rejects with non-403", async () => {
|
||||
@@ -956,4 +1058,134 @@ describe("ModelPricingEditor.vue", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: deleting a non-last price row must not leave the OTHER rows'
|
||||
// rendered inputs showing stale/shifted values. The form DATA was always
|
||||
// correct (the preview read it fine); the bug was a v-for :key (stable id) vs
|
||||
// index-based field-name mismatch, so reused field components kept stale name
|
||||
// bindings on a middle delete.
|
||||
describe("Price row delete — rendered inputs stay in sync (field-array keying)", () => {
|
||||
// Read each price-KEY field's rendered value (the OInput model-value =
|
||||
// the bound field's value). If the field component is bound to the wrong
|
||||
// path after a delete, this surfaces it (form data alone would not).
|
||||
const renderedPriceKeys = (wrapper: any) =>
|
||||
wrapper
|
||||
.findAllComponents(OFormInput)
|
||||
.filter((c: any) => /\.prices\[\d+\]\.key$/.test(c.props("name") || ""))
|
||||
.map((c: any) => c.findComponent(OInput).props("modelValue"));
|
||||
|
||||
it("removing the FIRST of four price rows leaves the other three inputs showing the correct keys", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
getForm(wrapper).setFieldValue("tiers", [
|
||||
tier({
|
||||
prices: [
|
||||
row("input", 1),
|
||||
row("output", 2),
|
||||
row("cache_read_input_tokens", 3),
|
||||
row("output_reasoning_tokens", 4),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
|
||||
// sanity — all four rendered in order
|
||||
expect(renderedPriceKeys(wrapper)).toEqual([
|
||||
"input",
|
||||
"output",
|
||||
"cache_read_input_tokens",
|
||||
"output_reasoning_tokens",
|
||||
]);
|
||||
|
||||
(wrapper.vm as any).removePrice(0, 0); // delete "input"
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
|
||||
// form data is correct (only "input" removed)
|
||||
expect(
|
||||
(wrapper.vm as any).formTiers[0].prices.map((p: any) => p.key),
|
||||
).toEqual(["output", "cache_read_input_tokens", "output_reasoning_tokens"]);
|
||||
|
||||
// the RENDERED inputs must match the data — not shifted, not blank
|
||||
expect(renderedPriceKeys(wrapper)).toEqual([
|
||||
"output",
|
||||
"cache_read_input_tokens",
|
||||
"output_reasoning_tokens",
|
||||
]);
|
||||
});
|
||||
|
||||
const renderedTierNames = (wrapper: any) =>
|
||||
wrapper
|
||||
.findAllComponents(OFormInput)
|
||||
.filter((c: any) => /^tiers\[\d+\]\.name$/.test(c.props("name") || ""))
|
||||
.map((c: any) => c.findComponent(OInput).props("modelValue"));
|
||||
|
||||
it("removing a MIDDLE tier keeps the remaining tiers' name inputs correct", async () => {
|
||||
const wrapper = createWrapper();
|
||||
await flushPromises();
|
||||
getForm(wrapper).setFieldValue("tiers", [
|
||||
tier({ name: "Default", prices: [row("input", 1)] }),
|
||||
tier({
|
||||
name: "Tier B",
|
||||
condition: { usage_key: "input", operator: "gt", value: 100 },
|
||||
}),
|
||||
tier({
|
||||
name: "Tier C",
|
||||
condition: { usage_key: "input", operator: "gt", value: 200 },
|
||||
}),
|
||||
]);
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
|
||||
expect(renderedTierNames(wrapper)).toEqual(["Default", "Tier B", "Tier C"]);
|
||||
|
||||
(wrapper.vm as any).removeTier(1); // delete "Tier B"
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
|
||||
expect((wrapper.vm as any).formTiers.map((t: any) => t.name)).toEqual([
|
||||
"Default",
|
||||
"Tier C",
|
||||
]);
|
||||
expect(renderedTierNames(wrapper)).toEqual(["Default", "Tier C"]);
|
||||
});
|
||||
|
||||
it("edit-mode renders prices in the SAME order the API returns them (frontend does not reorder)", async () => {
|
||||
mockService.get.mockResolvedValue({
|
||||
data: {
|
||||
id: "m1",
|
||||
name: "GPT",
|
||||
match_pattern: "gpt-4o",
|
||||
enabled: true,
|
||||
tiers: [
|
||||
{
|
||||
name: "Default",
|
||||
condition: null,
|
||||
// API returns this exact key order
|
||||
prices: {
|
||||
input: 0.0000001,
|
||||
output: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
output_reasoning_tokens: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const wrapper = createWrapper({ query: { id: "m1" } });
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
// modelToForm preserves the map's key order exactly — no sort, no reverse
|
||||
expect(
|
||||
(wrapper.vm as any).formTiers[0].prices.map((p: any) => p.key),
|
||||
).toEqual([
|
||||
"input",
|
||||
"output",
|
||||
"cache_read_input_tokens",
|
||||
"output_reasoning_tokens",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -35,6 +35,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</AppPageHeader>
|
||||
|
||||
<!-- Form Body -->
|
||||
<OForm
|
||||
:form="form"
|
||||
v-slot="{ isSubmitting }"
|
||||
class="flex flex-col"
|
||||
style="flex: 1; min-height: 0;"
|
||||
>
|
||||
<div
|
||||
class="px-3 py-3"
|
||||
style="
|
||||
@@ -76,13 +82,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
/>
|
||||
</OButton>
|
||||
</div>
|
||||
<OInput
|
||||
v-model="model.name"
|
||||
<OFormInput
|
||||
name="name"
|
||||
:placeholder="t('modelPricing.modelNamePlaceholder')"
|
||||
:error="nameTouched && !!nameError"
|
||||
:error-message="nameError"
|
||||
@blur="nameTouched = true"
|
||||
@update:model-value="nameTouched = true"
|
||||
data-test="model-pricing-name-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -109,13 +111,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
/>
|
||||
</OButton>
|
||||
</div>
|
||||
<OInput
|
||||
v-model="model.match_pattern"
|
||||
<OFormInput
|
||||
name="match_pattern"
|
||||
:placeholder="t('modelPricing.matchPatternPlaceholder')"
|
||||
:error="patternTouched && !!regexError"
|
||||
:error-message="regexError"
|
||||
@blur="patternTouched = true"
|
||||
@update:model-value="patternTouched = true"
|
||||
data-test="model-pricing-pattern-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -206,8 +204,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<div class="form-card-body flex flex-col gap-3 px-4 pt-[10px] pb-2">
|
||||
<div
|
||||
v-for="(tier, idx) in model.tiers"
|
||||
:key="idx as number"
|
||||
v-for="(tier, idx) in formTiers"
|
||||
:key="idx"
|
||||
class="border border-(--o2-border-color) rounded-[10px] overflow-hidden shadow-[0_1px_4px_rgba(0,0,0,0.05)] dark:shadow-[0_1px_4px_rgba(0,0,0,0.3)]"
|
||||
>
|
||||
<!-- Tier Header -->
|
||||
@@ -216,19 +214,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<span class="tier-name-label text-xs font-medium opacity-50 whitespace-nowrap shrink-0">{{
|
||||
t("modelPricing.tierName")
|
||||
}}</span>
|
||||
<OInput
|
||||
v-model="tier.name"
|
||||
<OFormInput
|
||||
:name="`tiers[${idx}].name`"
|
||||
:placeholder="t('modelPricing.tierNamePlaceholder')"
|
||||
:data-test="`model-pricing-tier-name-input-${idx}`"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<OButton
|
||||
v-if="model.tiers.length > 1"
|
||||
v-if="formTiers.length > 1"
|
||||
variant="outline-destructive"
|
||||
size="icon"
|
||||
type="button"
|
||||
:data-test="`model-pricing-tier-remove-btn-${idx}`"
|
||||
@click="removeTier(idx as number)"
|
||||
@click="removeTier(idx)"
|
||||
>
|
||||
<OIcon name="delete" size="sm" />
|
||||
</OButton>
|
||||
@@ -239,7 +238,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<div class="tier-body p-3 px-4 flex flex-col gap-3">
|
||||
<!-- Condition row (non-default tiers only) -->
|
||||
<div
|
||||
v-if="(idx as number) > 0 && tier.condition"
|
||||
v-if="idx > 0 && tier.condition"
|
||||
class="py-3 px-[14px] rounded-lg bg-[rgba(0,0,0,0.02)] border border-(--o2-border-color) dark:bg-[rgba(255,255,255,0.03)]"
|
||||
>
|
||||
<div class="sub-label mb-2 text-[11px] font-semibold tracking-[0.06em] opacity-65">
|
||||
@@ -247,25 +246,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
<div class="flex gap-2 items-end flex-nowrap">
|
||||
<div class="flex-1 min-w-[130px]">
|
||||
<OInput
|
||||
v-model="tier.condition.usage_key"
|
||||
<OFormInput
|
||||
:name="`tiers[${idx}].condition.usage_key`"
|
||||
:label="t('modelPricing.usageKeyCol')"
|
||||
:placeholder="t('modelPricing.usageKeyPlaceholder')"
|
||||
:data-test="`model-pricing-tier-condition-key-input-${idx}`"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-[90px] flex-shrink-0">
|
||||
<OSelect
|
||||
v-model="tier.condition.operator"
|
||||
<OFormSelect
|
||||
:name="`tiers[${idx}].condition.operator`"
|
||||
:options="operators"
|
||||
labelKey="label"
|
||||
valueKey="value"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
:data-test="`model-pricing-tier-condition-operator-select-${idx}`"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-[140px] flex-shrink-0">
|
||||
<OInput
|
||||
v-model.number="tier.condition.value"
|
||||
<OFormInput
|
||||
:name="`tiers[${idx}].condition.value`"
|
||||
:label="t('modelPricing.threshold')"
|
||||
type="number"
|
||||
:data-test="`model-pricing-tier-condition-value-input-${idx}`"
|
||||
@@ -283,10 +282,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
v-for="tpl in usageTemplates"
|
||||
:key="tpl.name"
|
||||
variant="pricing-chip"
|
||||
:active="isTemplateActive(tier, tpl.keys)"
|
||||
type="button"
|
||||
:active="isTemplateActive(idx, tpl.keys)"
|
||||
class="!rounded-[1.25rem] !h-auto !py-[0.3125rem] !px-[0.875rem] !text-xs !font-medium !gap-[0.375rem]"
|
||||
:data-test="`model-pricing-tier-template-btn-${idx}-${tpl.name.toLowerCase()}`"
|
||||
@click="applyTemplate(tier, tpl.keys)"
|
||||
@click="applyTemplate(idx, tpl.keys)"
|
||||
>
|
||||
<template #icon-left>
|
||||
<span
|
||||
@@ -296,9 +296,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
{{ tpl.name }}
|
||||
<span
|
||||
v-if="isTemplateActive(tier, tpl.keys)"
|
||||
v-if="isTemplateActive(idx, tpl.keys)"
|
||||
class="text-sm leading-none opacity-75 ml-0.5 hover:opacity-100"
|
||||
@click.stop="clearTemplate(tier, tpl.keys)"
|
||||
@click.stop="clearTemplate(idx, tpl.keys)"
|
||||
>×</span
|
||||
>
|
||||
</OButton>
|
||||
@@ -316,7 +316,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<div class="price-table overflow-hidden">
|
||||
<!-- Column headers (only when rows exist) -->
|
||||
<div
|
||||
v-if="Object.keys(tier.prices).length"
|
||||
v-if="tier.prices.length"
|
||||
class="price-table-head grid grid-cols-[1fr_160px_auto] gap-2 py-[6px] px-3 text-[11px] font-semibold tracking-[0.01em] opacity-45"
|
||||
>
|
||||
<span>{{ t("modelPricing.usageKeyCol") }}</span>
|
||||
@@ -324,54 +324,57 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<span></span>
|
||||
</div>
|
||||
|
||||
<!-- Existing rows — use stable numeric index as :key so
|
||||
renaming a key doesn't destroy/recreate the DOM node -->
|
||||
<!-- Existing rows — form-owned (tiers[i].prices[j]); value
|
||||
is held PER-MILLION so it binds directly. -->
|
||||
<!-- Key by INDEX (matching the index-based field names): a
|
||||
stable-id key makes Vue reuse+reorder row components on a
|
||||
middle delete, but each field's `form.Field` binds to its
|
||||
`name` at creation and does NOT re-bind when the name
|
||||
shifts — so reused rows would show stale (shifted) values.
|
||||
Index keys keep each position's name fixed. (Same pattern
|
||||
as CreateDestinationForm's headers.) -->
|
||||
<div
|
||||
v-for="(entry, entryIdx) in priceEntries(tier)"
|
||||
:key="entry.stableId"
|
||||
class="price-row grid grid-cols-[1fr_160px_auto] gap-2 items-center py-0.5 px-3"
|
||||
v-for="(_entry, entryIdx) in tier.prices"
|
||||
:key="entryIdx"
|
||||
class="price-row grid grid-cols-[1fr_160px_auto] gap-2 items-start py-0.5 px-3"
|
||||
>
|
||||
<OInput
|
||||
:model-value="entry.key"
|
||||
<OFormInput
|
||||
:name="`tiers[${idx}].prices[${entryIdx}].key`"
|
||||
:placeholder="t('modelPricing.usageKeyPlaceholder')"
|
||||
:data-test="`model-pricing-price-key-input-${idx}-${entryIdx}`"
|
||||
@update:model-value="
|
||||
(val: any) => renamePriceByIndex(tier, entryIdx, val)
|
||||
"
|
||||
/>
|
||||
<OInput
|
||||
:model-value="toPerMillion(entry.value)"
|
||||
<OFormInput
|
||||
:name="`tiers[${idx}].prices[${entryIdx}].value`"
|
||||
type="number"
|
||||
:min="0"
|
||||
step="0.01"
|
||||
:placeholder="t('modelPricing.pricePlaceholder')"
|
||||
:data-test="`model-pricing-price-value-input-${idx}-${entryIdx}`"
|
||||
@update:model-value="
|
||||
(val: any) =>
|
||||
updatePrice(
|
||||
tier,
|
||||
entry.key,
|
||||
fromPerMillion(Number(val)),
|
||||
)
|
||||
"
|
||||
>
|
||||
<template #icon-left
|
||||
><span class="price-dollar text-xs pb-0.5">$</span></template
|
||||
>
|
||||
</OInput>
|
||||
<OButton
|
||||
variant="outline-destructive"
|
||||
size="icon"
|
||||
:data-test="`model-pricing-price-delete-btn-${idx}-${entryIdx}`"
|
||||
@click="deletePrice(tier, entry.key)"
|
||||
>
|
||||
<OIcon name="delete" size="sm" />
|
||||
</OButton>
|
||||
</OFormInput>
|
||||
<!-- Fixed input-height band keeps the delete button centered
|
||||
against the input row even when the key field grows a
|
||||
below-field error (row is items-start so the error can't
|
||||
push the value input / this button downward). -->
|
||||
<div class="h-8.5 flex items-center">
|
||||
<OButton
|
||||
variant="outline-destructive"
|
||||
size="icon"
|
||||
type="button"
|
||||
:data-test="`model-pricing-price-delete-btn-${idx}-${entryIdx}`"
|
||||
@click="removePrice(idx, entryIdx)"
|
||||
>
|
||||
<OIcon name="delete" size="sm" />
|
||||
</OButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="!Object.keys(tier.prices).length"
|
||||
v-if="!tier.prices.length"
|
||||
class="price-empty flex flex-col items-center p-4 gap-[3px]"
|
||||
>
|
||||
<div class="price-empty-title text-xs font-medium">
|
||||
@@ -382,21 +385,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add row -->
|
||||
<!-- Add row (staging draft, form-owned) -->
|
||||
<div
|
||||
class="price-add-row grid grid-cols-[1fr_160px_auto] gap-2 items-center py-1 px-3"
|
||||
:class="{
|
||||
'price-add-row--no-top': !Object.keys(tier.prices)
|
||||
.length,
|
||||
'price-add-row--no-top': !tier.prices.length,
|
||||
}"
|
||||
>
|
||||
<OInput
|
||||
v-model="addState[idx as number].key"
|
||||
<OFormInput
|
||||
:name="`tiers[${idx}].draftKey`"
|
||||
:placeholder="t('modelPricing.addUsageKeyPlaceholder')"
|
||||
:data-test="`model-pricing-add-price-key-input-${idx}`"
|
||||
/>
|
||||
<OInput
|
||||
v-model.number="addState[idx as number].value"
|
||||
<OFormInput
|
||||
:name="`tiers[${idx}].draftValue`"
|
||||
type="number"
|
||||
:min="0"
|
||||
step="0.01"
|
||||
@@ -406,13 +408,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<template #icon-left
|
||||
><span class="price-dollar text-xs pb-0.5">$</span></template
|
||||
>
|
||||
</OInput>
|
||||
</OFormInput>
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="icon"
|
||||
:disabled="!addState[idx as number].key.trim()"
|
||||
type="button"
|
||||
:disabled="!String(tier.draftKey ?? '').trim()"
|
||||
:data-test="`model-pricing-add-price-btn-${idx}`"
|
||||
@click="addPrice(tier, idx)"
|
||||
@click="addPrice(idx)"
|
||||
>
|
||||
<OIcon name="add" size="xs" />
|
||||
</OButton>
|
||||
@@ -421,7 +424,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
<!-- Price Preview Table -->
|
||||
<div
|
||||
v-if="previewEntries(tier, idx as number).length"
|
||||
v-if="previewEntries(tier).length"
|
||||
class="mt-5 border rounded"
|
||||
style="
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
@@ -456,11 +459,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="entry in previewEntries(tier, idx as number)"
|
||||
:key="entry.stableId"
|
||||
v-for="entry in previewEntries(tier)"
|
||||
:key="entry.pending ? '__pending__' : entry.key"
|
||||
class="border-b last:border-none"
|
||||
:class="{
|
||||
'preview-row-pending opacity-50 italic': entry.stableId === -1,
|
||||
'preview-row-pending opacity-50 italic': entry.pending,
|
||||
}"
|
||||
style="border-color: var(--o2-border-color)"
|
||||
>
|
||||
@@ -470,10 +473,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
{{ entry.key }}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-gray-600">
|
||||
${{ formatPreviewCost(entry.value, 1000) }}
|
||||
${{ formatPreviewCost(fromPerMillion(entry.value), 1000) }}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-gray-600">
|
||||
${{ formatPreviewCost(entry.value, 1000000) }}
|
||||
${{ formatPreviewCost(fromPerMillion(entry.value), 1000000) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -485,6 +488,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="sm-action"
|
||||
type="button"
|
||||
class="self-start"
|
||||
data-test="model-pricing-add-tier-btn"
|
||||
@click="addTier"
|
||||
@@ -501,6 +505,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="sm-action"
|
||||
type="button"
|
||||
:disabled="isSubmitting"
|
||||
@click="goBack"
|
||||
data-test="model-pricing-editor-cancel-btn"
|
||||
>
|
||||
@@ -509,14 +515,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<OButton
|
||||
variant="primary"
|
||||
size="sm-action"
|
||||
:loading="saving"
|
||||
:disabled="!!nameError || !!regexError"
|
||||
@click="save"
|
||||
type="submit"
|
||||
:loading="isSubmitting"
|
||||
data-test="model-pricing-editor-save-btn"
|
||||
>
|
||||
{{ t("modelPricing.save") }}
|
||||
</OButton>
|
||||
</div>
|
||||
</OForm>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -529,25 +535,26 @@ import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
|
||||
import modelPricingService from "@/services/model_pricing";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.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 { useOForm } from "@/lib/forms/Form/useOForm";
|
||||
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 AppPageHeader from "@/components/common/AppPageHeader.vue";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import {
|
||||
makeModelPricingSchema,
|
||||
type ModelPricingForm,
|
||||
} from "./ModelPricingEditor.schema";
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const saving = ref(false);
|
||||
const existingModels = ref<any[]>([]);
|
||||
const nameTouched = ref(false);
|
||||
const patternTouched = ref(false);
|
||||
const addState = ref<Array<{ key: string; value: number }>>([
|
||||
{ key: "", value: 0 },
|
||||
]);
|
||||
|
||||
const showExamples = ref(false);
|
||||
const copiedPattern = ref<string | null>(null);
|
||||
|
||||
@@ -574,38 +581,10 @@ const orgIdentifier = computed(
|
||||
() => store.state.selectedOrganization?.identifier || "",
|
||||
);
|
||||
|
||||
/** Real-time name validation. */
|
||||
const nameError = computed(() => {
|
||||
const name = model.value.name;
|
||||
if (!name || !name.trim()) return t("modelPricing.nameRequired");
|
||||
if (name.length > 256) return t("modelPricing.nameTooLong");
|
||||
return "";
|
||||
});
|
||||
|
||||
/**
|
||||
* Strip Rust/PCRE inline flag groups that JavaScript RegExp doesn't understand.
|
||||
* Handles: (?i), (?m), (?s), (?x), (?u), and combinations like (?ims).
|
||||
* Does NOT strip flag-scoped groups like (?i:...) — those are left as non-capturing groups.
|
||||
*/
|
||||
function stripInlineFlags(pattern: string): string {
|
||||
// (?FLAGS) where FLAGS is one or more of i, m, s, x, u — standalone (not followed by ':')
|
||||
return pattern.replace(/\(\?[imsxu]+\)/g, "");
|
||||
}
|
||||
|
||||
/** Real-time regex validation — shows error as the user types. */
|
||||
const regexError = computed(() => {
|
||||
const pattern = model.value.match_pattern;
|
||||
if (!pattern || !pattern.trim()) return t("modelPricing.patternRequired");
|
||||
if (pattern.length > 512) return t("modelPricing.patternTooLong");
|
||||
try {
|
||||
// Strip Rust-specific inline flags before testing with JS RegExp.
|
||||
// The backend (Rust regex crate) is the authority; this is a best-effort client check.
|
||||
new RegExp(stripInlineFlags(pattern));
|
||||
return "";
|
||||
} catch (e: any) {
|
||||
return t("modelPricing.invalidRegex", { error: e.message });
|
||||
}
|
||||
});
|
||||
// Scalar validation is now schema-driven (name + match_pattern). The manual
|
||||
// nameError/regexError computeds + nameTouched/patternTouched refs + the
|
||||
// :disabled Save gate are gone (R3).
|
||||
const modelPricingSchema = makeModelPricingSchema(t);
|
||||
|
||||
const isEdit = computed(
|
||||
() => !!route.query.id && route.query.duplicate !== "true",
|
||||
@@ -616,6 +595,29 @@ const headerTitle = computed(() =>
|
||||
|
||||
const model = ref<any>(createEmptyModel());
|
||||
|
||||
// Dynamic defaults (edit-prefill projects the working model) → a typed computed.
|
||||
// `model.value` holds the API-shaped record (per-token price MAP); the form holds
|
||||
// the per-million ROW shape, so convert. For edit mode the async load re-seeds
|
||||
// the form via form.reset(modelToForm(...)) in onBeforeMount once data arrives.
|
||||
const modelPricingDefaults = computed(
|
||||
(): ModelPricingForm => modelToForm(model.value),
|
||||
);
|
||||
|
||||
// Rule ③ OWNER pattern: this component OWNS <OForm> and must read the form-owned
|
||||
// `tiers` array reactively to drive the v-for rows, so it creates the form here
|
||||
// with useOForm and hands it to <OForm :form="form"> — ONE source of truth, no
|
||||
// mirror ref / store.subscribe copy. `save` is the awaited submit handler (auto
|
||||
// Save spinner). Async edit-prefill re-seeds via form.reset() once data loads.
|
||||
const form = useOForm<ModelPricingForm>({
|
||||
defaultValues: modelPricingDefaults.value,
|
||||
schema: modelPricingSchema,
|
||||
onSubmit: save,
|
||||
});
|
||||
|
||||
// Reactive view of the form-owned `tiers` array — form.useStore tracks array
|
||||
// mutations (a plain form.state.values read in a computed would not).
|
||||
const formTiers = form.useStore((s: any) => s.values.tiers ?? []);
|
||||
|
||||
function createEmptyModel() {
|
||||
return {
|
||||
id: null,
|
||||
@@ -626,15 +628,80 @@ function createEmptyModel() {
|
||||
};
|
||||
}
|
||||
|
||||
// API-shaped tier (per-token price map). Used to seed the working `model`.
|
||||
function newTier(name: string, condition: any = null) {
|
||||
return { name, condition, prices: {} as Record<string, number> };
|
||||
}
|
||||
|
||||
function resetAddState(tierCount: number) {
|
||||
addState.value = Array.from({ length: tierCount }, () => ({
|
||||
key: "",
|
||||
value: 0,
|
||||
// ── map ↔ rows converters (the field-array unlock) ───────────────────────────
|
||||
// One blank FORM tier (per-million row shape) for "Add tier".
|
||||
function newFormTier(name: string, condition: any = null) {
|
||||
return {
|
||||
name,
|
||||
condition,
|
||||
prices: [] as Array<{ key: string; value: number }>,
|
||||
draftKey: "",
|
||||
draftValue: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// API model (per-token price MAP) → FORM value (per-million ROW array).
|
||||
function modelToForm(m: any): ModelPricingForm {
|
||||
const tiers = (m?.tiers ?? []).map((tier: any, i: number) => ({
|
||||
name: tier.name ?? "",
|
||||
condition:
|
||||
i === 0
|
||||
? null
|
||||
: tier.condition
|
||||
? { ...tier.condition }
|
||||
: { usage_key: "input", operator: "gt", value: 0 },
|
||||
prices: Object.entries(tier.prices ?? {}).map(([k, v]) => ({
|
||||
key: k,
|
||||
value: toPerMillion(Number(v)),
|
||||
})),
|
||||
draftKey: "",
|
||||
draftValue: 0,
|
||||
}));
|
||||
return {
|
||||
name: m?.name ?? "",
|
||||
match_pattern: m?.match_pattern ?? "",
|
||||
tiers,
|
||||
};
|
||||
}
|
||||
|
||||
// FORM value (per-million ROW array) → API tiers (per-token price MAP). Drops
|
||||
// blank keys and auto-commits each tier's non-empty draft row.
|
||||
function formToModelTiers(tiers: any[]): any[] {
|
||||
return (tiers ?? []).map((tier: any, i: number) => {
|
||||
const prices: Record<string, number> = {};
|
||||
for (const row of tier.prices ?? []) {
|
||||
const k = String(row.key ?? "").trim();
|
||||
if (k) prices[k] = fromPerMillion(Number(row.value) || 0);
|
||||
}
|
||||
const dk = String(tier.draftKey ?? "").trim();
|
||||
if (dk) prices[dk] = fromPerMillion(Number(tier.draftValue) || 0);
|
||||
return {
|
||||
name: tier.name,
|
||||
condition:
|
||||
i === 0
|
||||
? null
|
||||
: tier.condition
|
||||
? {
|
||||
usage_key: tier.condition.usage_key,
|
||||
operator: tier.condition.operator,
|
||||
value: Number(tier.condition.value) || 0,
|
||||
}
|
||||
: null,
|
||||
prices,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Replace the whole form-owned tiers array (the safe field-array mutation, as
|
||||
// CreateDestinationForm does for `headers`); `formTiers` (form.useStore) re-syncs
|
||||
// reactively and the template re-renders.
|
||||
function setTiers(next: any[]) {
|
||||
form.setFieldValue("tiers", next, { dontUpdateMeta: true });
|
||||
}
|
||||
|
||||
const usageTemplates = [
|
||||
@@ -669,99 +736,93 @@ const operators = [
|
||||
{ label: "!=", value: "neq" },
|
||||
];
|
||||
|
||||
// ── Field-array operations (whole-array setFieldValue; `formTiers`
|
||||
// (form.useStore) re-syncs reactively + re-renders) ──────────────────────
|
||||
function addTier() {
|
||||
model.value.tiers.push(
|
||||
newTier(t("modelPricing.tierConditionalName"), {
|
||||
setTiers([
|
||||
...formTiers.value,
|
||||
newFormTier(t("modelPricing.tierConditionalName"), {
|
||||
usage_key: "input",
|
||||
operator: "gt",
|
||||
value: 200000,
|
||||
}),
|
||||
);
|
||||
addState.value.push({ key: "", value: 0 });
|
||||
]);
|
||||
}
|
||||
|
||||
function removeTier(idx: number) {
|
||||
model.value.tiers.splice(idx, 1);
|
||||
addState.value.splice(idx, 1);
|
||||
setTiers(formTiers.value.filter((_: any, i: number) => i !== idx));
|
||||
}
|
||||
|
||||
function updatePrice(tier: any, key: string, value: number) {
|
||||
tier.prices[key] = value;
|
||||
function removePrice(tierIdx: number, entryIdx: number) {
|
||||
setTiers(
|
||||
formTiers.value.map((tier: any, i: number) =>
|
||||
i !== tierIdx
|
||||
? tier
|
||||
: {
|
||||
...tier,
|
||||
prices: tier.prices.filter(
|
||||
(_: any, j: number) => j !== entryIdx,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function deletePrice(tier: any, key: string) {
|
||||
delete tier.prices[key];
|
||||
tier.prices = { ...tier.prices };
|
||||
// Commit a tier's staging draft row into its committed price rows, then clear
|
||||
// the draft. (Validation of the key happens schema-side / at submit.)
|
||||
function addPrice(tierIdx: number) {
|
||||
setTiers(
|
||||
formTiers.value.map((tier: any, i: number) => {
|
||||
if (i !== tierIdx) return tier;
|
||||
const dk = String(tier.draftKey ?? "").trim();
|
||||
if (!dk) return tier;
|
||||
return {
|
||||
...tier,
|
||||
prices: [
|
||||
...tier.prices,
|
||||
{ key: dk, value: Number(tier.draftValue) || 0 },
|
||||
],
|
||||
draftKey: "",
|
||||
draftValue: 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Stable ID counter for price entries — survives key renames without resetting. */
|
||||
let nextStableId = 0;
|
||||
const stableIdMap = new WeakMap<any, Map<number, number>>();
|
||||
|
||||
/** Convert tier.prices object to an array with stable IDs for v-for :key.
|
||||
* Each entry gets a numeric stableId that persists across key renames. */
|
||||
function priceEntries(
|
||||
tier: any,
|
||||
): Array<{ key: string; value: number; stableId: number }> {
|
||||
if (!stableIdMap.has(tier)) stableIdMap.set(tier, new Map());
|
||||
const idMap = stableIdMap.get(tier)!;
|
||||
return Object.entries(tier.prices || {}).map(([k, v], idx) => {
|
||||
if (!idMap.has(idx)) idMap.set(idx, nextStableId++);
|
||||
return { key: k, value: Number(v), stableId: idMap.get(idx)! };
|
||||
});
|
||||
}
|
||||
|
||||
/** Rename a price entry by its array index — keeps the DOM node alive. */
|
||||
function renamePriceByIndex(tier: any, index: number, newKey: string) {
|
||||
const entries = Object.entries(tier.prices);
|
||||
if (index < 0 || index >= entries.length) return;
|
||||
const [oldKey, val] = entries[index];
|
||||
if (newKey === oldKey) return;
|
||||
const keyError = validateUsageKey(newKey);
|
||||
if (keyError) {
|
||||
notifyWarn(keyError);
|
||||
return;
|
||||
}
|
||||
// Rebuild the object preserving insertion order with the new key at the same position
|
||||
const rebuilt: Record<string, number> = {};
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
if (i === index) {
|
||||
rebuilt[newKey] = val as number;
|
||||
} else {
|
||||
rebuilt[entries[i][0]] = entries[i][1] as number;
|
||||
}
|
||||
}
|
||||
tier.prices = rebuilt;
|
||||
}
|
||||
|
||||
function renamePrice(tier: any, oldKey: string, newKey: string) {
|
||||
if (!newKey || newKey === oldKey) return;
|
||||
const val = tier.prices[oldKey];
|
||||
delete tier.prices[oldKey];
|
||||
tier.prices[newKey] = val;
|
||||
tier.prices = { ...tier.prices };
|
||||
}
|
||||
|
||||
function isTemplateActive(tier: any, templateKeys: string[]): boolean {
|
||||
const priceKeys = Object.keys(tier.prices || {});
|
||||
function isTemplateActive(tierIdx: number, templateKeys: string[]): boolean {
|
||||
const tier = formTiers.value[tierIdx];
|
||||
if (!tier) return false;
|
||||
const priceKeys = (tier.prices ?? []).map((r: any) => r.key);
|
||||
if (priceKeys.length !== templateKeys.length) return false;
|
||||
return templateKeys.every((k) => priceKeys.includes(k));
|
||||
}
|
||||
|
||||
function applyTemplate(tier: any, keys: string[]) {
|
||||
const next: Record<string, number> = {};
|
||||
for (const key of keys) {
|
||||
next[key] = tier.prices[key] ?? 0;
|
||||
}
|
||||
tier.prices = next;
|
||||
function applyTemplate(tierIdx: number, keys: string[]) {
|
||||
setTiers(
|
||||
formTiers.value.map((tier: any, i: number) => {
|
||||
if (i !== tierIdx) return tier;
|
||||
const newPrices = keys.map((k) => {
|
||||
const found = (tier.prices ?? []).find((r: any) => r.key === k);
|
||||
return { key: k, value: found ? found.value : 0 };
|
||||
});
|
||||
return { ...tier, prices: newPrices };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function clearTemplate(tier: any, keys: string[]) {
|
||||
const next = { ...tier.prices };
|
||||
for (const key of keys) {
|
||||
delete next[key];
|
||||
}
|
||||
tier.prices = next;
|
||||
function clearTemplate(tierIdx: number, keys: string[]) {
|
||||
setTiers(
|
||||
formTiers.value.map((tier: any, i: number) =>
|
||||
i !== tierIdx
|
||||
? tier
|
||||
: {
|
||||
...tier,
|
||||
prices: (tier.prices ?? []).filter(
|
||||
(r: any) => !keys.includes(r.key),
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function toPerMillion(perToken: number): number {
|
||||
@@ -772,22 +833,19 @@ function fromPerMillion(perMillion: number): number {
|
||||
return perMillion > 0 ? perMillion / 1_000_000 : 0;
|
||||
}
|
||||
|
||||
/** Entries for the live preview = committed prices + any pending add-row entry. */
|
||||
function previewEntries(tier: any, idx: number) {
|
||||
const committed = priceEntries(tier);
|
||||
const pending = addState.value[idx];
|
||||
if (pending?.key.trim()) {
|
||||
const pendingValue = fromPerMillion(pending.value || 0);
|
||||
// Don't duplicate if the key already exists
|
||||
if (!committed.find((e) => e.key === pending.key.trim())) {
|
||||
committed.push({
|
||||
key: pending.key.trim(),
|
||||
value: pendingValue,
|
||||
stableId: -1,
|
||||
});
|
||||
}
|
||||
/** Live preview = committed price rows + the (non-empty) draft. Values are
|
||||
* PER-MILLION; the template converts to per-token for the cost columns. */
|
||||
function previewEntries(
|
||||
tier: any,
|
||||
): Array<{ key: string; value: number; pending?: boolean }> {
|
||||
const out = (tier.prices ?? [])
|
||||
.filter((r: any) => String(r.key ?? "").trim())
|
||||
.map((r: any) => ({ key: String(r.key), value: Number(r.value) || 0 }));
|
||||
const dk = String(tier.draftKey ?? "").trim();
|
||||
if (dk && !out.find((e: any) => e.key === dk)) {
|
||||
out.push({ key: dk, value: Number(tier.draftValue) || 0, pending: true });
|
||||
}
|
||||
return committed;
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatPreviewCost(costPerUnit: number, multiplier: number) {
|
||||
@@ -806,23 +864,6 @@ function validateUsageKey(key: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function addPrice(tier: any, idx: number | string): boolean {
|
||||
const i = Number(idx);
|
||||
const key = (addState.value[i]?.key || "").trim();
|
||||
if (!key) return true;
|
||||
const keyError = validateUsageKey(key);
|
||||
if (keyError) {
|
||||
notifyWarn(keyError);
|
||||
return false;
|
||||
}
|
||||
tier.prices = {
|
||||
...tier.prices,
|
||||
[key]: fromPerMillion(addState.value[i].value || 0),
|
||||
};
|
||||
addState.value[i] = { key: "", value: 0 };
|
||||
return true;
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push({
|
||||
name: "modelPricing",
|
||||
@@ -847,59 +888,55 @@ function notifyError(prefix: string, e: any) {
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const m = model.value;
|
||||
// @submit handler. The schema validates name/match_pattern + per-row key
|
||||
// validity + condition.usage_key before @submit fires (R3). Two STRUCTURAL
|
||||
// rules that don't map to a single field stay here as guards (toasts): a draft
|
||||
// row with a value but no key, and "the default tier needs ≥1 non-zero price".
|
||||
// `value` is the raw form value (per-million ROW shape); convert to the API
|
||||
// (per-token MAP) shape for the payload.
|
||||
async function save(value?: ModelPricingForm) {
|
||||
const tiers = (value?.tiers ?? formTiers.value) as any[];
|
||||
|
||||
// Auto-commit any pending add-row values before validation.
|
||||
// Users often type a price and hit Save without clicking "+".
|
||||
for (let i = 0; i < m.tiers.length; i++) {
|
||||
const pending = addState.value[i];
|
||||
if (pending && pending.value > 0 && !pending.key.trim()) {
|
||||
notifyWarn(
|
||||
t("modelPricing.tierPriceMissingKey", { name: m.tiers[i].name }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (pending && pending.key.trim()) {
|
||||
if (!addPrice(m.tiers[i], i)) return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate condition usage_key on non-default tiers — must not be a pure integer
|
||||
for (let i = 1; i < m.tiers.length; i++) {
|
||||
const c = m.tiers[i].condition;
|
||||
if (!c) continue;
|
||||
const key = (c.usage_key || "").trim();
|
||||
if (key && /^\d+$/.test(key)) {
|
||||
notifyWarn(
|
||||
t("modelPricing.tierUsageKeyPlainNumber", {
|
||||
name: m.tiers[i].name || `#${i + 1}`,
|
||||
}),
|
||||
);
|
||||
// Draft-row guards: a non-empty draft key must be a valid usage key; a draft
|
||||
// with a value but no key is an error (mirrors the old addPrice/save guards).
|
||||
for (const tier of tiers) {
|
||||
const dk = String(tier.draftKey ?? "").trim();
|
||||
if (dk) {
|
||||
const keyError = validateUsageKey(dk);
|
||||
if (keyError) {
|
||||
notifyWarn(keyError);
|
||||
return;
|
||||
}
|
||||
} else if (Number(tier.draftValue) > 0) {
|
||||
notifyWarn(t("modelPricing.tierPriceMissingKey", { name: tier.name }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Name and pattern have inline errors — just mark fields as touched so errors show,
|
||||
// no duplicate snackbar needed.
|
||||
if (nameError.value || regexError.value) {
|
||||
nameTouched.value = true;
|
||||
patternTouched.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Require at least one price in the default tier (no inline field for this)
|
||||
const defaultTier = m.tiers?.[0];
|
||||
// Require at least one non-zero price in the default tier (committed + draft).
|
||||
const defaultTier = tiers[0];
|
||||
if (defaultTier) {
|
||||
const priceValues = Object.values(defaultTier.prices || {}) as number[];
|
||||
if (priceValues.length === 0 || priceValues.every((v: number) => v === 0)) {
|
||||
const values = (defaultTier.prices ?? [])
|
||||
.filter((r: any) => String(r.key ?? "").trim())
|
||||
.map((r: any) => Number(r.value) || 0);
|
||||
if (String(defaultTier.draftKey ?? "").trim()) {
|
||||
values.push(Number(defaultTier.draftValue) || 0);
|
||||
}
|
||||
if (values.length === 0 || values.every((v: number) => v === 0)) {
|
||||
notifyWarn(t("modelPricing.addDefaultPrice"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (m.tiers.length > 0) {
|
||||
m.tiers[0].condition = null;
|
||||
}
|
||||
|
||||
// Build the API-shaped payload: keep the loaded record's metadata (id,
|
||||
// enabled, valid_from, sort_order, org_id, source) from `model`, take the
|
||||
// validated scalars + converted tiers from the form.
|
||||
const m: any = {
|
||||
...model.value,
|
||||
name: value?.name ?? model.value.name,
|
||||
match_pattern: value?.match_pattern ?? model.value.match_pattern,
|
||||
tiers: formToModelTiers(tiers),
|
||||
};
|
||||
|
||||
// Warn if another enabled org entry has the same pattern and higher priority
|
||||
// (same valid_from context — it will shadow this entry at runtime).
|
||||
@@ -916,7 +953,8 @@ async function save() {
|
||||
return other.name.localeCompare(m.name) < 0;
|
||||
});
|
||||
|
||||
saving.value = true;
|
||||
// Loading is form-driven: OForm awaits this handler, so the Save button's
|
||||
// spinner (isSubmitting) spans the POST — no manual flag needed.
|
||||
try {
|
||||
if (m.id) {
|
||||
await modelPricingService.update(orgIdentifier.value, m.id, m);
|
||||
@@ -939,8 +977,6 @@ async function save() {
|
||||
goBack();
|
||||
} catch (e: any) {
|
||||
notifyError(t("modelPricing.errSave"), e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -966,8 +1002,6 @@ onBeforeMount(async () => {
|
||||
const found = res.data;
|
||||
if (found) {
|
||||
model.value = JSON.parse(JSON.stringify(found));
|
||||
nameTouched.value = true;
|
||||
patternTouched.value = true; // existing model — show validation immediately
|
||||
if (isDuplicate) {
|
||||
model.value.id = null;
|
||||
model.value.org_id = orgIdentifier.value;
|
||||
@@ -984,12 +1018,16 @@ onBeforeMount(async () => {
|
||||
};
|
||||
}
|
||||
}
|
||||
// Data arrived after mount → re-seed the whole form (scalars + the
|
||||
// converted tier/price rows) once (the documented "async edit-prefill"
|
||||
// pattern; not a per-keystroke watch). formTiers (form.useStore) updates
|
||||
// reactively from the reset — no manual re-sync.
|
||||
form.reset(modelToForm(model.value));
|
||||
}
|
||||
} catch (e: any) {
|
||||
notifyError(t("modelPricing.errLoadModel"), e);
|
||||
}
|
||||
}
|
||||
resetAddState(model.value.tiers.length);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// Provider-discriminated validation schema for OrgStorageEditor.vue. The storage
|
||||
// provider is chosen from a custom card grid (not an <input>), so it is bridged
|
||||
// into the form as `selectedProvider` (the documented sanctioned discriminator
|
||||
// bridge, as CreateDestinationForm does) and `superRefine` branches on it.
|
||||
//
|
||||
// Restores the Quasar BEFORE rules (truthy→Zod inversion) — every per-provider
|
||||
// credential was `(v)=>!!v?.trim() || t(<key>)`:
|
||||
// • AwsCredentials → bucket_name, access_key, secret_key required (trim)
|
||||
// • AzureCredentials→ storage_account, bucket_name, secret_key required
|
||||
// • GcpCredentials → bucket_name, access_key required
|
||||
// • AwsRoleArn → bucket_name, region, role_arn, external_id required
|
||||
//
|
||||
// Validation TIMING is owned by OForm (submit-then-change via revalidateLogic);
|
||||
// this file only describes WHAT is valid.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export const makeOrgStorageEditorSchema = (t: (_key: string) => string) =>
|
||||
z
|
||||
.object({
|
||||
// Bridged discriminator (the provider card grid).
|
||||
selectedProvider: z.string().optional().default(""),
|
||||
// All credential fields are loose at the object level; the per-provider
|
||||
// requireds are enforced in superRefine below.
|
||||
server_url: z.string().optional().default(""),
|
||||
region: z.string().optional().default(""),
|
||||
bucket_name: z.string().optional().default(""),
|
||||
access_key: z.string().optional().default(""),
|
||||
storage_account: z.string().optional().default(""),
|
||||
secret_key: z.string().optional().default(""),
|
||||
role_arn: z.string().optional().default(""),
|
||||
external_id: z.string().optional().default(""),
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
const require = (field: keyof typeof val, msgKey: string) => {
|
||||
if (!String(val[field] ?? "").trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [field],
|
||||
message: t(msgKey),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
switch (val.selectedProvider) {
|
||||
case "AwsCredentials":
|
||||
require("bucket_name", "storage_settings.bucketNameRequired");
|
||||
require("access_key", "storage_settings.accessKeyRequired");
|
||||
require("secret_key", "storage_settings.secretKeyRequired");
|
||||
break;
|
||||
case "AzureCredentials":
|
||||
require("storage_account", "storage_settings.storageAccountRequired");
|
||||
require("bucket_name", "storage_settings.bucketNameRequired");
|
||||
require("secret_key", "storage_settings.secretKeyRequired");
|
||||
break;
|
||||
case "GcpCredentials":
|
||||
require("bucket_name", "storage_settings.bucketNameRequired");
|
||||
require("access_key", "storage_settings.accessKeyRequired");
|
||||
break;
|
||||
case "AwsRoleArn":
|
||||
require("bucket_name", "storage_settings.bucketNameRequired");
|
||||
require("region", "storage_settings.regionRequired");
|
||||
require("role_arn", "storage_settings.roleARNRequired");
|
||||
require("external_id", "storage_settings.externalIdRequired");
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
export type OrgStorageEditorForm = z.infer<
|
||||
ReturnType<typeof makeOrgStorageEditorSchema>
|
||||
>;
|
||||
@@ -0,0 +1,364 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
|
||||
import OrgStorageEditor from "./OrgStorageEditor.vue";
|
||||
import i18n from "@/locales";
|
||||
import { nextTick } from "vue";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import { makeOrgStorageEditorSchema } from "./OrgStorageEditor.schema";
|
||||
|
||||
const { mockCreate, mockUpdate, mockGet, mockToast } = vi.hoisted(() => ({
|
||||
mockCreate: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockGet: vi.fn(),
|
||||
mockToast: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/feedback/Toast/useToast", () => ({
|
||||
toast: mockToast,
|
||||
}));
|
||||
|
||||
vi.mock("@/services/org_storage", () => ({
|
||||
default: {
|
||||
get: mockGet,
|
||||
create: mockCreate,
|
||||
update: mockUpdate,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/aws-exports", () => ({
|
||||
default: { isCloud: "false", isEnterprise: "true" },
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/zincutils", async () => {
|
||||
const actual = await vi.importActual("@/utils/zincutils");
|
||||
return {
|
||||
...actual,
|
||||
getImageURL: (path: string) => `/mock/${path}`,
|
||||
};
|
||||
});
|
||||
|
||||
const mockStore = {
|
||||
...store,
|
||||
dispatch: vi.fn(),
|
||||
state: {
|
||||
...store.state,
|
||||
theme: "light",
|
||||
selectedOrganization: { identifier: "test-org-123" },
|
||||
zoConfig: { ...store.state.zoConfig },
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("vuex", () => ({
|
||||
useStore: () => mockStore,
|
||||
}));
|
||||
|
||||
// OStepper / OStep render their slots so both steps' content is available; the
|
||||
// per-provider fields are still gated by selectedProvider (v-if). OForm /
|
||||
// OFormInput are real so the provider-discriminated schema actually runs.
|
||||
const createWrapper = (action: "add" | "edit" = "add") =>
|
||||
mount(OrgStorageEditor, {
|
||||
props: { action },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
provide: { store: mockStore },
|
||||
stubs: {
|
||||
OStepper: { template: "<div><slot /></div>" },
|
||||
OStep: { template: "<div><slot /></div>" },
|
||||
AppPageHeader: { template: "<div><slot name='title' /></div>" },
|
||||
OIcon: { template: "<span />" },
|
||||
OButton: {
|
||||
template: `<button :data-test="$attrs['data-test']" :type="type" @click="$emit('click', $event)"><slot /></button>`,
|
||||
props: ["type", "variant", "disabled"],
|
||||
emits: ["click"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const getForm = (wrapper: any) => wrapper.vm.storageForm.form;
|
||||
|
||||
const selectProvider = async (wrapper: any, provider: string) => {
|
||||
await wrapper
|
||||
.find(`[data-test="storage-settings-provider-card-${provider}"]`)
|
||||
.trigger("click");
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
describe("OrgStorageEditor", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreate.mockResolvedValue({ data: {} });
|
||||
mockUpdate.mockResolvedValue({ data: {} });
|
||||
mockGet.mockResolvedValue({ data: { provider: null, data: {} } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("mounts and shows the provider cards in add mode", () => {
|
||||
const wrapper = createWrapper("add");
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-test="storage-settings-provider-card-AwsCredentials"]')
|
||||
.exists(),
|
||||
).toBe(true);
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-test="storage-settings-provider-card-AwsRoleArn"]')
|
||||
.exists(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("renders the AWS credential fields after selecting the provider", async () => {
|
||||
const wrapper = createWrapper("add");
|
||||
await selectProvider(wrapper, "AwsCredentials");
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-test="storage-settings-bucket-name-input"]').exists(),
|
||||
).toBe(true);
|
||||
expect(
|
||||
wrapper.find('[data-test="storage-settings-access-key-input"]').exists(),
|
||||
).toBe(true);
|
||||
expect(
|
||||
wrapper.find('[data-test="storage-settings-secret-key-input"]').exists(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("bridges the selected provider into the form", async () => {
|
||||
const wrapper = createWrapper("add");
|
||||
await selectProvider(wrapper, "AwsCredentials");
|
||||
expect(getForm(wrapper).state.values.selectedProvider).toBe(
|
||||
"AwsCredentials",
|
||||
);
|
||||
});
|
||||
|
||||
describe("Provider-discriminated validation (real OForm)", () => {
|
||||
it("blocks submit and does NOT call create when AWS required fields are empty", async () => {
|
||||
const wrapper = createWrapper("add");
|
||||
await selectProvider(wrapper, "AwsCredentials");
|
||||
|
||||
await getForm(wrapper).handleSubmit();
|
||||
await flushPromises();
|
||||
|
||||
expect(getForm(wrapper).state.isValid).toBe(false);
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates the AWS config with the correct payload when valid", async () => {
|
||||
const wrapper = createWrapper("add");
|
||||
await selectProvider(wrapper, "AwsCredentials");
|
||||
|
||||
const form = getForm(wrapper);
|
||||
form.setFieldValue("bucket_name", "my-bucket");
|
||||
form.setFieldValue("region", "us-east-1");
|
||||
form.setFieldValue("access_key", "AKIA123");
|
||||
form.setFieldValue("secret_key", "secret123");
|
||||
await nextTick();
|
||||
|
||||
await form.handleSubmit();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith("test-org-123", {
|
||||
provider: "AwsCredentials",
|
||||
data: {
|
||||
bucket_name: "my-bucket",
|
||||
server_url: "",
|
||||
region: "us-east-1",
|
||||
access_key: "AKIA123",
|
||||
secret_key: "secret123",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks submit when AwsRoleArn is missing role_arn / external_id (restored per-provider rules)", async () => {
|
||||
const wrapper = createWrapper("add");
|
||||
await selectProvider(wrapper, "AwsRoleArn");
|
||||
|
||||
const form = getForm(wrapper);
|
||||
form.setFieldValue("bucket_name", "role-bucket");
|
||||
form.setFieldValue("region", "us-east-1");
|
||||
// role_arn + external_id intentionally left empty
|
||||
await nextTick();
|
||||
|
||||
await form.handleSubmit();
|
||||
await flushPromises();
|
||||
|
||||
expect(form.state.isValid).toBe(false);
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates the AwsRoleArn config when all required fields are present", async () => {
|
||||
const wrapper = createWrapper("add");
|
||||
await selectProvider(wrapper, "AwsRoleArn");
|
||||
|
||||
const form = getForm(wrapper);
|
||||
form.setFieldValue("bucket_name", "role-bucket");
|
||||
form.setFieldValue("region", "us-east-1");
|
||||
form.setFieldValue("role_arn", "arn:aws:iam::123:role/x");
|
||||
form.setFieldValue("external_id", "ext-123");
|
||||
await nextTick();
|
||||
|
||||
await form.handleSubmit();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith("test-org-123", {
|
||||
provider: "AwsRoleArn",
|
||||
data: {
|
||||
bucket_name: "role-bucket",
|
||||
region: "us-east-1",
|
||||
role_arn: "arn:aws:iam::123:role/x",
|
||||
external_id: "ext-123",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Azure provider validation (real OForm)", () => {
|
||||
it("blocks submit and does NOT call create when Azure required fields are empty", async () => {
|
||||
const wrapper = createWrapper("add");
|
||||
await selectProvider(wrapper, "AzureCredentials");
|
||||
|
||||
await getForm(wrapper).handleSubmit();
|
||||
await flushPromises();
|
||||
|
||||
expect(getForm(wrapper).state.isValid).toBe(false);
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates the Azure config with the correct payload when valid", async () => {
|
||||
const wrapper = createWrapper("add");
|
||||
await selectProvider(wrapper, "AzureCredentials");
|
||||
|
||||
const form = getForm(wrapper);
|
||||
form.setFieldValue("storage_account", "myaccount");
|
||||
form.setFieldValue("bucket_name", "az-bucket");
|
||||
form.setFieldValue("secret_key", "az-secret");
|
||||
await nextTick();
|
||||
|
||||
await form.handleSubmit();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith("test-org-123", {
|
||||
provider: "AzureCredentials",
|
||||
data: {
|
||||
bucket_name: "az-bucket",
|
||||
server_url: "",
|
||||
storage_account: "myaccount",
|
||||
secret_key: "az-secret",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Provider switch reset (④c dependent-field side-effect)", () => {
|
||||
it("clears the previous provider's credentials when the provider changes", async () => {
|
||||
const wrapper = createWrapper("add");
|
||||
await selectProvider(wrapper, "AwsCredentials");
|
||||
|
||||
const form = getForm(wrapper);
|
||||
form.setFieldValue("bucket_name", "aws-bucket");
|
||||
form.setFieldValue("access_key", "AKIA-should-clear");
|
||||
form.setFieldValue("secret_key", "secret-should-clear");
|
||||
await nextTick();
|
||||
// sanity: the credentials are set before the provider switch
|
||||
expect(form.state.values.access_key).toBe("AKIA-should-clear");
|
||||
|
||||
// Switching the provider fires the create-mode watch → form.reset(blank),
|
||||
// wiping the previous provider's credentials (the rulebook's ④c side-effect).
|
||||
await selectProvider(wrapper, "AwsRoleArn");
|
||||
await flushPromises();
|
||||
|
||||
const values = getForm(wrapper).state.values;
|
||||
expect(values.selectedProvider).toBe("AwsRoleArn");
|
||||
expect(values.access_key).toBe("");
|
||||
expect(values.secret_key).toBe("");
|
||||
expect(values.bucket_name).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GCP provider validation (schema-level — card not in the grid)", () => {
|
||||
// GCP is intentionally not offered as a provider card (providerDefinitions
|
||||
// comments it out), so it can't be exercised through the UI — but the
|
||||
// superRefine branch still exists, so it is covered directly here.
|
||||
const schema = makeOrgStorageEditorSchema((k: string) => k);
|
||||
|
||||
it("requires bucket_name and access_key for GcpCredentials", () => {
|
||||
const res = schema.safeParse({ selectedProvider: "GcpCredentials" });
|
||||
expect(res.success).toBe(false);
|
||||
const paths = res.success
|
||||
? []
|
||||
: res.error.issues.map((iss: any) => iss.path.join("."));
|
||||
expect(paths).toContain("bucket_name");
|
||||
expect(paths).toContain("access_key");
|
||||
});
|
||||
|
||||
it("passes for GcpCredentials when bucket_name and access_key are present", () => {
|
||||
const res = schema.safeParse({
|
||||
selectedProvider: "GcpCredentials",
|
||||
bucket_name: "gcp-bucket",
|
||||
access_key: "gcp-key",
|
||||
});
|
||||
expect(res.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edit mode", () => {
|
||||
it("prefills non-credential fields and calls update on submit", async () => {
|
||||
mockGet.mockResolvedValue({
|
||||
data: {
|
||||
provider: "AwsCredentials",
|
||||
data: {
|
||||
bucket_name: "existing-bucket",
|
||||
region: "eu-west-1",
|
||||
server_url: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = createWrapper("edit");
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
const form = getForm(wrapper);
|
||||
// Non-credential fields prefilled from the loaded config
|
||||
expect(form.state.values.bucket_name).toBe("existing-bucket");
|
||||
expect(form.state.values.region).toBe("eu-west-1");
|
||||
|
||||
// Credentials must be entered fresh
|
||||
form.setFieldValue("access_key", "new-key");
|
||||
form.setFieldValue("secret_key", "new-secret");
|
||||
await nextTick();
|
||||
|
||||
await form.handleSubmit();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith("test-org-123", {
|
||||
provider: "AwsCredentials",
|
||||
data: {
|
||||
bucket_name: "existing-bucket",
|
||||
server_url: "",
|
||||
region: "eu-west-1",
|
||||
access_key: "new-key",
|
||||
secret_key: "new-secret",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<!-- Stepper -->
|
||||
<div class="card-container h-[calc(100vh-7rem)] py-2 px-3 overflow-auto">
|
||||
<div style="max-width: 720px;">
|
||||
<OForm ref="storageForm" :default-values="{}" @submit="submitStorage">
|
||||
<OForm
|
||||
ref="storageForm"
|
||||
:schema="orgStorageEditorSchema"
|
||||
:default-values="orgStorageEditorDefaults"
|
||||
@submit="submitStorage"
|
||||
>
|
||||
<OStepper
|
||||
v-model="step"
|
||||
ref="stepper"
|
||||
@@ -135,51 +140,48 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<!-- AwsCredentials Fields -->
|
||||
<template v-if="selectedProvider === 'AwsCredentials'">
|
||||
<div class="flex flex-col gap-y-3">
|
||||
<OInput
|
||||
<OFormInput
|
||||
v-if="!isCloud"
|
||||
data-test="storage-settings-server-url-input"
|
||||
v-model="formData.server_url"
|
||||
name="server_url"
|
||||
label="Server URL"
|
||||
class="no-border showLabelOnTop"
|
||||
flat
|
||||
:disabled="isEditMode"
|
||||
/>
|
||||
<OInput
|
||||
<OFormInput
|
||||
data-test="storage-settings-region-input"
|
||||
v-model="formData.region"
|
||||
name="region"
|
||||
label="Region"
|
||||
class="no-border showLabelOnTop"
|
||||
flat
|
||||
:disabled="isEditMode || !!cloudRegion"
|
||||
/>
|
||||
<OInput
|
||||
<OFormInput
|
||||
data-test="storage-settings-bucket-name-input"
|
||||
v-model="formData.bucket_name"
|
||||
label="Bucket Name *"
|
||||
name="bucket_name"
|
||||
label="Bucket Name"
|
||||
required
|
||||
class="no-border showLabelOnTop"
|
||||
flat
|
||||
:disabled="isEditMode"
|
||||
:error="!!fieldErrors.bucket_name"
|
||||
:error-message="fieldErrors.bucket_name"
|
||||
/>
|
||||
<OInput
|
||||
<OFormInput
|
||||
data-test="storage-settings-access-key-input"
|
||||
v-model="formData.access_key"
|
||||
label="Access Key *"
|
||||
name="access_key"
|
||||
label="Access Key"
|
||||
required
|
||||
class="no-border showLabelOnTop"
|
||||
flat
|
||||
:error="!!fieldErrors.access_key"
|
||||
:error-message="fieldErrors.access_key"
|
||||
/>
|
||||
<OInput
|
||||
<OFormInput
|
||||
data-test="storage-settings-secret-key-input"
|
||||
v-model="formData.secret_key"
|
||||
label="Secret Key *"
|
||||
name="secret_key"
|
||||
label="Secret Key"
|
||||
required
|
||||
class="no-border showLabelOnTop"
|
||||
flat
|
||||
type="password"
|
||||
:error="!!fieldErrors.secret_key"
|
||||
:error-message="fieldErrors.secret_key"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -187,40 +189,37 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<!-- AzureCredentials Fields -->
|
||||
<template v-if="selectedProvider === 'AzureCredentials'">
|
||||
<div class="flex flex-col gap-y-3">
|
||||
<OInput
|
||||
<OFormInput
|
||||
data-test="storage-settings-access-key-input"
|
||||
v-model="formData.storage_account"
|
||||
label="Storage Account Name *"
|
||||
name="storage_account"
|
||||
label="Storage Account Name"
|
||||
required
|
||||
class="no-border showLabelOnTop"
|
||||
flat
|
||||
:disabled="isEditMode"
|
||||
:error="!!fieldErrors.storage_account"
|
||||
:error-message="fieldErrors.storage_account"
|
||||
/>
|
||||
<OInput
|
||||
<OFormInput
|
||||
data-test="storage-settings-bucket-name-input"
|
||||
v-model="formData.bucket_name"
|
||||
label="Bucket Name *"
|
||||
name="bucket_name"
|
||||
label="Bucket Name"
|
||||
required
|
||||
class="no-border showLabelOnTop"
|
||||
flat
|
||||
:disabled="isEditMode"
|
||||
:error="!!fieldErrors.bucket_name"
|
||||
:error-message="fieldErrors.bucket_name"
|
||||
/>
|
||||
<OInput
|
||||
<OFormInput
|
||||
data-test="storage-settings-secret-key-input"
|
||||
v-model="formData.secret_key"
|
||||
label="Secret Key *"
|
||||
name="secret_key"
|
||||
label="Secret Key"
|
||||
required
|
||||
class="no-border showLabelOnTop"
|
||||
flat
|
||||
type="password"
|
||||
:error="!!fieldErrors.secret_key"
|
||||
:error-message="fieldErrors.secret_key"
|
||||
/>
|
||||
<OInput
|
||||
<OFormInput
|
||||
v-if="!isCloud"
|
||||
data-test="storage-settings-server-url-input"
|
||||
v-model="formData.server_url"
|
||||
name="server_url"
|
||||
label="Server URL"
|
||||
class="no-border showLabelOnTop"
|
||||
flat
|
||||
@@ -232,29 +231,27 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<!-- GcpCredentials Fields -->
|
||||
<template v-if="selectedProvider === 'GcpCredentials'">
|
||||
<div class="flex flex-col gap-y-3">
|
||||
<OInput
|
||||
<OFormInput
|
||||
data-test="storage-settings-bucket-name-input"
|
||||
v-model="formData.bucket_name"
|
||||
label="Bucket Name *"
|
||||
name="bucket_name"
|
||||
label="Bucket Name"
|
||||
required
|
||||
class="no-border showLabelOnTop"
|
||||
flat
|
||||
:disabled="isEditMode"
|
||||
:error="!!fieldErrors.bucket_name"
|
||||
:error-message="fieldErrors.bucket_name"
|
||||
/>
|
||||
<OInput
|
||||
<OFormInput
|
||||
data-test="storage-settings-access-key-input"
|
||||
v-model="formData.access_key"
|
||||
label="Access Key *"
|
||||
name="access_key"
|
||||
label="Access Key"
|
||||
required
|
||||
class="no-border showLabelOnTop"
|
||||
flat
|
||||
:error="!!fieldErrors.access_key"
|
||||
:error-message="fieldErrors.access_key"
|
||||
/>
|
||||
<OInput
|
||||
<OFormInput
|
||||
v-if="!isCloud"
|
||||
data-test="storage-settings-server-url-input"
|
||||
v-model="formData.server_url"
|
||||
name="server_url"
|
||||
label="Server URL"
|
||||
class="no-border showLabelOnTop"
|
||||
flat
|
||||
@@ -282,43 +279,39 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-y-3">
|
||||
<OInput
|
||||
<OFormInput
|
||||
data-test="storage-settings-bucket-name-input"
|
||||
v-model="formData.bucket_name"
|
||||
label="Bucket Name *"
|
||||
name="bucket_name"
|
||||
label="Bucket Name"
|
||||
required
|
||||
class="no-border showLabelOnTop"
|
||||
flat
|
||||
:disabled="isEditMode"
|
||||
:error="!!fieldErrors.bucket_name"
|
||||
:error-message="fieldErrors.bucket_name"
|
||||
/>
|
||||
<OInput
|
||||
<OFormInput
|
||||
data-test="storage-settings-region-input"
|
||||
v-model="formData.region"
|
||||
label="Region *"
|
||||
name="region"
|
||||
label="Region"
|
||||
required
|
||||
class="no-border showLabelOnTop"
|
||||
flat
|
||||
:disabled="isEditMode || !!cloudRegion"
|
||||
:error="!!fieldErrors.region"
|
||||
:error-message="fieldErrors.region"
|
||||
/>
|
||||
<OInput
|
||||
<OFormInput
|
||||
data-test="storage-settings-role-arn-input"
|
||||
v-model="formData.role_arn"
|
||||
label="Role ARN *"
|
||||
name="role_arn"
|
||||
label="Role ARN"
|
||||
required
|
||||
class="no-border showLabelOnTop"
|
||||
flat
|
||||
:error="!!fieldErrors.role_arn"
|
||||
:error-message="fieldErrors.role_arn"
|
||||
/>
|
||||
<OInput
|
||||
<OFormInput
|
||||
data-test="storage-settings-role-external-id-input"
|
||||
v-model="formData.external_id"
|
||||
label="External Id *"
|
||||
name="external_id"
|
||||
label="External Id"
|
||||
required
|
||||
class="no-border showLabelOnTop"
|
||||
flat
|
||||
:error="!!fieldErrors.external_id"
|
||||
:error-message="fieldErrors.external_id"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -407,7 +400,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted, reactive, watch } from "vue";
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
|
||||
defineOptions({ name: "OrgStorageEditor" });
|
||||
import { useI18n } from "vue-i18n";
|
||||
@@ -419,10 +412,14 @@ import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OStepper from "@/lib/navigation/Stepper/OStepper.vue";
|
||||
import OStep from "@/lib/navigation/Stepper/OStep.vue";
|
||||
import OInput from "@/lib/forms/Input/OInput.vue";
|
||||
import OForm from "@/lib/forms/Form/OForm.vue";
|
||||
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
|
||||
import AppPageHeader from "@/components/common/AppPageHeader.vue";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import {
|
||||
makeOrgStorageEditorSchema,
|
||||
type OrgStorageEditorForm,
|
||||
} from "./OrgStorageEditor.schema";
|
||||
|
||||
const props = defineProps<{
|
||||
action: "add" | "edit";
|
||||
@@ -439,15 +436,15 @@ const { t } = useI18n();
|
||||
const step = ref(1);
|
||||
const selectedProvider = ref("");
|
||||
|
||||
const fieldErrors = reactive<Record<string, string>>({
|
||||
bucket_name: "",
|
||||
access_key: "",
|
||||
secret_key: "",
|
||||
storage_account: "",
|
||||
region: "",
|
||||
role_arn: "",
|
||||
external_id: "",
|
||||
});
|
||||
// OForm instance ref — used for the selectedProvider bridge (setFieldValue),
|
||||
// provider-change reset, and async edit-prefill (form.reset).
|
||||
const storageForm = ref<any>(null);
|
||||
|
||||
// Schema-driven validation replaces the manual fieldErrors map +
|
||||
// validateStorageForm(). Provider-discriminated via superRefine on the bridged
|
||||
// `selectedProvider` field.
|
||||
const orgStorageEditorSchema = makeOrgStorageEditorSchema(t);
|
||||
|
||||
const existingConfig = ref<any>(null);
|
||||
|
||||
const isEditMode = computed(() => props.action === "edit");
|
||||
@@ -458,16 +455,26 @@ const headerTitle = computed(() =>
|
||||
);
|
||||
const isCloud = computed(() => config.isCloud === "true");
|
||||
|
||||
const formData = reactive({
|
||||
bucket_name: "",
|
||||
server_url: "",
|
||||
region: (store.state as any).zoConfig?.org_storage_region || "",
|
||||
access_key: "",
|
||||
storage_account: "",
|
||||
secret_key: "",
|
||||
role_arn: "",
|
||||
external_id: "",
|
||||
});
|
||||
// Blank form values for a given provider (region seeded from cloud/store).
|
||||
function blankFormValues(provider: string): OrgStorageEditorForm {
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
bucket_name: "",
|
||||
server_url: "",
|
||||
region: cloudRegion.value || (store.state as any).zoConfig?.org_storage_region || "",
|
||||
access_key: "",
|
||||
storage_account: "",
|
||||
secret_key: "",
|
||||
role_arn: "",
|
||||
external_id: "",
|
||||
};
|
||||
}
|
||||
|
||||
// Dynamic defaults (region from cloud/store, provider projected from the card
|
||||
// grid) → a typed computed.
|
||||
const orgStorageEditorDefaults = computed((): OrgStorageEditorForm =>
|
||||
blankFormValues(selectedProvider.value),
|
||||
);
|
||||
|
||||
const cloudProviders = computed(() => {
|
||||
const raw = (store.state as any).zoConfig?.org_storage_providers;
|
||||
@@ -525,74 +532,42 @@ function prevStep() {
|
||||
if (step.value > 1) step.value--;
|
||||
}
|
||||
|
||||
function resetFormData() {
|
||||
formData.bucket_name = "";
|
||||
formData.server_url = "";
|
||||
formData.region = cloudRegion.value;
|
||||
formData.access_key = "";
|
||||
formData.storage_account = "";
|
||||
formData.secret_key = "";
|
||||
formData.role_arn = "";
|
||||
formData.external_id = "";
|
||||
}
|
||||
|
||||
function buildDataPayload() {
|
||||
function buildDataPayload(value: OrgStorageEditorForm) {
|
||||
const data: Record<string, string> = {};
|
||||
switch (selectedProvider.value) {
|
||||
case "AwsCredentials":
|
||||
data.bucket_name = formData.bucket_name;
|
||||
data.server_url = isCloud.value ? "" : formData.server_url;
|
||||
data.region = formData.region;
|
||||
data.access_key = formData.access_key;
|
||||
data.secret_key = formData.secret_key;
|
||||
data.bucket_name = value.bucket_name;
|
||||
data.server_url = isCloud.value ? "" : value.server_url;
|
||||
data.region = value.region;
|
||||
data.access_key = value.access_key;
|
||||
data.secret_key = value.secret_key;
|
||||
break;
|
||||
case "AzureCredentials":
|
||||
data.bucket_name = formData.bucket_name;
|
||||
data.server_url = isCloud.value ? "" : formData.server_url;
|
||||
data.storage_account = formData.storage_account;
|
||||
data.secret_key = formData.secret_key;
|
||||
data.bucket_name = value.bucket_name;
|
||||
data.server_url = isCloud.value ? "" : value.server_url;
|
||||
data.storage_account = value.storage_account;
|
||||
data.secret_key = value.secret_key;
|
||||
break;
|
||||
case "GcpCredentials":
|
||||
data.bucket_name = formData.bucket_name;
|
||||
data.server_url = isCloud.value ? "" : formData.server_url;
|
||||
data.access_key = formData.access_key;
|
||||
data.bucket_name = value.bucket_name;
|
||||
data.server_url = isCloud.value ? "" : value.server_url;
|
||||
data.access_key = value.access_key;
|
||||
break;
|
||||
case "AwsRoleArn":
|
||||
data.bucket_name = formData.bucket_name;
|
||||
data.region = formData.region;
|
||||
data.role_arn = formData.role_arn;
|
||||
data.external_id = formData.external_id;
|
||||
data.bucket_name = value.bucket_name;
|
||||
data.region = value.region;
|
||||
data.role_arn = value.role_arn;
|
||||
data.external_id = value.external_id;
|
||||
break;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function validateStorageForm(): boolean {
|
||||
Object.keys(fieldErrors).forEach((k) => (fieldErrors[k] = ""));
|
||||
const p = selectedProvider.value;
|
||||
if (p === "AwsCredentials") {
|
||||
if (!formData.bucket_name?.trim()) fieldErrors.bucket_name = t("storage_settings.bucketNameRequired");
|
||||
if (!formData.access_key?.trim()) fieldErrors.access_key = t("storage_settings.accessKeyRequired");
|
||||
if (!formData.secret_key?.trim()) fieldErrors.secret_key = t("storage_settings.secretKeyRequired");
|
||||
} else if (p === "AzureCredentials") {
|
||||
if (!formData.storage_account?.trim()) fieldErrors.storage_account = t("storage_settings.storageAccountRequired");
|
||||
if (!formData.bucket_name?.trim()) fieldErrors.bucket_name = t("storage_settings.bucketNameRequired");
|
||||
if (!formData.secret_key?.trim()) fieldErrors.secret_key = t("storage_settings.secretKeyRequired");
|
||||
} else if (p === "GcpCredentials") {
|
||||
if (!formData.bucket_name?.trim()) fieldErrors.bucket_name = t("storage_settings.bucketNameRequired");
|
||||
if (!formData.access_key?.trim()) fieldErrors.access_key = t("storage_settings.accessKeyRequired");
|
||||
} else if (p === "AwsRoleArn") {
|
||||
if (!formData.bucket_name?.trim()) fieldErrors.bucket_name = t("storage_settings.bucketNameRequired");
|
||||
if (!formData.region?.trim()) fieldErrors.region = t("storage_settings.regionRequired");
|
||||
if (!formData.role_arn?.trim()) fieldErrors.role_arn = t("storage_settings.roleARNRequired");
|
||||
if (!formData.external_id?.trim()) fieldErrors.external_id = t("storage_settings.externalIdRequired");
|
||||
}
|
||||
return Object.values(fieldErrors).every((e) => !e);
|
||||
}
|
||||
|
||||
async function submitStorage() {
|
||||
if (!validateStorageForm()) return;
|
||||
|
||||
// @submit handler — fires only once the provider-discriminated schema passes,
|
||||
// so the manual validateStorageForm()/fieldErrors are gone. The Save button is
|
||||
// inline (type=submit inside <OForm>) so Enter works natively; OForm awaits this
|
||||
// so the spinner spans the POST.
|
||||
async function submitStorage(value: OrgStorageEditorForm) {
|
||||
const dismiss = toast({
|
||||
variant: "loading",
|
||||
message: "Please wait...",
|
||||
@@ -602,7 +577,7 @@ async function submitStorage() {
|
||||
const orgId = store.state.selectedOrganization.identifier;
|
||||
const payload = {
|
||||
provider: selectedProvider.value,
|
||||
data: buildDataPayload(),
|
||||
data: buildDataPayload(value),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -648,17 +623,22 @@ onMounted(async () => {
|
||||
step.value = 2;
|
||||
// data is already a parsed object from the API
|
||||
const parsed = res.data.data || {};
|
||||
// prefill non-credential fields (they will be disabled in the form)
|
||||
formData.bucket_name = parsed.bucket_name || "";
|
||||
formData.server_url = parsed.server_url || "";
|
||||
formData.region = parsed.region || "";
|
||||
// credentials must be entered fresh — never prefill with masked values
|
||||
formData.access_key = "";
|
||||
formData.secret_key = "";
|
||||
formData.role_arn = "";
|
||||
// external id is something that is technically a credential, but not masked
|
||||
formData.external_id = parsed.external_id || "";
|
||||
formData.storage_account = parsed.storage_account || "";
|
||||
// Prefill the form once the (async) config arrives — form.reset, not a
|
||||
// per-field loop (the documented "data arrives after mount" pattern).
|
||||
// Non-credential fields are disabled in the form; credentials must be
|
||||
// entered fresh — never prefill with masked values. external_id is a
|
||||
// credential but not masked, so it IS prefilled.
|
||||
storageForm.value?.form?.reset({
|
||||
selectedProvider: res.data.provider,
|
||||
bucket_name: parsed.bucket_name || "",
|
||||
server_url: parsed.server_url || "",
|
||||
region: parsed.region || "",
|
||||
access_key: "",
|
||||
secret_key: "",
|
||||
role_arn: "",
|
||||
external_id: parsed.external_id || "",
|
||||
storage_account: parsed.storage_account || "",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
@@ -669,10 +649,13 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for provider change to reset form data (skip in edit mode since provider is locked)
|
||||
// Bridge the provider card grid into the form (the documented sanctioned
|
||||
// discriminator bridge) so superRefine can branch on it. On a create-mode
|
||||
// provider change, reset the credential fields fresh for the new provider.
|
||||
watch(selectedProvider, (newProvider) => {
|
||||
storageForm.value?.form?.setFieldValue("selectedProvider", newProvider);
|
||||
if (newProvider && !isEditMode.value) {
|
||||
resetFormData();
|
||||
storageForm.value?.form?.reset(blankFormValues(newProvider));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// Validation schemas for the two self-owned dialogs in OrganizationManagement.vue
|
||||
// (meta-org admin): the External Contract dialog and the Extend-Trial dialog.
|
||||
//
|
||||
// Both dialogs previously gated their POST on imperative JS guards inside the
|
||||
// @click:primary handlers (a toast + early-return for a missing contract end
|
||||
// date). Those guards are replaced here by Zod rules wired to <OForm>, so the
|
||||
// footer Save + Enter submit through `form-id` and validation is schema-driven.
|
||||
//
|
||||
// Validation TIMING is owned by OForm (submit-then-change via revalidateLogic);
|
||||
// these schemas only describe WHAT is valid.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
// ── External Contract dialog ─────────────────────────────────────────────────
|
||||
// The single date field is required. The message is mode-aware so it reads
|
||||
// "End date is required." when creating a new contract and "New end date is
|
||||
// required." when extending an existing one (mirrors the old toast guards).
|
||||
export const makeContractSchema = (mode: "create" | "extend") =>
|
||||
z.object({
|
||||
contractEndDate: z
|
||||
.string()
|
||||
.min(
|
||||
1,
|
||||
mode === "create"
|
||||
? "End date is required."
|
||||
: "New end date is required.",
|
||||
),
|
||||
});
|
||||
|
||||
export type ContractForm = z.infer<ReturnType<typeof makeContractSchema>>;
|
||||
|
||||
export const contractDefaults = (): ContractForm => ({ contractEndDate: "" });
|
||||
|
||||
// ── Extend-Trial dialog ──────────────────────────────────────────────────────
|
||||
// The week count comes from a custom pill grid (1–4), bridged into the form via
|
||||
// setFieldValue. It must be at least one week. `z.coerce.number()` because the
|
||||
// bridged value can arrive as a string.
|
||||
export const extendTrialSchema = z.object({
|
||||
extendedTrial: z.coerce
|
||||
.number()
|
||||
.min(1, "Trial extension must be at least 1 week."),
|
||||
});
|
||||
|
||||
export type ExtendTrialForm = z.infer<typeof extendTrialSchema>;
|
||||
|
||||
export const extendTrialDefaults = (): ExtendTrialForm => ({ extendedTrial: 1 });
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import OrganizationManagement from "./OrganizationManagement.vue";
|
||||
import { extendTrialSchema } from "./OrganizationManagement.schema";
|
||||
import store from "../../test/unit/helpers/store";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { nextTick } from "vue";
|
||||
@@ -342,7 +343,8 @@ describe("OrganizationManagement.vue", () => {
|
||||
wrapper = createWrapper();
|
||||
expect(wrapper.vm.contractPrompt).toBe(false);
|
||||
expect(wrapper.vm.contractMode).toBe("create");
|
||||
expect(wrapper.vm.contractEndDate).toBe("");
|
||||
// contractEndDate is now form-owned (OFormInput) — the schema default is "".
|
||||
expect(wrapper.vm.contractDefaults()).toEqual({ contractEndDate: "" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1180,22 +1182,16 @@ describe("OrganizationManagement.vue", () => {
|
||||
expect(wrapper.vm.extendTrialPrompt).toBe(false);
|
||||
});
|
||||
|
||||
it("should call updateTrialPeriod when primary is clicked on the extend-trial dialog", async () => {
|
||||
it("should call updateTrialPeriod when the extend-trial form is submitted (real OForm)", async () => {
|
||||
mockExtendTrialPeriod.mockResolvedValue({ data: true });
|
||||
wrapper = createWrapper();
|
||||
wrapper.vm.extendTrialPrompt = true;
|
||||
wrapper.vm.extendTrialDataRow = { name: "Acme", identifier: "acme-id" };
|
||||
wrapper.vm.toggleExtendTrialDialog({ name: "Acme", identifier: "acme-id" });
|
||||
wrapper.vm.extendedTrial = 3;
|
||||
await nextTick();
|
||||
|
||||
const dialog = wrapper
|
||||
.findAll('[data-test="o-dialog-stub"]')
|
||||
.find((d: any) =>
|
||||
(d.attributes("data-title") || "").startsWith("Extend Trial"),
|
||||
);
|
||||
expect(dialog?.exists()).toBe(true);
|
||||
|
||||
await dialog!.find('[data-test="o-dialog-stub-primary"]').trigger("click");
|
||||
const form = wrapper.findComponent({ name: "OForm" });
|
||||
expect(form.exists()).toBe(true);
|
||||
await (form.vm as any).form.handleSubmit();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockExtendTrialPeriod).toHaveBeenCalledWith(
|
||||
@@ -1266,24 +1262,23 @@ describe("OrganizationManagement.vue", () => {
|
||||
expect(wrapper.vm.contractPrompt).toBe(false);
|
||||
});
|
||||
|
||||
it("should invoke submitContract when primary is clicked", async () => {
|
||||
it("should call create_external_contract when the contract form is submitted with a valid date (real OForm)", async () => {
|
||||
mockCreateExternalContract?.mockResolvedValue?.({ data: true });
|
||||
wrapper = createWrapper();
|
||||
wrapper.vm.toggleContractDialog(
|
||||
{ name: "Acme", identifier: "acme" },
|
||||
"create",
|
||||
);
|
||||
wrapper.vm.contractEndDate = "2030-12-31";
|
||||
await nextTick();
|
||||
|
||||
const dialog = wrapper
|
||||
.findAll('[data-test="o-dialog-stub"]')
|
||||
.find((d: any) =>
|
||||
(d.attributes("data-title") || "").includes("External Contract"),
|
||||
);
|
||||
expect(dialog?.exists()).toBe(true);
|
||||
// Fill the form-owned date field, then submit through the real OForm.
|
||||
await wrapper
|
||||
.find('[data-test="contract-end-date-input"]')
|
||||
.setValue("2030-12-31");
|
||||
|
||||
await dialog!.find('[data-test="o-dialog-stub-primary"]').trigger("click");
|
||||
const form = wrapper.findComponent({ name: "OForm" });
|
||||
expect(form.exists()).toBe(true);
|
||||
await (form.vm as any).form.handleSubmit();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockCreateExternalContract).toHaveBeenCalledWith(
|
||||
@@ -1294,9 +1289,8 @@ describe("OrganizationManagement.vue", () => {
|
||||
});
|
||||
|
||||
describe("toggleContractDialog Function", () => {
|
||||
it("should open the contract dialog in create mode and reset the end date", () => {
|
||||
it("should open the contract dialog in create mode", () => {
|
||||
wrapper = createWrapper();
|
||||
wrapper.vm.contractEndDate = "2025-01-01";
|
||||
wrapper.vm.toggleContractDialog(
|
||||
{ name: "Acme", identifier: "acme" },
|
||||
"create",
|
||||
@@ -1308,7 +1302,8 @@ describe("OrganizationManagement.vue", () => {
|
||||
name: "Acme",
|
||||
identifier: "acme",
|
||||
});
|
||||
expect(wrapper.vm.contractEndDate).toBe("");
|
||||
// The end date is no longer a component ref — the form re-seeds it to ""
|
||||
// on each open (dialog body remounts), proven by the real-OForm gate tests.
|
||||
});
|
||||
|
||||
it("should open the contract dialog in extend mode", () => {
|
||||
@@ -1333,43 +1328,40 @@ describe("OrganizationManagement.vue", () => {
|
||||
store.state.zoConfig.meta_org = "different_org";
|
||||
});
|
||||
|
||||
it("should warn and return when create mode has no end date", async () => {
|
||||
// Validation is now schema-driven: an empty end date blocks the submit
|
||||
// (the old toast required-guards are gone). Mount the REAL <OForm> and prove
|
||||
// the schema gates the submit and the service is NOT called.
|
||||
it("blocks the create submit and does not call the service when the end date is empty (real OForm)", async () => {
|
||||
wrapper = createWrapper();
|
||||
wrapper.vm.toggleContractDialog(
|
||||
{ name: "Acme", identifier: "acme" },
|
||||
"create",
|
||||
);
|
||||
wrapper.vm.contractEndDate = "";
|
||||
await nextTick();
|
||||
|
||||
await wrapper.vm.submitContract();
|
||||
const form = wrapper.findComponent({ name: "OForm" });
|
||||
expect(form.exists()).toBe(true);
|
||||
await (form.vm as any).form.handleSubmit();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockToastFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variant: "error",
|
||||
message: "End date is required.",
|
||||
}),
|
||||
);
|
||||
expect((form.vm as any).form.state.isValid).toBe(false);
|
||||
expect(mockCreateExternalContract).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should warn and return when extend mode has no end date", async () => {
|
||||
it("blocks the extend submit and does not call the service when the end date is empty (real OForm)", async () => {
|
||||
wrapper = createWrapper();
|
||||
wrapper.vm.toggleContractDialog(
|
||||
{ name: "Acme", identifier: "acme" },
|
||||
"extend",
|
||||
);
|
||||
wrapper.vm.contractEndDate = "";
|
||||
await nextTick();
|
||||
|
||||
await wrapper.vm.submitContract();
|
||||
const form = wrapper.findComponent({ name: "OForm" });
|
||||
expect(form.exists()).toBe(true);
|
||||
await (form.vm as any).form.handleSubmit();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockToastFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variant: "error",
|
||||
message: "New end date is required.",
|
||||
}),
|
||||
);
|
||||
expect((form.vm as any).form.state.isValid).toBe(false);
|
||||
expect(mockExtendExternalContract).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1380,9 +1372,8 @@ describe("OrganizationManagement.vue", () => {
|
||||
{ name: "Acme", identifier: "acme" },
|
||||
"create",
|
||||
);
|
||||
wrapper.vm.contractEndDate = "2030-12-31";
|
||||
|
||||
await wrapper.vm.submitContract();
|
||||
await wrapper.vm.submitContract({ contractEndDate: "2030-12-31" });
|
||||
await flushPromises();
|
||||
|
||||
expect(mockCreateExternalContract).toHaveBeenCalledTimes(1);
|
||||
@@ -1404,8 +1395,7 @@ describe("OrganizationManagement.vue", () => {
|
||||
{ name: "Acme", identifier: "acme" },
|
||||
"create",
|
||||
);
|
||||
wrapper.vm.contractEndDate = "2030-12-31";
|
||||
await wrapper.vm.submitContract();
|
||||
await wrapper.vm.submitContract({ contractEndDate: "2030-12-31" });
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.vm.contractPrompt).toBe(false);
|
||||
@@ -1421,9 +1411,8 @@ describe("OrganizationManagement.vue", () => {
|
||||
{ name: "Acme", identifier: "acme" },
|
||||
"create",
|
||||
);
|
||||
wrapper.vm.contractEndDate = "2030-12-31";
|
||||
|
||||
await wrapper.vm.submitContract();
|
||||
await wrapper.vm.submitContract({ contractEndDate: "2030-12-31" });
|
||||
await flushPromises();
|
||||
|
||||
expect(mockToastFn).toHaveBeenCalledWith(
|
||||
@@ -1443,9 +1432,8 @@ describe("OrganizationManagement.vue", () => {
|
||||
{ name: "Acme", identifier: "acme" },
|
||||
"create",
|
||||
);
|
||||
wrapper.vm.contractEndDate = "2030-12-31";
|
||||
|
||||
await wrapper.vm.submitContract();
|
||||
await wrapper.vm.submitContract({ contractEndDate: "2030-12-31" });
|
||||
await flushPromises();
|
||||
|
||||
expect(mockToastFn).toHaveBeenCalledWith(
|
||||
@@ -1463,9 +1451,8 @@ describe("OrganizationManagement.vue", () => {
|
||||
{ name: "Beta", identifier: "beta" },
|
||||
"extend",
|
||||
);
|
||||
wrapper.vm.contractEndDate = "2030-12-31";
|
||||
|
||||
await wrapper.vm.submitContract();
|
||||
await wrapper.vm.submitContract({ contractEndDate: "2030-12-31" });
|
||||
await flushPromises();
|
||||
|
||||
expect(mockExtendExternalContract).toHaveBeenCalledTimes(1);
|
||||
@@ -1484,9 +1471,8 @@ describe("OrganizationManagement.vue", () => {
|
||||
{ name: "Beta", identifier: "beta" },
|
||||
"extend",
|
||||
);
|
||||
wrapper.vm.contractEndDate = "2030-12-31";
|
||||
|
||||
await wrapper.vm.submitContract();
|
||||
await wrapper.vm.submitContract({ contractEndDate: "2030-12-31" });
|
||||
await flushPromises();
|
||||
|
||||
expect(mockToastFn).toHaveBeenCalledWith(
|
||||
@@ -1917,3 +1903,29 @@ describe("OrganizationManagement.vue", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("extendTrialSchema", () => {
|
||||
it("rejects a week count below 1 (the .min(1) rule)", () => {
|
||||
expect(extendTrialSchema.safeParse({ extendedTrial: 0 }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("coerces a numeric string and still enforces the minimum", () => {
|
||||
// The pill value is bridged in and can arrive as a string.
|
||||
expect(extendTrialSchema.safeParse({ extendedTrial: "0" }).success).toBe(
|
||||
false,
|
||||
);
|
||||
const res = extendTrialSchema.safeParse({ extendedTrial: "2" });
|
||||
expect(res.success).toBe(true);
|
||||
if (res.success) expect(res.data.extendedTrial).toBe(2);
|
||||
});
|
||||
|
||||
it("accepts the in-range pill values 1..4", () => {
|
||||
for (const v of [1, 2, 3, 4]) {
|
||||
expect(extendTrialSchema.safeParse({ extendedTrial: v }).success).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,27 +149,35 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
sub-title="Set the new trial extension period."
|
||||
:secondary-button-label="t('common.cancel')"
|
||||
:primary-button-label="`Extend trial by ${extendedTrial} week(s)`"
|
||||
form-id="org-extend-trial-form"
|
||||
@click:secondary="extendTrialPrompt = false"
|
||||
@click:primary="updateTrialPeriod(extendTrialDataRow.identifier, extendedTrial)"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="font-bold">Week(s)</div>
|
||||
<div class="flex gap-1">
|
||||
<span
|
||||
v-for="page in 4"
|
||||
:key="page"
|
||||
@click="extendedTrial = page"
|
||||
:class="[
|
||||
'cursor-pointer px-2 py-1 border border-gray-300',
|
||||
extendedTrial === page
|
||||
? 'bg-(--o2-primary-btn-bg) text-(--o2-primary-btn-text) border-(--o2-primary-btn-bg)'
|
||||
: 'bg-white text-gray-700 border-gray-300',
|
||||
]"
|
||||
>
|
||||
{{ page }}
|
||||
</span>
|
||||
<OForm
|
||||
id="org-extend-trial-form"
|
||||
ref="extendTrialFormRef"
|
||||
:schema="extendTrialSchema"
|
||||
:default-values="extendTrialDefaults"
|
||||
@submit="onExtendTrialSubmit"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="font-bold">Week(s)</div>
|
||||
<div class="flex gap-1">
|
||||
<span
|
||||
v-for="page in 4"
|
||||
:key="page"
|
||||
@click="extendedTrial = page"
|
||||
:class="[
|
||||
'cursor-pointer px-2 py-1 border border-gray-300',
|
||||
extendedTrial === page
|
||||
? 'bg-(--o2-primary-btn-bg) text-(--o2-primary-btn-text) border-(--o2-primary-btn-bg)'
|
||||
: 'bg-white text-gray-700 border-gray-300',
|
||||
]"
|
||||
>
|
||||
{{ page }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OForm>
|
||||
</ODialog>
|
||||
|
||||
<!-- External Contract Dialog -->
|
||||
@@ -180,25 +188,31 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:title="`${contractMode === 'create' ? 'Create' : 'Extend'} External Contract for ${contractDataRow?.name}`"
|
||||
:secondary-button-label="t('common.cancel')"
|
||||
:primary-button-label="contractMode === 'create' ? 'Create Contract' : 'Extend Contract'"
|
||||
form-id="org-contract-form"
|
||||
@click:secondary="contractPrompt = false"
|
||||
@click:primary="submitContract"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<div class="font-bold mb-1">
|
||||
{{ contractMode === 'create' ? 'End Date' : 'New End Date' }}
|
||||
</div>
|
||||
<OInput
|
||||
v-model="contractEndDate"
|
||||
type="date"
|
||||
data-test="contract-end-date-input"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="contractMode === 'extend' && contractDataRow?.contract_end_date"
|
||||
class="text-xs text-gray-500"
|
||||
<OForm
|
||||
id="org-contract-form"
|
||||
:schema="contractSchema"
|
||||
:default-values="contractDefaults()"
|
||||
@submit="submitContract"
|
||||
>
|
||||
Current end date: {{ formatMicrosToDate(contractDataRow.contract_end_date) }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<OFormInput
|
||||
name="contractEndDate"
|
||||
type="date"
|
||||
data-test="contract-end-date-input"
|
||||
:label="contractMode === 'create' ? 'End Date' : 'New End Date'"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="contractMode === 'extend' && contractDataRow?.contract_end_date"
|
||||
class="text-xs text-gray-500"
|
||||
>
|
||||
Current end date: {{ formatMicrosToDate(contractDataRow.contract_end_date) }}
|
||||
</div>
|
||||
</OForm>
|
||||
</ODialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -217,7 +231,8 @@ import { useStore } from "vuex";
|
||||
import { useRouter } from "vue-router";
|
||||
import OrganizationServices from "@/services/organizations";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OInput from "@/lib/forms/Input/OInput.vue";
|
||||
import OForm from "@/lib/forms/Form/OForm.vue";
|
||||
import OFormInput from "@/lib/forms/Input/OFormInput.vue";
|
||||
import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
|
||||
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
@@ -230,6 +245,13 @@ import { useConfirmDialog } from "@/composables/useConfirmDialog";
|
||||
import AppPageHeader from "@/components/common/AppPageHeader.vue";
|
||||
import { useShortcuts } from "@/lib/vue-shortcut-manager";
|
||||
import { isInputFocused } from "@/utils/keyboardShortcuts";
|
||||
import {
|
||||
makeContractSchema,
|
||||
contractDefaults,
|
||||
extendTrialSchema,
|
||||
type ContractForm,
|
||||
type ExtendTrialForm,
|
||||
} from "./OrganizationManagement.schema";
|
||||
|
||||
export default defineComponent({
|
||||
name: "PageAlerts",
|
||||
@@ -239,7 +261,8 @@ export default defineComponent({
|
||||
OButton,
|
||||
ODialog,
|
||||
OTooltip,
|
||||
OInput,
|
||||
OForm,
|
||||
OFormInput,
|
||||
OSearchInput,
|
||||
OTable,
|
||||
},
|
||||
@@ -261,7 +284,26 @@ export default defineComponent({
|
||||
const contractPrompt = ref(false);
|
||||
const contractDataRow = ref<any>({});
|
||||
const contractMode = ref<"create" | "extend">("create");
|
||||
const contractEndDate = ref("");
|
||||
|
||||
// ── Form schemas (Options-API: MUST be returned from setup() or :schema
|
||||
// resolves to undefined and validation silently no-ops). ───────────────
|
||||
// The contract message is mode-aware; the dialog body remounts on open
|
||||
// (reka-ui), so a freshly-mounted <OForm> always reads the current schema.
|
||||
const contractSchema = computed(() => makeContractSchema(contractMode.value));
|
||||
|
||||
// Extend-trial week count is bridged from the pill grid into the form below.
|
||||
// Dynamic defaults (project the current pill value) → a typed computed.
|
||||
const extendTrialFormRef = ref<any>(null);
|
||||
const extendTrialDefaults = computed(
|
||||
(): ExtendTrialForm => ({ extendedTrial: extendedTrial.value }),
|
||||
);
|
||||
|
||||
// Keep the form's copy of the bridged pill value in sync (the pill grid is a
|
||||
// custom control, not an <input>, so it is bridged via setFieldValue — the
|
||||
// documented sanctioned exception, as CreateDestinationForm does).
|
||||
watch(extendedTrial, (v) => {
|
||||
extendTrialFormRef.value?.form?.setFieldValue("extendedTrial", Number(v));
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
@@ -457,21 +499,21 @@ export default defineComponent({
|
||||
const toggleContractDialog = (row: any, mode: "create" | "extend") => {
|
||||
contractDataRow.value = row;
|
||||
contractMode.value = mode;
|
||||
contractEndDate.value = "";
|
||||
// No contractEndDate reset needed: the dialog body remounts on open and
|
||||
// <OForm :default-values> re-seeds the field to blank.
|
||||
contractPrompt.value = true;
|
||||
};
|
||||
|
||||
const submitContract = () => {
|
||||
// @submit handler — fires only once the schema passes (contractEndDate
|
||||
// required), so the old toast required-guards are gone. Awaited by OForm, so
|
||||
// the footer Save spinner spans the POST automatically.
|
||||
const submitContract = async (value: ContractForm) => {
|
||||
const metaOrg = store.state.selectedOrganization.identifier;
|
||||
|
||||
if (contractMode.value === "create") {
|
||||
if (!contractEndDate.value) {
|
||||
toast({ variant: "error", message: "End date is required." });
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
org_id: contractDataRow.value.identifier,
|
||||
end_date: dateToMicros(contractEndDate.value),
|
||||
end_date: dateToMicros(value.contractEndDate),
|
||||
};
|
||||
|
||||
loading.value = true;
|
||||
@@ -480,7 +522,7 @@ export default defineComponent({
|
||||
message: "Creating external contract...",
|
||||
timeout: 0,
|
||||
});
|
||||
OrganizationServices.create_external_contract(metaOrg, payload)
|
||||
return OrganizationServices.create_external_contract(metaOrg, payload)
|
||||
.then(() => {
|
||||
toast({
|
||||
variant: "success",
|
||||
@@ -503,13 +545,9 @@ export default defineComponent({
|
||||
});
|
||||
});
|
||||
} else {
|
||||
if (!contractEndDate.value) {
|
||||
toast({ variant: "error", message: "New end date is required." });
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
org_id: contractDataRow.value.identifier,
|
||||
new_end_date: dateToMicros(contractEndDate.value),
|
||||
new_end_date: dateToMicros(value.contractEndDate),
|
||||
};
|
||||
|
||||
loading.value = true;
|
||||
@@ -518,7 +556,7 @@ export default defineComponent({
|
||||
message: "Extending external contract...",
|
||||
timeout: 0,
|
||||
});
|
||||
OrganizationServices.extend_external_contract(metaOrg, payload)
|
||||
return OrganizationServices.extend_external_contract(metaOrg, payload)
|
||||
.then(() => {
|
||||
toast({
|
||||
variant: "success",
|
||||
@@ -630,7 +668,7 @@ export default defineComponent({
|
||||
"Please wait while processing trial period extension request...",
|
||||
timeout: 0,
|
||||
});
|
||||
OrganizationServices.extend_trial_period(
|
||||
return OrganizationServices.extend_trial_period(
|
||||
store.state.selectedOrganization.identifier,
|
||||
payload,
|
||||
)
|
||||
@@ -663,6 +701,16 @@ export default defineComponent({
|
||||
});
|
||||
};
|
||||
|
||||
// @submit handler for the extend-trial dialog — awaited by OForm so the
|
||||
// footer Save spinner spans the POST. The week count comes from the
|
||||
// schema-validated form value (bridged from the pill grid).
|
||||
const onExtendTrialSubmit = async (value: ExtendTrialForm) => {
|
||||
return updateTrialPeriod(
|
||||
extendTrialDataRow.value?.identifier,
|
||||
Number(value.extendedTrial),
|
||||
);
|
||||
};
|
||||
|
||||
const filterData = (rows: string | any[], terms: string) => {
|
||||
var filtered = [];
|
||||
terms = terms.toLowerCase();
|
||||
@@ -704,7 +752,6 @@ export default defineComponent({
|
||||
contractPrompt,
|
||||
contractDataRow,
|
||||
contractMode,
|
||||
contractEndDate,
|
||||
toggleContractDialog,
|
||||
submitContract,
|
||||
confirmRevokeContract,
|
||||
@@ -714,7 +761,15 @@ export default defineComponent({
|
||||
filterData,
|
||||
visibleRows,
|
||||
store,
|
||||
// Form wiring (Options-API: schemas/defaults MUST be returned so :schema
|
||||
// resolves and validation runs).
|
||||
contractSchema,
|
||||
contractDefaults,
|
||||
extendTrialSchema,
|
||||
extendTrialDefaults,
|
||||
extendTrialFormRef,
|
||||
onExtendTrialSubmit,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// Validation schema for OrganizationSettings.vue (Settings > Organization > Log
|
||||
// Details). The trace-id / span-id field names are required and must match the
|
||||
// allowed character set; the two switches are non-validated form state; the
|
||||
// crossLinks composite (CrossLinkManager) has no OForm* equivalent so it stays
|
||||
// bare and is merged at submit (declared optional here for documentation).
|
||||
//
|
||||
// Restores the Quasar BEFORE rule (truthy→Zod inversion):
|
||||
// `!!v ? isValidTraceField || 'Use alphanumeric…' : nameRequired`
|
||||
// → required (min 1) + regex(/^[a-zA-Z0-9+=,.@_-]+$/).
|
||||
//
|
||||
// Validation TIMING is owned by OForm (submit-then-change via revalidateLogic);
|
||||
// this file only describes WHAT is valid.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export const orgSettingsFieldNameRegex = /^[a-zA-Z0-9+=,.@_-]+$/;
|
||||
|
||||
const FIELD_FORMAT_MESSAGE =
|
||||
"Use alphanumeric and '+=,.@-_' characters only, without spaces.";
|
||||
|
||||
export const makeOrganizationSettingsSchema = (t: (_key: string) => string) =>
|
||||
z.object({
|
||||
// NO .trim(): OForm/TanStack validates with the schema but SAVES the raw value,
|
||||
// so .trim() would let "trace_id " pass yet persist the space (breaks field lookup).
|
||||
// Validate the raw value; the regex already rejects spaces.
|
||||
traceIdFieldName: z
|
||||
.string()
|
||||
.min(1, t("common.nameRequired"))
|
||||
.regex(orgSettingsFieldNameRegex, FIELD_FORMAT_MESSAGE),
|
||||
spanIdFieldName: z
|
||||
.string()
|
||||
.min(1, t("common.nameRequired"))
|
||||
.regex(orgSettingsFieldNameRegex, FIELD_FORMAT_MESSAGE),
|
||||
// Non-validated form state (R1-strict: still form-owned via OFormSwitch).
|
||||
toggleIngestionLogs: z.boolean().optional().default(false),
|
||||
usageStreamEnabled: z.boolean().optional().default(false),
|
||||
// CrossLinkManager is a composite custom control (documented no-OForm*
|
||||
// exception) — kept bare and merged at submit; optional so it never blocks.
|
||||
crossLinks: z.array(z.any()).optional(),
|
||||
});
|
||||
|
||||
export type OrganizationSettingsForm = z.infer<
|
||||
ReturnType<typeof makeOrganizationSettingsSchema>
|
||||
>;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,20 +8,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<OForm
|
||||
id="organization-settings-form"
|
||||
class="mb-3"
|
||||
:schema="organizationSettingsSchema"
|
||||
:default-values="organizationSettingsDefaults"
|
||||
@submit="saveOrgSettings"
|
||||
v-slot="{ isSubmitting }"
|
||||
>
|
||||
<div
|
||||
data-test="add-role-rolename-input-btn"
|
||||
class="trace-id-field-name o2-input mb-2 w-100"
|
||||
>
|
||||
<OInput
|
||||
<OFormInput
|
||||
data-test="settings-org-trace-id-input"
|
||||
v-model.trim="traceIdFieldName"
|
||||
:label="t('settings.traceIdFieldName') + ' *'"
|
||||
name="traceIdFieldName"
|
||||
:label="t('settings.traceIdFieldName')"
|
||||
required
|
||||
class="py-3 showLabelOnTop"
|
||||
:error="!!traceIdFieldNameError"
|
||||
:error-message="traceIdFieldNameError"
|
||||
help-text="Use alphanumeric and '+=,.@-_' characters only, without spaces."
|
||||
@update:model-value="updateFieldName('trace'); traceIdFieldNameError = ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -29,31 +34,29 @@
|
||||
data-test="add-role-rolename-input-btn"
|
||||
class="span-id-field-name o2-input w-100"
|
||||
>
|
||||
<OInput
|
||||
<OFormInput
|
||||
data-test="settings-org-span-id-input"
|
||||
v-model.trim="spanIdFieldName"
|
||||
:label="t('settings.spanIdFieldName') + ' *'"
|
||||
name="spanIdFieldName"
|
||||
:label="t('settings.spanIdFieldName')"
|
||||
required
|
||||
class="py-3 showLabelOnTop"
|
||||
:error="!!spanIdFieldNameError"
|
||||
:error-message="spanIdFieldNameError"
|
||||
help-text="Use alphanumeric and '+=,.@-_' characters only, without spaces."
|
||||
@update:model-value="updateFieldName('span'); spanIdFieldNameError = ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="config.isCloud !== 'true'" data-test="add-toggle-ingestion" class="span-id-field-name o2-input w-100">
|
||||
<OSwitch
|
||||
<OFormSwitch
|
||||
data-test="add-toggle-ingestion-btn"
|
||||
v-model="toggleIngestionLogs"
|
||||
name="toggleIngestionLogs"
|
||||
:label="t('settings.toggleIngestionLogsLabel')"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div data-test="add-toggle-usage-stream" class="o2-input">
|
||||
<OSwitch
|
||||
<OFormSwitch
|
||||
data-test="add-toggle-usage-stream-btn"
|
||||
v-model="usageStreamEnabled"
|
||||
name="usageStreamEnabled"
|
||||
:label="t('settings.usageStreamEnabledLabel')"
|
||||
class="mt-2"
|
||||
/>
|
||||
@@ -82,10 +85,10 @@
|
||||
variant="primary"
|
||||
size="sm-action"
|
||||
type="submit"
|
||||
@click="saveOrgSettings"
|
||||
:loading="isSubmitting"
|
||||
>{{ t('alerts.save') }}</OButton>
|
||||
</div>
|
||||
</div>
|
||||
</OForm>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -96,40 +99,42 @@ import organizations from "@/services/organizations";
|
||||
import { useStore } from "vuex";
|
||||
import CrossLinkManager from "@/components/cross-linking/CrossLinkManager.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OInput from "@/lib/forms/Input/OInput.vue";
|
||||
import OSwitch from "@/lib/forms/Switch/OSwitch.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 OSeparator from '@/lib/core/Separator/OSeparator.vue';
|
||||
import config from "@/aws-exports";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import {
|
||||
makeOrganizationSettingsSchema,
|
||||
type OrganizationSettingsForm,
|
||||
} from "./OrganizationSettings.schema";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const traceIdFieldName = ref(
|
||||
store.state?.organizationData?.organizationSettings?.trace_id_field_name,
|
||||
);
|
||||
// Schema-driven validation replaces the hand-rolled validate()/error refs.
|
||||
const organizationSettingsSchema = makeOrganizationSettingsSchema(t);
|
||||
|
||||
const spanIdFieldName = ref(
|
||||
store.state?.organizationData?.organizationSettings?.span_id_field_name,
|
||||
);
|
||||
|
||||
|
||||
const isValidSpanField = ref(true);
|
||||
const isValidTraceField = ref(true);
|
||||
const traceIdFieldNameError = ref('');
|
||||
const spanIdFieldNameError = ref('');
|
||||
const toggleIngestionLogs = ref(
|
||||
store.state?.organizationData?.organizationSettings?.toggle_ingestion_logs ||
|
||||
false,
|
||||
);
|
||||
const usageStreamEnabled = ref(
|
||||
store.state?.organizationData?.organizationSettings?.usage_stream_enabled ||
|
||||
false,
|
||||
);
|
||||
// CrossLinkManager has no OForm* equivalent (composite) — kept as local state
|
||||
// and merged at submit (the documented exception).
|
||||
const crossLinks = ref(
|
||||
store.state?.organizationData?.organizationSettings?.cross_links || [],
|
||||
);
|
||||
const formDirty = ref(false);
|
||||
|
||||
// Dynamic defaults (edit-prefill from the store) → a typed computed. The trace/
|
||||
// span/toggle values are form-owned (OFormInput / OFormSwitch).
|
||||
const organizationSettingsDefaults = computed((): OrganizationSettingsForm => {
|
||||
const s = store.state?.organizationData?.organizationSettings;
|
||||
return {
|
||||
traceIdFieldName: s?.trace_id_field_name ?? "",
|
||||
spanIdFieldName: s?.span_id_field_name ?? "",
|
||||
toggleIngestionLogs: s?.toggle_ingestion_logs ?? false,
|
||||
usageStreamEnabled: s?.usage_stream_enabled ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
watch(
|
||||
() => store.state?.organizationData?.organizationSettings?.cross_links,
|
||||
@@ -139,56 +144,18 @@ watch(
|
||||
}
|
||||
},
|
||||
);
|
||||
const formDirty = ref(false);
|
||||
|
||||
const isValidRoleName = computed(() => {
|
||||
const roleNameRegex = /^[a-zA-Z0-9+=,.@_-]+$/;
|
||||
// Check if the role name is valid
|
||||
return roleNameRegex.test(traceIdFieldName.value);
|
||||
});
|
||||
|
||||
const validateFieldName = (value: string) => {
|
||||
const roleNameRegex = /^[a-zA-Z0-9+=,.@_-]+$/;
|
||||
// Check if the role name is valid
|
||||
return roleNameRegex.test(value);
|
||||
};
|
||||
|
||||
const updateFieldName = (fieldName: string) => {
|
||||
if (fieldName === "span")
|
||||
isValidSpanField.value = validateFieldName(spanIdFieldName.value);
|
||||
|
||||
if (fieldName === "trace")
|
||||
isValidTraceField.value = validateFieldName(traceIdFieldName.value);
|
||||
};
|
||||
|
||||
const validateOrgSettings = () => {
|
||||
let valid = true;
|
||||
if (!traceIdFieldName.value) {
|
||||
traceIdFieldNameError.value = t('common.nameRequired');
|
||||
valid = false;
|
||||
} else if (!isValidTraceField.value) {
|
||||
traceIdFieldNameError.value = `Use alphanumeric and '+=,.@-_' characters only, without spaces.`;
|
||||
valid = false;
|
||||
}
|
||||
if (!spanIdFieldName.value) {
|
||||
spanIdFieldNameError.value = t('common.nameRequired');
|
||||
valid = false;
|
||||
} else if (!isValidSpanField.value) {
|
||||
spanIdFieldNameError.value = `Use alphanumeric and '+=,.@-_' characters only, without spaces.`;
|
||||
valid = false;
|
||||
}
|
||||
return valid;
|
||||
};
|
||||
|
||||
const saveOrgSettings = async () => {
|
||||
if (!validateOrgSettings()) return;
|
||||
// @submit fires only once the schema passes (both field names required + regex),
|
||||
// so the old validateOrgSettings()/error refs are gone. Awaited by OForm so the
|
||||
// inline Save button's spinner spans the POST.
|
||||
const saveOrgSettings = async (value: OrganizationSettingsForm) => {
|
||||
try {
|
||||
const payload: any = {
|
||||
trace_id_field_name: traceIdFieldName.value,
|
||||
span_id_field_name: spanIdFieldName.value,
|
||||
toggle_ingestion_logs: toggleIngestionLogs.value,
|
||||
trace_id_field_name: value.traceIdFieldName,
|
||||
span_id_field_name: value.spanIdFieldName,
|
||||
toggle_ingestion_logs: value.toggleIngestionLogs,
|
||||
cross_links: crossLinks.value,
|
||||
usage_stream_enabled: usageStreamEnabled.value,
|
||||
usage_stream_enabled: value.usageStreamEnabled,
|
||||
};
|
||||
|
||||
await organizations.post_organization_settings(
|
||||
@@ -198,11 +165,11 @@ const saveOrgSettings = async () => {
|
||||
|
||||
const updatedSettings: any = {
|
||||
...store.state?.organizationData?.organizationSettings,
|
||||
trace_id_field_name: traceIdFieldName.value,
|
||||
span_id_field_name: spanIdFieldName.value,
|
||||
toggle_ingestion_logs: toggleIngestionLogs.value,
|
||||
trace_id_field_name: value.traceIdFieldName,
|
||||
span_id_field_name: value.spanIdFieldName,
|
||||
toggle_ingestion_logs: value.toggleIngestionLogs,
|
||||
cross_links: crossLinks.value,
|
||||
usage_stream_enabled: usageStreamEnabled.value,
|
||||
usage_stream_enabled: value.usageStreamEnabled,
|
||||
};
|
||||
|
||||
store.dispatch("setOrganizationSettings", updatedSettings);
|
||||
@@ -220,4 +187,13 @@ const saveOrgSettings = async () => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Exposed for unit tests that exercise the submit handler directly.
|
||||
defineExpose({
|
||||
crossLinks,
|
||||
formDirty,
|
||||
saveOrgSettings,
|
||||
organizationSettingsSchema,
|
||||
organizationSettingsDefaults,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -3951,6 +3951,8 @@
|
||||
"saveChanges": "Speichern",
|
||||
"invalidDomain": "Bitte geben Sie einen gültigen Domainnamen ein",
|
||||
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse für diese Domain ein",
|
||||
"domainRequired": "Die Domain ist erforderlich",
|
||||
"emailRequired": "Die E-Mail-Adresse ist erforderlich",
|
||||
"domainAlreadyExists": "Diese Domain wurde bereits hinzugefügt",
|
||||
"domainAdded": "Domain wurde erfolgreich hinzugefügt",
|
||||
"confirmRemoveDomain": "Möchten Sie die Domain '{domain}' wirklich entfernen?",
|
||||
@@ -3967,6 +3969,7 @@
|
||||
"pageSubtitle": "Passen Sie die Einstellungen für Ihre Anwendung an",
|
||||
"platformSettings": "Plattform-Einstellungen",
|
||||
"scrapeIntervalRequired": "Das Scrape-Intervall ist erforderlich",
|
||||
"scrapeIntervalPositive": "Das Scrape-Intervall muss eine positive Zahl sein",
|
||||
"scrapeIntervalDescription": "Das Scrape-Intervall ist die Häufigkeit in Sekunden, mit der das Überwachungssystem Metriken erfasst.",
|
||||
"maxSeriesPerQueryLabel": "Max. Serien pro Abfrage",
|
||||
"maxSeriesPerQueryDescription": "Maximale Anzahl von Serien, die von einer einzelnen PromQL-Abfrage zurückgegeben werden können. Hilft, das Einfrieren der Benutzeroberfläche bei großen Ergebnismengen zu verhindern. Lassen Sie das Feld leer, um den Systemstandard (40.000) zu verwenden.",
|
||||
|
||||
@@ -3955,6 +3955,8 @@
|
||||
"saveChanges": "Save",
|
||||
"invalidDomain": "Please enter a valid domain name",
|
||||
"invalidEmail": "Please enter a valid email address for this domain",
|
||||
"domainRequired": "Domain is required",
|
||||
"emailRequired": "Email is required",
|
||||
"domainAlreadyExists": "This domain has already been added",
|
||||
"domainAdded": "Domain added successfully",
|
||||
"confirmRemoveDomain": "Are you sure you want to remove the domain '{domain}'?",
|
||||
@@ -3971,6 +3973,7 @@
|
||||
"pageSubtitle": "Adjust settings for your Application",
|
||||
"platformSettings": "Platform Settings",
|
||||
"scrapeIntervalRequired": "Scrape interval is required",
|
||||
"scrapeIntervalPositive": "Scrape interval must be a positive number",
|
||||
"scrapeIntervalDescription": "The scrape interval is the frequency, in seconds, at which the monitoring system collects metrics.",
|
||||
"maxSeriesPerQueryLabel": "Max Series Per Query",
|
||||
"maxSeriesPerQueryDescription": "Maximum number of series that can be returned from a single PromQL query. Helps prevent UI freezing with large result sets. Leave empty to use system default (40,000).",
|
||||
|
||||
@@ -3951,6 +3951,8 @@
|
||||
"saveChanges": "Guardar",
|
||||
"invalidDomain": "Introduce un nombre de dominio válido",
|
||||
"invalidEmail": "Introduce una dirección de correo electrónico válida para este dominio",
|
||||
"domainRequired": "Se requiere el dominio",
|
||||
"emailRequired": "Se requiere el correo electrónico",
|
||||
"domainAlreadyExists": "Este dominio ya se ha agregado",
|
||||
"domainAdded": "El dominio se agregó correctamente",
|
||||
"confirmRemoveDomain": "¿Estás seguro de que quieres eliminar el dominio '{domain}'?",
|
||||
@@ -3967,6 +3969,7 @@
|
||||
"pageSubtitle": "Ajusta la configuración de tu aplicación",
|
||||
"platformSettings": "Configuración de plataforma",
|
||||
"scrapeIntervalRequired": "Se requiere un intervalo de raspado",
|
||||
"scrapeIntervalPositive": "El intervalo de raspado debe ser un número positivo",
|
||||
"scrapeIntervalDescription": "El intervalo de raspado es la frecuencia, en segundos, con la que el sistema de monitoreo recopila las métricas.",
|
||||
"maxSeriesPerQueryLabel": "Número máximo de series por consulta",
|
||||
"maxSeriesPerQueryDescription": "Número máximo de series que se pueden devolver desde una sola consulta de PromQL. Ayuda a evitar que la interfaz de usuario se bloquee con conjuntos de resultados grandes. Déjelo vacío para usar los valores predeterminados del sistema (40 000).",
|
||||
|
||||
@@ -3951,6 +3951,8 @@
|
||||
"saveChanges": "Enregistrer",
|
||||
"invalidDomain": "Veuillez saisir un nom de domaine valide",
|
||||
"invalidEmail": "Veuillez saisir une adresse e-mail valide pour ce domaine",
|
||||
"domainRequired": "Le domaine est requis",
|
||||
"emailRequired": "L'adresse e-mail est requise",
|
||||
"domainAlreadyExists": "Ce domaine a déjà été ajouté",
|
||||
"domainAdded": "Domaine ajouté avec succès",
|
||||
"confirmRemoveDomain": "Êtes-vous sûr de vouloir supprimer le domaine « {domain} » ?",
|
||||
@@ -3967,6 +3969,7 @@
|
||||
"pageSubtitle": "Ajustez les paramètres de votre application",
|
||||
"platformSettings": "Paramètres de la plateforme",
|
||||
"scrapeIntervalRequired": "Un intervalle de raclage est requis",
|
||||
"scrapeIntervalPositive": "L'intervalle de raclage doit être un nombre positif",
|
||||
"scrapeIntervalDescription": "L'intervalle de scrape est la fréquence, en secondes, à laquelle le système de surveillance collecte les métriques.",
|
||||
"maxSeriesPerQueryLabel": "Nombre maximum de séries par requête",
|
||||
"maxSeriesPerQueryDescription": "Nombre maximum de séries pouvant être renvoyées à partir d'une seule requête ProMQL. Aide à prévenir le gel de l'interface utilisateur en cas de grands ensembles de résultats. Laissez ce champ vide pour utiliser la valeur par défaut du système (40 000).",
|
||||
|
||||
@@ -2981,6 +2981,8 @@
|
||||
"saveChanges": "Save",
|
||||
"invalidDomain": "Please enter a valid domain name",
|
||||
"invalidEmail": "Please enter a valid email address for this domain",
|
||||
"domainRequired": "Domain is required",
|
||||
"emailRequired": "Email is required",
|
||||
"domainAlreadyExists": "This domain has already been added",
|
||||
"domainAdded": "Domain added successfully",
|
||||
"confirmRemoveDomain": "Are you sure you want to remove the domain '{domain}'?",
|
||||
@@ -2997,6 +2999,7 @@
|
||||
"pageSubtitle": "Adjust settings for your Application",
|
||||
"platformSettings": "Platform Settings",
|
||||
"scrapeIntervalRequired": "Scrape interval is required",
|
||||
"scrapeIntervalPositive": "Scrape interval must be a positive number",
|
||||
"scrapeIntervalDescription": "The scrape interval is the frequency, in seconds, at which the monitoring system collects metrics.",
|
||||
"maxSeriesPerQueryLabel": "Max Series Per Query",
|
||||
"maxSeriesPerQueryDescription": "Maximum number of series that can be returned from a single PromQL query. Helps prevent UI freezing with large result sets. Leave empty to use system default (40,000).",
|
||||
|
||||
@@ -3951,6 +3951,8 @@
|
||||
"saveChanges": "Salva",
|
||||
"invalidDomain": "Inserisci un nome di dominio valido",
|
||||
"invalidEmail": "Inserisci un indirizzo email valido per questo dominio",
|
||||
"domainRequired": "È richiesto il dominio",
|
||||
"emailRequired": "È richiesto l'indirizzo email",
|
||||
"domainAlreadyExists": "Questo dominio è già stato aggiunto",
|
||||
"domainAdded": "Dominio aggiunto con successo",
|
||||
"confirmRemoveDomain": "Sei sicuro di voler rimuovere il dominio '{domain}'?",
|
||||
@@ -3967,6 +3969,7 @@
|
||||
"pageSubtitle": "Modifica le impostazioni per la tua applicazione",
|
||||
"platformSettings": "Impostazioni della piattaforma",
|
||||
"scrapeIntervalRequired": "È richiesto l'intervallo di raschiatura",
|
||||
"scrapeIntervalPositive": "L'intervallo di raschiatura deve essere un numero positivo",
|
||||
"scrapeIntervalDescription": "L'intervallo di scrape è la frequenza, in secondi, con cui il sistema di monitoraggio raccoglie le metriche.",
|
||||
"maxSeriesPerQueryLabel": "Serie massima per query",
|
||||
"maxSeriesPerQueryDescription": "Numero massimo di serie che possono essere restituite da una singola query ProMQL. Aiuta a prevenire il blocco dell'interfaccia utente con set di risultati di grandi dimensioni. Lascia vuoto per utilizzare l'impostazione predefinita del sistema (40.000).",
|
||||
|
||||
@@ -3951,6 +3951,8 @@
|
||||
"saveChanges": "[保存]",
|
||||
"invalidDomain": "有効なドメイン名を入力してください",
|
||||
"invalidEmail": "このドメインの有効なメールアドレスを入力してください",
|
||||
"domainRequired": "ドメインが必要です",
|
||||
"emailRequired": "メールアドレスが必要です",
|
||||
"domainAlreadyExists": "このドメインは既に追加されています",
|
||||
"domainAdded": "ドメインは正常に追加されました",
|
||||
"confirmRemoveDomain": "ドメイン '{domain}' を削除してよろしいですか?",
|
||||
@@ -3967,6 +3969,7 @@
|
||||
"pageSubtitle": "アプリケーションの設定を調整する",
|
||||
"platformSettings": "プラットフォーム設定",
|
||||
"scrapeIntervalRequired": "スクレイプ間隔が必要です",
|
||||
"scrapeIntervalPositive": "スクレイプ間隔は正の数でなければなりません",
|
||||
"scrapeIntervalDescription": "スクレイプ間隔は、モニタリングシステムがメトリックを収集する頻度(秒単位)です。",
|
||||
"maxSeriesPerQueryLabel": "クエリあたりの最大シリーズ数",
|
||||
"maxSeriesPerQueryDescription": "1 つの PromQL クエリから返されるシリーズの最大数。結果セットが大きい場合に UI がフリーズするのを防ぐのに役立ちます。システムデフォルト (40,000) を使用するには空のままにします。",
|
||||
@@ -5328,13 +5331,13 @@
|
||||
"loading": "オンライン評価設定をロード中...",
|
||||
"loadError": "オンライン評価を読み込めませんでした",
|
||||
"deletePrompt": "{name} を削除しますか?",
|
||||
"deleteTitle": "{ラベル} を削除",
|
||||
"deleteTitle": "{label} を削除",
|
||||
"deleteConfirmMessage": "「{name}」を削除してよろしいですか?このアクションは元に戻せません。",
|
||||
"deleted": "{ラベル} は削除されました",
|
||||
"deleted": "{label} は削除されました",
|
||||
"deleteError": "{label} を削除できませんでした",
|
||||
"saved": "{label} が保存されました",
|
||||
"newButton": "新規 {ラベル}",
|
||||
"search": "検索 {ラベル}",
|
||||
"newButton": "新規 {label}",
|
||||
"search": "検索 {label}",
|
||||
"refresh": "リフレッシュ",
|
||||
"allStatuses": "すべてのステータス",
|
||||
"noRowsFound": "{label} が見つかりません",
|
||||
|
||||
@@ -3951,6 +3951,8 @@
|
||||
"saveChanges": "저장",
|
||||
"invalidDomain": "유효한 도메인 이름을 입력하세요",
|
||||
"invalidEmail": "이 도메인의 유효한 이메일 주소를 입력하십시오.",
|
||||
"domainRequired": "도메인이 필요합니다.",
|
||||
"emailRequired": "이메일 주소가 필요합니다.",
|
||||
"domainAlreadyExists": "이 도메인은 이미 추가되었습니다",
|
||||
"domainAdded": "도메인이 성공적으로 추가되었습니다.",
|
||||
"confirmRemoveDomain": "'{domain}' 도메인을 정말로 제거하시겠습니까?",
|
||||
@@ -3967,6 +3969,7 @@
|
||||
"pageSubtitle": "애플리케이션 설정 조정",
|
||||
"platformSettings": "플랫폼 설정",
|
||||
"scrapeIntervalRequired": "스크래핑 간격이 필요합니다.",
|
||||
"scrapeIntervalPositive": "스크래핑 간격은 양수여야 합니다.",
|
||||
"scrapeIntervalDescription": "스크랩 간격은 모니터링 시스템이 메트릭을 수집하는 빈도 (초) 입니다.",
|
||||
"maxSeriesPerQueryLabel": "쿼리당 최대 시리즈 수",
|
||||
"maxSeriesPerQueryDescription": "단일 PromQL 쿼리에서 반환할 수 있는 최대 시리즈 수입니다.결과 집합이 클 때 UI가 멈추는 것을 방지하는 데 도움이 됩니다.시스템 기본값 (40,000) 을 사용하려면 비워 두십시오.",
|
||||
@@ -5328,16 +5331,16 @@
|
||||
"loading": "온라인 평가 구성을 로드하는 중...",
|
||||
"loadError": "온라인 평가를 로드하지 못했습니다.",
|
||||
"deletePrompt": "{name} 을 (를) 삭제하시겠습니까?",
|
||||
"deleteTitle": "{라벨} 삭제",
|
||||
"deleteTitle": "{label} 삭제",
|
||||
"deleteConfirmMessage": "정말로 “{name}”을 삭제하시겠습니까?이 작업은 취소할 수 없습니다.",
|
||||
"deleted": "{라벨} 삭제됨",
|
||||
"deleted": "{label} 삭제됨",
|
||||
"deleteError": "{label} 을 (를) 삭제하지 못했습니다.",
|
||||
"saved": "{라벨} 저장됨",
|
||||
"newButton": "새 {라벨}",
|
||||
"search": "{라벨} 검색",
|
||||
"saved": "{label} 저장됨",
|
||||
"newButton": "새 {label}",
|
||||
"search": "{label} 검색",
|
||||
"refresh": "새로 고침",
|
||||
"allStatuses": "모든 상태",
|
||||
"noRowsFound": "{라벨} 을 (를) 찾을 수 없습니다.",
|
||||
"noRowsFound": "{label} 을 (를) 찾을 수 없습니다.",
|
||||
"getStartedHint": "하나를 생성하여 온라인 배선 평가를 시작하십시오.",
|
||||
"columns": {
|
||||
"name": "이름",
|
||||
|
||||
@@ -3951,6 +3951,8 @@
|
||||
"saveChanges": "Opslaan",
|
||||
"invalidDomain": "Voer een geldige domeinnaam in",
|
||||
"invalidEmail": "Voer een geldig e-mailadres in voor dit domein",
|
||||
"domainRequired": "Domein is vereist",
|
||||
"emailRequired": "E-mailadres is vereist",
|
||||
"domainAlreadyExists": "Dit domein is al toegevoegd",
|
||||
"domainAdded": "Domein succesvol toegevoegd",
|
||||
"confirmRemoveDomain": "Weet je zeker dat je het domein '{domain}' wilt verwijderen?",
|
||||
@@ -3967,6 +3969,7 @@
|
||||
"pageSubtitle": "Instellingen voor uw toepassing aanpassen",
|
||||
"platformSettings": "Platform-instellingen",
|
||||
"scrapeIntervalRequired": "Schraapinterval is vereist",
|
||||
"scrapeIntervalPositive": "Het schraapinterval moet een positief getal zijn",
|
||||
"scrapeIntervalDescription": "Het schraapinterval is de frequentie, in seconden, waarmee het bewakingssysteem statistieken verzamelt.",
|
||||
"maxSeriesPerQueryLabel": "Max. aantal reeksen per zoekopdracht",
|
||||
"maxSeriesPerQueryDescription": "Maximaal aantal series dat kan worden geretourneerd uit een enkele ProMQL-query. Helpt bij grote resultatensets te voorkomen dat de gebruikersinterface vastloopt. Laat dit veld leeg om de systeemstandaard te gebruiken (40.000).",
|
||||
|
||||
@@ -3951,6 +3951,8 @@
|
||||
"saveChanges": "Salvar",
|
||||
"invalidDomain": "Insira um nome de domínio válido",
|
||||
"invalidEmail": "Insira um endereço de e-mail válido para este domínio",
|
||||
"domainRequired": "O domínio é necessário",
|
||||
"emailRequired": "O e-mail é necessário",
|
||||
"domainAlreadyExists": "Este domínio já foi adicionado",
|
||||
"domainAdded": "Domínio adicionado com sucesso",
|
||||
"confirmRemoveDomain": "Tem certeza de que deseja remover o domínio '{domain}'?",
|
||||
@@ -3967,6 +3969,7 @@
|
||||
"pageSubtitle": "Ajuste as configurações do seu aplicativo",
|
||||
"platformSettings": "Configurações da plataforma",
|
||||
"scrapeIntervalRequired": "O intervalo de raspagem é necessário",
|
||||
"scrapeIntervalPositive": "O intervalo de raspagem deve ser um número positivo",
|
||||
"scrapeIntervalDescription": "O intervalo de raspagem é a frequência, em segundos, na qual o sistema de monitoramento coleta métricas.",
|
||||
"maxSeriesPerQueryLabel": "Série máxima por consulta",
|
||||
"maxSeriesPerQueryDescription": "Número máximo de séries que podem ser retornadas de uma única consulta ProMQL. Ajuda a evitar o congelamento da interface com grandes conjuntos de resultados. Deixe em branco para usar o padrão do sistema (40.000).",
|
||||
|
||||
@@ -3951,6 +3951,8 @@
|
||||
"saveChanges": "Kaydet",
|
||||
"invalidDomain": "Lütfen geçerli bir alan adı girin",
|
||||
"invalidEmail": "Lütfen bu alan adı için geçerli bir e-posta adresi girin",
|
||||
"domainRequired": "Alan adı gereklidir",
|
||||
"emailRequired": "E-posta adresi gereklidir",
|
||||
"domainAlreadyExists": "Bu alan adı zaten eklendi",
|
||||
"domainAdded": "Etki alanı başarıyla eklendi",
|
||||
"confirmRemoveDomain": "'{ domain} 'alan adını kaldırmak istediğinizden emin misiniz?",
|
||||
@@ -3967,6 +3969,7 @@
|
||||
"pageSubtitle": "Uygulamanızın ayarlarını ayarlama",
|
||||
"platformSettings": "Platform Ayarları",
|
||||
"scrapeIntervalRequired": "Kazıma aralığı gereklidir",
|
||||
"scrapeIntervalPositive": "Kazıma aralığı pozitif bir sayı olmalıdır",
|
||||
"scrapeIntervalDescription": "Kazıma aralığı, izleme sisteminin metrikleri topladığı saniye cinsinden sıklıktır.",
|
||||
"maxSeriesPerQueryLabel": "Sorgu Başına Maksimum Seri",
|
||||
"maxSeriesPerQueryDescription": "Tek bir PromQL sorgusundan döndürülebilecek maksimum seri sayısı. Büyük sonuç kümeleriyle kullanıcı arayüzünün donmasını önlemeye yardımcı olur. Sistem varsayılanını kullanmak için boş bırakın (40.000).",
|
||||
|
||||
@@ -3951,6 +3951,8 @@
|
||||
"saveChanges": "保存",
|
||||
"invalidDomain": "请输入有效的域名",
|
||||
"invalidEmail": "请输入该域的有效电子邮件地址",
|
||||
"domainRequired": "需要域名",
|
||||
"emailRequired": "需要电子邮件地址",
|
||||
"domainAlreadyExists": "此域已被添加",
|
||||
"domainAdded": "域名已成功添加",
|
||||
"confirmRemoveDomain": "您确定要移除域名 '{domain}' 吗?",
|
||||
@@ -3967,6 +3969,7 @@
|
||||
"pageSubtitle": "调整应用程序的设置",
|
||||
"platformSettings": "平台设置",
|
||||
"scrapeIntervalRequired": "需要抓取间隔",
|
||||
"scrapeIntervalPositive": "抓取间隔必须为正数",
|
||||
"scrapeIntervalDescription": "抓取间隔是监控系统收集指标的频率,以秒为单位。",
|
||||
"maxSeriesPerQueryLabel": "每个查询的最大序列数",
|
||||
"maxSeriesPerQueryDescription": "单个 PromQL 查询可以返回的最大序列数。使用大型结果集有助于防止界面冻结。留空以使用系统默认值 (40,000)。",
|
||||
@@ -5241,7 +5244,7 @@
|
||||
"nameTooLong": "型号名称必须少于 256 个字符",
|
||||
"patternRequired": "匹配模式为必填项",
|
||||
"patternTooLong": "匹配模式必须少于 512 个字符",
|
||||
"invalidRegex": "无效的正则表达式:{错误}",
|
||||
"invalidRegex": "无效的正则表达式:{error}",
|
||||
"usageKeyPureInteger": "用法键不能是纯整数",
|
||||
"usageKeyContainsSpaces": "使用密钥不得包含空格",
|
||||
"tierPriceMissingKey": "等级 “{name}” 的价格没有使用密钥。添加使用密钥(例如 “输入”)或使用快速设置模板。",
|
||||
@@ -5327,17 +5330,17 @@
|
||||
"description": "配置 LLM 提供商、分数定义、评分器和实时评测任务。",
|
||||
"loading": "正在加载在线评估配置...",
|
||||
"loadError": "加载在线评估失败",
|
||||
"deletePrompt": "删除 {姓名}?",
|
||||
"deleteTitle": "删除 {标签}",
|
||||
"deletePrompt": "删除 {name}?",
|
||||
"deleteTitle": "删除 {label}",
|
||||
"deleteConfirmMessage": "您确定要删除 “{name}” 吗?此操作无法撤消。",
|
||||
"deleted": "{label} 已删除",
|
||||
"deleteError": "删除 {label} 失败",
|
||||
"saved": "{label} 已保存",
|
||||
"newButton": "新的 {标签}",
|
||||
"search": "搜索 {标签}",
|
||||
"newButton": "新的 {label}",
|
||||
"search": "搜索 {label}",
|
||||
"refresh": "刷新",
|
||||
"allStatuses": "所有状态",
|
||||
"noRowsFound": "未找到 {标签}",
|
||||
"noRowsFound": "未找到 {label}",
|
||||
"getStartedHint": "创建一个,开始接线在线评估。",
|
||||
"columns": {
|
||||
"name": "姓名",
|
||||
|
||||
@@ -3329,6 +3329,8 @@
|
||||
"saveChanges": "儲存",
|
||||
"invalidDomain": "請輸入有效的網域名稱",
|
||||
"invalidEmail": "請輸入此網域的有效電子郵件地址",
|
||||
"domainRequired": "網域為必填",
|
||||
"emailRequired": "電子郵件地址為必填",
|
||||
"domainAlreadyExists": "此網域已被新增",
|
||||
"domainAdded": "網域新增成功",
|
||||
"confirmRemoveDomain": "確定要移除網域 '{domain}' 嗎?",
|
||||
@@ -3345,6 +3347,7 @@
|
||||
"pageSubtitle": "調整應用程式的設定",
|
||||
"platformSettings": "平台設定",
|
||||
"scrapeIntervalRequired": "擷取間隔為必填",
|
||||
"scrapeIntervalPositive": "擷取間隔必須為正數",
|
||||
"scrapeIntervalDescription": "擷取間隔是監控系統收集指標的頻率 (以秒為單位)。",
|
||||
"maxSeriesPerQueryLabel": "每次查詢最大系列數",
|
||||
"maxSeriesPerQueryDescription": "單一 PromQL 查詢可傳回的最大系列數。有助於防止大型結果集導致 UI 凍結。留空以使用系統預設值 (40,000)。",
|
||||
|
||||
Reference in New Issue
Block a user