diff --git a/src/ui/src/components/core/content/CoreLink.vue b/src/ui/src/components/core/content/CoreLink.vue index 83b4a4f4..d35828d9 100644 --- a/src/ui/src/components/core/content/CoreLink.vue +++ b/src/ui/src/components/core/content/CoreLink.vue @@ -25,7 +25,6 @@ export default { type: FieldType.Text, default: "https://writer.com", desc: "Specify a URL or choose a page. Keep in mind that you can only link to pages for which a key has been specified.", - // TODO: build dynamic schema options: (wf: Core) => { return Object.fromEntries( wf diff --git a/src/ui/src/constants/validator.spec.ts b/src/ui/src/constants/validator.spec.ts new file mode 100644 index 00000000..fd2ebaf0 --- /dev/null +++ b/src/ui/src/constants/validator.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + getJsonSchemaValidator, + validatorCssClassname, + validatorCssSize, +} from "./validators"; + +describe("validators", () => { + describe("CSS Classname", () => { + const validator = getJsonSchemaValidator(validatorCssClassname); + + it.each([ + "", + "foo bar", + "foo", + "foo bar baz", + " foo bar ", + "foo123 bar_baz", + "class1 class2", + "foo-bar", + "foo bar-baz", + ])("should be valid class names: %s", (value) => { + expect(validator(value)).toBe(true); + }); + + it.each(["123", "-abc"])( + "should be invalid class names: %s", + (value) => { + expect(validator(value)).toBe(false); + }, + ); + }); + + describe("CSS size", () => { + const validator = getJsonSchemaValidator(validatorCssSize); + + it.each(["", "12px", "1rem", "2vh"])( + "should be valid size: %s", + (value) => { + expect(validator(value)).toBe(true); + }, + ); + + it.each(["px", "vh"])("should be invalid class names: %s", (value) => { + expect(validator(value)).toBe(false); + }); + }); +}); diff --git a/src/ui/src/constants/validators.ts b/src/ui/src/constants/validators.ts index ea8045c6..57e251f0 100644 --- a/src/ui/src/constants/validators.ts +++ b/src/ui/src/constants/validators.ts @@ -1,6 +1,17 @@ import injectionKeys from "@/injectionKeys"; import { Ajv, Format, type SchemaObject } from "ajv"; -import { inject, provide } from "vue"; +import { inject } from "vue"; + +export enum ValidatorCustomFormat { + /** + * Check that the workflow key is existing + */ + WriterWorkflowKey = "writer#workflowKey", + CssClassnames = "cssClassnames", + CssSize = "cssSize", + Uri = "uri", + Uuid = "uuid", +} /** * We use an URL to define the `$id` of the schema. The URL doesn't have to exist, it's only used for caching. @@ -33,14 +44,13 @@ export function buildJsonSchemaForNumberBetween( export const validatorCssClassname: SchemaObject = { $id: generateSchemaId("cssClassname"), type: "string", - pattern: "(^[a-zA-Z_][a-zA-Z0-9_-]*$)|(^$)", + format: ValidatorCustomFormat.CssClassnames, }; export const validatorCssSize: SchemaObject = { $id: generateSchemaId("cssSize"), type: "string", - pattern: - "(^([+-]?\\d*\\.?\\d+)(px|em|%|vh|vw|rem|pt|pc|in|cm|mm|ex|ch|vmin|vmax|fr)$)|(^$)", + format: ValidatorCustomFormat.CssSize, }; export const validatorEnumYesNo: SchemaObject = buildJsonSchemaForEnum([ @@ -157,15 +167,6 @@ export function getJsonSchemaValidator(schema: SchemaObject) { // custom formats -export enum ValidatorCustomFormat { - /** - * Check that the workflow key is existing - */ - WriterWorkflowKey = "writer#workflowKey", - Uri = "uri", - Uuid = "uuid", -} - export const validatorCustomSchemas: Record< ValidatorCustomFormat, { format: Format; errorMessage: string } @@ -178,6 +179,14 @@ export const validatorCustomSchemas: Record< format: /^(?:urn:uuid:)?[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i, errorMessage: "must be a valid UUID", }, + [ValidatorCustomFormat.CssClassnames]: { + format: /(^\s*[a-zA-Z_][-\w]*(\s+[a-zA-Z_][-\w]*)*\s*$)|(^$)/, + errorMessage: "must be a valid list of CSS classes separated by spaces", + }, + [ValidatorCustomFormat.CssSize]: { + format: /(^([+-]?\d*\.?\d+)(px|em|%|vh|vw|rem|pt|pc|in|cm|mm|ex|ch|vmin|vmax|fr)$)|(^$)/, + errorMessage: "must be a valid CSS size", + }, [ValidatorCustomFormat.WriterWorkflowKey]: { format: { type: "string", diff --git a/src/ui/src/renderer/useFieldsErrors.spec.ts b/src/ui/src/renderer/useFieldsErrors.spec.ts index a9c369c6..286e2f9c 100644 --- a/src/ui/src/renderer/useFieldsErrors.spec.ts +++ b/src/ui/src/renderer/useFieldsErrors.spec.ts @@ -2,7 +2,12 @@ import { describe, vi, it, expect, beforeAll } from "vitest"; import { useFieldsErrors } from "./useFieldsErrors"; import { computed, ref } from "vue"; import { generateCore } from "@/core"; -import { Core, FieldType, InstancePath } from "@/writerTypes"; +import { + Core, + FieldType, + InstancePath, + WriterComponentDefinition, +} from "@/writerTypes"; import { validatorCustomSchemas } from "@/constants/validators"; const getEvaluatedFields = vi.fn(); @@ -20,15 +25,20 @@ describe(useFieldsErrors.name, () => { ]); let core: Core; + const dummyComponent: WriterComponentDefinition = { + name: "dummmy component", + description: "", + }; + beforeAll(() => { core = generateCore(); - vi.spyOn(core, "getComponentById").mockReturnValue({} as any); + // @ts-expect-error return a dummy mock + vi.spyOn(core, "getComponentById").mockReturnValue({}); }); it("should validate a field as number", () => { vi.spyOn(core, "getComponentDefinition").mockReturnValue({ - name: "dummmy component", - description: "", + ...dummyComponent, fields: { value: { name: "value", @@ -54,8 +64,7 @@ describe(useFieldsErrors.name, () => { it("should validate a field as string", () => { vi.spyOn(core, "getComponentDefinition").mockReturnValue({ - name: "dummmy component", - description: "", + ...dummyComponent, fields: { value: { name: "value", @@ -80,4 +89,33 @@ describe(useFieldsErrors.name, () => { expect(errors.value).toStrictEqual({ value: undefined }); }); + + it("should validate a field as options", () => { + vi.spyOn(core, "getComponentDefinition").mockReturnValue({ + ...dummyComponent, + fields: { + value: { + name: "value", + type: FieldType.Text, + options: { + a: "A", + b: "B", + c: "C", + }, + }, + }, + }); + + const value = ref("test"); + getEvaluatedFields.mockReturnValue({ value }); + + const errors = useFieldsErrors(core, instancePath); + expect(errors.value).toStrictEqual({ + value: "must be equal to one of the allowed values: a,b,c", + }); + + value.value = "a"; + + expect(errors.value).toStrictEqual({ value: undefined }); + }); }); diff --git a/src/ui/src/renderer/useFieldsErrors.ts b/src/ui/src/renderer/useFieldsErrors.ts index 0eafe394..251201e6 100644 --- a/src/ui/src/renderer/useFieldsErrors.ts +++ b/src/ui/src/renderer/useFieldsErrors.ts @@ -51,8 +51,9 @@ function computeFieldErrors( if ( schema === undefined && - Array.isArray(field.options) && - field.options.length > 0 + typeof field.options === "object" && + field.options !== null && + Object.keys(field.options).length > 0 ) { // set an automatic enum schema for options fields schema = buildJsonSchemaForEnum(Object.keys(field.options)); diff --git a/tests/e2e/tests/builderFieldValidation.spec.ts b/tests/e2e/tests/builderFieldValidation.spec.ts new file mode 100644 index 00000000..dbbec0ca --- /dev/null +++ b/tests/e2e/tests/builderFieldValidation.spec.ts @@ -0,0 +1,86 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Builder field validation", () => { + let url: string; + + test.beforeAll(async ({ request }) => { + const response = await request.post(`/preset/section`); + expect(response.ok()).toBeTruthy(); + ({ url } = await response.json()); + }); + + test.afterAll(async ({ request }) => { + await request.delete(url); + }); + + test.beforeEach(async ({ page }) => { + await page.goto(url, { waitUntil: "domcontentloaded" }); + test.setTimeout(5000); + }); + + test("should display error for invalid button fields", async ({ page }) => { + await page + .locator(`.BuilderSidebarToolkit [data-component-type="button"]`) + .dragTo(page.locator(".CoreSection")); + await page.locator(`button.CoreButton.component`).click(); + + // is disabled + + const isDisabledInput = page.locator( + '.BuilderFieldsText[data-automation-key="isDisabled"] input', + ); + + await isDisabledInput.fill("maybe"); + expect(await isDisabledInput.getAttribute("aria-invalid")).toBe("true"); + + await isDisabledInput.fill("yes"); + expect(await isDisabledInput.getAttribute("aria-invalid")).toBe("false"); + + // css classes + + const cssClasses = page.locator( + '.BuilderFieldsText[data-automation-key="cssClasses"] input', + ); + await cssClasses.fill("1234"); + expect(await cssClasses.getAttribute("aria-invalid")).toBe("true"); + + await cssClasses.fill("class1 class2"); + expect(await cssClasses.getAttribute("aria-invalid")).toBe("false"); + }); + + test("should display error for invalid multiselectinput fields", async ({ + page, + }) => { + await page + .locator( + `.BuilderSidebarToolkit [data-component-type="multiselectinput"]`, + ) + .dragTo(page.locator(".CoreSection")); + await page.locator(`.CoreMultiselectInput.component`).click(); + + // maximum count + + const maximunCountInput = page.locator( + '.BuilderFieldsText[data-automation-key="maximumCount"] input', + ); + + await maximunCountInput.fill("-1"); + expect(await maximunCountInput.getAttribute("aria-invalid")).toBe("true"); + + await maximunCountInput.fill("2"); + expect(await maximunCountInput.getAttribute("aria-invalid")).toBe("false"); + + // options + + await page.locator(".BuilderFieldsOptions button").nth(1).click(); + + const optionsTextarea = page.locator( + '.BuilderFieldsObject[data-automation-key="options"] textarea', + ); + await optionsTextarea.fill(JSON.stringify(true)); + expect(await optionsTextarea.getAttribute("aria-invalid")).toBe("true"); + + await optionsTextarea.fill(JSON.stringify({ a: "A", b: "B" })); + expect(await optionsTextarea.getAttribute("aria-invalid")).toBe("false"); + }); +});