From 0356b96099fe38d8febc0b89adec55c8b60f699d Mon Sep 17 00:00:00 2001 From: Davide Di Pumpo Date: Mon, 3 Nov 2025 19:42:05 +0100 Subject: [PATCH 1/5] Tries withDefault --- .../src/components/OmegaForm/useOmegaForm.ts | 62 +++++++++++++++++-- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts b/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts index 19b787ee16..c92ba73868 100644 --- a/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts +++ b/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts @@ -2,7 +2,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as api from "@opentelemetry/api" -import { type DeepKeys, DeepValue, type FormAsyncValidateOrFn, type FormValidateOrFn, type StandardSchemaV1, StandardSchemaV1Issue, useForm, ValidationError, ValidationErrorMap } from "@tanstack/vue-form" +import { type DeepKeys, DeepValue, type FormAsyncValidateOrFn, FormValidateOrFn, type FormValidateOrFn, type StandardSchemaV1, StandardSchemaV1Issue, useForm, ValidationError, ValidationErrorMap } from "@tanstack/vue-form" import { Array, Data, Effect, Fiber, Option, Order, S } from "effect-app" import { runtimeFiberAsPromise } from "effect-app/utils" import { isObject } from "effect/Predicate" @@ -677,7 +677,8 @@ export const useOmegaForm = < // Normalize default values based on schema metadata // Convert empty strings to null/undefined for nullable fields // Also initialize missing nullable fields with null/undefined - const normalizeDefaultValues = (values: Partial): Partial => { + const normalizeDefaultValues = (values?: Partial): Partial | undefined => { + if (!values) return undefined const normalized: any = { ...values } // Process all fields in the schema metadata @@ -704,12 +705,61 @@ export const useOmegaForm = < return normalized } + // Extract defaults from metadata + const extractDefaultsFromMeta = (metaRecord: MetaRecord): Partial => { + const result: any = {} + + for (const [path, fieldMeta] of Object.entries(metaRecord)) { + if (fieldMeta?.defaultValue) { + try { + // Execute the default getter function + const value = fieldMeta.defaultValue() + + // Build nested object structure from path + const pathParts = path.split(".") + let current = result + + for (let i = 0; i < pathParts.length - 1; i++) { + if (!current[pathParts[i]]) { + current[pathParts[i]] = {} + } + current = current[pathParts[i]] + } + + current[pathParts[pathParts.length - 1]] = value + } catch (error) { + // Skip fields where default function throws + console.debug(`Could not extract default for field ${path}:`, error) + } + } + } + + return result + } + + // Extract default values from schema constructors (e.g., withDefaultConstructor) + const extractSchemaDefaults = (defaultValues: Partial = {}) => { + try { + // Note: Partial schemas don't have .make() method yet (https://github.com/Effect-TS/effect/issues/4222) + // We can only extract defaults if ALL required fields have withDefaultConstructor + if ("make" in schema && typeof (schema as any).make === "function") { + try { + // Try with empty object - works only if all required fields have defaults + return (schema as any).make(defaultValues) + } catch { + // If make fails, try to extract defaults from metadata + } + } + return {} + } catch (error) { + console.warn("Could not extract schema constructor defaults:", error) + return {} + } + } const defaultValues = computed(() => { // Normalize tanstack default values at the beginning - const normalizedTanstackDefaults = tanstackFormOptions?.defaultValues - ? normalizeDefaultValues(tanstackFormOptions.defaultValues) - : undefined + const normalizedTanstackDefaults = extractSchemaDefaults(normalizeDefaultValues(tanstackFormOptions?.defaultValues)) if ( normalizedTanstackDefaults @@ -764,7 +814,7 @@ export const useOmegaForm = < } // to be sure we have a valid object at the end of the gathering process - defValuesPatch ??= {} + defValuesPatch ??= extractSchemaDefaults({}) if (!normalizedTanstackDefaults) { // we just return what we gathered from the query/storage From 4bb8d22d3af87e547acc55dd4fcd020d9e4f11b7 Mon Sep 17 00:00:00 2001 From: Davide Di Pumpo Date: Mon, 3 Nov 2025 20:08:33 +0100 Subject: [PATCH 2/5] refactor: simplify default value extraction in useOmegaForm --- .../src/components/OmegaForm/useOmegaForm.ts | 42 +------------------ 1 file changed, 2 insertions(+), 40 deletions(-) diff --git a/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts b/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts index c92ba73868..1038803a56 100644 --- a/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts +++ b/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts @@ -2,7 +2,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as api from "@opentelemetry/api" -import { type DeepKeys, DeepValue, type FormAsyncValidateOrFn, FormValidateOrFn, type FormValidateOrFn, type StandardSchemaV1, StandardSchemaV1Issue, useForm, ValidationError, ValidationErrorMap } from "@tanstack/vue-form" +import { type DeepKeys, DeepValue, type FormAsyncValidateOrFn, type FormValidateOrFn, type StandardSchemaV1, StandardSchemaV1Issue, useForm, ValidationError, ValidationErrorMap } from "@tanstack/vue-form" import { Array, Data, Effect, Fiber, Option, Order, S } from "effect-app" import { runtimeFiberAsPromise } from "effect-app/utils" import { isObject } from "effect/Predicate" @@ -705,52 +705,14 @@ export const useOmegaForm = < return normalized } - // Extract defaults from metadata - const extractDefaultsFromMeta = (metaRecord: MetaRecord): Partial => { - const result: any = {} - - for (const [path, fieldMeta] of Object.entries(metaRecord)) { - if (fieldMeta?.defaultValue) { - try { - // Execute the default getter function - const value = fieldMeta.defaultValue() - - // Build nested object structure from path - const pathParts = path.split(".") - let current = result - - for (let i = 0; i < pathParts.length - 1; i++) { - if (!current[pathParts[i]]) { - current[pathParts[i]] = {} - } - current = current[pathParts[i]] - } - - current[pathParts[pathParts.length - 1]] = value - } catch (error) { - // Skip fields where default function throws - console.debug(`Could not extract default for field ${path}:`, error) - } - } - } - - return result - } // Extract default values from schema constructors (e.g., withDefaultConstructor) const extractSchemaDefaults = (defaultValues: Partial = {}) => { try { // Note: Partial schemas don't have .make() method yet (https://github.com/Effect-TS/effect/issues/4222) - // We can only extract defaults if ALL required fields have withDefaultConstructor if ("make" in schema && typeof (schema as any).make === "function") { - try { - // Try with empty object - works only if all required fields have defaults - return (schema as any).make(defaultValues) - } catch { - // If make fails, try to extract defaults from metadata - } + return (schema as any).make(defaultValues, { disableValidation: true }) } - return {} } catch (error) { console.warn("Could not extract schema constructor defaults:", error) return {} From af4fcb72c801780912c6f1b7ec02ba80a01014f1 Mon Sep 17 00:00:00 2001 From: Davide Di Pumpo Date: Mon, 3 Nov 2025 20:58:58 +0100 Subject: [PATCH 3/5] test: add tests for OmegaForm with default constructor and query string persistency --- .../WithDefaultConstructorPersistency.test.ts | 101 ++++++++++++++++++ .../stories/OmegaForm.stories.ts | 7 ++ .../OmegaForm/WithDefaultConstructor.vue | 52 +++++++++ 3 files changed, 160 insertions(+) create mode 100644 packages/vue-components/__tests__/OmegaForm/WithDefaultConstructorPersistency.test.ts create mode 100644 packages/vue-components/stories/OmegaForm/WithDefaultConstructor.vue diff --git a/packages/vue-components/__tests__/OmegaForm/WithDefaultConstructorPersistency.test.ts b/packages/vue-components/__tests__/OmegaForm/WithDefaultConstructorPersistency.test.ts new file mode 100644 index 0000000000..030376400d --- /dev/null +++ b/packages/vue-components/__tests__/OmegaForm/WithDefaultConstructorPersistency.test.ts @@ -0,0 +1,101 @@ +import { mount } from "@vue/test-utils" +import { S } from "effect-app" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { useOmegaForm } from "../../src/components/OmegaForm" +import OmegaIntlProvider from "../OmegaIntlProvider.vue" + +describe("OmegaForm withDefaultConstructor with persistency", () => { + beforeEach(() => { + // Mock window.history.replaceState to avoid DOMException in tests + vi.spyOn(window.history, "replaceState").mockImplementation(() => {}) + }) + + it("should apply withDefaultConstructor defaults and override with query string persistency", async () => { + const AddSchema = S.Struct({ + first: S.PositiveNumber.pipe(S.withDefaultConstructor(() => S.PositiveNumber(100))), + second: S.PositiveNumber.pipe(S.withDefaultConstructor(() => S.PositiveNumber(100))), + 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) + }))), + fifth: S.Email + }) + + // Simulate query string parameters + // The persistency key is based on pathname and schema keys + // 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"] + const persistencyKey = `${pathname}-${keys.join("-")}` + const queryValue = JSON.stringify({ first: 1234 }) + + // Mock window.location properties + Object.defineProperty(window, "location", { + value: { + pathname, + search: `?${persistencyKey}=${encodeURIComponent(queryValue)}`, + href: `http://localhost${pathname}?${persistencyKey}=${encodeURIComponent(queryValue)}`, + replace: vi.fn(), + reload: vi.fn() + }, + writable: true + }) + + const wrapper = mount({ + components: { + OmegaIntlProvider + }, + template: ` + + + + + + `, + setup() { + const form = useOmegaForm( + AddSchema, + {}, + { + persistency: { + policies: ["querystring"], + keys: ["first"], + overrideDefaultValues: true + } + } + ) + return { form } + } + }) + + await wrapper.vm.$nextTick() + + // Check that errors is an empty array + const errorsText = wrapper.find("[data-testid=\"errors\"]").text() + expect(errorsText).toBe("Errors: []") + + // Check that values match the expected output + const valuesText = wrapper.find("[data-testid=\"values\"]").text() + const values = JSON.parse(valuesText.replace("Values: ", "")) + + expect(values).toEqual({ + first: 1234, // Overridden by query string + second: 100, // Default from withDefaultConstructor + third: null, // Default from NullOr withDefault + fourth: { + addForm: null, + b: 100 // Default from withDefaultConstructor + } + }) + }) +}) diff --git a/packages/vue-components/stories/OmegaForm.stories.ts b/packages/vue-components/stories/OmegaForm.stories.ts index 420bfedf4e..a719068581 100644 --- a/packages/vue-components/stories/OmegaForm.stories.ts +++ b/packages/vue-components/stories/OmegaForm.stories.ts @@ -29,6 +29,7 @@ import TanstackComponent from "./OmegaForm/Tanstack.vue" import UnionComponent from "./OmegaForm/Union.vue" import UsingOmegaFormComponent from "./OmegaForm/UsingOmegaForm.vue" import WindowExitPreventionComponent from "./OmegaForm/WindowExitPrevention.vue" +import WithDefaultConstructorComponent from "./OmegaForm/WithDefaultConstructor.vue" const mockIntl = { locale: ref("en"), @@ -238,3 +239,9 @@ export const CustomLabelSlot: Story = { template: "" }) } +export const WithDefaultConstructor: Story = { + render: () => ({ + components: { WithDefaultConstructorComponent }, + template: "" + }) +} diff --git a/packages/vue-components/stories/OmegaForm/WithDefaultConstructor.vue b/packages/vue-components/stories/OmegaForm/WithDefaultConstructor.vue new file mode 100644 index 0000000000..1344414c16 --- /dev/null +++ b/packages/vue-components/stories/OmegaForm/WithDefaultConstructor.vue @@ -0,0 +1,52 @@ + + + From 8412a5f04dea8c6a05944a586bbada150fabf711 Mon Sep 17 00:00:00 2001 From: Davide Di Pumpo Date: Mon, 3 Nov 2025 22:02:12 +0100 Subject: [PATCH 4/5] fix: ensure numeric validation properties handle exclusive limits correctly --- .../src/components/OmegaForm/OmegaFormStuff.ts | 16 ++++++++-------- .../components/OmegaForm/OmegaInternalInput.vue | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/vue-components/src/components/OmegaForm/OmegaFormStuff.ts b/packages/vue-components/src/components/OmegaForm/OmegaFormStuff.ts index cc5b77e0ab..c1d01465c7 100644 --- a/packages/vue-components/src/components/OmegaForm/OmegaFormStuff.ts +++ b/packages/vue-components/src/components/OmegaForm/OmegaFormStuff.ts @@ -772,7 +772,7 @@ export const generateInputStandardSchemaFromFieldMeta = ( }) } - if (meta.maxLength) { + if (typeof meta.maxLength === "number") { schema = schema.pipe(S.maxLength(meta.maxLength)).annotations({ message: () => trans("validation.string.maxLength", { @@ -780,7 +780,7 @@ export const generateInputStandardSchemaFromFieldMeta = ( }) }) } - if (meta.minLength) { + if (typeof meta.minLength === "number") { schema = schema.pipe(S.minLength(meta.minLength)).annotations({ message: () => trans("validation.string.minLength", { @@ -800,16 +800,16 @@ export const generateInputStandardSchemaFromFieldMeta = ( message: () => trans("validation.empty") }) } - if (meta.minimum) { + if (typeof meta.minimum === "number") { schema = schema.pipe(S.greaterThanOrEqualTo(meta.minimum)).annotations({ message: () => - trans("validation.number.min", { + trans(meta.minimum === 0 ? "validation.number.positive" : "validation.number.min", { minimum: meta.minimum, isExclusive: true }) }) } - if (meta.maximum) { + if (typeof meta.maximum === "number") { schema = schema.pipe(S.lessThanOrEqualTo(meta.maximum)).annotations({ message: () => trans("validation.number.max", { @@ -818,16 +818,16 @@ export const generateInputStandardSchemaFromFieldMeta = ( }) }) } - if (meta.exclusiveMinimum) { + if (typeof meta.exclusiveMinimum === "number") { schema = schema.pipe(S.greaterThan(meta.exclusiveMinimum)).annotations({ message: () => - trans("validation.number.min", { + trans(meta.exclusiveMinimum === 0 ? "validation.number.positive" : "validation.number.min", { minimum: meta.exclusiveMinimum, isExclusive: false }) }) } - if (meta.exclusiveMaximum) { + if (typeof meta.exclusiveMaximum === "number") { schema = schema.pipe(S.lessThan(meta.exclusiveMaximum)).annotations({ message: () => trans("validation.number.max", { diff --git a/packages/vue-components/src/components/OmegaForm/OmegaInternalInput.vue b/packages/vue-components/src/components/OmegaForm/OmegaInternalInput.vue index b099d85c99..be3044a1e6 100644 --- a/packages/vue-components/src/components/OmegaForm/OmegaInternalInput.vue +++ b/packages/vue-components/src/components/OmegaForm/OmegaInternalInput.vue @@ -154,8 +154,8 @@ const inputProps: ComputedRef> = computed(() => ({ required: isRequired.value, minLength: props.meta?.type === "string" && props.meta?.minLength, maxLength: props.meta?.type === "string" && props.meta?.maxLength, - max: props.meta?.type === "number" && props.meta?.maximum, - min: props.meta?.type === "number" && props.meta?.minimum, + max: props.meta?.type === "number" && (props.meta?.maximum || props.meta?.exclusiveMaximum), + min: props.meta?.type === "number" && (props.meta?.minimum || props.meta?.exclusiveMinimum), errorMessages: errors.value, error: !!errors.value.length, type: fieldType.value, From a79b9aad85cfd60b59f27ca31d96a5ec3260beca Mon Sep 17 00:00:00 2001 From: Davide Di Pumpo Date: Mon, 3 Nov 2025 22:03:39 +0100 Subject: [PATCH 5/5] Adds withDefaultConstructor default and improves numbers validations on OmegaForm --- .changeset/quick-parts-look.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/quick-parts-look.md diff --git a/.changeset/quick-parts-look.md b/.changeset/quick-parts-look.md new file mode 100644 index 0000000000..3688f9c5b5 --- /dev/null +++ b/.changeset/quick-parts-look.md @@ -0,0 +1,5 @@ +--- +"@effect-app/vue-components": minor +--- + +Adds withDefaultConstructor default and improves numbers validations on OmegaForm