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: 0 additions & 5 deletions .changeset/honest-actors-arrive.md

This file was deleted.

43 changes: 12 additions & 31 deletions documentation/docs/20-core-concepts/60-remote-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -818,56 +818,37 @@ Some forms may be repeated as part of a list. In this case you can create separa
{/each}
```

### Multiple submit buttons
### buttonProps

It's possible for a `<form>` to have multiple submit buttons. For example, you might have a single form that allows you to log in or register depending on which button was clicked.
By default, submitting a form will send a request to the URL indicated by the `<form>` element's [`action`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/form#attributes_for_form_submission) attribute, which in the case of a remote function is a property on the form object generated by SvelteKit.

To accomplish this, add a field to your schema for the button value, and use `as('submit', value)` to bind it:
It's possible for a `<button>` inside the `<form>` to send the request to a _different_ URL, using the [`formaction`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/button#formaction) attribute. For example, you might have a single form that allows you to log in or register depending on which button was clicked.

This attribute exists on the `buttonProps` property of a form object:

```svelte
<!--- file: src/routes/login/+page.svelte --->
<script>
import { loginOrRegister } from '$lib/auth';
import { login, register } from '$lib/auth.remote';
</script>

<form {...loginOrRegister}>
<form {...login}>
<label>
Your username
<input {...loginOrRegister.fields.username.as('text')} />
<input {...login.fields.username.as('text')} />
</label>

<label>
Your password
<input {...loginOrRegister.fields._password.as('password')} />
<input {...login.fields._password.as('password')} />
</label>

<button {...loginOrRegister.fields.action.as('submit', 'login')}>login</button>
<button {...loginOrRegister.fields.action.as('submit', 'register')}>register</button>
<button>login</button>
<button {...register.buttonProps}>register</button>
</form>
```

In your form handler, you can check which button was clicked:

```js
/// file: $lib/auth.js
import * as v from 'valibot';
import { form } from '$app/server';

export const loginOrRegister = form(
v.object({
username: v.string(),
_password: v.string(),
action: v.picklist(['login', 'register'])
}),
async ({ username, _password, action }) => {
if (action === 'login') {
// handle login
} else {
// handle registration
}
}
);
```
Like the form object itself, `buttonProps` has an `enhance` method for customizing submission behaviour.

## command

Expand Down
24 changes: 24 additions & 0 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2078,6 +2078,30 @@ export type RemoteForm<Input extends RemoteFormInput | void, Output> = {
get pending(): number;
/** Access form fields using object notation */
fields: RemoteFormFields<Input>;
/** Spread this onto a `<button>` or `<input type="submit">` */
buttonProps: {
type: 'submit';
formmethod: 'POST';
formaction: string;
onclick: (event: Event) => void;
/** Use the `enhance` method to influence what happens when the form is submitted. */
enhance(
callback: (opts: {
form: HTMLFormElement;
data: Input;
submit: () => Promise<void> & {
updates: (...queries: Array<RemoteQuery<any> | RemoteQueryOverride>) => Promise<void>;
};
}) => void | Promise<void>
): {
type: 'submit';
formmethod: 'POST';
formaction: string;
onclick: (event: Event) => void;
};
/** The number of pending submissions */
get pending(): number;
};
};

/**
Expand Down
34 changes: 25 additions & 9 deletions packages/kit/src/runtime/app/server/remote/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,21 @@ export function form(validate_or_fn, maybe_fn) {
}
});

const button_props = {
type: 'submit',
onclick: () => {}
};

Object.defineProperty(button_props, 'enhance', {
value: () => {
return { type: 'submit', formaction: instance.buttonProps.formaction, onclick: () => {} };
}
});

Object.defineProperty(instance, 'buttonProps', {
value: button_props
});

/** @type {RemoteInfo} */
const __ = {
type: 'form',
Expand Down Expand Up @@ -175,6 +190,11 @@ export function form(validate_or_fn, maybe_fn) {
enumerable: true
});

Object.defineProperty(button_props, 'formaction', {
get: () => `?/remote=${__.id}`,
enumerable: true
});

Object.defineProperty(instance, 'fields', {
get() {
const data = get_cache(__)?.[''];
Expand Down Expand Up @@ -202,15 +222,6 @@ export function form(validate_or_fn, maybe_fn) {
// TODO 3.0 remove
if (DEV) {
throw_on_old_property_access(instance);

Object.defineProperty(instance, 'buttonProps', {
get() {
throw new Error(
'`form.buttonProps` has been removed: Instead of `<button {...form.buttonProps}>, use `<button {...form.fields.action.as("submit", "value")}>`.' +
' See the PR for more info: https://github.com/sveltejs/kit/pull/14622'
);
}
});
}

