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/dry-clocks-travel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect-app/vue-components": minor
---

Accept root level unions as OmegaForm citizen
5 changes: 5 additions & 0 deletions .changeset/tangy-lights-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect-app/vue-components": patch
---

Adds support to nested constructors in OmegaForm defaultsValue with schema
25 changes: 17 additions & 8 deletions packages/vue-components/src/components/OmegaForm/InputProps.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { DeepKeys, DeepValue, FieldApi, FieldAsyncValidateOrFn, FieldValidateAsyncFn, FieldValidateFn, FieldValidateOrFn, FormAsyncValidateOrFn, FormValidateOrFn, StandardSchemaV1 } from "@tanstack/vue-form"
import { type IsUnion } from "effect-app/utils"

export type OmegaFieldInternalApi<From extends Record<PropertyKey, any>, TName extends DeepKeys<From>> = FieldApi<
/* in out TParentData*/ From,
Expand Down Expand Up @@ -60,10 +61,13 @@ export type VuetifyInputProps<From extends Record<PropertyKey, any>, TName exten
// Utility type to extract _tag literal values from a discriminated union
// For a union like { _tag: "A", ... } | { _tag: "B", ... }, this returns "A" | "B"
// For nullable unions like { _tag: "A" } | { _tag: "B" } | null, this still returns "A" | "B" (excluding null)
export type ExtractTagValue<From extends Record<PropertyKey, any>, TName extends DeepKeys<From>> =
DeepValue<From, TName> extends infer U ? U extends { _tag: infer Tag } ? Tag
: never
export type ExtractTagValue<
From extends Record<PropertyKey, any>,
TName extends DeepKeys<From> | undefined
> = IsUnion<TName> extends true ? From extends { _tag: infer Tag } ? Tag : never
: DeepValue<From, TName> extends infer U ? U extends { _tag: infer Tag } ? Tag
: never
: never

// Utility type to extract a specific branch from a discriminated union based on _tag value
// For union { _tag: "A", foo: string } | { _tag: "B", bar: number } and Tag="A", returns { _tag: "A", foo: string }
Expand All @@ -72,16 +76,21 @@ export type ExtractUnionBranch<T, Tag> = T extends { _tag: Tag } ? T

// Option type for TaggedUnion component with strongly-typed value
// The value can be either one of the _tag values OR null (for the placeholder)
export type TaggedUnionOption<From extends Record<PropertyKey, any>, TName extends DeepKeys<From>> = {
export type TaggedUnionOption<From extends Record<PropertyKey, any>, TName extends DeepKeys<From> | undefined> = {
readonly title: string
readonly value: ExtractTagValue<From, TName> | null
}

// Options array must ALWAYS start with a null option (placeholder), followed by the actual options
export type TaggedUnionOptionsArray<From extends Record<PropertyKey, any>, TName extends DeepKeys<From>> = readonly [
{ readonly title: string; readonly value: null },
...ReadonlyArray<{ readonly title: string; readonly value: ExtractTagValue<From, TName> }>
]
export type TaggedUnionOptionsArray<
From extends Record<PropertyKey, any>,
TName extends DeepKeys<From> | undefined
> =
| readonly [
{ readonly title: string; readonly value: null },
...ReadonlyArray<{ readonly title: string; readonly value: ExtractTagValue<From, TName> }>
]
| ReadonlyArray<{ readonly title: string; readonly value: ExtractTagValue<From, TName> }>

// Props for TaggedUnion component
export type TaggedUnionProps<From extends Record<PropertyKey, any>, TName extends DeepKeys<From>> = {
Expand Down
52 changes: 52 additions & 0 deletions packages/vue-components/src/components/OmegaForm/OmegaFormStuff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,58 @@ const flattenMeta = <From, To>(
return flattenMeta(S.make(ast.from))
}

// Handle root-level Union types (discriminated unions)
if (ast._tag === "Union") {
const unionAst = ast as any
const types = unionAst.types || []

// Filter out null/undefined types and unwrap transformations
const nonNullTypes = types
.filter((t: any) => t._tag !== "UndefinedKeyword" && t !== S.Null.ast)
.map(getTransformationFrom)

// Check if this is a discriminated union (all members are structs)
const allStructs = nonNullTypes.every((t: any) => t._tag === "TypeLiteral" && "propertySignatures" in t)

if (allStructs && nonNullTypes.length > 0) {
// Extract discriminator values from each union member
const discriminatorValues: any[] = []

// Merge metadata from all union members
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"
)

if (tagProp && S.AST.isLiteral(tagProp.type)) {
discriminatorValues.push(tagProp.type.literal)
}

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

// Merge into result
Object.assign(result, memberMeta)
}
}

// Create metadata for the discriminator field
if (discriminatorValues.length > 0) {
result["_tag" as DeepKeys<To>] = {
type: "select",
members: discriminatorValues,
required: true
} as FieldMeta
}

return result
}
}

if ("propertySignatures" in ast) {
const meta = createMeta<To>({
propertySignatures: ast.propertySignatures
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,59 +4,40 @@
generic="
From extends Record<PropertyKey, any>,
To extends Record<PropertyKey, any>,
Name extends DeepKeys<From>
Name extends DeepKeys<From> | undefined = DeepKeys<From>
"
>
import { type DeepKeys, type DeepValue } from "@tanstack/vue-form"
import { onMounted } from "vue"
import { type TaggedUnionOption, type TaggedUnionOptionsArray } from "./InputProps"
import { type DeepKeys } from "@tanstack/vue-form"
import { type TaggedUnionOption } from "./InputProps"
import { type FieldPath } from "./OmegaFormStuff"
import OmegaTaggedUnionInternal from "./OmegaTaggedUnionInternal.vue"
import { type useOmegaForm } from "./useOmegaForm"

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

// Initialize the union field on mount
onMounted(() => {
const currentValue = props.form.getFieldValue(props.name)
const meta = props.form.meta[props.name as keyof typeof props.form.meta]

if (currentValue === undefined) {
if (meta?.nullableOrUndefined === "null" || !meta?.required) {
// Initialize to null for nullable/optional unions
props.form.setFieldValue(props.name, null as DeepValue<From, Name>)
} else {
// For required unions, initialize with first non-null option
const firstOption = props.options.find((opt) => opt.value !== null)
if (firstOption && firstOption.value) {
props.form.setFieldValue(props.name, {
_tag: firstOption.value
} as DeepValue<From, Name>)
}
}
}
})
</script>

<template>
<form.Input
:name="`${name}._tag` as FieldPath<From>"
:label="label"
:type="type ?? 'select'"
:options="options as unknown as TaggedUnionOption<From, Name>[]"
/>
<form.Field :name="name">
<slot name="OmegaCustomInput">
<form.Input
:name="(name ? `${name}._tag` : '_tag') as FieldPath<From>"
:label="label"
:type="type ?? 'select'"
:options="options"
/>
</slot>
<form.Field :name="(name ?? '') as any">
<template #default="{ field, state }">
<slot v-if="state.value" />
<OmegaTaggedUnionInternal
:field="field as any"
:state="state.value"
:name="name"
>
<template
v-for="(_, slotname) in $slots"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<slot
v-if="state?._tag"
:name="state?._tag"
:name="`${name ? `${name}.` : ''}${state?._tag}`"
v-bind="{ field, state }"
/>
</template>
Expand All @@ -22,6 +22,7 @@ import { type OmegaFieldInternalApi } from "./InputProps"
const props = defineProps<{
state: DeepValue<From, Name>
field: OmegaFieldInternalApi<From, Name>
name?: DeepKeys<From>
}>()

// Watch for _tag changes
Expand Down
Loading