Skip to content

Commit 2a74553

Browse files
authored
Omegaform nested errors (#581)
* feat: Enhance OmegaForm with union metadata handling and nested meta flattening * feat: Enhance OmegaForm with union metadata handling and nested meta flattening (#576) * fup * fix: Add key to component for reactivity and include translation in schema generation * chore: enhance OmegaForm components with improved metadata handling and cleanup logic * chore: refactor OmegaTaggedUnion component for improved prop handling and metadata access * chore: enhance metadata handling by adding unionDefaultValues to support default values for tagged schemas
1 parent d54c8fc commit 2a74553

File tree

9 files changed

+205
-58
lines changed

9 files changed

+205
-58
lines changed

.changeset/polite-crews-camp.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+
feat: Enhance OmegaForm with union metadata handling and nested meta flattening

.changeset/twelve-moments-pull.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": minor
3+
---
4+
5+
chore: enhance metadata handling by adding unionDefaultValues to support default values for tagged schemas

.changeset/twenty-dolls-bathe.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+
fix: Add key to component for reactivity and include translation in schema generation

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

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@ export const createMeta = <T = any>(
356356

357357
if (property?._tag === "TypeLiteral" && "propertySignatures" in property) {
358358
return createMeta<T>({
359+
parent, // Pass parent to maintain the key prefix for nested structures
359360
meta,
360361
propertySignatures: property.propertySignatures
361362
})
@@ -404,7 +405,13 @@ export const createMeta = <T = any>(
404405
property: p.type,
405406
meta: { required: isRequired, nullableOrUndefined }
406407
})
407-
acc[key as NestedKeyOf<T>] = parentMeta as FieldMeta
408+
// If parentMeta is a MetaRecord (nested structure from ExtendedClass), merge it
409+
// Otherwise assign as single FieldMeta
410+
if (parentMeta && typeof parentMeta === "object" && !("type" in parentMeta)) {
411+
Object.assign(acc, parentMeta)
412+
} else {
413+
acc[key as NestedKeyOf<T>] = parentMeta as FieldMeta
414+
}
408415
}
409416

410417
// Process each non-null type and merge their metadata
@@ -681,12 +688,37 @@ export const createMeta = <T = any>(
681688
return acc
682689
}
683690

691+
// Helper to flatten nested meta structure into dot-notation keys
692+
const flattenMeta = <T>(meta: MetaRecord<T> | FieldMeta, parentKey: string = ""): MetaRecord<T> => {
693+
const result: MetaRecord<T> = {}
694+
695+
for (const key in meta) {
696+
const value = (meta as any)[key]
697+
const newKey = parentKey ? `${parentKey}.${key}` : key
698+
699+
if (value && typeof value === "object" && "type" in value) {
700+
result[newKey as DeepKeys<T>] = value as FieldMeta
701+
} else if (value && typeof value === "object") {
702+
Object.assign(result, flattenMeta<T>(value, newKey))
703+
}
704+
}
705+
706+
return result
707+
}
708+
684709
const metadataFromAst = <From, To>(
685710
schema: S.Schema<To, From, never>
686-
): { meta: MetaRecord<To>; defaultValues: Record<string, any> } => {
711+
): {
712+
meta: MetaRecord<To>
713+
defaultValues: Record<string, any>
714+
unionMeta: Record<string, MetaRecord<To>>
715+
unionDefaultValues: Record<string, Record<string, any>>
716+
} => {
687717
const ast = schema.ast
688718
const newMeta: MetaRecord<To> = {}
689719
const defaultValues: Record<string, any> = {}
720+
const unionMeta: Record<string, MetaRecord<To>> = {}
721+
const unionDefaultValues: Record<string, Record<string, any>> = {}
690722

691723
if (ast._tag === "Transformation" || ast._tag === "Refinement") {
692724
return metadataFromAst(S.make(ast.from))
@@ -709,24 +741,34 @@ const metadataFromAst = <From, To>(
709741
// Extract discriminator values from each union member
710742
const discriminatorValues: any[] = []
711743

712-
// Merge metadata from all union members
744+
// Store metadata for each union member by its tag value
713745
for (const memberType of nonNullTypes) {
714746
if ("propertySignatures" in memberType) {
715747
// Find the discriminator field (usually _tag)
716748
const tagProp = memberType.propertySignatures.find(
717749
(p: any) => p.name.toString() === "_tag"
718750
)
719751

752+
let tagValue: string | null = null
720753
if (tagProp && S.AST.isLiteral(tagProp.type)) {
721-
discriminatorValues.push(tagProp.type.literal)
754+
tagValue = tagProp.type.literal as string
755+
discriminatorValues.push(tagValue)
722756
}
723757

724758
// Create metadata for this member's properties
725759
const memberMeta = createMeta<To>({
726760
propertySignatures: memberType.propertySignatures
727761
})
728762

729-
// Merge into result
763+
// Store per-tag metadata for reactive lookup
764+
if (tagValue) {
765+
unionMeta[tagValue] = flattenMeta<To>(memberMeta)
766+
// Create default values for this tag's schema
767+
const memberSchema = S.make(memberType)
768+
unionDefaultValues[tagValue] = defaultsValueFromSchema(memberSchema as any)
769+
}
770+
771+
// Merge into result (for backward compatibility)
730772
Object.assign(newMeta, memberMeta)
731773
}
732774
}
@@ -740,7 +782,7 @@ const metadataFromAst = <From, To>(
740782
} as FieldMeta
741783
}
742784

743-
return { meta: newMeta, defaultValues }
785+
return { meta: newMeta, defaultValues, unionMeta, unionDefaultValues }
744786
}
745787
}
746788

@@ -750,7 +792,7 @@ const metadataFromAst = <From, To>(
750792
})
751793

752794
if (Object.values(meta).every((value) => value && "type" in value)) {
753-
return { meta: meta as MetaRecord<To>, defaultValues }
795+
return { meta: meta as MetaRecord<To>, defaultValues, unionMeta, unionDefaultValues }
754796
}
755797

756798
const flattenObject = (
@@ -770,7 +812,7 @@ const metadataFromAst = <From, To>(
770812
flattenObject(meta)
771813
}
772814

773-
return { meta: newMeta, defaultValues }
815+
return { meta: newMeta, defaultValues, unionMeta, unionDefaultValues }
774816
}
775817

776818
export const duplicateSchema = <From, To>(
@@ -784,16 +826,21 @@ export const generateMetaFromSchema = <From, To>(
784826
): {
785827
schema: S.Schema<To, From, never>
786828
meta: MetaRecord<To>
829+
unionMeta: Record<string, MetaRecord<To>>
830+
unionDefaultValues: Record<string, Record<string, any>>
787831
} => {
788-
const { meta } = metadataFromAst(schema)
832+
const { meta, unionMeta, unionDefaultValues } = metadataFromAst(schema)
789833

790-
return { schema, meta }
834+
return { schema, meta, unionMeta, unionDefaultValues }
791835
}
792836

793837
export const generateInputStandardSchemaFromFieldMeta = (
794-
meta: FieldMeta
838+
meta: FieldMeta,
839+
trans?: ReturnType<typeof useIntl>["trans"]
795840
): StandardSchemaV1<any, any> => {
796-
const { trans } = useIntl()
841+
if (!trans) {
842+
trans = useIntl().trans
843+
}
797844
let schema: S.Schema<any, any, never>
798845
switch (meta.type) {
799846
case "string":
@@ -850,7 +897,6 @@ export const generateInputStandardSchemaFromFieldMeta = (
850897
})
851898
}
852899
if (typeof meta.minimum === "number") {
853-
console.log("pippocazzo", meta)
854900
schema = schema.pipe(S.greaterThanOrEqualTo(meta.minimum)).annotations({
855901
message: () =>
856902
trans(meta.minimum === 0 ? "validation.number.positive" : "validation.number.min", {

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

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<template>
22
<component
33
:is="form.Field"
4+
:key="fieldKey"
45
:name="name"
56
:validators="{
67
onChange: schema,
@@ -41,6 +42,7 @@
4142
>
4243
import { type DeepKeys } from "@tanstack/vue-form"
4344
import { computed, inject, type Ref, useAttrs } from "vue"
45+
import { useIntl } from "../../utils"
4446
import { type FieldMeta, generateInputStandardSchemaFromFieldMeta, type OmegaInputPropsBase } from "./OmegaFormStuff"
4547
import OmegaInternalInput from "./OmegaInternalInput.vue"
4648
import { useErrorLabel } from "./useOmegaForm"
@@ -74,18 +76,35 @@ const getMetaFromArray = inject<Ref<(name: string) => FieldMeta | null> | null>(
7476
)
7577
7678
const meta = computed(() => {
77-
if (getMetaFromArray?.value && getMetaFromArray.value(props.name as DeepKeys<From>)) {
78-
return getMetaFromArray.value(propsName.value)
79+
const fromArray = getMetaFromArray?.value?.(props.name as DeepKeys<From>)
80+
if (fromArray) {
81+
return fromArray
7982
}
80-
return props.form.meta[propsName.value]
83+
const formMeta = props.form.meta[propsName.value]
84+
return formMeta
8185
})
8286
87+
// Key to force Field re-mount when meta type changes (for TaggedUnion support)
88+
const fieldKey = computed(() => {
89+
const m = meta.value
90+
if (!m) return propsName.value
91+
// Include type and key constraints in the key so Field re-mounts when validation rules change
92+
// Cast to any since not all FieldMeta variants have these properties
93+
const fm = m as any
94+
return `${propsName.value}-${fm.type}-${fm.minLength ?? ""}-${fm.maxLength ?? ""}-${fm.minimum ?? ""}-${
95+
fm.maximum ?? ""
96+
}`
97+
})
98+
99+
// Call useIntl during setup to avoid issues when computed re-evaluates
100+
const { trans } = useIntl()
101+
83102
const schema = computed(() => {
84103
if (!meta.value) {
85104
console.log(props.name, Object.keys(props.form.meta), props.form.meta)
86105
throw new Error("Meta is undefined")
87106
}
88-
return generateInputStandardSchemaFromFieldMeta(meta.value)
107+
return generateInputStandardSchemaFromFieldMeta(meta.value, trans)
89108
})
90109
91110
const errori18n = useErrorLabel(props.form)

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,57 @@
66
Name extends DeepKeys<From> | undefined = DeepKeys<From>"
77
>
88
import { type DeepKeys } from "@tanstack/vue-form"
9+
import { computed, provide, ref, watch } from "vue"
910
import { type TaggedUnionOption } from "./InputProps"
1011
import { type FieldPath } from "./OmegaFormStuff"
1112
import OmegaTaggedUnionInternal from "./OmegaTaggedUnionInternal.vue"
1213
import { type useOmegaForm } from "./useOmegaForm"
1314
14-
defineProps<{
15+
const props = defineProps<{
1516
name?: Name
1617
form: ReturnType<typeof useOmegaForm<From, To>>
1718
type?: "select" | "radio"
1819
options: TaggedUnionOption<From, Name>[]
1920
label?: string
2021
}>()
22+
23+
// Track the current tag value reactively
24+
const currentTag = ref<string | null>(null)
25+
26+
// Watch the form's _tag field value
27+
const tagPath = computed(() => props.name ? `${props.name}._tag` : "_tag")
28+
const formValues = props.form.useStore((state) => state.values)
29+
watch(
30+
() => {
31+
const path = tagPath.value
32+
// Navigate to the nested value
33+
return path.split(".").reduce((acc: any, key) => acc?.[key], formValues.value) as string | null
34+
},
35+
(newTag) => {
36+
currentTag.value = newTag ?? null
37+
},
38+
{ immediate: true }
39+
)
40+
41+
// Provide tag-specific metadata to all child Input components
42+
const getMetaFromArray = computed(() => {
43+
const tag = currentTag.value
44+
45+
const getMeta = (path: string) => {
46+
if (!tag) return null
47+
48+
// Get the tag-specific metadata
49+
const tagMeta = props.form.unionMeta[tag]
50+
if (!tagMeta) return null
51+
52+
// Look up the meta for this path
53+
return tagMeta[path as keyof typeof tagMeta] ?? null
54+
}
55+
56+
return getMeta
57+
})
58+
59+
provide("getMetaFromArray", getMetaFromArray)
2160
</script>
2261

2362
<template>

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,15 @@ watch(() => props.state, (newTag, oldTag) => {
3131
props.field.setValue(null as DeepValue<From, Name>)
3232
}
3333
34-
if (newTag !== oldTag) {
35-
props.form.reset(values.value)
34+
if (newTag !== oldTag && newTag) {
35+
// Get default values for the new tag to ensure correct types
36+
const tagDefaults = (props.form as any).unionDefaultValues?.[newTag as string] ?? {}
37+
// Merge: keep _tag from current values, but use tag defaults for other fields
38+
const resetValues = {
39+
...tagDefaults,
40+
_tag: newTag
41+
}
42+
props.form.reset(resetValues as any)
3643
setTimeout(() => {
3744
props.field.validate("change")
3845
}, 0)

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,8 @@ export type OmegaConfig<T> = {
217217

218218
export interface OF<From, To> extends OmegaFormApi<From, To> {
219219
meta: MetaRecord<From>
220+
unionMeta: Record<string, MetaRecord<From>>
221+
unionDefaultValues: Record<string, Record<string, any>>
220222
clear: () => void
221223
i18nNamespace?: string
222224
ignorePreventCloseEvents?: boolean
@@ -682,7 +684,7 @@ export const useOmegaForm = <
682684
const standardSchema = S.standardSchemaV1(schema)
683685
const decode = S.decode(schema)
684686

685-
const { meta } = generateMetaFromSchema(schema)
687+
const { meta, unionMeta, unionDefaultValues } = generateMetaFromSchema(schema)
686688

687689
const persistencyKey = computed(() => {
688690
if (omegaConfig?.persistency?.id) {
@@ -952,6 +954,8 @@ export const useOmegaForm = <
952954
i18nNamespace: omegaConfig?.i18nNamespace,
953955
ignorePreventCloseEvents: omegaConfig?.ignorePreventCloseEvents,
954956
meta,
957+
unionMeta,
958+
unionDefaultValues,
955959
clear,
956960
handleSubmit: (meta?: Record<string, any>) => {
957961
const span = api.trace.getSpan(api.context.active())
@@ -961,7 +965,15 @@ export const useOmegaForm = <
961965
handleSubmitEffect,
962966
registerField: (field: ComputedRef<{ name: string; label: string; id: string }>) => {
963967
watch(field, (f) => fieldMap.value.set(f.name, { label: f.label, id: f.id }), { immediate: true })
964-
onUnmounted(() => fieldMap.value.delete(field.value.name)) // todo; perhap only when owned (id match)
968+
onUnmounted(() => {
969+
// Only delete if this component instance still owns the registration (id matches)
970+
// This prevents the old component from removing the new component's registration
971+
// when Vue re-keys and mounts new before unmounting old
972+
const current = fieldMap.value.get(field.value.name)
973+
if (current?.id === field.value.id) {
974+
fieldMap.value.delete(field.value.name)
975+
}
976+
})
965977
}
966978
})
967979

0 commit comments

Comments
 (0)