diff --git a/.changeset/dry-clocks-travel.md b/.changeset/dry-clocks-travel.md new file mode 100644 index 0000000000..043cbd2080 --- /dev/null +++ b/.changeset/dry-clocks-travel.md @@ -0,0 +1,5 @@ +--- +"@effect-app/vue-components": minor +--- + +Accept root level unions as OmegaForm citizen diff --git a/.changeset/tangy-lights-tickle.md b/.changeset/tangy-lights-tickle.md new file mode 100644 index 0000000000..fe0bdad333 --- /dev/null +++ b/.changeset/tangy-lights-tickle.md @@ -0,0 +1,5 @@ +--- +"@effect-app/vue-components": patch +--- + +Adds support to nested constructors in OmegaForm defaultsValue with schema diff --git a/packages/vue-components/src/components/OmegaForm/InputProps.ts b/packages/vue-components/src/components/OmegaForm/InputProps.ts index fd8b214ee3..090c257ca0 100644 --- a/packages/vue-components/src/components/OmegaForm/InputProps.ts +++ b/packages/vue-components/src/components/OmegaForm/InputProps.ts @@ -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, TName extends DeepKeys> = FieldApi< /* in out TParentData*/ From, @@ -60,10 +61,13 @@ export type VuetifyInputProps, 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, TName extends DeepKeys> = - DeepValue extends infer U ? U extends { _tag: infer Tag } ? Tag - : never +export type ExtractTagValue< + From extends Record, + TName extends DeepKeys | undefined +> = IsUnion extends true ? From extends { _tag: infer Tag } ? Tag : never + : DeepValue 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 } @@ -72,16 +76,21 @@ export type ExtractUnionBranch = 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, TName extends DeepKeys> = { +export type TaggedUnionOption, TName extends DeepKeys | undefined> = { readonly title: string readonly value: ExtractTagValue | null } // Options array must ALWAYS start with a null option (placeholder), followed by the actual options -export type TaggedUnionOptionsArray, TName extends DeepKeys> = readonly [ - { readonly title: string; readonly value: null }, - ...ReadonlyArray<{ readonly title: string; readonly value: ExtractTagValue }> -] +export type TaggedUnionOptionsArray< + From extends Record, + TName extends DeepKeys | undefined +> = + | readonly [ + { readonly title: string; readonly value: null }, + ...ReadonlyArray<{ readonly title: string; readonly value: ExtractTagValue }> + ] + | ReadonlyArray<{ readonly title: string; readonly value: ExtractTagValue }> // Props for TaggedUnion component export type TaggedUnionProps, TName extends DeepKeys> = { diff --git a/packages/vue-components/src/components/OmegaForm/OmegaFormStuff.ts b/packages/vue-components/src/components/OmegaForm/OmegaFormStuff.ts index c1d01465c7..b1c5dc98b8 100644 --- a/packages/vue-components/src/components/OmegaForm/OmegaFormStuff.ts +++ b/packages/vue-components/src/components/OmegaForm/OmegaFormStuff.ts @@ -695,6 +695,58 @@ const flattenMeta = ( 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({ + propertySignatures: memberType.propertySignatures + }) + + // Merge into result + Object.assign(result, memberMeta) + } + } + + // Create metadata for the discriminator field + if (discriminatorValues.length > 0) { + result["_tag" as DeepKeys] = { + type: "select", + members: discriminatorValues, + required: true + } as FieldMeta + } + + return result + } + } + if ("propertySignatures" in ast) { const meta = createMeta({ propertySignatures: ast.propertySignatures diff --git a/packages/vue-components/src/components/OmegaForm/OmegaTaggedUnion.vue b/packages/vue-components/src/components/OmegaForm/OmegaTaggedUnion.vue index ad9aab7450..489f8e1e19 100644 --- a/packages/vue-components/src/components/OmegaForm/OmegaTaggedUnion.vue +++ b/packages/vue-components/src/components/OmegaForm/OmegaTaggedUnion.vue @@ -4,59 +4,40 @@ generic=" From extends Record, To extends Record, - Name extends DeepKeys + Name extends DeepKeys | undefined = DeepKeys " > -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> type?: "select" | "radio" - options: TaggedUnionOptionsArray + options: TaggedUnionOption[] 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) - } 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) - } - } - } -})