diff --git a/.changeset/fresh-indexed-fields.md b/.changeset/fresh-indexed-fields.md new file mode 100644 index 000000000..5c76d47db --- /dev/null +++ b/.changeset/fresh-indexed-fields.md @@ -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. diff --git a/packages/vue-components/__tests__/OmegaForm/IndexedInputErrors.test.ts b/packages/vue-components/__tests__/OmegaForm/IndexedInputErrors.test.ts new file mode 100644 index 000000000..5023651bd --- /dev/null +++ b/packages/vue-components/__tests__/OmegaForm/IndexedInputErrors.test.ts @@ -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: ` + + + + + + + ` + })) + + 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: ` + + + + + + ` + })) + + expect(wrapper.get("input").element.value).toBe("existing") + wrapper.unmount() + }) +}) diff --git a/packages/vue-components/src/components/OmegaForm/OmegaArray.vue b/packages/vue-components/src/components/OmegaForm/OmegaArray.vue index ec1ca29eb..5be0d562d 100644 --- a/packages/vue-components/src/components/OmegaForm/OmegaArray.vue +++ b/packages/vue-components/src/components/OmegaForm/OmegaArray.vue @@ -44,7 +44,7 @@ generic="From extends Record, To extends Record, Name extends DeepKeys" > 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>() @@ -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) diff --git a/packages/vue-components/src/components/OmegaForm/OmegaInput.vue b/packages/vue-components/src/components/OmegaForm/OmegaInput.vue index 842317075..ddc094b55 100644 --- a/packages/vue-components/src/components/OmegaForm/OmegaInput.vue +++ b/packages/vue-components/src/components/OmegaForm/OmegaInput.vue @@ -85,10 +85,10 @@ const getMetaFromArray = inject FieldMeta | null> | null>( ) const meta = computed(() => { - if (getMetaFromArray?.value && getMetaFromArray.value(props.name as DeepKeys)) { - 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] }) const errori18n = useErrorLabel(props.form) diff --git a/packages/vue-components/src/components/OmegaForm/errors.ts b/packages/vue-components/src/components/OmegaForm/errors.ts index 69bba7591..28b878601 100644 --- a/packages/vue-components/src/components/OmegaForm/errors.ts +++ b/packages/vue-components/src/components/OmegaForm/errors.ts @@ -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) => { const { formatMessage } = useIntl() const humanize = (str: string) => { @@ -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 } @@ -120,15 +122,16 @@ export const makeFieldMap = () => { const fieldMap = ref(new Map()) 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) } }) } diff --git a/packages/vue-components/stories/OmegaForm.stories.ts b/packages/vue-components/stories/OmegaForm.stories.ts index 98b8dfb53..0d9ed9278 100644 --- a/packages/vue-components/stories/OmegaForm.stories.ts +++ b/packages/vue-components/stories/OmegaForm.stories.ts @@ -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" @@ -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: "" + }) +} + export const Tanstack: Story = { render: () => ({ components: { TanstackComponent }, diff --git a/packages/vue-components/stories/OmegaForm/IndexedTable.vue b/packages/vue-components/stories/OmegaForm/IndexedTable.vue new file mode 100644 index 000000000..871cd5861 --- /dev/null +++ b/packages/vue-components/stories/OmegaForm/IndexedTable.vue @@ -0,0 +1,79 @@ + + +