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/fresh-indexed-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect-app/vue-components": patch
---

Render indexed OmegaForm inputs outside form.Array and avoid duplicate error entries for registered array fields.
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { mount } from "@vue/test-utils"
import * as S from "effect-app/Schema"
import { describe, expect, it } from "vitest"
import { defineComponent, nextTick, ref } from "vue"
import { useOmegaForm } from "../../src/components/OmegaForm"

describe("OmegaForm indexed inputs", () => {
it.each([0, 13])("renders rows[%i].text outside form.Array with one labelled error", async (index) => {
const wrapper = mount(defineComponent({
setup() {
const form = useOmegaForm(
S.Struct({
rows: S.Array(S.Struct({ text: S.String.pipe(S.check(S.isMinLength(2))) }))
}),
{
defaultValues: {
rows: Array.from({ length: index + 1 }, (_, i) => ({ text: i === index ? "" : "valid" }))
}
}
)
return { form, name: `rows[${index}].text`, visible: ref(true) }
},
template: `
<component :is="form.Form">
<component v-if="visible" :is="form.Input" :name="name" label="Row text">
<template #default="{ id, state, errorMessages }">
<input :id="id" :value="state.value" />
<span data-testid="field-errors">{{ errorMessages.join(', ') }}</span>
</template>
</component>
<component :is="form.Errors" />
</component>
`
}))

const input = wrapper.get("input")
await wrapper.vm.form.handleSubmit()
await nextTick()

expect(wrapper.get("[data-testid=\"field-errors\"]").text()).not.toBe("")
const entries = wrapper.findAll("[role=\"alert\"] .error-item")
expect(entries).toHaveLength(1)
expect(entries[0].get("label").text()).toBe("Row text")
expect(entries[0].get("label").attributes("for")).toBe(input.attributes("id"))

wrapper.vm.visible = false
await nextTick()
await wrapper.vm.form.handleSubmit()
await nextTick()

const unregistered = wrapper.findAll("[role=\"alert\"] .error-item")
expect(unregistered).toHaveLength(1)
expect(unregistered[0].get("label").attributes("for")).toBe(`rows.${index}.text`)
wrapper.unmount()
})

it("still resolves indexed metadata inside form.Array", () => {
const wrapper = mount(defineComponent({
setup() {
return {
form: useOmegaForm(S.Struct({ rows: S.Array(S.Struct({ text: S.String })) }), {
defaultValues: { rows: [{ text: "existing" }] }
})
}
},
template: `
<component :is="form.Form">
<component :is="form.Array" name="rows">
<template #default="{ index }">
<component :is="form.Input" :name="'rows[' + index + '].text'">
<template #default="{ state }"><input :value="state.value" /></template>
</component>
</template>
</component>
</component>
`
}))

expect(wrapper.get("input").element.value).toBe("existing")
wrapper.unmount()
})
})
15 changes: 1 addition & 14 deletions packages/vue-components/src/components/OmegaForm/OmegaArray.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
generic="From extends Record<PropertyKey, any>, To extends Record<PropertyKey, any>, Name extends DeepKeys<From>"
>
import { type DeepKeys } from "@tanstack/vue-form"
import { computed, onMounted, provide } from "vue"
import { computed, onMounted } from "vue"
import { type OmegaArrayProps } from "./types"

const props = defineProps<OmegaArrayProps<From, To, Name>>()
Expand Down Expand Up @@ -73,17 +73,4 @@ onMounted(async () => {
props.form.setFieldValue(props.name, props.defaultItems)
}
})

const getMetaFromArray = computed(() => {
const getMeta = (path: string) => {
// Transform path like 'a[0].b[11].c' into 'a.b.c'
const simplifiedPath = path.replace(/\[\d+\]/g, "")

return props.form.meta[simplifiedPath as keyof typeof props.form.meta]
}

return getMeta
})

provide("getMetaFromArray", getMetaFromArray)
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,10 @@ const getMetaFromArray = inject<Ref<(name: string) => FieldMeta | null> | null>(
)

const meta = computed(() => {
if (getMetaFromArray?.value && getMetaFromArray.value(props.name as DeepKeys<From>)) {
return getMetaFromArray.value(propsName.value)
}
const fromArray = getMetaFromArray?.value?.(propsName.value)
if (fromArray) return fromArray
return props.form.meta[propsName.value]
?? props.form.meta[propsName.value.replace(/\[\d+\]/g, "") as DeepKeys<From>]
})

const errori18n = useErrorLabel(props.form)
Expand Down
11 changes: 7 additions & 4 deletions packages/vue-components/src/components/OmegaForm/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { useIntl } from "../../utils"
import type { OmegaError } from "./types"
import type { OF } from "./useOmegaForm"

const normalizePath = (path: string) => path.replace(/\[(\d+)\]/g, ".$1")

