Skip to content

Commit c3cd0a2

Browse files
authored
fix: support German translations for S.Int field validation (#605)
* feat: implement German translations for integer validation and update related components * chore: add changeset for integer fix in vue-components
1 parent 1de0ba1 commit c3cd0a2

File tree

5 files changed

+180
-24
lines changed

5 files changed

+180
-24
lines changed

.changeset/lazy-chairs-glow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@effect-app/vue-components": patch
3+
---
4+
5+
the fix for integers
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { S } from "effect-app"
2+
import { describe, expect, it } from "vitest"
3+
import { generateInputStandardSchemaFromFieldMeta, generateMetaFromSchema } from "../src/components/OmegaForm/OmegaFormStuff"
4+
5+
// mock German translations
6+
const germanTranslations: Record<string, string> = {
7+
"validation.integer.expected": "Es wird eine ganze Zahl erwartet, tatsächlich: {actualValue}",
8+
"validation.number.expected": "Es wird eine Zahl erwartet, tatsächlich: {actualValue}",
9+
"validation.empty": "Das Feld darf nicht leer sein"
10+
}
11+
12+
const mockTrans = (id: string, values?: Record<string, any>) => {
13+
let text = germanTranslations[id] || id
14+
if (values) {
15+
Object.entries(values).forEach(([key, value]) => {
16+
text = text.replace(`{${key}}`, String(value))
17+
})
18+
}
19+
return text
20+
}
21+
22+
describe("Integer validation with German translations", () => {
23+
it("should generate int metadata for S.Int fields", () => {
24+
const TestSchema = S.Struct({
25+
value: S.Int
26+
})
27+
28+
const { meta } = generateMetaFromSchema(TestSchema)
29+
console.log("Meta:", JSON.stringify(meta, null, 2))
30+
31+
expect(meta.value?.type).toBe("int")
32+
})
33+
34+
it("should show German error for decimal values", async () => {
35+
const TestSchema = S.Struct({
36+
value: S.Int
37+
})
38+
39+
const { meta } = generateMetaFromSchema(TestSchema)
40+
const schema = generateInputStandardSchemaFromFieldMeta(meta.value!, mockTrans)
41+
42+
// test with a decimal value
43+
const result = await schema["~standard"].validate(59.5)
44+
console.log("Validation result for 59.5:", JSON.stringify(result, null, 2))
45+
46+
expect(result.issues).toBeDefined()
47+
expect(result.issues?.[0]?.message).toContain("ganze Zahl")
48+
})
49+
50+
it("should show German error for undefined values", async () => {
51+
const TestSchema = S.Struct({
52+
value: S.Int
53+
})
54+
55+
const { meta } = generateMetaFromSchema(TestSchema)
56+
const schema = generateInputStandardSchemaFromFieldMeta(meta.value!, mockTrans)
57+
58+
// test with undefined value
59+
const result = await schema["~standard"].validate(undefined)
60+
console.log("Validation result for undefined:", JSON.stringify(result, null, 2))
61+
62+
expect(result.issues).toBeDefined()
63+
// should be German empty message
64+
expect(result.issues?.[0]?.message).toBe("Das Feld darf nicht leer sein")
65+
})
66+
67+
it("should accept valid integer values", async () => {
68+
const TestSchema = S.Struct({
69+
value: S.Int
70+
})
71+
72+
const { meta } = generateMetaFromSchema(TestSchema)
73+
const schema = generateInputStandardSchemaFromFieldMeta(meta.value!, mockTrans)
74+
75+
// test with a valid integer
76+
const result = await schema["~standard"].validate(59)
77+
console.log("Validation result for 59:", JSON.stringify(result, null, 2))
78+
79+
expect(result.issues).toBeUndefined()
80+
expect("value" in result && result.value).toBe(59)
81+
})
82+
})

packages/vue-components/src/components/OmegaForm/OmegaFormStuff.ts

Lines changed: 88 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ export type StringFieldMeta = BaseFieldMeta & {
246246
}
247247

248248
export type NumberFieldMeta = BaseFieldMeta & {
249-
type: "number"
249+
type: "number" | "int"
250250
minimum?: number
251251
maximum?: number
252252
exclusiveMinimum?: number
@@ -346,12 +346,23 @@ export const createMeta = <T = any>(
346346
): MetaRecord<T> | FieldMeta => {
347347
// unwraps class (Class are transformations)
348348
// this calls createMeta recursively, so wrapped transformations are also unwrapped
349+
// BUT: check for Int title annotation first - S.Int and branded Int have title "Int" or "int"
350+
// and we don't want to lose that information by unwrapping
349351
if (property && property._tag === "Transformation") {
350-
return createMeta<T>({
351-
parent,
352-
meta,
353-
property: property.from
354-
})
352+
const titleOnTransform = S
353+
.AST
354+
.getAnnotation(property, S.AST.TitleAnnotationId)
355+
.pipe(Option.getOrElse(() => ""))
356+
357+
// only unwrap if this is NOT an Int type
358+
if (titleOnTransform !== "Int" && titleOnTransform !== "int") {
359+
return createMeta<T>({
360+
parent,
361+
meta,
362+
property: property.from
363+
})
364+
}
365+
// if it's Int, fall through to process it with the Int type
355366
}
356367

357368
if (property?._tag === "TypeLiteral" && "propertySignatures" in property) {
@@ -655,24 +666,32 @@ export const createMeta = <T = any>(
655666

656667
meta = { ...JSONAnnotation, ...meta }
657668

658-
if ("from" in property) {
669+
// check the title annotation BEFORE following "from" to detect refinements like S.Int
670+
const titleType = S
671+
.AST
672+
.getAnnotation(
673+
property,
674+
S.AST.TitleAnnotationId
675+
)
676+
.pipe(
677+
Option.getOrElse(() => {
678+
return "unknown"
679+
})
680+
)
681+
682+
// if this is S.Int (a refinement), set the type and skip following "from"
683+
// otherwise we'd lose the "Int" information and get "number" instead
684+
if (titleType === "Int" || titleType === "int") {
685+
meta["type"] = "int"
686+
// don't follow "from" for Int refinements
687+
} else if ("from" in property) {
659688
return createMeta<T>({
660689
parent,
661690
meta,
662691
property: property.from
663692
})
664693
} else {
665-
meta["type"] = S
666-
.AST
667-
.getAnnotation(
668-
property,
669-
S.AST.TitleAnnotationId
670-
)
671-
.pipe(
672-
Option.getOrElse(() => {
673-
return "unknown"
674-
})
675-
)
694+
meta["type"] = titleType
676695
}
677696

678697
return meta as FieldMeta
@@ -869,9 +888,59 @@ export const generateInputStandardSchemaFromFieldMeta = (
869888
}
870889
break
871890

891+
case "int": {
892+
// create a custom integer schema with translations
893+
// S.Number with empty message, then S.int with integer message
894+
schema = S
895+
.Number
896+
.annotations({
897+
message: () => trans("validation.empty")
898+
})
899+
.pipe(
900+
S.int({ message: (issue) => trans("validation.integer.expected", { actualValue: String(issue.actual) }) })
901+
)
902+
if (typeof meta.minimum === "number") {
903+
schema = schema.pipe(S.greaterThanOrEqualTo(meta.minimum)).annotations({
904+
message: () =>
905+
trans(meta.minimum === 0 ? "validation.number.positive" : "validation.number.min", {
906+
minimum: meta.minimum,
907+
isExclusive: true
908+
})
909+
})
910+
}
911+
if (typeof meta.maximum === "number") {
912+
schema = schema.pipe(S.lessThanOrEqualTo(meta.maximum)).annotations({
913+
message: () =>
914+
trans("validation.number.max", {
915+
maximum: meta.maximum,
916+
isExclusive: true
917+
})
918+
})
919+
}
920+
if (typeof meta.exclusiveMinimum === "number") {
921+
schema = schema.pipe(S.greaterThan(meta.exclusiveMinimum)).annotations({
922+
message: () =>
923+
trans(meta.exclusiveMinimum === 0 ? "validation.number.positive" : "validation.number.min", {
924+
minimum: meta.exclusiveMinimum,
925+
isExclusive: false
926+
})
927+
})
928+
}
929+
if (typeof meta.exclusiveMaximum === "number") {
930+
schema = schema.pipe(S.lessThan(meta.exclusiveMaximum)).annotations({
931+
message: () =>
932+
trans("validation.number.max", {
933+
maximum: meta.exclusiveMaximum,
934+
isExclusive: false
935+
})
936+
})
937+
}
938+
break
939+
}
940+
872941
case "number":
873942
schema = S.Number.annotations({
874-
message: () => trans("validation.empty")
943+
message: () => trans("validation.number.expected", { actualValue: "NaN" })
875944
})
876945

877946
if (meta.required) {

packages/vue-components/src/components/OmegaForm/OmegaInputVuetify.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,12 @@
8181
</v-textarea>
8282
<component
8383
:is="inputProps.type === 'range' ? 'v-slider' : 'v-text-field'"
84-
v-if="inputProps.type === 'number' || inputProps.type === 'range'"
84+
v-if="inputProps.type === 'number' || inputProps.type === 'int' || inputProps.type === 'range'"
8585
:id="inputProps.id"
8686
:required="inputProps.required"
8787
:min="inputProps.min"
8888
:max="inputProps.max"
89-
:type="inputProps.type"
89+
:type="inputProps.type === 'int' ? 'number' : inputProps.type"
9090
:name="field.name"
9191
:label="inputProps.label"
9292
:error-messages="inputProps.errorMessages"

packages/vue-components/stories/OmegaForm/IntegerValidationGerman.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,9 @@ const PackageSchema = S.Struct({
8181
})
8282
8383
const form = useOmegaForm(PackageSchema, {
84-
onSubmit: (values) => {
85-
console.log("Form submitted with values:", values)
86-
alert(`Packen erfolgreich!\n${JSON.stringify(values, null, 2)}`)
84+
onSubmit: ({ value }) => {
85+
console.log("Form submitted with values:", value)
86+
alert(`Packen erfolgreich!\n${JSON.stringify(value, null, 2)}`)
8787
return undefined as any
8888
}
8989
})

0 commit comments

Comments
 (0)