Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/polite-crews-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect-app/vue-components": patch
---

feat: Enhance OmegaForm with union metadata handling and nested meta flattening
5 changes: 5 additions & 0 deletions .changeset/twelve-moments-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect-app/vue-components": minor
---

chore: enhance metadata handling by adding unionDefaultValues to support default values for tagged schemas
5 changes: 5 additions & 0 deletions .changeset/twenty-dolls-bathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect-app/vue-components": patch
---

fix: Add key to component for reactivity and include translation in schema generation
72 changes: 59 additions & 13 deletions packages/vue-components/src/components/OmegaForm/OmegaFormStuff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ export const createMeta = <T = any>(

if (property?._tag === "TypeLiteral" && "propertySignatures" in property) {
return createMeta<T>({
parent, // Pass parent to maintain the key prefix for nested structures
meta,
propertySignatures: property.propertySignatures
})
Expand Down Expand Up @@ -404,7 +405,13 @@ export const createMeta = <T = any>(
property: p.type,
meta: { required: isRequired, nullableOrUndefined }
})
acc[key as NestedKeyOf<T>] = parentMeta as FieldMeta
// If parentMeta is a MetaRecord (nested structure from ExtendedClass), merge it
// Otherwise assign as single FieldMeta
if (parentMeta && typeof parentMeta === "object" && !("type" in parentMeta)) {
Object.assign(acc, parentMeta)
} else {
acc[key as NestedKeyOf<T>] = parentMeta as FieldMeta
}
}

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

// Helper to flatten nested meta structure into dot-notation keys
const flattenMeta = <T>(meta: MetaRecord<T> | FieldMeta, parentKey: string = ""): MetaRecord<T> => {
const result: MetaRecord<T> = {}

for (const key in meta) {
const value = (meta as any)[key]
const newKey = parentKey ? `${parentKey}.${key}` : key

if (value && typeof value === "object" && "type" in value) {
result[newKey as DeepKeys<T>] = value as FieldMeta
} else if (value && typeof value === "object") {
Object.assign(result, flattenMeta<T>(value, newKey))
}
}

return result
}

const metadataFromAst = <From, To>(
schema: S.Schema<To, From, never>
): { meta: MetaRecord<To>; defaultValues: Record<string, any> } => {
): {
meta: MetaRecord<To>
defaultValues: Record<string, any>
unionMeta: Record<string, MetaRecord<To>>
unionDefaultValues: Record<string, Record<string, any>>
} => {
const ast = schema.ast
const newMeta: MetaRecord<To> = {}
const defaultValues: Record<string, any> = {}
const unionMeta: Record<string, MetaRecord<To>> = {}
const unionDefaultValues: Record<string, Record<string, any>> = {}

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

// Merge metadata from all union members
// Store metadata for each union member by its tag value
for (const memberType of nonNullTypes) {
if ("propertySignatures" in memberType) {
// Find the discriminator field (usually _tag)
const tagProp = memberType.propertySignatures.find(
(p: any) => p.name.toString() === "_tag"
)

let tagValue: string | null = null
if (tagProp && S.AST.isLiteral(tagProp.type)) {
discriminatorValues.push(tagProp.type.literal)
tagValue = tagProp.type.literal as string
discriminatorValues.push(tagValue)
}

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

// Merge into result
// Store per-tag metadata for reactive lookup
if (tagValue) {
unionMeta[tagValue] = flattenMeta<To>(memberMeta)
// Create default values for this tag's schema
const memberSchema = S.make(memberType)
unionDefaultValues[tagValue] = defaultsValueFromSchema(memberSchema as any)
}

// Merge into result (for backward compatibility)
Object.assign(newMeta, memberMeta)
}
}
Expand All @@ -740,7 +782,7 @@ const metadataFromAst = <From, To>(
} as FieldMeta
}

return { meta: newMeta, defaultValues }
return { meta: newMeta, defaultValues, unionMeta, unionDefaultValues }
}
}

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

if (Object.values(meta).every((value) => value && "type" in value)) {
return { meta: meta as MetaRecord<To>, defaultValues }
return { meta: meta as MetaRecord<To>, defaultValues, unionMeta, unionDefaultValues }
}

