diff --git a/packages/react/test-app/Pages/FormHelper/Data.tsx b/packages/react/test-app/Pages/FormHelper/Data.tsx index a867ee549..26b408afb 100644 --- a/packages/react/test-app/Pages/FormHelper/Data.tsx +++ b/packages/react/test-app/Pages/FormHelper/Data.tsx @@ -1,10 +1,12 @@ import { useForm, usePage } from '@inertiajs/react' +import { useEffect, useState } from 'react' export default ({ errors }: { errors?: { name?: string; handle?: string } }) => { const form = useForm({ name: 'foo', handle: 'example', remember: false, + custom: {}, }) const page = usePage() @@ -33,6 +35,23 @@ export default ({ errors }: { errors?: { name?: string; handle?: string } }) => form.setDefaults('name', 'single value') } + const addCustomOtherProp = () => { + form.setData((prevData) => ({ + ...prevData, + custom: { + ...prevData.custom, + other_prop: 'dynamic_value', + }, + })) + } + + const [formDataOutput, setFormDataOutput] = useState('') + + // Effect to watch form.data and update formDataOutput + useEffect(() => { + setFormDataOutput(JSON.stringify(form.data)) + }, [form.data]) + return (
{form.errors.remember && {form.errors.remember}} + + {form.errors.accept_tos && {form.errors.accept_tos}} + @@ -90,7 +121,15 @@ export default ({ errors }: { errors?: { name?: string; handle?: string } }) => Reassign single default + + Form has {form.hasErrors ? '' : 'no '}errors + +
+ {formDataOutput} +
) } diff --git a/packages/svelte/src/useForm.ts b/packages/svelte/src/useForm.ts index 6d8df8095..8ee7fe611 100644 --- a/packages/svelte/src/useForm.ts +++ b/packages/svelte/src/useForm.ts @@ -74,8 +74,10 @@ export default function useForm>( let recentlySuccessfulTimeoutId: ReturnType | null = null let transform = (data: TForm) => data as object - const store = writable>({ - ...(restored ? restored.data : data), + const initialData = restored ? restored.data : cloneDeep(defaults) + + const formObject: InertiaForm = { + ...initialData, isDirty: false, errors: (restored ? restored.errors : {}) as FormDataErrors, hasErrors: false, @@ -89,7 +91,10 @@ export default function useForm>( }) }, data() { - return Object.keys(data).reduce((carry, key) => { + return (Object.keys(this) as Array>).reduce((carry, key) => { + if (RESERVED_KEYS.includes(key)) { + return carry + } return set(carry, key, get(this, key)) }, {} as TForm) }, @@ -257,9 +262,13 @@ export default function useForm>( cancel() { cancelToken?.cancel() }, - } as InertiaForm) + } as InertiaForm + + const store = writable>(formObject) + + const RESERVED_KEYS = Object.keys(formObject).filter((key) => !(key in initialData)) - store.subscribe((form) => { + store.subscribe((form: InertiaForm) => { if (form.isDirty === isEqual(form.data(), defaults)) { form.setStore('isDirty', !form.isDirty) } diff --git a/packages/svelte/test-app/Pages/FormHelper/Data.svelte b/packages/svelte/test-app/Pages/FormHelper/Data.svelte index 81f827a0b..776280969 100644 --- a/packages/svelte/test-app/Pages/FormHelper/Data.svelte +++ b/packages/svelte/test-app/Pages/FormHelper/Data.svelte @@ -5,6 +5,7 @@ name: 'foo', handle: 'example', remember: false, + custom: {}, }) const submit = () => { @@ -33,6 +34,22 @@ const reassignSingle = () => { $form.defaults('name', 'single value') } + + // New functions for dynamic properties + const addAcceptTos = () => { + $form.accept_tos = true // Add root-level dynamic property + } + + const addCustomOtherProp = () => { + $form.custom.other_prop = 'dynamic_value' // Add nested dynamic property + } + + // Reactive property to hold the stringified form.data() output + import { writable } from 'svelte/store' + const formDataOutput = writable('') + + // Watch $form.data() and update formDataOutput + $: formDataOutput.set(JSON.stringify($form.data()))
@@ -58,6 +75,14 @@ {$form.errors.remember} {/if} + + {#if $form.errors.accept_tos} + {$form.errors.accept_tos} + {/if} + @@ -67,5 +92,10 @@ + + Form has {$form.hasErrors ? '' : 'no '}errors + + +
diff --git a/packages/vue3/src/useForm.ts b/packages/vue3/src/useForm.ts index bbb0f58e5..b6c107547 100644 --- a/packages/vue3/src/useForm.ts +++ b/packages/vue3/src/useForm.ts @@ -63,8 +63,10 @@ export default function useForm>( let recentlySuccessfulTimeoutId = null let transform = (data) => data + const initialData = restored ? restored.data : cloneDeep(defaults) + const form = reactive({ - ...(restored ? restored.data : cloneDeep(defaults)), + ...initialData, isDirty: false, errors: (restored ? restored.errors : {}) as FormDataErrors, hasErrors: false, @@ -73,7 +75,10 @@ export default function useForm>( wasSuccessful: false, recentlySuccessful: false, data() { - return (Object.keys(defaults) as Array>).reduce((carry, key) => { + return (Object.keys(this) as Array>).reduce((carry, key) => { + if (RESERVED_KEYS.includes(key)) { + return carry + } return set(carry, key, get(this, key)) }, {} as Partial) as TForm }, @@ -258,6 +263,8 @@ export default function useForm>( }, }) + const RESERVED_KEYS = Object.keys(form).filter((key) => !(key in initialData)) + watch( form, (newValue) => { diff --git a/packages/vue3/test-app/Pages/FormHelper/DataDynamic.vue b/packages/vue3/test-app/Pages/FormHelper/DataDynamic.vue new file mode 100644 index 000000000..36a862cbb --- /dev/null +++ b/packages/vue3/test-app/Pages/FormHelper/DataDynamic.vue @@ -0,0 +1,54 @@ + + + diff --git a/tests/app/server.js b/tests/app/server.js index 27efa3255..2cea7a432 100644 --- a/tests/app/server.js +++ b/tests/app/server.js @@ -157,6 +157,13 @@ app.post('/form-helper/data', (req, res) => }), ) +app.post('/form-helper/data-dynamic', (req, res) => + inertia.render(req, res, { + component: 'FormHelper/DataDynamic', + props: {}, + }), +) + app.get('/form-helper/nested', (req, res) => inertia.render(req, res, { component: 'FormHelper/Nested', diff --git a/tests/form-component.spec.ts b/tests/form-component.spec.ts index 3a2f25c4e..82589e839 100644 --- a/tests/form-component.spec.ts +++ b/tests/form-component.spec.ts @@ -109,6 +109,56 @@ test.describe('Form Component', () => { }) }) + test.describe('Dynamic Properties', () => { + test.beforeEach(async ({ page }) => { + pageLoads.watch(page) + await page.goto('/form-helper/data-dynamic') + }) + + test('initial data() output contains only initial properties', async ({ page }) => { + const formDataOutput = await page.locator('#form-data-output').innerText() + const data = JSON.parse(formDataOutput) + + expect(data).toEqual({ + name: 'foo', + handle: 'example', + remember: false, + custom: {}, + }) + }) + + test('data() output includes root-level dynamic property', async ({ page }) => { + await page.check('input[name="accept_tos"]') + + const formDataOutput = await page.locator('#form-data-output').innerText() + const data = JSON.parse(formDataOutput) + + expect(data).toEqual({ + name: 'foo', + handle: 'example', + remember: false, + custom: {}, + accept_tos: true, + }) + }) + + test('data() output includes nested dynamic property', async ({ page }) => { + await page.getByRole('button', { name: 'Add custom.other_prop' }).click() + + const formDataOutput = await page.locator('#form-data-output').innerText() + const data = JSON.parse(formDataOutput) + + expect(data).toEqual({ + name: 'foo', + handle: 'example', + remember: false, + custom: { + other_prop: 'dynamic_value', + }, + }) + }) + }) + test.describe('Headers', () => { test.beforeEach(async ({ page }) => { pageLoads.watch(page)