Object.defineProperty(instance, 'result', {
Expand All @@ -228,6 +239,11 @@ export function form(validate_or_fn, maybe_fn) {
get: () => 0
});

// On the server, buttonProps.pending is always 0
Object.defineProperty(button_props, 'pending', {
get: () => 0
});

Object.defineProperty(instance, 'preflight', {
// preflight is a noop on the server
value: () => instance
Expand Down
69 changes: 60 additions & 9 deletions packages/kit/src/runtime/client/remote-functions/form.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -425,23 +425,74 @@ export function form(id) {
)
);

/** @param {Parameters<RemoteForm<any, any>['buttonProps']['enhance']>[0]} callback */
const form_action_onclick = (callback) => {
/** @param {Event} event */
return async (event) => {
const target = /** @type {HTMLButtonElement} */ (event.currentTarget);
const form = target.form;
if (!form) return;

// Prevent this from firing the form's submit event
event.stopPropagation();
event.preventDefault();

const form_data = new FormData(form, target);

if (DEV) {
const enctype = target.hasAttribute('formenctype')
? target.formEnctype
: clone(form).enctype;

validate_form_data(form_data, enctype);
}

await handle_submit(form, form_data, callback);
};
};

/** @type {RemoteForm<any, any>['buttonProps']} */
// @ts-expect-error we gotta set enhance as a non-enumerable property
const button_props = {
type: 'submit',
formmethod: 'POST',
formaction: action,
onclick: form_action_onclick(({ submit, form }) =>
submit().then(() => {
if (!issues.$) {
form.reset();
}
})
)
};

Object.defineProperty(button_props, 'enhance', {
/** @type {RemoteForm<any, any>['buttonProps']['enhance']} */
value: (callback) => {
return {
type: 'submit',
formmethod: 'POST',
formaction: action,
onclick: form_action_onclick(callback)
};
}
});

Object.defineProperty(button_props, 'pending', {
get: () => pending_count
});

let validate_id = 0;

// TODO 3.0 remove
if (DEV) {
throw_on_old_property_access(instance);

Object.defineProperty(instance, 'buttonProps', {
get() {
throw new Error(
'`form.buttonProps` has been removed: Instead of `<button {...form.buttonProps}>, use `<button {...form.fields.action.as("submit", "value")}>`.' +
' See the PR for more info: https://github.com/sveltejs/kit/pull/14622'
);
}
});
}

Object.defineProperties(instance, {
buttonProps: {
value: button_props
},
fields: {
get: () =>
create_field_proxy(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
<script>
import { get_message, set_message, resolve_deferreds } from './form.remote.js';
import {
get_message,
set_message,
resolve_deferreds,
set_reverse_message
} from './form.remote.js';

const { params } = $props();

Expand All @@ -22,14 +27,19 @@

<input {...set_message.fields.message.as('text')} />
<input {...set_message.fields.test_name.as('hidden', params.test_name)} />

<button {...set_message.fields.action.as('submit', 'normal')}>set message</button>
<button {...set_message.fields.action.as('submit', 'reverse')}>set reverse message</button>
<!--
NOTE: there really probably should be a `set_reverse_message' test_name hidden field here, but it collides with the one above.
This kind of lines up with our discussions from earlier where we were talking about needing to include the RF hash in the field name.
If we do that and this test starts failing, all we'll need to do is add the hidden field back in.
-->
<button>set message</button>
<button {...set_reverse_message.buttonProps}>set reverse message</button>
</form>

<p>set_message.input.message: {set_message.fields.message.value()}</p>
<p>set_message.pending: {set_message.pending}</p>
<p>set_message.result: {set_message.result}</p>
<p>set_reverse_message.result: {set_reverse_message.result}</p>

<hr />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,10 @@ export const set_message = form(
test_name: v.string(),
id: v.optional(v.string()),
message: v.picklist(
['hello', 'goodbye', 'unexpected error', 'expected error', 'redirect', 'backwards'],
['hello', 'goodbye', 'unexpected error', 'expected error', 'redirect'],
'message is invalid'
),
uppercase: v.optional(v.string()),
action: v.optional(v.picklist(['normal', 'reverse']))
uppercase: v.optional(v.string())
}),
async (data) => {
if (data.message === 'unexpected error') {
Expand All @@ -38,11 +37,7 @@ export const set_message = form(
const instance = instances.get(data.test_name) ?? { message: 'initial', deferreds: [] };
instances.set(data.test_name, instance);

if (data.action === 'reverse') {
instance.message = data.message.split('').reverse().join('');
} else {
instance.message = data.uppercase === 'true' ? data.message.toUpperCase() : data.message;
}
instance.message = data.uppercase === 'true' ? data.message.toUpperCase() : data.message;

if (getRequestEvent().isRemoteRequest) {
const deferred = Promise.withResolvers<void>();
Expand Down
9 changes: 5 additions & 4 deletions packages/kit/test/apps/async/test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,21 +135,22 @@ test.describe('remote functions', () => {
await page.waitForURL('/remote');
});

test('form multiple submit buttons work', async ({ page, javaScriptEnabled }) => {
await page.goto('/remote/form/multiple-submit');
test('form.buttonProps works', async ({ page, javaScriptEnabled }) => {
await page.goto('/remote/form/button-props');

await page.fill('[data-unscoped] input', 'backwards');
await page.getByText('set reverse message').click();

if (javaScriptEnabled) {
await page.getByText('resolve deferreds').click();
await page.getByText('message.current: sdrawkcab').waitFor();
await expect(page.getByText('await get_message():')).toHaveText(
'await get_message(): sdrawkcab'
);
}

await expect(page.getByText('set_message.result')).toHaveText('set_message.result: sdrawkcab');
await expect(page.getByText('set_reverse_message.result')).toHaveText(
'set_reverse_message.result: sdrawkcab'
);
});

test('form scoping with for(...) works', async ({ page, javaScriptEnabled }) => {
Expand Down
24 changes: 24 additions & 0 deletions packages/kit/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2054,6 +2054,30 @@ declare module '@sveltejs/kit' {
get pending(): number;
/** Access form fields using object notation */
fields: RemoteFormFields<Input>;
/** Spread this onto a `<button>` or `<input type="submit">` */
buttonProps: {
type: 'submit';
formmethod: 'POST';
formaction: string;
onclick: (event: Event) => void;
/** Use the `enhance` method to influence what happens when the form is submitted. */
enhance(
callback: (opts: {
form: HTMLFormElement;
data: Input;
submit: () => Promise<void> & {
updates: (...queries: Array<RemoteQuery<any> | RemoteQueryOverride>) => Promise<void>;
};
}) => void | Promise<void>
): {
type: 'submit';
formmethod: 'POST';
formaction: string;
onclick: (event: Event) => void;
};
/** The number of pending submissions */
get pending(): number;
};
};

/**
Expand Down
Loading