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 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/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, diff --git a/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts b/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts index 19b787ee16..1038803a56 100644 --- a/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts +++ b/packages/vue-components/src/components/OmegaForm/useOmegaForm.ts @@ -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 @@ -705,11 +706,22 @@ export const useOmegaForm = < return normalized } + // 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) + if ("make" in schema && typeof (schema as any).make === "function") { + return (schema as any).make(defaultValues, { disableValidation: true }) + } + } 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 +776,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 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 @@ + + +