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

Adds withDefaultConstructor default and improves numbers validations on OmegaForm
Original file line number Diff line number Diff line change
@@ -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: `
<OmegaIntlProvider>
<component :is="form.Form" :subscribe="['errors', 'values']" show-errors-on="onChange">
<template #default="{ subscribedValues: { errors, values } }">
<div data-testid="errors">Errors: {{ JSON.stringify(errors) }}</div>
<div data-testid="values">Values: {{ JSON.stringify(values) }}</div>
</template>
</component>
</OmegaIntlProvider>
`,
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
}
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -772,15 +772,15 @@ export const generateInputStandardSchemaFromFieldMeta = (
})
}

if (meta.maxLength) {
if (typeof meta.maxLength === "number") {
schema = schema.pipe(S.maxLength(meta.maxLength)).annotations({
message: () =>
trans("validation.string.maxLength", {
maxLength: meta.maxLength
})
})
}
if (meta.minLength) {
if (typeof meta.minLength === "number") {
schema = schema.pipe(S.minLength(meta.minLength)).annotations({
message: () =>
trans("validation.string.minLength", {
Expand All @@ -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", {
Expand All @@ -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", {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,8 @@ const inputProps: ComputedRef<InputProps<From, Name>> = 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,
Expand Down
22 changes: 17 additions & 5 deletions packages/vue-components/src/components/OmegaForm/useOmegaForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<From>): Partial<From> => {
const normalizeDefaultValues = (values?: Partial<From>): Partial<From> | undefined => {
if (!values) return undefined
const normalized: any = { ...values }

// Process all fields in the schema metadata
Expand Down Expand Up @@ -705,11 +706,22 @@ export const useOmegaForm = <
return normalized
}

// Extract default values from schema constructors (e.g., withDefaultConstructor)
const extractSchemaDefaults = (defaultValues: Partial<From> = {}) => {
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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/vue-components/stories/OmegaForm.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -238,3 +239,9 @@ export const CustomLabelSlot: Story = {
template: "<CustomLabelSlotComponent />"
})
}
export const WithDefaultConstructor: Story = {
render: () => ({
components: { WithDefaultConstructorComponent },
template: "<WithDefaultConstructorComponent />"
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<template>
<addForm.Form
:subscribe="['errors', 'values']"
show-errors-on="onChange"
>
<template #default="{ subscribedValues: { errors, values: vvv } }">
<div>Errors: {{ errors }}</div>
<div>Values: {{ vvv }}</div>
</template>
</addForm.Form>
</template>

<script setup lang="ts">
import { S } from "effect-app"
import { ref, watch } from "vue"
import { useOmegaForm } from "../../src/components/OmegaForm"

const sum = ref(0)
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
})

const addForm = useOmegaForm(
AddSchema,
{},
{
persistency: {
policies: ["querystring"],
keys: ["first"],
overrideDefaultValues: true
}
}
)

const values = addForm.useStore(({ values }) => values)

watch(values, ({ first, second }) => {
sum.value = first + second
})
</script>