export const useErrorLabel = (form: OF<any, any>) => {
const { formatMessage } = useIntl()
const humanize = (str: string) => {
Expand Down Expand Up @@ -49,7 +51,7 @@ export const eHoc = (errorProps: {
return acc
}

const fieldInfo = fieldMap.value.get(key)
const fieldInfo = fieldMap.value.get(normalizePath(key))
if (!fieldInfo) {
return acc
}
Expand Down Expand Up @@ -120,15 +122,16 @@ export const makeFieldMap = () => {
const fieldMap = ref(new Map<string, { label: string; id: string }>())
const registerField = (field: ComputedRef<{ name: string; label: string; id: string }>) => {
watch(field, (f) => {
fieldMap.value.set(f.name, { label: f.label, id: f.id })
fieldMap.value.set(normalizePath(f.name), { label: f.label, id: f.id })
}, { immediate: true })
onUnmounted(() => {
// Only delete if we still own this entry (id matches)
// This prevents old components from deleting entries registered by new components
// during re-mount transitions (e.g., when :key changes)
const currentEntry = fieldMap.value.get(field.value.name)
const path = normalizePath(field.value.name)
const currentEntry = fieldMap.value.get(path)
if (currentEntry?.id === field.value.id) {
fieldMap.value.delete(field.value.name)
fieldMap.value.delete(path)
}
})
}
Expand Down
17 changes: 17 additions & 0 deletions packages/vue-components/stories/OmegaForm.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import EmailFormComponent from "./OmegaForm/EmailForm.vue"
import EnterSubmitReproComponent from "./OmegaForm/EnterSubmitRepro.vue"
import FormInputComponent from "./OmegaForm/form.Input.vue"
import FormTaggedUnionComponent from "./OmegaForm/FormTaggedUnion.vue"
import IndexedTableComponent from "./OmegaForm/IndexedTable.vue"
import InputRegistryComponent from "./OmegaForm/InputRegistry.vue"
import IntegerValidationGermanComponent from "./OmegaForm/IntegerValidationGerman.vue"
import IntersectionExampleComponent from "./OmegaForm/IntersectionExample.vue"
Expand Down Expand Up @@ -288,6 +289,22 @@ export const Array: Story = {
})
}

export const IndexedTable: Story = {
parameters: {
docs: {
description: {
story: "A sortable, paginated v-data-table with indexed form.Input cells outside form.Array. "
+ "Each item retains its source index before table sorting and pagination, while its stable ID is the row key. "
+ "Submit with Row 14 empty to see one labelled summary error alongside the field error."
}
}
},
render: () => ({
components: { IndexedTableComponent },
template: "<IndexedTableComponent />"
})
}

export const Tanstack: Story = {
render: () => ({
components: { TanstackComponent },
Expand Down
79 changes: 79 additions & 0 deletions packages/vue-components/stories/OmegaForm/IndexedTable.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<script setup lang="ts">
import * as S from "effect-app/Schema"
import { computed, ref } from "vue"
import { VDataTable } from "vuetify/components"
import { useOmegaForm } from "../../src/components/OmegaForm"

const schema = S.Struct({
rows: S.Array(S.Struct({
id: S.String,
name: S.String,
text: S.String.pipe(S.check(S.isMinLength(2)))
}))
})

const submitted = ref(false)
const form = useOmegaForm(schema, {
defaultValues: {
rows: Array.from({ length: 14 }, (_, index) => ({
id: `row-${index + 1}`,
name: `Row ${String(index + 1).padStart(2, "0")}`,
text: index === 13 ? "" : `Text ${index + 1}`
}))
},
onSubmit: async () => {
submitted.value = true
}
})

const values = form.useStore((state) => state.values)
const items = computed(() => values.value.rows.map((row, sourceIndex) => ({ ...row, sourceIndex })))
const headers = [
{ title: "Row", key: "name" },
{ title: "Text", key: "text", sortable: false }
]
</script>

<template>
<h2>Editable table with indexed inputs</h2>
<p class="mb-4">
Sort the rows or change pages, then edit a text. Changes stay attached to the original row. Row 14 starts empty:
submit to see its field error and one labelled entry in the error summary. Enter at least two characters to correct
it.
</p>
<form.Form>
<VDataTable
:headers="headers"
:items="items"
item-value="id"
:items-per-page="5"
:items-per-page-options="[5, 10, 20]"
:sort-by="[{ key: 'name', order: 'desc' }]"
>
<template #item.text="{ item }">
<form.Input
:name="`rows[${item.sourceIndex}].text`"
:label="`${item.name} text`"
/>
</template>
</VDataTable>
<form.Errors />
<v-btn
type="submit"
class="my-4"
@click="submitted = false"
>
Submit
</v-btn>
<p
v-if="submitted"
role="status"
>
All rows are valid.
</p>
<details>
<summary>Current form values (original row order)</summary>
<pre>{{ values }}</pre>
</details>
</form.Form>
</template>
Loading