From c8b874b7210d8660ab4ef39c26a26b7395557000 Mon Sep 17 00:00:00 2001 From: Davide Di Pumpo Date: Tue, 4 Nov 2025 10:51:44 +0100 Subject: [PATCH 1/4] Adds support to nested constructors in OmegaForm defaultsValue with schema --- .changeset/tangy-lights-tickle.md | 5 ++ .../WithDefaultConstructorPersistency.test.ts | 20 ++++--- .../src/components/OmegaForm/useOmegaForm.ts | 55 ++++++++++++++++++- .../OmegaForm/WithDefaultConstructor.vue | 13 ++--- 4 files changed, 74 insertions(+), 19 deletions(-) create mode 100644 .changeset/tangy-lights-tickle.md 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/__tests__/OmegaForm/WithDefaultConstructorPersistency.test.ts b/packages/vue-components/__tests__/OmegaForm/WithDefaultConstructorPersistency.test.ts index 6b0ddd20e4..0a9237c331 100644 --- a/packages/vue-components/__tests__/OmegaForm/WithDefaultConstructorPersistency.test.ts +++ b/packages/vue-components/__tests__/OmegaForm/WithDefaultConstructorPersistency.test.ts @@ -17,13 +17,12 @@ describe("OmegaForm withDefaultConstructor with persistency", () => { third: S.NullOr(S.String).withDefault, fourth: S .Struct({ - addForm: S.NullOr(S.String), - b: S.PositiveNumber - }) - .pipe(S.withDefaultConstructor(() => ({ - addForm: null, - b: S.PositiveNumber(100) - }))), + addForm: S.NullOr(S.String).withDefault, + b: S.PositiveNumber.pipe(S.withDefaultConstructor(() => S.PositiveNumber(100))), + c: S.Struct({ + d: S.Number.pipe(S.withDefaultConstructor(() => 10)) + }) + }), fifth: S.Email, sixth: S.NumberFromString.pipe(S.withDefaultConstructor(() => 1000)) }) @@ -33,7 +32,7 @@ describe("OmegaForm withDefaultConstructor with persistency", () => { // Format: pathname-key1-key2-key3... // Keys from meta will be flattened with dot notation for nested fields const pathname = "/test" - const keys = ["first", "second", "third", "fourth.addForm", "fourth.b", "fifth", "sixth"] + const keys = ["first", "second", "third", "fourth.addForm", "fourth.b", "fourth.c.d", "fifth", "sixth"] const persistencyKey = `${pathname}-${keys.join("-")}` const queryValue = JSON.stringify({ first: 1234 }) @@ -95,7 +94,10 @@ describe("OmegaForm withDefaultConstructor with persistency", () => { third: null, // Default from NullOr withDefault fourth: { addForm: null, - b: 100 // Default from withDefaultConstructor + b: 100, // Default from withDefaultConstructor + c: { + d: 10 // Default from withDefaultConstructor + } }, sixth: "1000" }) diff --git a/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts b/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts index f3bc868ab1..19a0e7f20f 100644 --- a/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts +++ b/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts @@ -706,17 +706,66 @@ export const useOmegaForm = < return normalized } + // Helper function to recursively extract default values from schema AST + const extractDefaultsFromAST = (schemaObj: any): any => { + const result: Record = {} + + // Check if this schema has fields (struct) + if (schemaObj?.fields && typeof schemaObj.fields === "object") { + for (const [key, fieldSchema] of Object.entries(schemaObj.fields)) { + // Check if this field has a default value in its AST + if ((fieldSchema as any)?.ast?.defaultValue) { + try { + const defaultValue = (fieldSchema as any).ast.defaultValue() + if (defaultValue !== undefined) { + result[key] = defaultValue + } + } catch { + // Silently ignore if defaultValue() throws + } + } + + // Recursively check nested fields for structs + const nestedDefaults = extractDefaultsFromAST(fieldSchema as any) + if (Object.keys(nestedDefaults).length > 0) { + // If we already have a default value for this key, merge with nested + if (result[key] && typeof result[key] === "object") { + Object.assign(result[key], nestedDefaults) + } else if (!result[key]) { + // Only set nested defaults if we don't have a default value + result[key] = nestedDefaults + } + } + } + } + + return result + } + // Extract default values from schema constructors (e.g., withDefaultConstructor) const extractSchemaDefaults = (defaultValues: Partial = {}) => { try { + // First try to use schema.make() if available // Note: Partial schemas don't have .make() method yet (https://github.com/Effect-TS/effect/issues/4222) if ("make" in schema && typeof (schema as any).make === "function") { - const decoded = (schema as any).make(defaultValues, { disableValidation: true }) + const decoded = (schema as any).make(defaultValues) return S.encodeSync(schema.pipe(S.partial))(decoded) } } catch (error) { - console.warn("Could not extract schema constructor defaults:", error) - return {} + // If make() fails, try to extract defaults from AST + if (window.location.hostname === "localhost") { + console.warn("schema.make() failed, extracting defaults from AST:", error) + } + try { + const astDefaults = extractDefaultsFromAST(schema) + + return S.encodeSync(schema.pipe(S.partial))(astDefaults) + } catch (astError) { + if (window.location.hostname === "localhost") { + console.warn("Could not extract defaults from AST:", astError) + } + return {} + } } } diff --git a/packages/vue-components/stories/OmegaForm/WithDefaultConstructor.vue b/packages/vue-components/stories/OmegaForm/WithDefaultConstructor.vue index de102f7599..88afb31482 100644 --- a/packages/vue-components/stories/OmegaForm/WithDefaultConstructor.vue +++ b/packages/vue-components/stories/OmegaForm/WithDefaultConstructor.vue @@ -23,13 +23,12 @@ const AddSchema = S.Struct({ third: S.NullOr(S.String).withDefault, fourth: S .Struct({ - addForm: S.NullOr(S.String), - b: S.PositiveNumber - }) - .pipe(S.withDefaultConstructor(() => ({ - addForm: null, - b: S.PositiveNumber(100) - }))), + addForm: S.NullOr(S.String).withDefault, + b: S.PositiveNumber.pipe(S.withDefaultConstructor(() => S.PositiveNumber(100))), + c: S.Struct({ + d: S.Number.pipe(S.withDefaultConstructor(() => 10)) + }) + }), fifth: S.Email, sixth: S.NumberFromString.pipe(S.withDefaultConstructor(() => 1000)) }) From d255d4c2d0335aca3cd6ad903f283f955a273f99 Mon Sep 17 00:00:00 2001 From: Davide Di Pumpo Date: Tue, 4 Nov 2025 20:50:49 +0100 Subject: [PATCH 2/4] Adds recursive utility to make all properties in a schema optional, enhancing default extraction for nested structures --- .../src/components/OmegaForm/useOmegaForm.ts | 114 +++++++++++++++++- 1 file changed, 111 insertions(+), 3 deletions(-) diff --git a/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts b/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts index 19a0e7f20f..e742762518 100644 --- a/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts +++ b/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts @@ -16,6 +16,86 @@ import OmegaInput from "./OmegaInput.vue" import OmegaTaggedUnion from "./OmegaTaggedUnion.vue" import OmegaForm from "./OmegaWrapper.vue" +/** + * Recursively makes all properties in a schema optional, including nested objects. + * Unlike S.partial which only makes top-level properties optional, this utility + * traverses the schema tree and applies partial transformation at every level. + * + * Handles: + * - TypeLiteral (structs): Makes all properties optional and recursively processes nested types + * - Union types: Recursively applies partial to each union member + * - Transformation types: Applies partial to both 'from' and 'to' sides + */ +const partialRecursive = (schema: S.Schema): S.Schema, Partial, R> => { + const ast = schema.ast + + // Handle Union types - recursively apply partial to each member + if (ast._tag === "Union") { + const partialMembers = (ast as any).types.map((memberAst: any) => { + const memberSchema = S.make(memberAst) + const partialMember = partialRecursive(memberSchema as any) + return partialMember.ast + }) + + const newAst = { + ...ast, + types: partialMembers + } + + return S.make(newAst as any) + } + + // Handle Transformation types (e.g., withDefaultConstructor) + if (ast._tag === "Transformation") { + // For transformations, apply partial to both the 'from' and 'to' sides + const fromSchema = S.make((ast as any).from) + const toSchema = S.make((ast as any).to) + const partialFrom = partialRecursive(fromSchema as any) + const partialTo = partialRecursive(toSchema as any) + + const newAst = { + ...ast, + from: partialFrom.ast, + to: partialTo.ast + } + + return S.make(newAst as any) + } + + // If this is a TypeLiteral (struct), recursively apply partial to nested fields + if (ast._tag === "TypeLiteral") { + const fields = ast.propertySignatures.map((prop: any) => { + const propType = prop.type + let newType = propType + + // Recursively handle nested complex types (structs, unions, transformations) + if (propType._tag === "TypeLiteral" || propType._tag === "Union" || propType._tag === "Transformation") { + const nestedSchema = S.make(propType) + const recursivePartial = partialRecursive(nestedSchema as any) + newType = recursivePartial.ast + } + + // Create a new property signature with isOptional: true + return { + ...prop, + type: newType, + isOptional: true + } + }) + + const newAst = { + ...ast, + propertySignatures: fields + } + + return S.make(newAst as any) + } + + // For other schema types (primitives, refinements, etc.), return as-is + // These types don't need to be made partial, and S.partial doesn't support them anyway + return schema as any +} + type keysRule = | { keys?: NestedKeyOf[] @@ -710,6 +790,34 @@ export const useOmegaForm = < const extractDefaultsFromAST = (schemaObj: any): any => { const result: Record = {} + // Check if this schema is a union + if (schemaObj?.members && Array.isArray(schemaObj.members)) { + // For unions, we try to find the first member that has a complete set of defaults + // Priority is given to members with default values for discriminator fields + for (const member of schemaObj.members) { + const memberDefaults = extractDefaultsFromAST(member) + if (Object.keys(memberDefaults).length > 0) { + // Check if this member has a default value for a discriminator field (like _tag) + // If it does, use this member's defaults + const hasDiscriminatorDefault = member?.fields && Object.entries(member.fields).some( + ([key, fieldSchema]: [string, any]) => { + // Common discriminator field names + if (key === "_tag" || key === "type" || key === "kind") { + return fieldSchema?.ast?.defaultValue !== undefined + } + return false + } + ) + + if (hasDiscriminatorDefault) { + return memberDefaults + } + } + } + // If no member has a discriminator default, return empty + return {} + } + // Check if this schema has fields (struct) if (schemaObj?.fields && typeof schemaObj.fields === "object") { for (const [key, fieldSchema] of Object.entries(schemaObj.fields)) { @@ -725,7 +833,7 @@ export const useOmegaForm = < } } - // Recursively check nested fields for structs + // Recursively check nested fields for structs and unions const nestedDefaults = extractDefaultsFromAST(fieldSchema as any) if (Object.keys(nestedDefaults).length > 0) { // If we already have a default value for this key, merge with nested @@ -749,7 +857,7 @@ export const useOmegaForm = < // Note: Partial schemas don't have .make() method yet (https://github.com/Effect-TS/effect/issues/4222) if ("make" in schema && typeof (schema as any).make === "function") { const decoded = (schema as any).make(defaultValues) - return S.encodeSync(schema.pipe(S.partial))(decoded) + return S.encodeSync(partialRecursive(schema))(decoded) } } catch (error) { // If make() fails, try to extract defaults from AST @@ -759,7 +867,7 @@ export const useOmegaForm = < try { const astDefaults = extractDefaultsFromAST(schema) - return S.encodeSync(schema.pipe(S.partial))(astDefaults) + return S.encodeSync(partialRecursive(schema))(astDefaults) } catch (astError) { if (window.location.hostname === "localhost") { console.warn("Could not extract defaults from AST:", astError) From 84e7921e4c97268f0179d804c6682ee015bb20cd Mon Sep 17 00:00:00 2001 From: Davide Di Pumpo Date: Tue, 4 Nov 2025 21:57:31 +0100 Subject: [PATCH 3/4] Enhances OmegaForm to support root-level tagged unions, allowing direct schema usage and improved field handling --- .../src/components/OmegaForm/InputProps.ts | 25 ++-- .../components/OmegaForm/OmegaFormStuff.ts | 52 ++++++++ .../components/OmegaForm/OmegaTaggedUnion.vue | 51 +++----- .../OmegaForm/OmegaTaggedUnionInternal.vue | 3 +- .../src/components/OmegaForm/useOmegaForm.ts | 22 ++-- .../stories/OmegaForm.stories.ts | 8 ++ .../stories/OmegaForm/FormTaggedUnion.vue | 5 +- .../OmegaForm/RootLevelTaggedUnion.vue | 115 ++++++++++++++++++ 8 files changed, 225 insertions(+), 56 deletions(-) create mode 100644 packages/vue-components/stories/OmegaForm/RootLevelTaggedUnion.vue 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) - } - } - } -})