diff --git a/.changeset/lazy-chairs-glow.md b/.changeset/lazy-chairs-glow.md new file mode 100644 index 000000000..a623f24e7 --- /dev/null +++ b/.changeset/lazy-chairs-glow.md @@ -0,0 +1,5 @@ +--- +"@effect-app/vue-components": patch +--- + +the fix for integers diff --git a/packages/vue-components/__tests__/IntegerValidation.test.ts b/packages/vue-components/__tests__/IntegerValidation.test.ts new file mode 100644 index 000000000..176535431 --- /dev/null +++ b/packages/vue-components/__tests__/IntegerValidation.test.ts @@ -0,0 +1,82 @@ +import { S } from "effect-app" +import { describe, expect, it } from "vitest" +import { generateInputStandardSchemaFromFieldMeta, generateMetaFromSchema } from "../src/components/OmegaForm/OmegaFormStuff" + +// mock German translations +const germanTranslations: Record = { + "validation.integer.expected": "Es wird eine ganze Zahl erwartet, tatsächlich: {actualValue}", + "validation.number.expected": "Es wird eine Zahl erwartet, tatsächlich: {actualValue}", + "validation.empty": "Das Feld darf nicht leer sein" +} + +const mockTrans = (id: string, values?: Record) => { + let text = germanTranslations[id] || id + if (values) { + Object.entries(values).forEach(([key, value]) => { + text = text.replace(`{${key}}`, String(value)) + }) + } + return text +} + +describe("Integer validation with German translations", () => { + it("should generate int metadata for S.Int fields", () => { + const TestSchema = S.Struct({ + value: S.Int + }) + + const { meta } = generateMetaFromSchema(TestSchema) + console.log("Meta:", JSON.stringify(meta, null, 2)) + + expect(meta.value?.type).toBe("int") + }) + + it("should show German error for decimal values", async () => { + const TestSchema = S.Struct({ + value: S.Int + }) + + const { meta } = generateMetaFromSchema(TestSchema) + const schema = generateInputStandardSchemaFromFieldMeta(meta.value!, mockTrans) + + // test with a decimal value + const result = await schema["~standard"].validate(59.5) + console.log("Validation result for 59.5:", JSON.stringify(result, null, 2)) + + expect(result.issues).toBeDefined() + expect(result.issues?.[0]?.message).toContain("ganze Zahl") + }) + + it("should show German error for undefined values", async () => { + const TestSchema = S.Struct({ + value: S.Int + }) + + const { meta } = generateMetaFromSchema(TestSchema) + const schema = generateInputStandardSchemaFromFieldMeta(meta.value!, mockTrans) + + // test with undefined value + const result = await schema["~standard"].validate(undefined) + console.log("Validation result for undefined:", JSON.stringify(result, null, 2)) + + expect(result.issues).toBeDefined() + // should be German empty message + expect(result.issues?.[0]?.message).toBe("Das Feld darf nicht leer sein") + }) + + it("should accept valid integer values", async () => { + const TestSchema = S.Struct({ + value: S.Int + }) + + const { meta } = generateMetaFromSchema(TestSchema) + const schema = generateInputStandardSchemaFromFieldMeta(meta.value!, mockTrans) + + // test with a valid integer + const result = await schema["~standard"].validate(59) + console.log("Validation result for 59:", JSON.stringify(result, null, 2)) + + expect(result.issues).toBeUndefined() + expect("value" in result && result.value).toBe(59) + }) +}) diff --git a/packages/vue-components/src/components/OmegaForm/OmegaFormStuff.ts b/packages/vue-components/src/components/OmegaForm/OmegaFormStuff.ts index 2004a6d79..27945aa70 100644 --- a/packages/vue-components/src/components/OmegaForm/OmegaFormStuff.ts +++ b/packages/vue-components/src/components/OmegaForm/OmegaFormStuff.ts @@ -246,7 +246,7 @@ export type StringFieldMeta = BaseFieldMeta & { } export type NumberFieldMeta = BaseFieldMeta & { - type: "number" + type: "number" | "int" minimum?: number maximum?: number exclusiveMinimum?: number @@ -346,12 +346,23 @@ export const createMeta = ( ): MetaRecord | FieldMeta => { // unwraps class (Class are transformations) // this calls createMeta recursively, so wrapped transformations are also unwrapped + // BUT: check for Int title annotation first - S.Int and branded Int have title "Int" or "int" + // and we don't want to lose that information by unwrapping if (property && property._tag === "Transformation") { - return createMeta({ - parent, - meta, - property: property.from - }) + const titleOnTransform = S + .AST + .getAnnotation(property, S.AST.TitleAnnotationId) + .pipe(Option.getOrElse(() => "")) + + // only unwrap if this is NOT an Int type + if (titleOnTransform !== "Int" && titleOnTransform !== "int") { + return createMeta({ + parent, + meta, + property: property.from + }) + } + // if it's Int, fall through to process it with the Int type } if (property?._tag === "TypeLiteral" && "propertySignatures" in property) { @@ -655,24 +666,32 @@ export const createMeta = ( meta = { ...JSONAnnotation, ...meta } - if ("from" in property) { + // check the title annotation BEFORE following "from" to detect refinements like S.Int + const titleType = S + .AST + .getAnnotation( + property, + S.AST.TitleAnnotationId + ) + .pipe( + Option.getOrElse(() => { + return "unknown" + }) + ) + + // if this is S.Int (a refinement), set the type and skip following "from" + // otherwise we'd lose the "Int" information and get "number" instead + if (titleType === "Int" || titleType === "int") { + meta["type"] = "int" + // don't follow "from" for Int refinements + } else if ("from" in property) { return createMeta({ parent, meta, property: property.from }) } else { - meta["type"] = S - .AST - .getAnnotation( - property, - S.AST.TitleAnnotationId - ) - .pipe( - Option.getOrElse(() => { - return "unknown" - }) - ) + meta["type"] = titleType } return meta as FieldMeta @@ -869,9 +888,59 @@ export const generateInputStandardSchemaFromFieldMeta = ( } break + case "int": { + // create a custom integer schema with translations + // S.Number with empty message, then S.int with integer message + schema = S + .Number + .annotations({ + message: () => trans("validation.empty") + }) + .pipe( + S.int({ message: (issue) => trans("validation.integer.expected", { actualValue: String(issue.actual) }) }) + ) + if (typeof meta.minimum === "number") { + schema = schema.pipe(S.greaterThanOrEqualTo(meta.minimum)).annotations({ + message: () => + trans(meta.minimum === 0 ? "validation.number.positive" : "validation.number.min", { + minimum: meta.minimum, + isExclusive: true + }) + }) + } + if (typeof meta.maximum === "number") { + schema = schema.pipe(S.lessThanOrEqualTo(meta.maximum)).annotations({ + message: () => + trans("validation.number.max", { + maximum: meta.maximum, + isExclusive: true + }) + }) + } + if (typeof meta.exclusiveMinimum === "number") { + schema = schema.pipe(S.greaterThan(meta.exclusiveMinimum)).annotations({ + message: () => + trans(meta.exclusiveMinimum === 0 ? "validation.number.positive" : "validation.number.min", { + minimum: meta.exclusiveMinimum, + isExclusive: false + }) + }) + } + if (typeof meta.exclusiveMaximum === "number") { + schema = schema.pipe(S.lessThan(meta.exclusiveMaximum)).annotations({ + message: () => + trans("validation.number.max", { + maximum: meta.exclusiveMaximum, + isExclusive: false + }) + }) + } + break + } + case "number": schema = S.Number.annotations({ - message: () => trans("validation.empty") + message: () => trans("validation.number.expected", { actualValue: "NaN" }) }) if (meta.required) { diff --git a/packages/vue-components/src/components/OmegaForm/OmegaInputVuetify.vue b/packages/vue-components/src/components/OmegaForm/OmegaInputVuetify.vue index 11c43b95a..02e7248fe 100644 --- a/packages/vue-components/src/components/OmegaForm/OmegaInputVuetify.vue +++ b/packages/vue-components/src/components/OmegaForm/OmegaInputVuetify.vue @@ -81,12 +81,12 @@ { - console.log("Form submitted with values:", values) - alert(`Packen erfolgreich!\n${JSON.stringify(values, null, 2)}`) + onSubmit: ({ value }) => { + console.log("Form submitted with values:", value) + alert(`Packen erfolgreich!\n${JSON.stringify(value, null, 2)}`) return undefined as any } })