const flattenObject = (
Expand All @@ -770,7 +812,7 @@ const metadataFromAst = <From, To>(
flattenObject(meta)
}

return { meta: newMeta, defaultValues }
return { meta: newMeta, defaultValues, unionMeta, unionDefaultValues }
}

export const duplicateSchema = <From, To>(
Expand All @@ -784,16 +826,21 @@ export const generateMetaFromSchema = <From, To>(
): {
schema: S.Schema<To, From, never>
meta: MetaRecord<To>
unionMeta: Record<string, MetaRecord<To>>
unionDefaultValues: Record<string, Record<string, any>>
} => {
const { meta } = metadataFromAst(schema)
const { meta, unionMeta, unionDefaultValues } = metadataFromAst(schema)

return { schema, meta }
return { schema, meta, unionMeta, unionDefaultValues }
}

export const generateInputStandardSchemaFromFieldMeta = (
meta: FieldMeta
meta: FieldMeta,
trans?: ReturnType<typeof useIntl>["trans"]
): StandardSchemaV1<any, any> => {
const { trans } = useIntl()
if (!trans) {
trans = useIntl().trans
}
let schema: S.Schema<any, any, never>
switch (meta.type) {
case "string":
Expand Down Expand Up @@ -850,7 +897,6 @@ export const generateInputStandardSchemaFromFieldMeta = (
})
}
if (typeof meta.minimum === "number") {
console.log("pippocazzo", meta)
schema = schema.pipe(S.greaterThanOrEqualTo(meta.minimum)).annotations({
message: () =>
trans(meta.minimum === 0 ? "validation.number.positive" : "validation.number.min", {
Expand Down
27 changes: 23 additions & 4 deletions packages/vue-components/src/components/OmegaForm/OmegaInput.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<template>
<component
:is="form.Field"
:key="fieldKey"
:name="name"
:validators="{
onChange: schema,
Expand Down Expand Up @@ -41,6 +42,7 @@
>
import { type DeepKeys } from "@tanstack/vue-form"
import { computed, inject, type Ref, useAttrs } from "vue"
import { useIntl } from "../../utils"
import { type FieldMeta, generateInputStandardSchemaFromFieldMeta, type OmegaInputPropsBase } from "./OmegaFormStuff"
import OmegaInternalInput from "./OmegaInternalInput.vue"
import { useErrorLabel } from "./useOmegaForm"
Expand Down Expand Up @@ -74,18 +76,35 @@ const getMetaFromArray = inject<Ref<(name: string) => FieldMeta | null> | null>(
)

const meta = computed(() => {
if (getMetaFromArray?.value && getMetaFromArray.value(props.name as DeepKeys<From>)) {
return getMetaFromArray.value(propsName.value)
const fromArray = getMetaFromArray?.value?.(props.name as DeepKeys<From>)
if (fromArray) {
return fromArray
}
return props.form.meta[propsName.value]
const formMeta = props.form.meta[propsName.value]
return formMeta
})

// Key to force Field re-mount when meta type changes (for TaggedUnion support)
const fieldKey = computed(() => {
const m = meta.value
if (!m) return propsName.value
// Include type and key constraints in the key so Field re-mounts when validation rules change
// Cast to any since not all FieldMeta variants have these properties
const fm = m as any
return `${propsName.value}-${fm.type}-${fm.minLength ?? ""}-${fm.maxLength ?? ""}-${fm.minimum ?? ""}-${
fm.maximum ?? ""
}`
})

// Call useIntl during setup to avoid issues when computed re-evaluates
const { trans } = useIntl()

const schema = computed(() => {
if (!meta.value) {
console.log(props.name, Object.keys(props.form.meta), props.form.meta)
throw new Error("Meta is undefined")
}
return generateInputStandardSchemaFromFieldMeta(meta.value)
return generateInputStandardSchemaFromFieldMeta(meta.value, trans)
})

const errori18n = useErrorLabel(props.form)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,57 @@
Name extends DeepKeys<From> | undefined = DeepKeys<From>"
>
import { type DeepKeys } from "@tanstack/vue-form"
import { computed, provide, ref, watch } from "vue"
import { type TaggedUnionOption } from "./InputProps"
import { type FieldPath } from "./OmegaFormStuff"
import OmegaTaggedUnionInternal from "./OmegaTaggedUnionInternal.vue"
import { type useOmegaForm } from "./useOmegaForm"

defineProps<{
const props = defineProps<{
name?: Name
form: ReturnType<typeof useOmegaForm<From, To>>
type?: "select" | "radio"
options: TaggedUnionOption<From, Name>[]
label?: string
}>()

// Track the current tag value reactively
const currentTag = ref<string | null>(null)

// Watch the form's _tag field value
const tagPath = computed(() => props.name ? `${props.name}._tag` : "_tag")
const formValues = props.form.useStore((state) => state.values)
watch(
() => {
const path = tagPath.value
// Navigate to the nested value
return path.split(".").reduce((acc: any, key) => acc?.[key], formValues.value) as string | null
},
(newTag) => {
currentTag.value = newTag ?? null
},
{ immediate: true }
)

// Provide tag-specific metadata to all child Input components
const getMetaFromArray = computed(() => {
const tag = currentTag.value

const getMeta = (path: string) => {
if (!tag) return null

// Get the tag-specific metadata
const tagMeta = props.form.unionMeta[tag]
if (!tagMeta) return null

// Look up the meta for this path
return tagMeta[path as keyof typeof tagMeta] ?? null
}

return getMeta
})

provide("getMetaFromArray", getMetaFromArray)
</script>

<template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,15 @@ watch(() => props.state, (newTag, oldTag) => {
props.field.setValue(null as DeepValue<From, Name>)
}

if (newTag !== oldTag) {
props.form.reset(values.value)
if (newTag !== oldTag && newTag) {
// Get default values for the new tag to ensure correct types
const tagDefaults = (props.form as any).unionDefaultValues?.[newTag as string] ?? {}
// Merge: keep _tag from current values, but use tag defaults for other fields
const resetValues = {
...tagDefaults,
_tag: newTag
}
props.form.reset(resetValues as any)
setTimeout(() => {
props.field.validate("change")
}, 0)
Expand Down
16 changes: 14 additions & 2 deletions packages/vue-components/src/components/OmegaForm/useOmegaForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@ export type OmegaConfig<T> = {

export interface OF<From, To> extends OmegaFormApi<From, To> {
meta: MetaRecord<From>
unionMeta: Record<string, MetaRecord<From>>
unionDefaultValues: Record<string, Record<string, any>>
clear: () => void
i18nNamespace?: string
ignorePreventCloseEvents?: boolean
Expand Down Expand Up @@ -682,7 +684,7 @@ export const useOmegaForm = <
const standardSchema = S.standardSchemaV1(schema)
const decode = S.decode(schema)

const { meta } = generateMetaFromSchema(schema)
const { meta, unionMeta, unionDefaultValues } = generateMetaFromSchema(schema)

const persistencyKey = computed(() => {
if (omegaConfig?.persistency?.id) {
Expand Down Expand Up @@ -952,6 +954,8 @@ export const useOmegaForm = <
i18nNamespace: omegaConfig?.i18nNamespace,
ignorePreventCloseEvents: omegaConfig?.ignorePreventCloseEvents,
meta,
unionMeta,
unionDefaultValues,
clear,
handleSubmit: (meta?: Record<string, any>) => {
const span = api.trace.getSpan(api.context.active())
Expand All @@ -961,7 +965,15 @@ export const useOmegaForm = <
handleSubmitEffect,
registerField: (field: ComputedRef<{ name: string; label: string; id: string }>) => {
watch(field, (f) => fieldMap.value.set(f.name, { label: f.label, id: f.id }), { immediate: true })
onUnmounted(() => fieldMap.value.delete(field.value.name)) // todo; perhap only when owned (id match)
onUnmounted(() => {
// Only delete if this component instance still owns the registration (id matches)
// This prevents the old component from removing the new component's registration
// when Vue re-keys and mounts new before unmounting old
const current = fieldMap.value.get(field.value.name)
if (current?.id === field.value.id) {
fieldMap.value.delete(field.value.name)
}
})
}
})

Expand Down
Loading
Loading